mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 08:54:42 +00:00
Compare commits
101
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4ddcf7a5b | ||
|
|
46b0466f3b | ||
|
|
429cf715ca | ||
|
|
4196fae928 | ||
|
|
bbd93f76ee | ||
|
|
9e70f5665a | ||
|
|
f4210f39aa | ||
|
|
448f258583 | ||
|
|
152a5ae731 | ||
|
|
9f39663722 | ||
|
|
568bd447e3 | ||
|
|
41a36fa593 | ||
|
|
f91ec81f7c | ||
|
|
af2fb8ab35 | ||
|
|
cdc64ecfa4 | ||
|
|
042bc36ba5 | ||
|
|
4f6e71e8d6 | ||
|
|
51505a7d3b | ||
|
|
13c318838f | ||
|
|
d9fb2ffe93 | ||
|
|
0d82eed6b5 | ||
|
|
2ec0ac3c25 | ||
|
|
6cb9685a6d | ||
|
|
8a972b8c2e | ||
|
|
cc56b392de | ||
|
|
cb331532ad | ||
|
|
da994fb400 | ||
|
|
c4baf750f2 | ||
|
|
31a408d108 | ||
|
|
3be00f5105 | ||
|
|
6e704f803c | ||
|
|
949462f39b | ||
|
|
53d91d63da | ||
|
|
4709b49dc4 | ||
|
|
f938bf963d | ||
|
|
1131241b73 | ||
|
|
ce39ab6f09 | ||
|
|
c242f4d8db | ||
|
|
66aeff70d1 | ||
|
|
adf8badade | ||
|
|
1b45d92dac | ||
|
|
42ed0a8a99 | ||
|
|
d8fca6fdc6 | ||
|
|
77f97f82bc | ||
|
|
26b1e122ae | ||
|
|
7dfb63d0c4 | ||
|
|
5c81d08959 | ||
|
|
96b2bea130 | ||
|
|
33338be16e | ||
|
|
32230e24c5 | ||
|
|
d641cb5179 | ||
|
|
58094053dc | ||
|
|
3f75a11a49 | ||
|
|
f0807c5deb | ||
|
|
e0ff0959a1 | ||
|
|
591dd3bc92 | ||
|
|
8fb31cf8c0 | ||
|
|
b99d2a010d | ||
|
|
41011ddc60 | ||
|
|
e9d440259c | ||
|
|
0d697e6330 | ||
|
|
5413b8122c | ||
|
|
8960e76d19 | ||
|
|
218bcaaa6b | ||
|
|
30cc0a8c21 | ||
|
|
a362664f47 | ||
|
|
20ce734367 | ||
|
|
ca2bdd66eb | ||
|
|
1aaac5b231 | ||
|
|
b60b74384b | ||
|
|
034f639c88 | ||
|
|
55e8cff9f4 | ||
|
|
06be203c5c | ||
|
|
007a8e36ae | ||
|
|
eb6bed8447 | ||
|
|
bfbd4d5175 | ||
|
|
52b84b7835 | ||
|
|
d68271565a | ||
|
|
ce7b14a143 | ||
|
|
c7e137d4b4 | ||
|
|
d37262be56 | ||
|
|
0952ddbd4b | ||
|
|
67628b48fe | ||
|
|
eba5bdf810 | ||
|
|
66676d49e6 | ||
|
|
064243288b | ||
|
|
a880c9619d | ||
|
|
6740d41fab | ||
|
|
ca6b54fc2e | ||
|
|
4cd23a8f17 | ||
|
|
3084f32852 | ||
|
|
ce8196f6c5 | ||
|
|
40b95f70f4 | ||
|
|
93b9ac9ded | ||
|
|
eb8b5b8f94 | ||
|
|
51b9ad7695 | ||
|
|
1c4400194a | ||
|
|
1502b7f90f | ||
|
|
16f688b726 | ||
|
|
b507fc1986 | ||
|
|
e546497a21 |
@@ -34,6 +34,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
|
||||
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
||||
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
|
||||
| [docs/keyboard-shortcuts.md](docs/keyboard-shortcuts.md) | The binding table, per-platform key sources, badges vs routing, iOS `UIKeyCommand` limits |
|
||||
| [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu |
|
||||
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
|
||||
| [docs/notifications.md](docs/notifications.md) | Notification tap ownership, native replay and acknowledgement, startup handoff, and payload QA |
|
||||
|
||||
+30
-11
@@ -183,23 +183,42 @@ Some providers can create their own child sessions inside one provider runtime.
|
||||
|
||||
The provider still owns the underlying runtime. Paseo keeps an agent record so the child can be opened, tracked, archived, and cascaded with the parent, but prompts and history hydration route through the provider adapter for that native child handle.
|
||||
|
||||
## Background workspaces
|
||||
## Background visibility
|
||||
|
||||
`workspace.background` controls discovery only. Background workspaces contain ordinary durable
|
||||
agents and terminals: provider history, plugin hooks, parentage, archive, and restart recovery
|
||||
keep their normal behavior. Visibility is creation-only; selecting an existing workspace cannot
|
||||
change it. New workspaces default to false, inherit the caller workspace's value when an agent
|
||||
creates one without an override, and accept explicit true or false.
|
||||
An agent is effectively background when it or its workspace is background:
|
||||
`agent.background || workspace.background`. Background controls discovery only. Background agents
|
||||
and the agents in background workspaces are ordinary durable agents: provider history, plugin
|
||||
hooks, parentage, finish notifications to a caller, archive, and restart recovery keep their
|
||||
normal behavior. Both flags are fixed at creation.
|
||||
|
||||
The daemon derives agent discovery from workspace ownership. Default workspace and agent lists,
|
||||
including History requests, exclude background work; `filter.includeBackground` includes it.
|
||||
Exact-ID operations bypass discovery filtering. The app replicates both kinds and filters only
|
||||
its sidebar with **Show background**, so direct navigation and restored tabs remain available.
|
||||
- **Agent.** `create_agent_request.background` marks the agent. Use it for helpers the user does
|
||||
not watch, such as a plugin's classifier in the user's open workspace. A background agent never
|
||||
raises attention, adds nothing to its workspace's status bucket or Last prompt time, and stays
|
||||
out of tabs, the parent's subagent track and agent lists in the app. A tab opened by id still
|
||||
works.
|
||||
- **Workspace.** `workspace.background` hides the workspace from the sidebar unless **Show
|
||||
background** is on.
|
||||
- **Inheritance.** When `background` is omitted, an agent created by a caller agent takes the
|
||||
caller's own `background`. A new workspace takes the caller's effective background. A request
|
||||
that creates its workspace applies `background` to both the agent and the workspace. Explicit
|
||||
true or false always wins.
|
||||
|
||||
`AgentDirectory` (`packages/server/src/server/agent/directory.ts`) owns visibility for lists.
|
||||
Default workspace and agent lists, including History requests, exclude background work;
|
||||
`includeBackground` includes it. Exact-ID operations bypass discovery filtering. The app
|
||||
replicates inclusively and filters at each surface.
|
||||
|
||||
Sources without the `background_agents` hello capability get workspace-only visibility on
|
||||
inclusive reads and subscriptions: agents in background workspaces, but no agent-level background
|
||||
agents. Older apps replicate inclusively and auto-open a tab for every root agent, so a classifier
|
||||
run would otherwise open a tab each turn. That visibility has its own directory sync generation.
|
||||
|
||||
Sequenced directories keep complete canonical collections. A filtered read must never replace
|
||||
those collections with its subset. Inclusive and default observations have distinct cursor
|
||||
scopes, so changing scope requires a fresh snapshot rather than reusing an incomplete cache.
|
||||
Clients gate creation and inclusive discovery on `features.backgroundWorkspaces`.
|
||||
Clients gate background workspaces and inclusive discovery on `features.backgroundWorkspaces`.
|
||||
They gate a background agent placed in an existing workspace or under a caller on
|
||||
`features.backgroundAgents`.
|
||||
|
||||
### Private daemon helpers
|
||||
|
||||
|
||||
+5
-1
@@ -174,7 +174,7 @@ REACT_NATIVE_PACKAGER_HOSTNAME=localhost \
|
||||
|
||||
This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:<port>` works directly; on Android you need `10.0.2.2` or `adb reverse`.
|
||||
|
||||
## Inverted timeline selection
|
||||
## Inverted timeline input
|
||||
|
||||
Android focus and selection visibility requests must not reposition inverted timelines. The
|
||||
`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for
|
||||
@@ -183,6 +183,10 @@ Native's Android list uses `scale: -1`, which decomposes into a 180-degree rotat
|
||||
`scaleY`; checking the scale property alone misses it. Dragging and explicit scroll
|
||||
commands still work; non-inverted scroll views keep Android's default behavior.
|
||||
|
||||
Mouse wheel axes also bypass the inversion transform. The same module reverses the vertical
|
||||
wheel axis for inverted scroll views before delegating to Android, preserving native scroll
|
||||
bounds and React Native's scroll events and disabled-scroll behavior.
|
||||
|
||||
Register this package before React Native's core package through its Expo config plugin. Normal
|
||||
Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does
|
||||
not change the shipped scroll view. Keep this behavior in the app's compiled native module.
|
||||
|
||||
@@ -777,6 +777,8 @@ The built-in `claude` provider appends concrete model IDs from `~/.claude/settin
|
||||
|
||||
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
|
||||
|
||||
Paseo's effort selector does not inherit `effortLevel` from Claude's settings.json. Select an effort in Paseo to choose a specific value.
|
||||
|
||||
### Gotcha: `extends: "claude"` with third-party endpoints
|
||||
|
||||
When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors.
|
||||
|
||||
+2
-1
@@ -593,7 +593,8 @@ rerun passes only by landing elsewhere.
|
||||
|
||||
To confirm, fetch the URL from several runners and compare `content-length`, the `cf-ray` colo
|
||||
suffix, and the number of `attestations` entries (#7730 has the probe). To fix, remove or
|
||||
replace that version in the lockfile and report the URL to npm so they purge it.
|
||||
replace that version in the lockfile. If the dependent’s range cannot be changed, pin a clean
|
||||
version in root `overrides` and regenerate the lockfile. Report the URL to npm so they purge it.
|
||||
|
||||
## ACP provider catalog versions
|
||||
|
||||
|
||||
+2
-1
@@ -96,7 +96,8 @@ hidden workspace can overwrite the remembered workspace before Settings or
|
||||
History returns.
|
||||
|
||||
Plugin settings use the distinct `settings/hosts/[serverId]/plugins/[pluginId]/[screenId]` leaf;
|
||||
Back returns to that host's Plugins page.
|
||||
Back returns to that host's Plugins page. Its screen tabs change `screenId` with `router.setParams`,
|
||||
so switching tabs adds no stack entries.
|
||||
|
||||
Settings detail routes are separate siblings on purpose. Keep
|
||||
`settings/[section]`, the host routes, the projects index, and project detail as
|
||||
|
||||
+8
-12
@@ -97,19 +97,15 @@ Register the adapter in `defaultForgeRegistry` with:
|
||||
- `matchesHost` from manifest `cloudHosts`
|
||||
- `probeHost` when self-hosted/Enterprise detection is supported
|
||||
|
||||
Current change-request lookup uses two identities deliberately:
|
||||
Current change-request lookup matches the branch and repository for both open and
|
||||
terminal requests. Prefer an open request, then a terminal request at the local
|
||||
HEAD, then the most recently updated terminal request. A reused branch can show
|
||||
its previous request until a new one opens. Auto-archive separately requires an
|
||||
observed open-to-merged transition and a fresh exact match between checkout HEAD
|
||||
and the PR head, along with the existing workspace safety checks.
|
||||
|
||||
- An open PR/MR belongs to the checkout when its head branch and head repository
|
||||
match. Its remote head SHA may differ because the checkout can be ahead,
|
||||
behind, or contain commits that have not been pushed yet.
|
||||
- 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
|
||||
as the lookup itself. Otherwise a commit made on the same branch can inherit the
|
||||
previous commit's cached terminal status until the cache expires.
|
||||
Keep checkout HEAD in adapter cache and poll identities so checkout changes
|
||||
refresh the facts used by archive eligibility.
|
||||
|
||||
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
|
||||
|
||||
@@ -28,6 +28,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Last prompt** — Sidebar workspace sort by the most recent human prompt recorded by an agent in that workspace, including archived agents. Agent output, agent-to-agent messages, scheduled prompts, and opening a workspace do not count. A workspace without a human prompt uses its creation time.
|
||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||
- **Background agent** — An agent hidden from default discovery and from app surfaces that announce agents: tabs, the subagent track, agent lists, workspace status and attention. It is otherwise an ordinary durable agent, addressable by id. An agent is effectively background when it or its workspace is background. Fixed at creation. Code: `background` on `AgentSnapshotPayload` and `create_agent_request`. See [agent-lifecycle.md](agent-lifecycle.md#background-visibility). Don't confuse with background work (provider shells and monitors) or the daemon's private internal helpers, which are never public ([agent-lifecycle.md](agent-lifecycle.md#private-daemon-helpers)).
|
||||
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
|
||||
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
|
||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing. Three other things are called "profile" in this codebase, so always qualify: **Agent profile**, **Terminal profile**, and the provider aliases in [custom-providers.md](custom-providers.md). Bare "profile" is forbidden in UI copy.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Keyboard shortcuts
|
||||
|
||||
Define shortcuts in [the shared binding table](../packages/app/src/keyboard/keyboard-shortcuts.ts). Platforms contribute key sources, not separate action maps, so matching and user rebinds stay consistent across runtimes. Add a shortcut through a binding; add platform support through a key source.
|
||||
|
||||
## Badges and routing
|
||||
|
||||
Routing runs whenever the app mounts its key sources, independently of layout.
|
||||
Use `useHardwareKeyboardPresent()` for hints and the Shortcuts settings section.
|
||||
iOS reads `GCKeyboard.coalesced` and connection notifications. Android watches input
|
||||
devices and hardware keyboard configuration. Web starts with a fine hover pointer and recognizes the first modified
|
||||
keydown, including on touch devices. Compact Electron keeps its bindings.
|
||||
|
||||
Keyboard navigation needs the sidebar's filtered workspace model even when its visual
|
||||
panel is hidden. Retain that model while keyboard navigation is active; gating it on
|
||||
panel visibility leaves number shortcuts with no targets after a compact startup.
|
||||
|
||||
Composer send-key customization remains a separate capability. Native retains its
|
||||
existing Return submission path; advertising its web-only customization would expose
|
||||
settings the native composer does not implement.
|
||||
|
||||
## Native sources
|
||||
|
||||
Use the iOS window press stream for hardware keys. `UIKeyCommand` only takes keys
|
||||
that must bypass the focused text control: composer Return and the top overlay's
|
||||
Enter and arrows. Keep the stream as the sole shortcut emitter: a command-owned
|
||||
press is still observed by the window.
|
||||
|
||||
The source spike used an iPad Pro 11-inch (M4) simulator on iOS 18.6 with a
|
||||
`UITextView` first responder. Both `UIWindow.sendEvent` and the root controller's
|
||||
`pressesBegan` received Cmd+B, Cmd+P and Escape. With Escape registered as a
|
||||
`UIKeyCommand`, the window observed it and the command handler also ran.
|
||||
|
||||
Simulator reserves Cmd+K for Toggle Software Keyboard. Injecting it produced
|
||||
HID 184 with empty characters rather than K; plain K, Cmd+B and Cmd+P arrived
|
||||
normally. Keep Simulator input capture off: Send Keyboard Input to Device
|
||||
prevented synthetic AppleScript input from arriving, including plain text.
|
||||
For simulator QA, use Cmd+B/P to check delivery and rebind the command center to
|
||||
Cmd+J to check toggle and rebind behavior. Cmd+K delivery needs a physical iPad.
|
||||
Simulator also reserves Cmd+1…4, Cmd+Shift+A and Cmd+comma. Exercise those actions
|
||||
through rebinds. For numeric injection, use AppleScript physical key codes (for example
|
||||
25 for 9); `keystroke "9"` did not deliver a digit in the QA session.
|
||||
Simulator exposes a virtual `GCKeyboard` even with Connect Hardware Keyboard off.
|
||||
Verify the iOS absence case on a physical iPhone.
|
||||
|
||||
Never register Backspace: it would take deletion away from text fields. Changes
|
||||
to the native module require a native rebuild; an over-the-air JavaScript update
|
||||
cannot deliver them.
|
||||
|
||||
## Overlay ownership
|
||||
|
||||
Register keyboard handling with `useOverlayRegistration` from `@/keyboard`.
|
||||
Pass the surface's open state, layer, handled keys and `onKeyDown`. The highest
|
||||
layer gets first refusal; the latest registration wins a tie. Native registration
|
||||
does not need a DOM ref. Web adds focus trapping and restoration through its adapter.
|
||||
|
||||
Capture a global overlay’s layer in state for its opening lifetime. Memo caches can
|
||||
be discarded or removed by React Compiler; recomputing against the registry lets an
|
||||
open modal rise above its own children and creates competing focus traps.
|
||||
|
||||
Declare Enter and arrow keys only while the surface owns them. Native sources
|
||||
use the top overlay's declarations to take those keys before text controls edit.
|
||||
Backspace stays with the focused input. Composition confirmation must not select
|
||||
an overlay row or submit the composer.
|
||||
|
||||
Android modals have their own windows. Observe them through React Native's
|
||||
`ExtraWindowEventListener` as well as the activity window. The dev client bypasses
|
||||
Expo's root-container handler, so an activity root-view wrapper misses presses.
|
||||
A modal can forward an unhandled event into the activity window. Observe the event
|
||||
once across that nested delivery without retaining recycled Android `KeyEvent`s.
|
||||
Keep an overlay-owned press consumed through key-up, even if its key-down closes
|
||||
the overlay; otherwise the modal's default Escape handler can close a second layer.
|
||||
|
||||
iOS scene startup installs the observing window after Expo creates its root view.
|
||||
Keep that integration in the hardware keyboard module's config plugin. Its window
|
||||
owns both the press stream and the text-control key commands, including above modals.
|
||||
|
||||
## Focus scopes
|
||||
|
||||
The composer and terminal declare focus with `useKeyboardFocusSurface`. Native
|
||||
scope derives from those declarations and command-center state. Releasing an old
|
||||
surface must not clear a newer owner's declaration during a tab switch. Web keeps
|
||||
its DOM-derived scope.
|
||||
|
||||
Escape interrupts an agent only in the message input or an unclaimed surface.
|
||||
A focused terminal excludes that binding. The source leaves terminal Escape alone;
|
||||
terminal Escape/Ctrl forwarding is a separate feature.
|
||||
+11
-3
@@ -529,13 +529,21 @@ need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh
|
||||
`send()` resolves after acceptance. Publish operation completion, prompt disposition, turn state,
|
||||
configuration, permissions, persistence, and complete timeline snapshots through `onEvent()`.
|
||||
Route messages, structured commands, steering, and command side effects through `session.prompt`.
|
||||
Provider settings are toggle/select data that Paseo renders in the composer. Keep private options in
|
||||
the opaque `ProviderSessionConfig.providerOptions` object on `session.open`.
|
||||
It contains the provider defaults and per-agent overrides merged by the daemon.
|
||||
A provider's configuration is the opaque `ProviderSessionConfig.providerOptions` object on
|
||||
`session.open`. It contains the provider defaults and per-agent overrides merged by the daemon.
|
||||
Validate and apply it inside the provider; core does not know your option shape.
|
||||
See [provider options](custom-providers.md#provider-options) for configuration and
|
||||
merge semantics.
|
||||
|
||||
The provider draws some of those keys in the composer by reporting `controls` and the values it runs
|
||||
(`providerOptions`) in `session.config`. A running-agent change arrives as
|
||||
`session.configure` with `changes.providerOptions` and follows the same apply-or-throw contract as
|
||||
built-in providers ([options flow](providers.md#options-flow)). `PluginAgentSession` in
|
||||
`packages/server/src/server/agent/plugin-provider.ts` is that adapter. A provider still reporting
|
||||
`settings` is read through `plugin-provider-options.ts`, which turns each setting into a control and
|
||||
its value into an option and is sent both shapes in `session.configure`; delete it with
|
||||
`COMPAT(plugin-provider-settings)`. The ACP shim still reports `settings`.
|
||||
|
||||
Providers add an optional veto to daemon baseline cleanup through the per-session
|
||||
[runtime safety query](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
|
||||
The daemon calls the same `AgentSession.canStopRuntime()` interface for built-ins and plugins;
|
||||
|
||||
+62
-3
@@ -30,6 +30,65 @@ at session startup. See [provider configuration](custom-providers.md#provider-op
|
||||
for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
|
||||
for native keys and examples.
|
||||
|
||||
### Options flow
|
||||
|
||||
`options` on the agent snapshot is the only option state. Controls describe how to draw an
|
||||
option and read `options`; they hold no value of their own. Any option can be read and set with
|
||||
or without a control.
|
||||
|
||||
- **Create and resume.** The provider opens with the options it is given and emits nothing about
|
||||
them while opening. `openOptionsPatch` carries every option the session ended up with that
|
||||
differs from what it was given, including a setting nobody asked for (a native default such as
|
||||
Cursor opening with Fast on). It is set before `createSession`/`resumeSession` resolves and the
|
||||
daemon stores it before the agent is published. A reopened agent resumes that state, so a
|
||||
native default the provider later changes does not follow. `options` on the snapshot is what
|
||||
the session opened with, and afterwards it moves only through an apply or an `options_changed`.
|
||||
Editing a provider default in `config.json` affects agents the next time they open.
|
||||
- **Live apply.** `applyOptions` receives the keys the patch touches, resolved against the
|
||||
`config.json` defaults. A removal arrives as the default the key falls back to, or `null` when
|
||||
there is none; the provider returns the setting to its own default if it can, or throws to
|
||||
reject. A key the running session cannot apply (one the provider reads only when it opens) is
|
||||
rejected too, so a stored option always describes what runs. Claude reads most options when it
|
||||
starts its query, and replacing a started query would end its background work, so after the
|
||||
first prompt only Fast can change. Codex fixes its approval policy and sandbox when the thread
|
||||
starts, so it applies only `service_tier` and `plan_mode` live. A session with no
|
||||
`applyOptions` rejects every patch; the daemon enforces this. The provider returns the options
|
||||
it ended up with when they differ from the request. The daemon
|
||||
stores the patch and that result in one step after the call resolves, onto the agent's options
|
||||
as they are at that moment, so the last write wins. Throwing rejects the whole patch and
|
||||
none of it is stored. Emit nothing from inside the call. If the provider's own state moved
|
||||
before it refused, it reports the moved values once as `options_changed` after the call has
|
||||
ended, because the stored options must match what runs.
|
||||
- **Old data converts in the agent manager.** Codex used to take `fast_mode`; it takes
|
||||
`service_tier` now. `AgentManager` runs `convertLegacyOptions` (`option-controls.ts`) on an
|
||||
agent's own options when it creates, resumes, imports, reloads or applies to it, and on draft
|
||||
controls, before defaults merge and before anything is stored (`true` is `"fast"`, `false` is
|
||||
`"default"`, and a `service_tier` in the same input wins). The manager converts for the adapter
|
||||
behind the provider id, following `derivedFromProviderId`, so a custom provider with
|
||||
`extends: "codex"` is converted too. Configured defaults convert in the provider registry. A
|
||||
stored record keeps `fast_mode` until the agent is next opened or applied to, and is stored
|
||||
converted from then on. Nothing past the manager sees `fast_mode` for Codex. Codex resolves
|
||||
`"fast"` to the model's Fast tier and reports the id it runs through `openOptionsPatch` or the
|
||||
`applyOptions` result like any other value. The `featureValues` to `options` fold is separate
|
||||
and provider-agnostic; it stays at the protocol and legacy edges.
|
||||
- **Clients draw from the daemon's answer.** The app reads `controls` and `options` from the
|
||||
agent snapshot for a running or closed agent. A draft and a profile form send everything they
|
||||
would create with (seed, the provider's last-used control values once the provider has said it
|
||||
still draws them, and the user's edits) in `provider.controls.list.request` and draw only what
|
||||
comes back, so an alias such as a legacy Fast profile's `service_tier: "fast"` is resolved once,
|
||||
by the daemon. A toggle shows over the last answer until its own answer arrives. The draft's
|
||||
layers live in a plain model (`agent-controls/draft-options-model.ts`): applying a profile
|
||||
replaces provider and options in one step, while choosing a provider by hand starts over. A
|
||||
profile saves its stored options plus the form's edits, so options no control draws survive an
|
||||
edit. A daemon without `server_info.features.providerOptionControls` sends `features` and
|
||||
`featureValues`; `packages/client/src/compat/legacy-provider-options.ts` is the one place that
|
||||
turns those into `controls` and `options` and back, so nothing in the app branches on it. A
|
||||
create that carries options first lists the controls for its own provider, model, mode and
|
||||
thinking level, since which options are controls differs by model, and sends those as
|
||||
`featureValues`; callers do not prime anything.
|
||||
- **Provider-initiated changes.** A setting the provider changes on its own is an
|
||||
`options_changed` event. The daemon stores and forwards it and does not push the old value back.
|
||||
|
||||
Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail
|
||||
closed for Hub unattended execution until it can approve one exact injected MCP
|
||||
server and tool identity without approving native tools.
|
||||
@@ -115,7 +174,7 @@ Submitted user-message wire items carry the same Paseo ID in `messageId` and `cl
|
||||
|
||||
Provider adapters must terminalize every transient timeline row before emitting the turn's terminal event. Codex may omit the completed `contextCompaction` item when a turn ends during compaction, so its adapter closes any pending root compaction before forwarding `turn_completed`, `turn_failed`, or `turn_canceled`. A terminal turn must never leave the client showing an operation as still loading.
|
||||
|
||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session feature listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listFeatures` implementation may return features from an incomplete, model-less draft and owns which features are valid in that state.
|
||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listControls` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session control listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listControls` implementation may return controls from an incomplete, model-less draft and owns which features are valid in that state.
|
||||
|
||||
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
|
||||
|
||||
@@ -478,7 +537,7 @@ interface AgentSession {
|
||||
readonly provider: AgentProvider;
|
||||
readonly id: string | null;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
readonly features?: AgentFeature[];
|
||||
readonly controls?: AgentOptionControl[];
|
||||
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
||||
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
||||
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
|
||||
@@ -500,7 +559,7 @@ interface AgentSession {
|
||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||
setModel?(modelId: string | null): Promise<void>;
|
||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
|
||||
setFeature?(featureId: string, value: unknown): Promise<void>;
|
||||
applyOptions?(options: ProviderOptions): Promise<void>;
|
||||
tryHandleOutOfBand?(prompt: AgentPromptInput): {
|
||||
run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>;
|
||||
} | null;
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-iAi82L6Lm75hdg/9tJYyYcHMhCJLtXGSpcNqgv9QQaw=
|
||||
sha256-p97CaALgkY+x9XVvS4/ILqmbwYSX0fSbVUgQl35BjiU=
|
||||
|
||||
Generated
+714
-615
File diff suppressed because it is too large
Load Diff
@@ -131,6 +131,7 @@
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "44.2.0",
|
||||
"electron-builder": "26.18.0",
|
||||
"expo-module-scripts": "^5.0.8",
|
||||
"get-port-cli": "^3.0.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
@@ -151,6 +152,7 @@
|
||||
"expo": "~57.0.30",
|
||||
"expo-router": "~57.0.28",
|
||||
"lightningcss": "1.30.1",
|
||||
"pkijs": "3.4.2",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-native": "0.86.3",
|
||||
|
||||
@@ -142,6 +142,7 @@ export default {
|
||||
searchPaths: ["../../node_modules", "./node_modules"],
|
||||
},
|
||||
plugins: [
|
||||
"./modules/paseo-hardware-keyboard/app.plugin",
|
||||
"expo-router",
|
||||
withPasteInput,
|
||||
withAndroidScroll,
|
||||
|
||||
@@ -94,3 +94,21 @@ it("allows all orientations on iPhone and iPad", async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
// Scene startup is the integration boundary for the iOS keyboard window.
|
||||
describe("hardware keyboard scene integration", () => {
|
||||
it("installs observation after Expo creates the scene window and is idempotent", () => {
|
||||
const {
|
||||
configureHardwareKeyboardAppDelegate,
|
||||
} = require("./modules/paseo-hardware-keyboard/app.plugin.js");
|
||||
const source =
|
||||
"import Expo\nclass Scene {\n super.scene(scene, willConnectTo: session, options: connectionOptions)\n}\n";
|
||||
const configured = configureHardwareKeyboardAppDelegate(source);
|
||||
expect(configured).toContain("import PaseoHardwareKeyboard");
|
||||
expect(configured).toContain("PaseoHardwareKeyboardWindow.observing(window)");
|
||||
expect(configured.indexOf("super.scene")).toBeLessThan(
|
||||
configured.indexOf(".observing(window)"),
|
||||
);
|
||||
expect(configureHardwareKeyboardAppDelegate(configured)).toBe(configured);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
expectNewWorkspaceForAddedProject,
|
||||
expectAddProjectResultVisible,
|
||||
navigateAddProjectResults,
|
||||
navigateAndDismissAddProject,
|
||||
watchAddProjectRenderWarnings,
|
||||
openAddProjectFlow,
|
||||
openAddProjectHostSelection,
|
||||
} from "../support/helpers/add-project-flow";
|
||||
@@ -146,6 +148,7 @@ test.describe("Add Project command-center flow", () => {
|
||||
test("Escape and Back restore searchable page input before closing at the root", async ({
|
||||
page,
|
||||
}) => {
|
||||
const renderWarnings = watchAddProjectRenderWarnings(page);
|
||||
await gotoAppShell(page);
|
||||
await addConnectedHostAndReload(page, {
|
||||
serverId: secondaryHost.serverId,
|
||||
@@ -183,8 +186,9 @@ test.describe("Add Project command-center flow", () => {
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expectAddProjectPage(page, "host");
|
||||
await page.keyboard.press("Escape");
|
||||
await navigateAndDismissAddProject(page);
|
||||
await expect(addProjectFlow(page)).not.toBeVisible();
|
||||
expect(renderWarnings).toEqual([]);
|
||||
});
|
||||
|
||||
test("New directory creates a Project on the selected remote host", async ({ page }) => {
|
||||
|
||||
@@ -62,7 +62,7 @@ async function readProviderFeaturePreferences(page: Page, provider: string): Pro
|
||||
const raw = localStorage.getItem(preferencesKey);
|
||||
if (!raw) return null;
|
||||
const preferences = JSON.parse(raw) as FormPreferences;
|
||||
return preferences.providerPreferences?.[providerId]?.featureValues ?? null;
|
||||
return preferences.providerPreferences?.[providerId]?.options ?? null;
|
||||
},
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, providerId: provider },
|
||||
);
|
||||
@@ -145,7 +145,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
|
||||
provider: featureProviderId,
|
||||
model: featureModelId,
|
||||
modeId: "bypassPermissions",
|
||||
featureValues: { fast_mode: true },
|
||||
options: { fast_mode: true },
|
||||
},
|
||||
]);
|
||||
const provider = await seedModelProvider({
|
||||
@@ -181,7 +181,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
|
||||
expect(createAgentRequest).toMatchObject({
|
||||
config: {
|
||||
provider: featureProviderId,
|
||||
featureValues: { fast_mode: true },
|
||||
providerOptions: { fast_mode: true },
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
expectWorkspaceShownIdle,
|
||||
finishAndArchiveAgent,
|
||||
startBackgroundAgent,
|
||||
} from "../support/helpers/background-agents";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectSubagentRowGone,
|
||||
expectSubagentRowVisible,
|
||||
openSubagentsTrack,
|
||||
seedParentWithSubagent,
|
||||
seedSubagent,
|
||||
} from "../support/helpers/subagents";
|
||||
import { expectOnlyWorkspaceAgentTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
test.describe("Background agents", () => {
|
||||
let workspace: SeededWorkspace;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
workspace = await seedWorkspace({
|
||||
repoPrefix: "background-agents-",
|
||||
title: "Classifier workspace",
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("a background agent runs in an open workspace without announcing itself", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agents = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Feature agent",
|
||||
childTitle: "Visible helper",
|
||||
});
|
||||
await openAgentRoute(page, { workspaceId: agents.workspaceId, agentId: agents.parent.id });
|
||||
await openSubagentsTrack(page);
|
||||
await expectSubagentRowVisible(page, agents.child.id);
|
||||
|
||||
const classifier = await test.step("a plugin starts a background agent", () =>
|
||||
startBackgroundAgent(workspace, { parentId: agents.parent.id, title: "Classifier" }));
|
||||
// A visible child created after the classifier reaches the app on the same stream, so once
|
||||
// its row shows, the app has processed the classifier too.
|
||||
const marker = await seedSubagent(workspace, {
|
||||
parentId: agents.parent.id,
|
||||
title: "Ordering marker",
|
||||
});
|
||||
|
||||
await test.step("it stays out of the track, the tabs and the sidebar status", async () => {
|
||||
await expectSubagentRowVisible(page, marker.id);
|
||||
await expectSubagentRowGone(page, classifier.id);
|
||||
await expectOnlyWorkspaceAgentTabsVisible(page, [agents.parent.id]);
|
||||
await expectWorkspaceShownIdle(page, "Classifier workspace");
|
||||
});
|
||||
|
||||
await test.step("the plugin reads its finish and archives it", async () => {
|
||||
await finishAndArchiveAgent(workspace, classifier.id);
|
||||
await expectOnlyWorkspaceAgentTabsVisible(page, [agents.parent.id]);
|
||||
await expectSubagentRowGone(page, classifier.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
@@ -59,6 +60,14 @@ async function openCompactActionsMenu(page: Page, workspaceId: string) {
|
||||
return menu;
|
||||
}
|
||||
|
||||
async function expectActionsInOrder(menu: Locator, labels: readonly string[]) {
|
||||
const items = menu.getByRole("menuitem");
|
||||
await expect(items).toHaveCount(labels.length);
|
||||
for (const [index, label] of labels.entries()) {
|
||||
await expect(items.nth(index).getByText(label, { exact: true })).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
test("compact git actions menu lists a promoted Pull once", async ({ page }) => {
|
||||
const repoPath = await createRemoteRepo("compact-menu-pull-");
|
||||
cleanupTasks.push(async () => {
|
||||
@@ -83,12 +92,7 @@ test("compact git actions menu lists a promoted Pull once", async ({ page }) =>
|
||||
const menu = await openCompactActionsMenu(page, workspaceId);
|
||||
|
||||
await expect(menu.getByTestId("changes-menu-pull")).toHaveCount(1);
|
||||
await expect(menu.getByRole("menuitem")).toHaveText([
|
||||
"Pull",
|
||||
"Push",
|
||||
"Pull and push",
|
||||
"Archive workspace",
|
||||
]);
|
||||
await expectActionsInOrder(menu, ["Pull", "Push", "Pull and push", "Archive workspace"]);
|
||||
});
|
||||
|
||||
test("compact git actions menu lists a promoted Push once", async ({ page }) => {
|
||||
@@ -106,10 +110,5 @@ test("compact git actions menu lists a promoted Push once", async ({ page }) =>
|
||||
const menu = await openCompactActionsMenu(page, workspaceId);
|
||||
|
||||
await expect(menu.getByTestId("changes-menu-push")).toHaveCount(1);
|
||||
await expect(menu.getByRole("menuitem")).toHaveText([
|
||||
"Push",
|
||||
"Pull",
|
||||
"Pull and push",
|
||||
"Archive workspace",
|
||||
]);
|
||||
await expectActionsInOrder(menu, ["Push", "Pull", "Pull and push", "Archive workspace"]);
|
||||
});
|
||||
|
||||
@@ -429,6 +429,32 @@ test("context details support keyboard entry and dismiss when the viewport moves
|
||||
await expect(details).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("context details stay open while the chat streams", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "context-window-streaming-",
|
||||
title: "Context details while streaming",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await submitMessageWithButton(page, "Stream while the context details are open.");
|
||||
const stop = page.getByRole("button", { name: /stop|cancel/i }).first();
|
||||
await expect(stop).toBeVisible();
|
||||
const details = await pressContextWindowMeter(page, /^Context window/);
|
||||
await expect(stop).toBeVisible();
|
||||
await qaScreenshot(page, "context-details-while-streaming");
|
||||
|
||||
await expect(stop).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(details).toBeVisible();
|
||||
await qaScreenshot(page, "context-details-after-streaming");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("pointer hovering context details preserves composer focus", async ({ page, agent }) => {
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
|
||||
@@ -10,6 +10,7 @@ test("plugin header and composer buttons share actions, menus, content, and upda
|
||||
await buttons.verifyObservationLifetime();
|
||||
await buttons.openWideMenusAndPopovers();
|
||||
await buttons.runAndUpdateActions();
|
||||
await buttons.keepTrackSpaceWithoutPills();
|
||||
await buttons.openCompactSheets();
|
||||
await buttons.useCompactWorkspaceMenu();
|
||||
await buttons.hideAndUnloadOpenButtons();
|
||||
|
||||
@@ -16,6 +16,14 @@ import {
|
||||
returnAndReopenSettings,
|
||||
returnToPluginsPage,
|
||||
expectSettingsUnavailable,
|
||||
openPluginsPage,
|
||||
expectActionsMenuWithoutSettings,
|
||||
expectNoSettingsCogWithoutScreens,
|
||||
openTabbedSettings,
|
||||
selectSettingsTab,
|
||||
openTabbedSettingsLink,
|
||||
expectSettingsTooltip,
|
||||
expectNamedPluginRow,
|
||||
} from "../support/helpers/plugin-settings";
|
||||
|
||||
test("display preferences survive a reload", async ({ page }) => {
|
||||
@@ -49,6 +57,32 @@ test("Back to plugins returns from a plugin settings screen", async ({ page }) =
|
||||
await returnToPluginsPage(page);
|
||||
});
|
||||
|
||||
test("plugin settings open from the cog and show a tab per screen", async ({ page }) => {
|
||||
await openPluginsPage(page);
|
||||
await test.step("plugin rows show the display name", async () => {
|
||||
await expectNamedPluginRow(page);
|
||||
});
|
||||
await test.step("the cog explains itself", async () => {
|
||||
await expectSettingsTooltip(page);
|
||||
});
|
||||
await test.step("the actions menu keeps only plugin actions", async () => {
|
||||
await expectActionsMenuWithoutSettings(page);
|
||||
});
|
||||
await test.step("tabs switch between the plugin's settings screens", async () => {
|
||||
await openTabbedSettings(page);
|
||||
await selectSettingsTab(page, "Advanced");
|
||||
await selectSettingsTab(page, "General");
|
||||
await returnToPluginsPage(page);
|
||||
});
|
||||
await test.step("a plugin without settings screens shows no cog", async () => {
|
||||
await expectNoSettingsCogWithoutScreens(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("a settings screen link opens its tab", async ({ page }) => {
|
||||
await openTabbedSettingsLink(page, "Advanced");
|
||||
});
|
||||
|
||||
test.describe("compact plugin settings", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from "node:path";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
BOTS_PLUGIN_ID,
|
||||
COMPACT,
|
||||
@@ -176,6 +177,37 @@ test.describe("Plugin sidebar items", () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
test("a short compact sidebar keeps workspaces usable while navigation scrolls", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 600 });
|
||||
await gotoAppShell(page);
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||
await expect(botRow(page, "status")).toBeAttached();
|
||||
const workspace = visibleTestId(page, `sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
await expect(workspace).toBeInViewport({ ratio: 1 });
|
||||
|
||||
await test.step("scrolling navigation reaches its last row without moving workspaces", async () => {
|
||||
const before = await boxOf(workspace);
|
||||
await visibleTestId(page, "sidebar-global-new-workspace").hover();
|
||||
await page.mouse.wheel(0, 800);
|
||||
await expect(botRow(page, "status")).toBeInViewport({ ratio: 1 });
|
||||
await expect(workspace).toBeInViewport({ ratio: 1 });
|
||||
expect((await boxOf(workspace)).y).toBe(before.y);
|
||||
await botRow(page, "status").click();
|
||||
await expect(page.getByText("Bot status details", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Close bot status", exact: true }).click();
|
||||
});
|
||||
|
||||
await test.step("the workspace still opens and closes the sidebar", async () => {
|
||||
await workspace.click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Open menu", exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(workspace).not.toBeInViewport();
|
||||
});
|
||||
});
|
||||
|
||||
test("a restored plugin screen waits for its catalog before rendering", async ({ page }) => {
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
movePointerAwayFromWorkspaceHoverCard,
|
||||
openMobileAgentSidebar,
|
||||
pinWorkspaceFromSidebar,
|
||||
scrollWorkspaceList,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
|
||||
@@ -582,6 +583,12 @@ test.describe("Half-screen desktop layout", () => {
|
||||
await page.getByTestId("menu-button").click();
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
await expect(sidebarScroll).toHaveJSProperty("scrollTop", scrollTop);
|
||||
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await focusWorkspaceRowWithKeyboard(page, lastWorkspaceId);
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
await scrollWorkspaceList(page);
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -117,3 +117,25 @@ export async function expectAddProjectResultVisible(page: Page, text: string): P
|
||||
export async function chooseAddHostFromProjectFlow(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: /Add host.*No connected hosts/ }).click();
|
||||
}
|
||||
|
||||
export async function navigateAndDismissAddProject(page: Page): Promise<void> {
|
||||
// Keep both keys in one task so Escape closes with a selection update still queued.
|
||||
await page.evaluate(() => {
|
||||
for (const key of ["ArrowDown", "Escape"]) {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, code: key, bubbles: true, cancelable: true }),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function watchAddProjectRenderWarnings(page: Page): string[] {
|
||||
const warnings: string[] = [];
|
||||
page.on("console", (message) => {
|
||||
const text = message.text();
|
||||
if (text.includes("Cannot update a component") && text.includes("AddProjectFlow")) {
|
||||
warnings.push(text);
|
||||
}
|
||||
});
|
||||
return warnings;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import type { SeededWorkspace } from "./seed-client";
|
||||
|
||||
/**
|
||||
* Starts a background agent the way a plugin helper does: in the workspace, under a parent,
|
||||
* streaming for about ten seconds. Resolves once the daemon reports it running.
|
||||
*/
|
||||
export async function startBackgroundAgent(
|
||||
workspace: Pick<SeededWorkspace, "client" | "repoPath" | "workspaceId">,
|
||||
input: { parentId: string; title: string },
|
||||
): Promise<{ id: string }> {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: input.title,
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: "classify the last turn",
|
||||
labels: { [PARENT_AGENT_ID_LABEL]: input.parentId },
|
||||
background: true,
|
||||
});
|
||||
await workspace.client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running");
|
||||
return { id: agent.id };
|
||||
}
|
||||
|
||||
/** Waits for the agent's turn to end and archives it, as the plugin does with a helper's reply. */
|
||||
export async function finishAndArchiveAgent(
|
||||
workspace: Pick<SeededWorkspace, "client">,
|
||||
agentId: string,
|
||||
): Promise<void> {
|
||||
const finished = await workspace.client.waitForFinish(agentId, 60_000);
|
||||
expect(finished.status).toBe("idle");
|
||||
await workspace.client.archiveAgent(agentId);
|
||||
}
|
||||
|
||||
export async function expectWorkspaceShownIdle(page: Page, workspaceName: string): Promise<void> {
|
||||
const row = page.getByRole("button", {
|
||||
name: new RegExp(`^${escapeRegExp(workspaceName)}(?:,|$)`),
|
||||
});
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row.getByTestId("workspace-status-indicator-running")).toHaveCount(0);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -54,12 +54,15 @@ export async function leaveContextWindowMeter(page: Page): Promise<void> {
|
||||
await expect(contextWindowDetails(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Compact screens open the meter's details in a sheet. */
|
||||
export async function pressContextWindowMeter(page: Page, name = METER_NAME): Promise<Locator> {
|
||||
/** Clicking the meter opens its details in the active layout. */
|
||||
export async function pressContextWindowMeter(
|
||||
page: Page,
|
||||
name: string | RegExp = METER_NAME,
|
||||
): Promise<Locator> {
|
||||
await page.getByRole("button", { name, exact: true }).click({ timeout: 30_000 });
|
||||
const sheet = contextWindowDetails(page);
|
||||
await expect(sheet).toBeVisible();
|
||||
return sheet;
|
||||
const details = contextWindowDetails(page);
|
||||
await expect(details).toBeVisible();
|
||||
return details;
|
||||
}
|
||||
|
||||
export async function closeContextWindowSheet(page: Page): Promise<void> {
|
||||
|
||||
@@ -4,6 +4,9 @@ import path from "node:path";
|
||||
import type { Locator, TestInfo } from "@playwright/test";
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { scrollAgentChatToBottom, waitForScrollableChat } from "./agent-bottom-anchor";
|
||||
import { expectTurnCopyButton } from "./agent-stream";
|
||||
import { submitMessage } from "./composer";
|
||||
import { buildAgentRoute } from "./mock-agent";
|
||||
import { openCommandCenter } from "./command-center";
|
||||
import {
|
||||
@@ -88,7 +91,7 @@ export default function contribute(client) {
|
||||
header("locked", { title: "Production locked", icon: "Lock", disabled: true, behavior: { kind: "action", onPress() {} } });
|
||||
const review = pill("review", { title: "Run review", icon: "Scan", label: "Review", behavior: { kind: "action", async onPress() { await new Promise((resolve) => { finishReview = resolve; }); review.update({ label: "Reviewed" }); } } });
|
||||
const pillMenu = pill("checks", { title: "Composer checks", icon: "ListChecks", label: "Checks", behavior: menu });
|
||||
pill("usage", { title: "Composer status", icon: StatusIcon, label: "Status", behavior: { kind: "popover", Content: Details } });
|
||||
const usage = pill("usage", { title: "Composer status", icon: StatusIcon, label: "Status", behavior: { kind: "popover", Content: Details } });
|
||||
const command = (id, title, onSelect) => client.addCommandCenterItem({ id, title, icon: "Settings", context: "workspace", onSelect });
|
||||
command("finish", "Finish deployment", () => finish());
|
||||
command("finish-review", "Finish review", () => finishReview());
|
||||
@@ -102,10 +105,16 @@ export default function contribute(client) {
|
||||
command("hide-menu", "Hide composer menu", () => pillMenu.update({ visible: false }));
|
||||
command("show-menu", "Show composer menu", () => pillMenu.update({ visible: true }));
|
||||
const lifetime = new AbortController();
|
||||
let pillsHidden = false;
|
||||
const applyWorkspace = (workspace) => {
|
||||
if (workspace.id !== workspaceId) return;
|
||||
const hidePills = workspace.name === "No composer pills";
|
||||
deploy.update({ visible: workspace.name !== "Compact checks" });
|
||||
pillMenu.update({ visible: workspace.name !== "Hide composer menu" });
|
||||
pillMenu.update({ visible: workspace.name !== "Hide composer menu" && !hidePills });
|
||||
if (hidePills === pillsHidden) return;
|
||||
pillsHidden = hidePills;
|
||||
review.update({ visible: !hidePills });
|
||||
usage.update({ visible: !hidePills });
|
||||
};
|
||||
void client.paseo.workspaces.list({ subscribe: {}, signal: lifetime.signal }).then(({ subscription }) => {
|
||||
subscription.subscribe({
|
||||
@@ -244,6 +253,38 @@ async function captureSchemes(page: Page, info: TestInfo, name: string, subject:
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
}
|
||||
|
||||
const COMPOSER_PILLS = ["Run review", "Composer checks", "Composer status"] as const;
|
||||
|
||||
function chevronOf(page: Page, title: string): Locator {
|
||||
return page
|
||||
.getByRole("button", { name: title, exact: true })
|
||||
.getByTestId("plugin-button-chevron");
|
||||
}
|
||||
|
||||
/** Composer pills that open a menu or popover carry a chevron; a one-tap action does not. */
|
||||
async function expectComposerPillChevrons(page: Page) {
|
||||
await expect(chevronOf(page, "Composer checks")).toHaveCount(1);
|
||||
await expect(chevronOf(page, "Composer status")).toHaveCount(1);
|
||||
await expect(chevronOf(page, "Run review")).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function setComposerPillsShown(
|
||||
page: Page,
|
||||
showcase: { setTitle(title: string): Promise<unknown> },
|
||||
shown: boolean,
|
||||
) {
|
||||
await showcase.setTitle(shown ? "Button showcase" : "No composer pills");
|
||||
for (const title of COMPOSER_PILLS) {
|
||||
await expect(page.getByRole("button", { name: title, exact: true })).toHaveCount(shown ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
async function lastTurnTop(page: Page): Promise<number> {
|
||||
const box = await page.getByRole("button", { name: "Copy turn" }).last().boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
return box!.y;
|
||||
}
|
||||
|
||||
async function expectHeaderTextTruncated(page: Page) {
|
||||
const title = page.getByTestId("workspace-header-title");
|
||||
const subtitle = page.getByTestId("workspace-header-subtitle");
|
||||
@@ -276,6 +317,7 @@ export async function withButtonShowcase(
|
||||
run: (buttons: {
|
||||
openWideMenusAndPopovers(): Promise<void>;
|
||||
runAndUpdateActions(): Promise<void>;
|
||||
keepTrackSpaceWithoutPills(): Promise<void>;
|
||||
openCompactSheets(): Promise<void>;
|
||||
useCompactWorkspaceMenu(): Promise<void>;
|
||||
hideAndUnloadOpenButtons(): Promise<void>;
|
||||
@@ -348,11 +390,7 @@ export async function withButtonShowcase(
|
||||
.getByRole("button", { name: "Header checks", exact: true })
|
||||
.getByTestId("plugin-button-chevron"),
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
page
|
||||
.getByRole("button", { name: "Composer checks", exact: true })
|
||||
.getByTestId("plugin-button-chevron"),
|
||||
).toHaveCount(0);
|
||||
await expectComposerPillChevrons(page);
|
||||
await expectSurfaceVisible(page);
|
||||
await captureSchemes(
|
||||
page,
|
||||
@@ -476,10 +514,31 @@ export async function withButtonShowcase(
|
||||
"Review ready",
|
||||
);
|
||||
}),
|
||||
keepTrackSpaceWithoutPills: () =>
|
||||
test.step("the composer track keeps its space when every pill leaves and returns", async () => {
|
||||
await page.setViewportSize(WIDE);
|
||||
await submitMessage(page, "Stream a turn under the composer pills");
|
||||
await expectTurnCopyButton(page);
|
||||
await waitForScrollableChat(page, { minScrollableDistance: 200, timeout: 30_000 });
|
||||
for (const viewport of [WIDE, COMPACT]) {
|
||||
await page.setViewportSize(viewport);
|
||||
await scrollAgentChatToBottom(page);
|
||||
const top = await lastTurnTop(page);
|
||||
await setComposerPillsShown(page, showcase, false);
|
||||
expect(await lastTurnTop(page)).toBe(top);
|
||||
await setComposerPillsShown(page, showcase, true);
|
||||
expect(await lastTurnTop(page)).toBe(top);
|
||||
}
|
||||
await page.setViewportSize(WIDE);
|
||||
}),
|
||||
openCompactSheets: () =>
|
||||
test.step("compact header leaves plugin actions to the workspace menu while composer triggers open sheets", async () => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expectComposerPillChevrons(page);
|
||||
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(2);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Composer checks", exact: true }),
|
||||
).toContainText("Checks");
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { copyPluginExample } from "./plugin-fixture";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
|
||||
import { expect, test as base, type Page } from "../fixtures";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { goBackInSettings, openCompactSettings, openHostSection } from "./settings";
|
||||
@@ -9,6 +12,54 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
|
||||
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
|
||||
const TABBED_PLUGIN_ID = "settings-tabs";
|
||||
const TABBED_PLUGIN_NAME = "Tabbed settings";
|
||||
/** Two settings screens, so the settings page shows a tab per screen. */
|
||||
const TABBED_SOURCE = `import React from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { defineTranslations, useTranslation } from "@getpaseo/plugin/client";
|
||||
|
||||
const messages = defineTranslations({ resources: { en: { icon: "advanced-tab-icon" } } });
|
||||
|
||||
function General({ theme }) {
|
||||
return <Text style={{ color: theme.colors.foreground }}>General settings body</Text>;
|
||||
}
|
||||
|
||||
function Advanced({ theme }) {
|
||||
return <Text style={{ color: theme.colors.foreground }}>Advanced settings body</Text>;
|
||||
}
|
||||
|
||||
// Component icons run with the plugin's runtime; one that throws costs its tab only the icon.
|
||||
function TranslatedIcon({ color, size }) {
|
||||
const { t } = useTranslation(messages);
|
||||
return <View testID={t("icon")} aria-hidden style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: color }} />;
|
||||
}
|
||||
|
||||
function BrokenIcon() {
|
||||
throw new Error("Settings tab icon exploded");
|
||||
}
|
||||
|
||||
export default function contribute(client) {
|
||||
client.addSettingsScreen({ id: "general", title: "General", icon: BrokenIcon, Component: General });
|
||||
client.addSettingsScreen({ id: "advanced", title: "Advanced", icon: TranslatedIcon, Component: Advanced });
|
||||
return () => {};
|
||||
}
|
||||
`;
|
||||
|
||||
async function writeTabbedPlugin() {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), `paseo-${TABBED_PLUGIN_ID}-`));
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: TABBED_PLUGIN_ID,
|
||||
name: TABBED_PLUGIN_NAME,
|
||||
requirements: pluginRequirements,
|
||||
}),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), TABBED_SOURCE);
|
||||
return { directory, cleanup: () => rm(directory, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
export const test = base.extend<{ settingsPlugin: SettingsClient }>({
|
||||
settingsPlugin: [
|
||||
async ({ e2eWorker, page }, provide, testInfo) => {
|
||||
@@ -16,16 +67,20 @@ export const test = base.extend<{ settingsPlugin: SettingsClient }>({
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const example = await copyPluginExample("settings");
|
||||
const tabbed = await writeTabbedPlugin();
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
await client.installDirectoryPlugin(tabbed.directory);
|
||||
await provide(client);
|
||||
await page.screenshot({ path: testInfo.outputPath("settings.png") });
|
||||
} finally {
|
||||
await client.removePlugin("settings-example");
|
||||
await client.removePlugin(TABBED_PLUGIN_ID);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await example.cleanup();
|
||||
await tabbed.cleanup();
|
||||
}
|
||||
},
|
||||
{ auto: true },
|
||||
@@ -44,9 +99,88 @@ export async function openCompactDisplaySettings(page: Page) {
|
||||
}
|
||||
async function openPluginScreen(page: Page) {
|
||||
await openHostSection(page, getServerId(), "plugins");
|
||||
await page.getByRole("button", { name: "Actions for settings-example", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
|
||||
await openSettingsFromCog(page);
|
||||
}
|
||||
/** The cog on a plugin's row. Its label is "Settings"; the row tells plugins apart. */
|
||||
function settingsButton(page: Page, pluginId: string) {
|
||||
return page
|
||||
.getByTestId(`plugin-row-${pluginId}`)
|
||||
.filter({ visible: true })
|
||||
.getByRole("button", { name: "Settings", exact: true });
|
||||
}
|
||||
async function openSettingsFromCog(page: Page) {
|
||||
await settingsButton(page, "settings-example").click();
|
||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
|
||||
// A plugin with one settings screen shows no tabs, and the header names only the plugin.
|
||||
await expect(page.getByTestId("plugin-settings-tabs")).toHaveCount(0);
|
||||
await expectSettingsHeader(page, "settings-example");
|
||||
}
|
||||
export async function openPluginsPage(page: Page) {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openHostSection(page, getServerId(), "plugins");
|
||||
}
|
||||
export async function expectActionsMenuWithoutSettings(page: Page) {
|
||||
await page.getByRole("button", { name: "Actions for settings-example", exact: true }).click();
|
||||
await expect(page.getByRole("menuitem", { name: "Reload", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: "Display", exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
export async function expectNoSettingsCogWithoutScreens(page: Page) {
|
||||
await page.getByRole("switch", { name: "settings-example: Disable" }).click();
|
||||
await expect(page.getByRole("switch", { name: "settings-example: Enable" })).toBeVisible();
|
||||
await expect(settingsButton(page, "settings-example")).toHaveCount(0);
|
||||
await page.getByRole("switch", { name: "settings-example: Enable" }).click();
|
||||
await expect(settingsButton(page, "settings-example")).toBeVisible();
|
||||
}
|
||||
export async function expectSettingsTooltip(page: Page) {
|
||||
await settingsButton(page, "settings-example").hover();
|
||||
await expect(page.getByRole("tooltip")).toHaveText("Settings");
|
||||
await page.mouse.move(0, 0);
|
||||
}
|
||||
/** A manifest `name` titles the row and its controls; the id stays visible beneath it. */
|
||||
export async function expectNamedPluginRow(page: Page) {
|
||||
const row = page.getByTestId(`plugin-row-${TABBED_PLUGIN_ID}`).filter({ visible: true });
|
||||
await expect(row.getByText(TABBED_PLUGIN_NAME, { exact: true })).toBeVisible();
|
||||
await expect(row.getByText(TABBED_PLUGIN_ID, { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("switch", { name: `${TABBED_PLUGIN_NAME}: Disable`, exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: `Actions for ${TABBED_PLUGIN_NAME}`, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
export async function openTabbedSettings(page: Page) {
|
||||
await settingsButton(page, TABBED_PLUGIN_ID).click();
|
||||
await expectSettingsTab(page, "General");
|
||||
// The Advanced icon reads the plugin's translations; the General icon throws and is dropped.
|
||||
await expect(page.getByTestId("advanced-tab-icon")).toBeVisible();
|
||||
}
|
||||
function settingsTab(page: Page, title: string) {
|
||||
return page.getByTestId("plugin-settings-tabs").getByRole("button", { name: title, exact: true });
|
||||
}
|
||||
export async function selectSettingsTab(page: Page, title: "General" | "Advanced") {
|
||||
await settingsTab(page, title).click();
|
||||
await expectSettingsTab(page, title);
|
||||
}
|
||||
export async function expectSettingsTab(page: Page, title: "General" | "Advanced") {
|
||||
const other = title === "General" ? "Advanced" : "General";
|
||||
await expect(settingsTab(page, title)).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.getByText(`${title} settings body`, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(`${other} settings body`, { exact: true })).toHaveCount(0);
|
||||
// The header names the plugin by its manifest name; the tab names the screen.
|
||||
await expectSettingsHeader(page, TABBED_PLUGIN_NAME);
|
||||
await expectAppRoute(
|
||||
page,
|
||||
`${buildSettingsHostSectionRoute(getServerId(), "plugins")}/${TABBED_PLUGIN_ID}/${title.toLowerCase()}`,
|
||||
);
|
||||
}
|
||||
export async function openTabbedSettingsLink(page: Page, title: "General" | "Advanced") {
|
||||
await gotoAppShell(page);
|
||||
await page.goto(
|
||||
`${buildSettingsHostSectionRoute(getServerId(), "plugins")}/${TABBED_PLUGIN_ID}/${title.toLowerCase()}`,
|
||||
);
|
||||
await expectSettingsTab(page, title);
|
||||
}
|
||||
export async function groupByWorkspace(page: Page) {
|
||||
await page.getByRole("button", { name: /^Group agents by:/ }).click();
|
||||
@@ -101,11 +235,7 @@ export async function discardTitle(page: Page) {
|
||||
}
|
||||
export async function returnAndReopenSettings(page: Page) {
|
||||
await goBackInSettings(page);
|
||||
const actions = page.getByRole("button", { name: "Actions for settings-example", exact: true });
|
||||
await expect(actions).toBeVisible();
|
||||
await actions.click();
|
||||
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
|
||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
|
||||
await openSettingsFromCog(page);
|
||||
}
|
||||
export async function returnToPluginsPage(page: Page) {
|
||||
await page.getByRole("button", { name: "Back to plugins", exact: true }).click();
|
||||
@@ -114,6 +244,10 @@ export async function returnToPluginsPage(page: Page) {
|
||||
page.getByRole("button", { name: "Actions for settings-example", exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
async function expectSettingsHeader(page: Page, title: string) {
|
||||
// Settings keeps the Plugins page mounted beneath, so read the visible title only.
|
||||
await expect(page.getByText(title, { exact: true }).filter({ visible: true })).toHaveCount(1);
|
||||
}
|
||||
export async function expectSettingsUnavailable(page: Page) {
|
||||
await expect(
|
||||
page.getByText("This plugin settings screen is unavailable.", { exact: true }),
|
||||
|
||||
@@ -118,6 +118,7 @@ export interface SeedDaemonClient {
|
||||
featureValues?: Record<string, unknown>;
|
||||
initialPrompt?: string;
|
||||
labels?: Record<string, string>;
|
||||
background?: boolean;
|
||||
}): Promise<{ id: string; status: string }>;
|
||||
fetchAgents(options?: { scope?: "active" }): Promise<{
|
||||
entries: Array<{
|
||||
|
||||
@@ -71,6 +71,15 @@ export async function focusWorkspaceRowWithKeyboard(
|
||||
await expect(row).toBeFocused();
|
||||
}
|
||||
|
||||
export async function scrollWorkspaceList(page: Page): Promise<void> {
|
||||
const list = page.getByTestId("sidebar-project-workspace-list-scroll");
|
||||
const before = await list.evaluate((element) => element.scrollTop);
|
||||
await list.evaluate((element) =>
|
||||
element.scrollBy({ top: element.scrollTop > 0 ? -40 : 40, behavior: "instant" }),
|
||||
);
|
||||
await expect.poll(() => list.evaluate((element) => element.scrollTop)).not.toBe(before);
|
||||
}
|
||||
|
||||
export async function expectWorkspaceHoverCardOpen(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-hover-card")).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
apply plugin: 'com.android.library'
|
||||
apply plugin: 'expo-module-gradle-plugin'
|
||||
|
||||
group = 'sh.paseo.keyboard'
|
||||
version = '0.1.0'
|
||||
android {
|
||||
namespace 'sh.paseo.keyboard'
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName '0.1.0'
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
implementation 'com.facebook.react:react-android'
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package sh.paseo.keyboard
|
||||
|
||||
import android.view.KeyCharacterMap
|
||||
|
||||
internal fun hardwareCharacterKey(character: Int): String =
|
||||
if (character and KeyCharacterMap.COMBINING_ACCENT != 0) "Dead"
|
||||
else String(Character.toChars(character))
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
package sh.paseo.keyboard
|
||||
|
||||
import android.content.ComponentCallbacks
|
||||
import android.content.res.Configuration
|
||||
import android.hardware.input.InputManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.Window
|
||||
import android.widget.EditText
|
||||
import android.view.inputmethod.BaseInputConnection
|
||||
import com.facebook.react.bridge.UiThreadUtil
|
||||
import com.facebook.react.bridge.ReactContext
|
||||
import com.facebook.react.interfaces.ExtraWindowEventListener
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
internal object HardwareKeyboard {
|
||||
var module: PaseoHardwareKeyboardModule? = null
|
||||
@Volatile var overlayKeys: Set<String> = emptySet()
|
||||
private val windows = mutableMapOf<Window, KeyboardWindowCallback>()
|
||||
val dispatcher = WindowKeyDispatcher<KeyEvent>()
|
||||
private val consumedPresses = mutableSetOf<Int>()
|
||||
|
||||
fun observe(window: Window) {
|
||||
if (windows.containsKey(window)) return
|
||||
val callback = KeyboardWindowCallback(window, window.callback)
|
||||
windows[window] = callback
|
||||
window.callback = callback
|
||||
}
|
||||
fun release(window: Window) {
|
||||
val callback = windows.remove(window) ?: return
|
||||
if (window.callback === callback) window.callback = callback.previous
|
||||
}
|
||||
fun releaseAll() {
|
||||
windows.keys.toList().forEach { release(it) }
|
||||
consumedPresses.clear()
|
||||
}
|
||||
|
||||
fun dispatch(event: KeyEvent, composing: Boolean): Boolean {
|
||||
if (event.flags and KeyEvent.FLAG_SOFT_KEYBOARD != 0) return false
|
||||
if (event.action != KeyEvent.ACTION_DOWN && event.action != KeyEvent.ACTION_UP) return false
|
||||
val code = code(event.keyCode)
|
||||
if (code.isEmpty()) return false
|
||||
val textModifiers = event.metaState and KeyEvent.META_CTRL_MASK.inv() and KeyEvent.META_META_MASK.inv()
|
||||
val character = event.getUnicodeChar(textModifiers)
|
||||
val named = !code.startsWith("Key") && !code.startsWith("Digit")
|
||||
val key = when {
|
||||
code.startsWith("Control") -> "Control"
|
||||
code.startsWith("Meta") -> "Meta"
|
||||
code.startsWith("Shift") -> "Shift"
|
||||
code.startsWith("Alt") -> "Alt"
|
||||
named && (character == 0 || code == "Enter" || code == "Tab" || code == "Backspace") -> code
|
||||
character != 0 -> hardwareCharacterKey(character)
|
||||
else -> code
|
||||
}
|
||||
module?.emit(mapOf(
|
||||
"key" to key, "code" to code,
|
||||
"altKey" to event.isAltPressed, "ctrlKey" to event.isCtrlPressed,
|
||||
"metaKey" to event.isMetaPressed, "shiftKey" to event.isShiftPressed,
|
||||
"repeat" to (event.repeatCount > 0), "isComposing" to composing,
|
||||
"phase" to if (event.action == KeyEvent.ACTION_UP) "up" else "down"
|
||||
))
|
||||
if (event.action == KeyEvent.ACTION_UP) return consumedPresses.remove(event.keyCode)
|
||||
val overlayOwnsKey = key != "Dead" && key in overlayKeys && !event.isCtrlPressed && !event.isMetaPressed && !event.isAltPressed && !composing
|
||||
if (overlayOwnsKey) consumedPresses.add(event.keyCode)
|
||||
return overlayOwnsKey
|
||||
}
|
||||
|
||||
private fun code(keyCode: Int): String = when (keyCode) {
|
||||
in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z -> "Key" + ('A'.code + keyCode - KeyEvent.KEYCODE_A).toChar()
|
||||
in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 -> "Digit" + (keyCode - KeyEvent.KEYCODE_0)
|
||||
in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12 -> "F" + (keyCode - KeyEvent.KEYCODE_F1 + 1)
|
||||
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> "Enter"
|
||||
KeyEvent.KEYCODE_ESCAPE -> "Escape"
|
||||
KeyEvent.KEYCODE_DEL -> "Backspace"
|
||||
KeyEvent.KEYCODE_FORWARD_DEL -> "Delete"
|
||||
KeyEvent.KEYCODE_TAB -> "Tab"
|
||||
KeyEvent.KEYCODE_SPACE -> "Space"
|
||||
KeyEvent.KEYCODE_DPAD_UP -> "ArrowUp"
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> "ArrowDown"
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> "ArrowLeft"
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> "ArrowRight"
|
||||
KeyEvent.KEYCODE_CTRL_LEFT -> "ControlLeft"
|
||||
KeyEvent.KEYCODE_CTRL_RIGHT -> "ControlRight"
|
||||
KeyEvent.KEYCODE_META_LEFT -> "MetaLeft"
|
||||
KeyEvent.KEYCODE_META_RIGHT -> "MetaRight"
|
||||
KeyEvent.KEYCODE_SHIFT_LEFT -> "ShiftLeft"
|
||||
KeyEvent.KEYCODE_SHIFT_RIGHT -> "ShiftRight"
|
||||
KeyEvent.KEYCODE_ALT_LEFT -> "AltLeft"
|
||||
KeyEvent.KEYCODE_ALT_RIGHT -> "AltRight"
|
||||
KeyEvent.KEYCODE_COMMA -> "Comma"
|
||||
KeyEvent.KEYCODE_PERIOD -> "Period"
|
||||
KeyEvent.KEYCODE_MINUS -> "Minus"
|
||||
KeyEvent.KEYCODE_EQUALS -> "Equal"
|
||||
KeyEvent.KEYCODE_LEFT_BRACKET -> "BracketLeft"
|
||||
KeyEvent.KEYCODE_RIGHT_BRACKET -> "BracketRight"
|
||||
KeyEvent.KEYCODE_BACKSLASH -> "Backslash"
|
||||
KeyEvent.KEYCODE_SEMICOLON -> "Semicolon"
|
||||
KeyEvent.KEYCODE_APOSTROPHE -> "Quote"
|
||||
KeyEvent.KEYCODE_GRAVE -> "Backquote"
|
||||
KeyEvent.KEYCODE_SLASH -> "Slash"
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
private class KeyboardWindowCallback(private val window: Window, val previous: Window.Callback) : Window.Callback by previous {
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
val editable = (window.currentFocus as? EditText)?.text
|
||||
val composing = editable != null && BaseInputConnection.getComposingSpanStart(editable) >= 0
|
||||
return HardwareKeyboard.dispatcher.dispatch(event, { HardwareKeyboard.dispatch(event, composing) }, { previous.dispatchKeyEvent(event) })
|
||||
}
|
||||
}
|
||||
|
||||
class PaseoHardwareKeyboardModule : Module(), ExtraWindowEventListener, InputManager.InputDeviceListener, ComponentCallbacks {
|
||||
private var inputManager: InputManager? = null
|
||||
private var registeredReactContext: ReactContext? = null
|
||||
private fun keyboardPresent(): Boolean {
|
||||
val config = registeredReactContext?.resources?.configuration
|
||||
return (config?.keyboard == Configuration.KEYBOARD_QWERTY && config.hardKeyboardHidden != Configuration.HARDKEYBOARDHIDDEN_YES) ||
|
||||
InputDevice.getDeviceIds().any { id -> InputDevice.getDevice(id)?.let { !it.isVirtual && it.keyboardType == InputDevice.KEYBOARD_TYPE_ALPHABETIC } == true }
|
||||
}
|
||||
private fun presenceChanged() { sendEvent("onHardwareKeyboardPresenceChanged", emptyMap<String, Any>()) }
|
||||
override fun onInputDeviceAdded(id: Int) { presenceChanged() }
|
||||
override fun onInputDeviceRemoved(id: Int) { presenceChanged() }
|
||||
override fun onInputDeviceChanged(id: Int) { presenceChanged() }
|
||||
override fun onConfigurationChanged(config: Configuration) { presenceChanged() }
|
||||
override fun onLowMemory() {}
|
||||
override fun onExtraWindowCreate(window: Window) { HardwareKeyboard.observe(window) }
|
||||
override fun onExtraWindowDestroy(window: Window) { HardwareKeyboard.release(window) }
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("PaseoHardwareKeyboard")
|
||||
Events("onHardwareKeyboardShortcut", "onHardwareKeyboardPresenceChanged")
|
||||
OnCreate {
|
||||
HardwareKeyboard.module = this@PaseoHardwareKeyboardModule
|
||||
val context = appContext.reactContext
|
||||
require(context is ReactContext) { "Hardware keyboard observation requires a React context" }
|
||||
registeredReactContext = context
|
||||
context.registerComponentCallbacks(this@PaseoHardwareKeyboardModule)
|
||||
inputManager = context.getSystemService(InputManager::class.java)
|
||||
inputManager?.registerInputDeviceListener(this@PaseoHardwareKeyboardModule, Handler(Looper.getMainLooper()))
|
||||
context.addExtraWindowEventListener(this@PaseoHardwareKeyboardModule)
|
||||
UiThreadUtil.runOnUiThread { appContext.currentActivity?.window?.let { HardwareKeyboard.observe(it) } }
|
||||
}
|
||||
OnDestroy {
|
||||
inputManager?.unregisterInputDeviceListener(this@PaseoHardwareKeyboardModule)
|
||||
inputManager = null
|
||||
registeredReactContext?.unregisterComponentCallbacks(this@PaseoHardwareKeyboardModule)
|
||||
registeredReactContext?.removeExtraWindowEventListener(this@PaseoHardwareKeyboardModule)
|
||||
registeredReactContext = null
|
||||
if (HardwareKeyboard.module === this@PaseoHardwareKeyboardModule) {
|
||||
HardwareKeyboard.module = null
|
||||
HardwareKeyboard.overlayKeys = emptySet()
|
||||
UiThreadUtil.runOnUiThread { if (HardwareKeyboard.module == null) HardwareKeyboard.releaseAll() }
|
||||
}
|
||||
}
|
||||
Function("getHardwareKeyboardPresent") { keyboardPresent() }
|
||||
Function("setOverlayKeys") { keys: List<String> ->
|
||||
HardwareKeyboard.overlayKeys = keys.filter { it in setOf("Escape", "Enter", "ArrowUp", "ArrowDown") }.toSet()
|
||||
}
|
||||
}
|
||||
internal fun emit(event: Map<String, Any>) { sendEvent("onHardwareKeyboardShortcut", event) }
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package sh.paseo.keyboard
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import expo.modules.core.interfaces.Package
|
||||
import expo.modules.core.interfaces.ReactActivityLifecycleListener
|
||||
|
||||
class PaseoHardwareKeyboardPackage : Package {
|
||||
override fun createReactActivityLifecycleListeners(activityContext: Context): List<ReactActivityLifecycleListener> = listOf(
|
||||
object : ReactActivityLifecycleListener {
|
||||
override fun onCreate(activity: Activity, savedInstanceState: Bundle?) { HardwareKeyboard.observe(activity.window) }
|
||||
override fun onResume(activity: Activity) { HardwareKeyboard.observe(activity.window) }
|
||||
override fun onDestroy(activity: Activity) { HardwareKeyboard.release(activity.window) }
|
||||
}
|
||||
)
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package sh.paseo.keyboard
|
||||
|
||||
// A modal can forward the same physical event into the activity window. Keep
|
||||
// observation outside that nested delivery, without retaining recycled KeyEvents.
|
||||
internal class WindowKeyDispatcher<Event : Any> {
|
||||
private val delivering = java.util.Collections.newSetFromMap(java.util.IdentityHashMap<Event, Boolean>())
|
||||
fun dispatch(event: Event, observe: () -> Boolean, deliver: () -> Boolean): Boolean {
|
||||
if (!delivering.add(event)) return deliver()
|
||||
return try { observe() || deliver() } finally { delivering.remove(event) }
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package sh.paseo.keyboard
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
class HardwareCharacterKeyTest {
|
||||
@Test fun deadAccentUsesDomKeyWithoutThrowing() {
|
||||
assertEquals("Dead", hardwareCharacterKey(Int.MIN_VALUE or 0xB4))
|
||||
}
|
||||
@Test fun ordinaryUnicodeRemainsText() {
|
||||
assertEquals("é", hardwareCharacterKey(0xE9))
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package sh.paseo.keyboard
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class WindowKeyDispatcherTest {
|
||||
@Test fun observesOnePressAcrossNestedWindows() {
|
||||
val dispatcher = WindowKeyDispatcher<Any>()
|
||||
val event = Any()
|
||||
var observations = 0
|
||||
var deliveries = 0
|
||||
dispatcher.dispatch(event, { observations++; false }, {
|
||||
dispatcher.dispatch(event, { observations++; false }, { deliveries++; false })
|
||||
})
|
||||
assertEquals(1, observations)
|
||||
assertEquals(1, deliveries)
|
||||
dispatcher.dispatch(event, { observations++; false }, { false })
|
||||
assertEquals(2, observations)
|
||||
}
|
||||
@Test fun consumedOverlayKeyDoesNotReachTheView() {
|
||||
val dispatcher = WindowKeyDispatcher<Any>()
|
||||
var deliveries = 0
|
||||
assertTrue(dispatcher.dispatch(Any(), { true }, { deliveries++; false }))
|
||||
assertEquals(0, deliveries)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
const { withAppDelegate } = require("expo/config-plugins");
|
||||
const SCENE_START = "super.scene(scene, willConnectTo: session, options: connectionOptions)";
|
||||
|
||||
function configureHardwareKeyboardAppDelegate(source) {
|
||||
source = source.replace(
|
||||
/^import PaseoHardwareKeyboard$/m,
|
||||
"internal import PaseoHardwareKeyboard",
|
||||
);
|
||||
if (source.includes("PaseoHardwareKeyboardWindow.observing(window)")) return source;
|
||||
if (!source.includes(SCENE_START)) {
|
||||
throw new Error("Hardware keyboard observation requires the app's Expo scene delegate");
|
||||
}
|
||||
const installation = `${SCENE_START}
|
||||
if let window {
|
||||
let observedWindow = PaseoHardwareKeyboardWindow.observing(window)
|
||||
self.window = observedWindow
|
||||
(UIApplication.shared.delegate as? ExpoReactNativeFactoryProvider)?.window = observedWindow
|
||||
}`;
|
||||
return "internal import PaseoHardwareKeyboard\n" + source.replace(SCENE_START, installation);
|
||||
}
|
||||
module.exports = function withHardwareKeyboardWindow(config) {
|
||||
return withAppDelegate(config, (modConfig) => {
|
||||
modConfig.modResults.contents = configureHardwareKeyboardAppDelegate(
|
||||
modConfig.modResults.contents,
|
||||
);
|
||||
return modConfig;
|
||||
});
|
||||
};
|
||||
module.exports.configureHardwareKeyboardAppDelegate = configureHardwareKeyboardAppDelegate;
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"platforms": ["ios"],
|
||||
"platforms": ["ios", "android"],
|
||||
"ios": {
|
||||
"modules": ["PaseoHardwareKeyboardModule"],
|
||||
"reactDelegateHandlers": ["PaseoHardwareKeyboardReactDelegateHandler"]
|
||||
"modules": ["PaseoHardwareKeyboardModule"]
|
||||
},
|
||||
"android": {
|
||||
"modules": ["sh.paseo.keyboard.PaseoHardwareKeyboardModule"]
|
||||
}
|
||||
}
|
||||
|
||||
+150
-12
@@ -1,53 +1,159 @@
|
||||
import ExpoModulesCore
|
||||
import UIKit
|
||||
import GameController
|
||||
|
||||
private let hardwareSubmitEventName = "onHardwareKeyboardSubmit"
|
||||
private let hardwareShortcutEventName = "onHardwareKeyboardShortcut"
|
||||
|
||||
private weak var activeModule: PaseoHardwareKeyboardModule?
|
||||
private var isHardwareSubmitEnabled = false
|
||||
private var overlayKeys: Set<String> = []
|
||||
|
||||
@objc
|
||||
public class PaseoHardwareKeyboardReactDelegateHandler: ExpoReactDelegateHandler {
|
||||
public override func createRootViewController() -> UIViewController? {
|
||||
return PaseoHardwareKeyboardRootViewController()
|
||||
/// UIKit spells non-character keys as sentinel input strings rather than as the
|
||||
/// character they would type. JS sends the DOM `code` and the mapping lives
|
||||
/// here, so the constants stay in the one place that owns UIKit vocabulary.
|
||||
private let namedKeyInputs: [String: String] = [
|
||||
"Escape": UIKeyCommand.inputEscape,
|
||||
"ArrowUp": UIKeyCommand.inputUpArrow,
|
||||
"ArrowDown": UIKeyCommand.inputDownArrow,
|
||||
"Enter": "\r",
|
||||
]
|
||||
|
||||
/// Whether the first responder is mid-IME-composition.
|
||||
///
|
||||
/// A committing key has to stay out of the way while a CJK candidate is being
|
||||
/// chosen: Enter confirms the candidate, and consuming it as a key command
|
||||
/// would submit the half-composed text instead.
|
||||
private func isComposingText() -> Bool {
|
||||
guard let responder = UIResponder.paseoCurrentFirstResponder as? UITextInput else {
|
||||
return false
|
||||
}
|
||||
return responder.markedTextRange != nil
|
||||
}
|
||||
|
||||
public class PaseoHardwareKeyboardModule: Module {
|
||||
private var keyboardObservers: [NSObjectProtocol] = []
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("PaseoHardwareKeyboard")
|
||||
|
||||
Events(hardwareSubmitEventName)
|
||||
Events(hardwareSubmitEventName, hardwareShortcutEventName, "onHardwareKeyboardPresenceChanged")
|
||||
|
||||
OnCreate {
|
||||
activeModule = self
|
||||
for name in [Notification.Name.GCKeyboardDidConnect, Notification.Name.GCKeyboardDidDisconnect] {
|
||||
self.keyboardObservers.append(NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
||||
self?.sendEvent("onHardwareKeyboardPresenceChanged", [:])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Function("getHardwareKeyboardPresent") { GCKeyboard.coalesced != nil }
|
||||
|
||||
Function("setHardwareKeyboardSubmitEnabled") { (enabled: Bool) in
|
||||
DispatchQueue.main.async {
|
||||
isHardwareSubmitEnabled = enabled
|
||||
}
|
||||
}
|
||||
|
||||
Function("setOverlayKeys") { (keys: [String]) in
|
||||
DispatchQueue.main.async {
|
||||
overlayKeys = Set(keys).intersection(["Enter", "ArrowUp", "ArrowDown"])
|
||||
UIMenuSystem.main.setNeedsRebuild()
|
||||
}
|
||||
}
|
||||
|
||||
OnDestroy {
|
||||
self.keyboardObservers.forEach(NotificationCenter.default.removeObserver)
|
||||
self.keyboardObservers = []
|
||||
if activeModule === self {
|
||||
activeModule = nil
|
||||
isHardwareSubmitEnabled = false
|
||||
overlayKeys = []
|
||||
}
|
||||
isHardwareSubmitEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func emitHardwareKeyboardSubmit() {
|
||||
sendEvent(hardwareSubmitEventName, [:])
|
||||
}
|
||||
|
||||
fileprivate func emitHardwareKeyboardPress(_ press: UIPress, isRepeat: Bool) {
|
||||
guard let key = press.key else { return }
|
||||
let code = hardwareKeyCode(key)
|
||||
let characters = key.characters
|
||||
let named = namedKeyInputs.first { $0.value == characters }?.key
|
||||
let keyName: String
|
||||
if code.hasPrefix("Meta") { keyName = "Meta" }
|
||||
else if code.hasPrefix("Control") { keyName = "Control" }
|
||||
else if code.hasPrefix("Shift") { keyName = "Shift" }
|
||||
else if code.hasPrefix("Alt") { keyName = "Alt" }
|
||||
else { keyName = named ?? (characters.isEmpty ? code : characters) }
|
||||
guard !code.isEmpty else { return }
|
||||
sendEvent(hardwareShortcutEventName, [
|
||||
"key": keyName,
|
||||
"code": code,
|
||||
"altKey": key.modifierFlags.contains(.alternate),
|
||||
"ctrlKey": key.modifierFlags.contains(.control),
|
||||
"metaKey": key.modifierFlags.contains(.command),
|
||||
"shiftKey": key.modifierFlags.contains(.shift),
|
||||
"repeat": isRepeat,
|
||||
"isComposing": isComposingText(),
|
||||
"phase": press.phase == .ended || press.phase == .cancelled ? "up" : "down",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
private final class PaseoHardwareKeyboardRootViewController: UIViewController {
|
||||
override var keyCommands: [UIKeyCommand]? {
|
||||
guard isHardwareSubmitEnabled && UIDevice.current.userInterfaceIdiom == .pad else {
|
||||
return super.keyCommands
|
||||
}
|
||||
public final class PaseoHardwareKeyboardWindow: UIWindow {
|
||||
private var pressedCodes: Set<Int> = []
|
||||
public static func observing(_ original: UIWindow) -> UIWindow {
|
||||
if original is PaseoHardwareKeyboardWindow { return original }
|
||||
guard let scene = original.windowScene else { return original }
|
||||
let observed = PaseoHardwareKeyboardWindow(windowScene: scene)
|
||||
observed.windowLevel = original.windowLevel
|
||||
observed.backgroundColor = original.backgroundColor
|
||||
let controller = original.rootViewController
|
||||
original.rootViewController = nil
|
||||
original.isHidden = true
|
||||
observed.rootViewController = controller
|
||||
observed.makeKeyAndVisible()
|
||||
return observed
|
||||
}
|
||||
|
||||
public override func sendEvent(_ event: UIEvent) {
|
||||
if let event = event as? UIPressesEvent {
|
||||
for press in event.allPresses where press.phase == .began || press.phase == .ended || press.phase == .cancelled {
|
||||
guard let key = press.key else { continue }
|
||||
let raw = Int(key.keyCode.rawValue)
|
||||
let isRepeat = press.phase == .began && pressedCodes.contains(raw)
|
||||
if press.phase == .began { pressedCodes.insert(raw) }
|
||||
else { pressedCodes.remove(raw) }
|
||||
activeModule?.emitHardwareKeyboardPress(press, isRepeat: isRepeat)
|
||||
}
|
||||
}
|
||||
super.sendEvent(event)
|
||||
}
|
||||
public override var keyCommands: [UIKeyCommand]? {
|
||||
var commands = super.keyCommands ?? []
|
||||
if isHardwareSubmitEnabled && !isPlainReturnClaimed && !isComposingText()
|
||||
&& UIDevice.current.userInterfaceIdiom == .pad
|
||||
{
|
||||
commands.append(makeSubmitCommand())
|
||||
}
|
||||
if !isComposingText() { commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand)) }
|
||||
return commands
|
||||
}
|
||||
|
||||
/// Whether something registered has already asked for an unmodified Return.
|
||||
///
|
||||
/// The submit command is registered for as long as the composer holds focus,
|
||||
/// which includes while a picker is open over it. Registering both would put
|
||||
/// two commands on one press, and the composer would win a press the picker
|
||||
/// asked for — Enter would create the workspace instead of choosing a project.
|
||||
private var isPlainReturnClaimed: Bool {
|
||||
overlayKeys.contains("Enter")
|
||||
}
|
||||
|
||||
private func makeSubmitCommand() -> UIKeyCommand {
|
||||
let command = UIKeyCommand(
|
||||
input: "\r",
|
||||
modifierFlags: [],
|
||||
@@ -56,7 +162,14 @@ private final class PaseoHardwareKeyboardRootViewController: UIViewController {
|
||||
if #available(iOS 15.0, *) {
|
||||
command.wantsPriorityOverSystemBehavior = true
|
||||
}
|
||||
return (super.keyCommands ?? []) + [command]
|
||||
return command
|
||||
}
|
||||
|
||||
private func makeShortcutCommand(_ key: String) -> UIKeyCommand? {
|
||||
guard let input = namedKeyInputs[key] else { return nil }
|
||||
let command = UIKeyCommand(input: input, modifierFlags: [], action: #selector(handleHardwareKeyboardShortcut(_:)))
|
||||
if #available(iOS 15.0, *) { command.wantsPriorityOverSystemBehavior = true }
|
||||
return command
|
||||
}
|
||||
|
||||
@objc
|
||||
@@ -67,6 +180,9 @@ private final class PaseoHardwareKeyboardRootViewController: UIViewController {
|
||||
activeModule?.emitHardwareKeyboardSubmit()
|
||||
}
|
||||
|
||||
@objc
|
||||
private func handleHardwareKeyboardShortcut(_ sender: UIKeyCommand) {}
|
||||
|
||||
private func canSubmitCurrentTextInput() -> Bool {
|
||||
guard let responder = UIResponder.paseoCurrentFirstResponder else {
|
||||
return false
|
||||
@@ -97,3 +213,25 @@ private extension UIResponder {
|
||||
UIResponder.currentFirstResponder = self
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func hardwareKeyCode(_ key: UIKey) -> String {
|
||||
let raw = key.keyCode.rawValue
|
||||
if raw >= 4 && raw <= 29 {
|
||||
let letter = UnicodeScalar(Int(raw) + 61)!
|
||||
return "Key" + String(letter)
|
||||
}
|
||||
if raw >= 30 && raw <= 38 { return "Digit" + String(raw - 29) }
|
||||
if raw == 39 { return "Digit0" }
|
||||
let codes: [Int: String] = [
|
||||
40: "Enter", 41: "Escape", 42: "Backspace", 43: "Tab", 44: "Space",
|
||||
45: "Minus", 46: "Equal", 47: "BracketLeft", 48: "BracketRight",
|
||||
49: "Backslash", 51: "Semicolon", 52: "Quote", 53: "Backquote",
|
||||
54: "Comma", 55: "Period", 56: "Slash", 76: "Delete", 79: "ArrowRight",
|
||||
80: "ArrowLeft", 81: "ArrowDown", 82: "ArrowUp", 224: "ControlLeft",
|
||||
225: "ShiftLeft", 226: "AltLeft", 227: "MetaLeft", 228: "ControlRight",
|
||||
229: "ShiftRight", 230: "AltRight", 231: "MetaRight",
|
||||
]
|
||||
if raw >= 58 && raw <= 69 { return "F" + String(raw - 57) }
|
||||
return codes[Int(raw)] ?? ""
|
||||
}
|
||||
|
||||
+60
-2
@@ -4,11 +4,13 @@ import android.graphics.Rect
|
||||
import android.text.Selection
|
||||
import android.text.Spannable
|
||||
import android.view.View
|
||||
import android.view.InputDevice
|
||||
import android.view.MotionEvent
|
||||
import android.os.SystemClock
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.facebook.react.bridge.BridgeReactContext
|
||||
import com.facebook.react.bridge.JavaOnlyArray
|
||||
import com.facebook.react.bridge.JavaOnlyMap
|
||||
import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
||||
@@ -24,6 +26,40 @@ import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ScrollSelectionTest {
|
||||
@Test fun mouseWheelMovesInvertedViewportInVisualDirection() = withScroll(inverted = true) { scroll, _ ->
|
||||
val before = scroll.scrollY
|
||||
withWheel(1f) { event ->
|
||||
assertTrue(scroll.dispatchGenericMotionEvent(event))
|
||||
assertTrue("Wheel up must move toward older messages: $before -> ${scroll.scrollY}", scroll.scrollY > before)
|
||||
assertEquals("The caller's event must not change", 1f, event.getAxisValue(MotionEvent.AXIS_VSCROLL))
|
||||
}
|
||||
val afterUp = scroll.scrollY
|
||||
withWheel(-1f) { event ->
|
||||
assertTrue(scroll.dispatchGenericMotionEvent(event))
|
||||
assertTrue("Wheel down must move toward newer messages: $afterUp -> ${scroll.scrollY}", scroll.scrollY < afterUp)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun mouseWheelKeepsOrdinaryScrollDirection() = withScroll(inverted = false) { scroll, _ ->
|
||||
val before = scroll.scrollY
|
||||
withWheel(1f) { event ->
|
||||
assertTrue(scroll.dispatchGenericMotionEvent(event))
|
||||
assertTrue(scroll.scrollY < before)
|
||||
}
|
||||
val afterUp = scroll.scrollY
|
||||
withWheel(-1f) { event ->
|
||||
assertTrue(scroll.dispatchGenericMotionEvent(event))
|
||||
assertTrue(scroll.scrollY > afterUp)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun mouseWheelRespectsDisabledScrolling() = withScroll(inverted = true) { scroll, _ ->
|
||||
scroll.setScrollEnabled(false)
|
||||
val before = scroll.scrollY
|
||||
withWheel(1f) { scroll.dispatchGenericMotionEvent(it) }
|
||||
assertEquals(before, scroll.scrollY)
|
||||
}
|
||||
|
||||
@Test fun focusingSelectableTextKeepsInvertedViewportStill() = withScroll(inverted = true) { scroll, text ->
|
||||
val before = scroll.scrollY
|
||||
assertTrue(text.requestFocus())
|
||||
@@ -70,7 +106,7 @@ class ScrollSelectionTest {
|
||||
val appContext = instrumentation.targetContext
|
||||
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext)
|
||||
SoLoader.init(appContext, OpenSourceMergedSoMapping)
|
||||
val reactContext = BridgeReactContext(appContext)
|
||||
val reactContext = ScrollTestContext(appContext)
|
||||
val context = ThemedReactContext(reactContext, appContext)
|
||||
val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
|
||||
val scroll = manager.createViewInstance(context)
|
||||
@@ -98,4 +134,26 @@ class ScrollSelectionTest {
|
||||
}
|
||||
|
||||
private fun exact(size: Int) = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY)
|
||||
|
||||
private fun withWheel(axis: Float, check: (MotionEvent) -> Unit) {
|
||||
val time = SystemClock.uptimeMillis()
|
||||
val properties = MotionEvent.PointerProperties().apply {
|
||||
id = 0
|
||||
toolType = MotionEvent.TOOL_TYPE_MOUSE
|
||||
}
|
||||
val coordinates = MotionEvent.PointerCoords().apply {
|
||||
x = 150f
|
||||
y = 150f
|
||||
setAxisValue(MotionEvent.AXIS_VSCROLL, axis)
|
||||
}
|
||||
val event = MotionEvent.obtain(
|
||||
time, time, MotionEvent.ACTION_SCROLL, 1, arrayOf(properties), arrayOf(coordinates),
|
||||
0, 0, 1f, 1f, 0, 0, InputDevice.SOURCE_MOUSE, 0,
|
||||
)
|
||||
try {
|
||||
check(event)
|
||||
} finally {
|
||||
event.recycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package sh.paseo.scroll;
|
||||
|
||||
import android.content.Context;
|
||||
import com.facebook.react.bridge.BridgeReactContext;
|
||||
import com.facebook.react.uimanager.events.BlackHoleEventDispatcher;
|
||||
import com.facebook.react.uimanager.events.EventDispatcher;
|
||||
import com.facebook.react.uimanager.events.EventDispatcherProvider;
|
||||
|
||||
/** Native-only tests use RN's dispatcher for a context without a JS instance. */
|
||||
final class ScrollTestContext extends BridgeReactContext implements EventDispatcherProvider {
|
||||
ScrollTestContext(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public EventDispatcher getEventDispatcher() {
|
||||
return BlackHoleEventDispatcher.INSTANCE;
|
||||
}
|
||||
}
|
||||
+42
-6
@@ -2,6 +2,8 @@ package sh.paseo.scroll
|
||||
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Rect
|
||||
import android.view.InputDevice
|
||||
import android.view.MotionEvent
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.ViewManagerOnDemandReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
@@ -30,13 +32,47 @@ private class PaseoScrollViewManager : ReactScrollViewManager() {
|
||||
private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) {
|
||||
private val transformValues = FloatArray(9)
|
||||
|
||||
private fun isVerticallyInverted(): Boolean {
|
||||
// Android FlatList uses scale: -1, which RN decomposes into a 180-degree
|
||||
// rotation with positive scaleY. Read the resulting transform, not scaleY.
|
||||
matrix.getValues(transformValues)
|
||||
return transformValues[Matrix.MSCALE_Y] < 0
|
||||
}
|
||||
|
||||
override fun onGenericMotionEvent(event: MotionEvent): Boolean {
|
||||
if (!isVerticallyInverted() || event.actionMasked != MotionEvent.ACTION_SCROLL ||
|
||||
!event.isFromSource(InputDevice.SOURCE_CLASS_POINTER)) {
|
||||
return super.onGenericMotionEvent(event)
|
||||
}
|
||||
if (!scrollEnabled) return false
|
||||
|
||||
// Android applies wheel axes directly to scrollY; the inversion transform only
|
||||
// transforms pointer coordinates. Reverse the vertical axis before delegating so
|
||||
// platform scroll bounds and scroll events still apply.
|
||||
val properties = Array(event.pointerCount) { index ->
|
||||
MotionEvent.PointerProperties().also { event.getPointerProperties(index, it) }
|
||||
}
|
||||
val coordinates = Array(event.pointerCount) { index ->
|
||||
MotionEvent.PointerCoords().also {
|
||||
event.getPointerCoords(index, it)
|
||||
it.setAxisValue(MotionEvent.AXIS_VSCROLL, -it.getAxisValue(MotionEvent.AXIS_VSCROLL))
|
||||
}
|
||||
}
|
||||
val correctedEvent = MotionEvent.obtain(
|
||||
event.downTime, event.eventTime, event.action, event.pointerCount, properties, coordinates,
|
||||
event.metaState, event.buttonState, event.xPrecision, event.yPrecision,
|
||||
event.deviceId, event.edgeFlags, event.source, event.flags,
|
||||
)
|
||||
return try {
|
||||
super.onGenericMotionEvent(correctedEvent)
|
||||
} finally {
|
||||
correctedEvent.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
// Both RN focus handling and Android selection visibility requests use this calculation.
|
||||
// Its coordinates ignore inversion. Keep focus/selection bookkeeping, but leave timeline
|
||||
// movement to user gestures and explicit scroll commands.
|
||||
override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int {
|
||||
// RN's Android scale: -1 becomes a 180-degree rotation with positive scaleY.
|
||||
matrix.getValues(transformValues)
|
||||
if (transformValues[Matrix.MSCALE_Y] < 0) return 0
|
||||
return super.computeScrollDeltaToGetChildRectOnScreen(rect)
|
||||
}
|
||||
override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int =
|
||||
if (isVerticallyInverted()) 0 else super.computeScrollDeltaToGetChildRectOnScreen(rect)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios",
|
||||
"build:apk": "npm --prefix ../.. run build:apk",
|
||||
"build:apk:arm64": "npm --prefix ../.. run build:apk:arm64",
|
||||
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest :react-native-enriched-markdown:testReleaseUnitTest --tests '*InlineCodeFormattingTest' --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
|
||||
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest :paseo-hardware-keyboard:testReleaseUnitTest :react-native-enriched-markdown:testReleaseUnitTest --tests '*InlineCodeFormattingTest' --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
|
||||
+2
-2
@@ -18,7 +18,7 @@ export interface AgentCommandsDraftConfig {
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
providerOptions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function normalizeAgentCommandsCwd(cwd: string): string {
|
||||
@@ -98,7 +98,7 @@ export function draftAgentCommandsQueryKey(input: {
|
||||
"thinking",
|
||||
draftConfig.thinkingOptionId ?? null,
|
||||
"features",
|
||||
draftConfig.featureValues ?? null,
|
||||
draftConfig.providerOptions ?? null,
|
||||
] as const;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentOptionControl } from "@getpaseo/protocol/agent-types";
|
||||
import { isControlActive } from "./option-controls";
|
||||
import {
|
||||
draftControlsRequest,
|
||||
openDraftOptions,
|
||||
ownDraftOptions,
|
||||
viewDraftOptions,
|
||||
} from "./draft-options-model";
|
||||
|
||||
const SPEED: AgentOptionControl = {
|
||||
type: "select",
|
||||
key: "service_tier",
|
||||
label: "Speed",
|
||||
defaultValue: "default",
|
||||
choices: [
|
||||
{ value: "default", label: "Standard" },
|
||||
{ value: "priority", label: "Fast" },
|
||||
],
|
||||
};
|
||||
const WEB_SEARCH: AgentOptionControl = { type: "toggle", key: "web_search", label: "Web search" };
|
||||
|
||||
function answer(
|
||||
provider: string,
|
||||
requestKey: string,
|
||||
options: Record<string, unknown>,
|
||||
controls = [SPEED, WEB_SEARCH],
|
||||
) {
|
||||
return { provider, requestKey, controls, options };
|
||||
}
|
||||
|
||||
describe("draft options model", () => {
|
||||
it("draws a legacy Fast profile as the Fast tier the daemon resolved", () => {
|
||||
const model = openDraftOptions({ provider: "codex", seed: { service_tier: "fast" } });
|
||||
model.applyResolved(answer("codex", "k1", { service_tier: "priority" }));
|
||||
|
||||
const view = viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k1" });
|
||||
|
||||
expect(view?.options).toEqual({ service_tier: "priority" });
|
||||
expect(view && isControlActive(SPEED, view.options)).toBe(true);
|
||||
// The daemon still gets the stored value to resolve, and so does create.
|
||||
expect(ownDraftOptions(model.getState())).toEqual({ service_tier: "fast" });
|
||||
});
|
||||
|
||||
it("applying a profile from another provider keeps its options through the provider change", () => {
|
||||
const model = openDraftOptions({ provider: "claude" });
|
||||
model.applyResolved(answer("claude", "k1", {}));
|
||||
|
||||
model.applyProfile({
|
||||
provider: "codex",
|
||||
options: { sandbox_mode: "read-only", service_tier: "fast" },
|
||||
});
|
||||
// The form follows the profile to its provider.
|
||||
model.observeProvider("codex");
|
||||
|
||||
expect(ownDraftOptions(model.getState())).toEqual({
|
||||
sandbox_mode: "read-only",
|
||||
service_tier: "fast",
|
||||
});
|
||||
expect(model.getState().resolved).toBeNull();
|
||||
});
|
||||
|
||||
it("choosing another provider by hand starts the options over", () => {
|
||||
const model = openDraftOptions({ provider: "codex", seed: { sandbox_mode: "read-only" } });
|
||||
model.setOption("web_search", true);
|
||||
|
||||
model.observeProvider("claude");
|
||||
|
||||
expect(ownDraftOptions(model.getState())).toBeUndefined();
|
||||
expect(model.getState().resolved).toBeNull();
|
||||
});
|
||||
|
||||
it("never pairs a manually chosen provider with the previous provider's options", () => {
|
||||
const model = openDraftOptions({ provider: "claude" });
|
||||
model.applyResolved(
|
||||
answer("claude", "k1", { fast_mode: true }, [
|
||||
{ type: "toggle", key: "fast_mode", label: "Fast" },
|
||||
]),
|
||||
);
|
||||
model.setOption("fast_mode", true);
|
||||
const form = { provider: "claude", cwd: "/repo" };
|
||||
expect(draftControlsRequest(model.getState(), form)).toMatchObject({
|
||||
provider: "claude",
|
||||
providerOptions: { fast_mode: true },
|
||||
});
|
||||
|
||||
// The form shows OpenCode on its next render; the model has not observed it yet.
|
||||
expect(draftControlsRequest(model.getState(), { ...form, provider: "opencode" })).toBeNull();
|
||||
|
||||
model.observeProvider("opencode");
|
||||
const request = draftControlsRequest(model.getState(), { ...form, provider: "opencode" });
|
||||
expect(request).toEqual({ provider: "opencode", cwd: "/repo" });
|
||||
});
|
||||
|
||||
it("stops sending a toggle once the model in use draws no control for it", () => {
|
||||
const FAST_MODE: AgentOptionControl = { type: "toggle", key: "fast_mode", label: "Fast" };
|
||||
const model = openDraftOptions({ provider: "claude", seed: { sandbox_mode: "read-only" } });
|
||||
model.applyResolved(answer("claude", "opus", { fast_mode: false }, [FAST_MODE]));
|
||||
model.setOption("fast_mode", true);
|
||||
expect(ownDraftOptions(model.getState())).toEqual({
|
||||
sandbox_mode: "read-only",
|
||||
fast_mode: true,
|
||||
});
|
||||
|
||||
// The form moves to a model without Fast; its answer lists no such control.
|
||||
model.applyResolved(answer("claude", "haiku", {}, []));
|
||||
|
||||
expect(ownDraftOptions(model.getState())).toEqual({ sandbox_mode: "read-only" });
|
||||
|
||||
// Back on a model that draws the control, the toggle applies again. The daemon's answer
|
||||
// for the request without it said Fast was off; the request that carries it follows.
|
||||
model.applyResolved(answer("claude", "opus-again", { fast_mode: false }, [FAST_MODE]));
|
||||
expect(ownDraftOptions(model.getState())).toEqual({
|
||||
sandbox_mode: "read-only",
|
||||
fast_mode: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("always sends an option no control has drawn", () => {
|
||||
const FAST_MODE: AgentOptionControl = { type: "toggle", key: "fast_mode", label: "Fast" };
|
||||
const model = openDraftOptions({ provider: "claude" });
|
||||
model.applyResolved(answer("claude", "k1", {}, [FAST_MODE]));
|
||||
|
||||
model.setOption("token_budget", 5);
|
||||
|
||||
expect(ownDraftOptions(model.getState())).toEqual({ token_budget: 5 });
|
||||
model.applyResolved(answer("claude", "k2", {}, []));
|
||||
expect(ownDraftOptions(model.getState())).toEqual({ token_budget: 5 });
|
||||
});
|
||||
|
||||
it("keeps a draft's seed when the first provider is chosen", () => {
|
||||
const model = openDraftOptions({ provider: null, seed: { web_search: true } });
|
||||
|
||||
model.observeProvider("claude");
|
||||
|
||||
expect(ownDraftOptions(model.getState())).toEqual({ web_search: true });
|
||||
});
|
||||
|
||||
it("sends a remembered value to the daemon, and draws only what comes back", () => {
|
||||
const model = openDraftOptions({ provider: "codex" });
|
||||
model.setRemembered({ service_tier: "fast", retired: true });
|
||||
// Nothing is known about the provider's controls yet.
|
||||
expect(ownDraftOptions(model.getState())).toBeUndefined();
|
||||
|
||||
model.applyResolved(answer("codex", "k1", { service_tier: "default" }));
|
||||
expect(ownDraftOptions(model.getState())).toEqual({ service_tier: "fast" });
|
||||
// The answer to the first request does not show the remembered value as if it applied.
|
||||
expect(
|
||||
viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k2" })?.options,
|
||||
).toEqual({ service_tier: "default" });
|
||||
|
||||
model.applyResolved(answer("codex", "k2", { service_tier: "priority" }));
|
||||
expect(
|
||||
viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k2" })?.options,
|
||||
).toEqual({ service_tier: "priority" });
|
||||
});
|
||||
|
||||
it("does not let a remembered value override the seed", () => {
|
||||
const model = openDraftOptions({ provider: "codex", seed: { service_tier: "default" } });
|
||||
model.applyResolved(answer("codex", "k1", { service_tier: "default" }));
|
||||
model.setRemembered({ service_tier: "fast" });
|
||||
|
||||
expect(ownDraftOptions(model.getState())).toEqual({ service_tier: "default" });
|
||||
});
|
||||
|
||||
it("lays a toggle over the previous answer until the new one arrives", () => {
|
||||
const model = openDraftOptions({ provider: "claude" });
|
||||
model.applyResolved(answer("claude", "k1", { web_search: false }));
|
||||
|
||||
model.setOption("web_search", true);
|
||||
|
||||
expect(
|
||||
viewDraftOptions(model.getState(), { provider: "claude", requestKey: "k2" })?.options,
|
||||
).toEqual({ web_search: true });
|
||||
model.applyResolved(answer("claude", "k2", { web_search: true }));
|
||||
expect(
|
||||
viewDraftOptions(model.getState(), { provider: "claude", requestKey: "k2" })?.options,
|
||||
).toEqual({ web_search: true });
|
||||
});
|
||||
|
||||
it("draws nothing for a provider the answer is not for", () => {
|
||||
const model = openDraftOptions({ provider: "claude" });
|
||||
model.applyResolved(answer("claude", "k1", {}));
|
||||
|
||||
expect(viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k1" })).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import type {
|
||||
AgentOptionControl,
|
||||
AgentSessionConfig,
|
||||
ProviderOptions,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import { withoutLostControls } from "./lost-controls";
|
||||
|
||||
/**
|
||||
* The options of one draft agent, as a plain model (see docs/forms.md). It owns which layers
|
||||
* make up the draft's options; the daemon owns what they mean. Every layer is sent to the daemon
|
||||
* (`ownDraftOptions`) and the UI draws what comes back (`viewDraftOptions`), so no alias,
|
||||
* default or legacy key is resolved here.
|
||||
*
|
||||
* - `seed`: options the draft started from (a profile, a forked agent).
|
||||
* - `remembered`: the provider's last-used control values.
|
||||
* - `edits`: toggles made in this draft.
|
||||
*
|
||||
* Choosing a provider by hand starts the options over; applying a profile replaces the provider
|
||||
* and the options in one step, so the provider change that follows does not clear them.
|
||||
*/
|
||||
export interface ResolvedDraftOptions {
|
||||
provider: string;
|
||||
/** Names the request that produced this answer; see `viewDraftOptions`. */
|
||||
requestKey: string;
|
||||
controls: AgentOptionControl[];
|
||||
options: ProviderOptions;
|
||||
}
|
||||
|
||||
export interface DraftOptionsState {
|
||||
provider: string | null;
|
||||
seed: ProviderOptions;
|
||||
remembered: ProviderOptions;
|
||||
edits: ProviderOptions;
|
||||
resolved: ResolvedDraftOptions | null;
|
||||
/** Every key an answer for this provider has drawn as a control. */
|
||||
drawn: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export interface DraftOptionsModel {
|
||||
getState: () => DraftOptionsState;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
/** The provider the draft's form now has. A different one than the model's is a manual choice. */
|
||||
observeProvider: (provider: string | null) => void;
|
||||
applyProfile: (profile: { provider: string; options: ProviderOptions }) => void;
|
||||
setRemembered: (remembered: ProviderOptions) => void;
|
||||
setOption: (key: string, value: unknown) => void;
|
||||
applyResolved: (resolved: ResolvedDraftOptions) => void;
|
||||
}
|
||||
|
||||
export function openDraftOptions(input: {
|
||||
provider: string | null;
|
||||
seed?: ProviderOptions;
|
||||
}): DraftOptionsModel {
|
||||
let state: DraftOptionsState = {
|
||||
provider: input.provider,
|
||||
seed: input.seed ?? {},
|
||||
remembered: {},
|
||||
edits: {},
|
||||
resolved: null,
|
||||
drawn: new Set(),
|
||||
};
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function publish(update: (current: DraftOptionsState) => DraftOptionsState): void {
|
||||
const next = update(state);
|
||||
if (next === state) return;
|
||||
state = next;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
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,
|
||||
drawn: new Set(),
|
||||
};
|
||||
}),
|
||||
applyProfile: (profile) =>
|
||||
publish((current) => ({
|
||||
...current,
|
||||
provider: profile.provider,
|
||||
seed: profile.options,
|
||||
edits: {},
|
||||
resolved: current.provider === profile.provider ? current.resolved : null,
|
||||
drawn: current.provider === profile.provider ? current.drawn : new Set(),
|
||||
})),
|
||||
setRemembered: (remembered) => publish((current) => ({ ...current, remembered })),
|
||||
setOption: (key, value) =>
|
||||
publish((current) =>
|
||||
Object.is(current.edits[key], value)
|
||||
? current
|
||||
: { ...current, edits: { ...current.edits, [key]: value } },
|
||||
),
|
||||
applyResolved: (resolved) =>
|
||||
publish((current) => ({
|
||||
...current,
|
||||
resolved,
|
||||
drawn: new Set([...current.drawn, ...resolved.controls.map((control) => control.key)]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the draft would create with, for the daemon to resolve. A last-used value applies
|
||||
* once the provider has told us it still draws a control for that key, and never over the seed.
|
||||
* An edit of a key an earlier answer drew as a control stops applying when the latest answer
|
||||
* draws none for it (the model changed to one without that control) and applies again when a
|
||||
* control for it returns. An edit of a key no answer has drawn is an option without a control
|
||||
* and always applies.
|
||||
*/
|
||||
export function ownDraftOptions(state: DraftOptionsState): ProviderOptions | undefined {
|
||||
const controlKeys = new Set((state.resolved?.controls ?? []).map((control) => control.key));
|
||||
const remembered = Object.fromEntries(
|
||||
Object.entries(state.remembered).filter(
|
||||
([key]) => controlKeys.has(key) && !(key in state.seed),
|
||||
),
|
||||
);
|
||||
const edits = withoutLostControls(state.edits, state.drawn, controlKeys);
|
||||
const own = { ...state.seed, ...remembered, ...edits };
|
||||
return Object.keys(own).length > 0 ? own : undefined;
|
||||
}
|
||||
|
||||
export type DraftControlsRequest = Pick<
|
||||
AgentSessionConfig,
|
||||
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "providerOptions"
|
||||
>;
|
||||
|
||||
/** What the draft's form has chosen; the options come from the model. */
|
||||
export interface DraftControlsForm {
|
||||
provider: string | null;
|
||||
cwd: string;
|
||||
modeId?: string | null;
|
||||
modelId?: string | null;
|
||||
thinkingOptionId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The listing request for the draft's form, built from one snapshot of the model. The form
|
||||
* reaches a new provider before the model has observed it, and the model still holds the
|
||||
* previous provider's options until it does; that render has no request, so a request never
|
||||
* pairs one provider with another's options.
|
||||
*/
|
||||
export function draftControlsRequest(
|
||||
state: DraftOptionsState,
|
||||
form: DraftControlsForm,
|
||||
): DraftControlsRequest | null {
|
||||
if (!form.provider || !form.cwd || form.provider !== state.provider) return null;
|
||||
const providerOptions = ownDraftOptions(state);
|
||||
return {
|
||||
provider: form.provider,
|
||||
cwd: form.cwd,
|
||||
...(form.modeId ? { modeId: form.modeId } : {}),
|
||||
...(form.modelId ? { model: form.modelId } : {}),
|
||||
...(form.thinkingOptionId ? { thinkingOptionId: form.thinkingOptionId } : {}),
|
||||
...(providerOptions ? { providerOptions } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What the controls draw: the daemon's answer for this provider. The last answer stays up while
|
||||
* a newer request is in flight, with the toggles made since laid over it, so a switch does not
|
||||
* wait for the round trip. It never carries a seed or remembered value the daemon has not
|
||||
* resolved.
|
||||
*/
|
||||
export function viewDraftOptions(
|
||||
state: DraftOptionsState,
|
||||
current: { provider: string | null; requestKey: string },
|
||||
): { controls: AgentOptionControl[]; options: ProviderOptions } | undefined {
|
||||
const { resolved } = state;
|
||||
if (!resolved || resolved.provider !== current.provider) return undefined;
|
||||
return {
|
||||
controls: resolved.controls,
|
||||
options:
|
||||
resolved.requestKey === current.requestKey
|
||||
? resolved.options
|
||||
: { ...resolved.options, ...state.edits },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Settings2 } from "lucide-react-native";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getAgentFeatureIcon } from "./icons";
|
||||
|
||||
describe("getAgentFeatureIcon", () => {
|
||||
it("draws the Lucide icon a plugin provider names", () => {
|
||||
const icon = getAgentFeatureIcon("Mic");
|
||||
expect(icon).not.toBe(Settings2);
|
||||
expect(getAgentFeatureIcon("Mic")).toBe(icon);
|
||||
});
|
||||
|
||||
it("falls back to the generic icon for a missing or unknown name", () => {
|
||||
expect(getAgentFeatureIcon()).toBe(Settings2);
|
||||
expect(getAgentFeatureIcon("not-an-icon")).toBe(Settings2);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ShieldQuestionMark,
|
||||
Zap,
|
||||
} from "lucide-react-native";
|
||||
import { isLucideIconName, pluginIconComponent } from "@/plugins/icons";
|
||||
import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
|
||||
export interface AgentControlIconProps {
|
||||
@@ -60,6 +61,9 @@ export function getAgentModeOptionIcon(
|
||||
return icon ? MODE_ICONS[icon] : undefined;
|
||||
}
|
||||
|
||||
/** A built-in control names its icon in kebab case; a plugin provider names a Lucide icon. */
|
||||
export function getAgentFeatureIcon(icon?: string): AgentControlIcon {
|
||||
return (icon ? FEATURE_ICONS[icon] : undefined) ?? Settings2;
|
||||
if (!icon) return Settings2;
|
||||
if (FEATURE_ICONS[icon]) return FEATURE_ICONS[icon];
|
||||
return isLucideIconName(icon) ? pluginIconComponent(icon) : Settings2;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
/**
|
||||
* The options that still apply after an answer from the daemon. A key an earlier answer drew as a
|
||||
* control and the latest answer does not (the model changed to one without that control) is
|
||||
* dropped, and applies again if a control for it returns. A key no answer has drawn is an option
|
||||
* without a control and always applies.
|
||||
*/
|
||||
export function withoutLostControls(
|
||||
options: ProviderOptions,
|
||||
drawn: ReadonlySet<string>,
|
||||
current: ReadonlySet<string>,
|
||||
): ProviderOptions {
|
||||
return Object.fromEntries(
|
||||
Object.entries(options).filter(([key]) => !drawn.has(key) || current.has(key)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentOptionControl } from "@getpaseo/protocol/agent-types";
|
||||
import {
|
||||
getControlTooltip,
|
||||
isControlActive,
|
||||
readSelectOption,
|
||||
readToggleOption,
|
||||
} from "./option-controls";
|
||||
|
||||
const speed: AgentOptionControl = {
|
||||
type: "select",
|
||||
key: "service_tier",
|
||||
label: "Speed",
|
||||
defaultValue: "default",
|
||||
choices: [
|
||||
{ value: "default", label: "Normal" },
|
||||
{ value: "priority", label: "Fast" },
|
||||
],
|
||||
};
|
||||
const fast: AgentOptionControl = { type: "toggle", key: "fast_mode", label: "Fast" };
|
||||
|
||||
describe("option controls", () => {
|
||||
it("reads a select from options and falls back to the control's default", () => {
|
||||
if (speed.type !== "select") throw new Error("select expected");
|
||||
expect(readSelectOption(speed, { service_tier: "priority" })).toBe("priority");
|
||||
expect(readSelectOption(speed, {})).toBe("default");
|
||||
expect(readSelectOption(speed, { service_tier: "unknown-tier" })).toBe("default");
|
||||
});
|
||||
|
||||
it("reads a toggle from options and is off when unset", () => {
|
||||
if (fast.type !== "toggle") throw new Error("toggle expected");
|
||||
expect(readToggleOption(fast, { fast_mode: true })).toBe(true);
|
||||
expect(readToggleOption(fast, {})).toBe(false);
|
||||
});
|
||||
|
||||
it("is active when the option differs from the control's default", () => {
|
||||
expect(isControlActive(speed, { service_tier: "priority" })).toBe(true);
|
||||
expect(isControlActive(speed, { service_tier: "default" })).toBe(false);
|
||||
expect(isControlActive(speed, {})).toBe(false);
|
||||
expect(isControlActive(fast, { fast_mode: true })).toBe(true);
|
||||
expect(isControlActive(fast, {})).toBe(false);
|
||||
});
|
||||
|
||||
it("prefers a control's tooltip over its label", () => {
|
||||
expect(getControlTooltip({ label: "Plan", tooltip: "Toggle plan mode" })).toBe(
|
||||
"Toggle plan mode",
|
||||
);
|
||||
expect(getControlTooltip({ label: "Custom" })).toBe("Custom");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type {
|
||||
AgentOptionControl,
|
||||
AgentOptionSelectControl,
|
||||
AgentOptionToggleControl,
|
||||
ProviderOptions,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
|
||||
/**
|
||||
* The composer's option controls. A control only describes how to draw one option key; the
|
||||
* value is read from `options`, which the daemon resolved (defaults, aliases, what runs).
|
||||
*/
|
||||
export interface ComposerOptionControls {
|
||||
controls: AgentOptionControl[];
|
||||
options: ProviderOptions;
|
||||
onSetOption: (key: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
export function readToggleOption(
|
||||
control: AgentOptionToggleControl,
|
||||
options: ProviderOptions,
|
||||
): boolean {
|
||||
const value = options[control.key];
|
||||
return typeof value === "boolean" ? value : (control.defaultValue ?? false);
|
||||
}
|
||||
|
||||
/** The selected choice's value, or the control's default when the option names no choice. */
|
||||
export function readSelectOption(
|
||||
control: AgentOptionSelectControl,
|
||||
options: ProviderOptions,
|
||||
): string | null {
|
||||
const value = options[control.key];
|
||||
if (typeof value === "string" && control.choices.some((choice) => choice.value === value)) {
|
||||
return value;
|
||||
}
|
||||
return control.defaultValue ?? null;
|
||||
}
|
||||
|
||||
/** Whether the option is set to something other than the control's default. */
|
||||
export function isControlActive(control: AgentOptionControl, options: ProviderOptions): boolean {
|
||||
if (control.type === "toggle") return readToggleOption(control, options);
|
||||
const value = readSelectOption(control, options);
|
||||
return value !== null && value !== control.defaultValue;
|
||||
}
|
||||
|
||||
export function getControlTooltip(control: Pick<AgentOptionControl, "label" | "tooltip">): string {
|
||||
return control.tooltip ?? control.label;
|
||||
}
|
||||
@@ -23,7 +23,7 @@ describe("materializeAgentProfile", () => {
|
||||
model: "claude-opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "think-hard",
|
||||
featureValues: { web_search: true },
|
||||
options: { web_search: true },
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
@@ -31,7 +31,7 @@ describe("materializeAgentProfile", () => {
|
||||
modelId: "claude-opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "think-hard",
|
||||
featureValues: { web_search: true },
|
||||
options: { web_search: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ describe("materializeAgentProfile", () => {
|
||||
modelId: "",
|
||||
modeId: "",
|
||||
thinkingOptionId: "",
|
||||
featureValues: {},
|
||||
options: {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("toAgentConfigApply", () => {
|
||||
model: "claude-opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "think-hard",
|
||||
featureValues: { web_search: true },
|
||||
options: { web_search: true },
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -69,7 +69,7 @@ describe("toAgentConfigApply", () => {
|
||||
modelId: "claude-opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "think-hard",
|
||||
featureValues: { web_search: true },
|
||||
options: { web_search: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,8 +81,8 @@ describe("toAgentConfigApply", () => {
|
||||
|
||||
it("omits an empty feature map so the agent keeps its own feature values", () => {
|
||||
expect(
|
||||
toAgentConfigApply(materializeAgentProfile(profile({ featureValues: {} }))),
|
||||
).not.toHaveProperty("featureValues");
|
||||
toAgentConfigApply(materializeAgentProfile(profile({ options: {} }))),
|
||||
).not.toHaveProperty("options");
|
||||
});
|
||||
|
||||
it("never carries a provider, which a running agent cannot change", () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface MaterializedAgentProfile {
|
||||
modelId: string;
|
||||
modeId: string;
|
||||
thinkingOptionId: string;
|
||||
featureValues: Record<string, unknown>;
|
||||
options: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function trimmed(value: string | undefined): string {
|
||||
@@ -25,7 +25,7 @@ export function materializeAgentProfile(profile: AgentProfile): MaterializedAgen
|
||||
modelId: trimmed(profile.model),
|
||||
modeId: trimmed(profile.modeId),
|
||||
thinkingOptionId: trimmed(profile.thinkingOptionId),
|
||||
featureValues: profile.featureValues ?? {},
|
||||
options: profile.options ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,8 +52,6 @@ export function toAgentConfigApply(profile: MaterializedAgentProfile): AgentConf
|
||||
...(profile.modelId ? { modelId: profile.modelId } : {}),
|
||||
...(profile.modeId ? { modeId: profile.modeId } : {}),
|
||||
...(profile.thinkingOptionId ? { thinkingOptionId: profile.thinkingOptionId } : {}),
|
||||
...(Object.keys(profile.featureValues).length > 0
|
||||
? { featureValues: profile.featureValues }
|
||||
: {}),
|
||||
...(Object.keys(profile.options).length > 0 ? { options: profile.options } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildFeatureRequestKey,
|
||||
buildControlsRequestKey,
|
||||
openAgentProfileForm,
|
||||
type AgentProfileFormModel,
|
||||
} from "./profile-form-model";
|
||||
@@ -70,6 +70,21 @@ function optionValues(options: readonly { value: string }[]): string[] {
|
||||
return options.map((option) => option.value);
|
||||
}
|
||||
|
||||
const FAST = {
|
||||
controls: [{ type: "toggle" as const, key: "fast_mode", label: "Fast" }],
|
||||
options: { fast_mode: false },
|
||||
};
|
||||
const NO_CONTROLS = { controls: [], options: {} };
|
||||
|
||||
function toggledOnOpus(): AgentProfileFormModel {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
model.setName("Fast");
|
||||
selectClaude(model);
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", FAST);
|
||||
model.setOption("fast_mode", true);
|
||||
return model;
|
||||
}
|
||||
|
||||
describe("openAgentProfileForm", () => {
|
||||
it("starts a create form empty and cannot submit", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
@@ -173,7 +188,7 @@ describe("openAgentProfileForm", () => {
|
||||
model: "claude-opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "think-hard",
|
||||
featureValues: { webSearch: true },
|
||||
options: { webSearch: true },
|
||||
notes: "Visual work only.",
|
||||
},
|
||||
});
|
||||
@@ -229,10 +244,11 @@ describe("openAgentProfileForm", () => {
|
||||
selectClaude(model);
|
||||
model.setModel("claude-opus-5", { label: "Opus 5" });
|
||||
model.setThinking("think-hard", { label: "Think hard" });
|
||||
model.applyFeatures(model.getState().featureRequestKey ?? "", [
|
||||
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
|
||||
]);
|
||||
model.setFeatureValue("webSearch", true);
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", {
|
||||
controls: [{ type: "toggle", key: "webSearch", label: "Web search" }],
|
||||
options: { webSearch: false },
|
||||
});
|
||||
model.setOption("webSearch", true);
|
||||
|
||||
model.setProvider("codex", { label: "Codex" });
|
||||
const state = model.getState();
|
||||
@@ -242,8 +258,9 @@ describe("openAgentProfileForm", () => {
|
||||
expect(state.modelDisplay).toEqual({ label: "GPT-5.2 Codex" });
|
||||
expect(state.thinkingOptionId).toBe("");
|
||||
expect(state.thinkingDisplay).toBeNull();
|
||||
expect(state.featureValues).toEqual({});
|
||||
expect(state.features).toEqual([]);
|
||||
expect(state.options).toEqual({});
|
||||
expect(state.optionEdits).toEqual({});
|
||||
expect(state.controls).toEqual([]);
|
||||
expect(state.name).toBe("Test writer");
|
||||
});
|
||||
|
||||
@@ -334,23 +351,28 @@ describe("openAgentProfileForm", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("features", () => {
|
||||
it("asks for features per provider/model/mode/thinking combination", () => {
|
||||
const WEB_SEARCH_CONTROLS = {
|
||||
controls: [{ type: "toggle" as const, key: "webSearch", label: "Web search" }],
|
||||
options: { webSearch: false },
|
||||
};
|
||||
|
||||
describe("controls", () => {
|
||||
it("asks for controls per provider/model/mode/thinking combination", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
selectClaude(model);
|
||||
|
||||
expect(model.getState().featureRequest).toEqual({
|
||||
expect(model.getState().controlsRequest).toEqual({
|
||||
provider: "claude",
|
||||
model: "claude-opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "think",
|
||||
});
|
||||
const firstKey = model.getState().featureRequestKey;
|
||||
const firstKey = model.getState().controlsRequestKey;
|
||||
|
||||
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
|
||||
|
||||
expect(model.getState().featureRequestKey).not.toBe(firstKey);
|
||||
expect(model.getState().featureRequest).toEqual({
|
||||
expect(model.getState().controlsRequestKey).not.toBe(firstKey);
|
||||
expect(model.getState().controlsRequest).toEqual({
|
||||
provider: "claude",
|
||||
model: "claude-haiku-4-5",
|
||||
modeId: "plan",
|
||||
@@ -361,77 +383,233 @@ describe("openAgentProfileForm", () => {
|
||||
it("moves from pending to complete only for the current request", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
selectClaude(model);
|
||||
const key = model.getState().featureRequestKey ?? "";
|
||||
expect(model.getState().featureResolution).toBe("pending");
|
||||
const key = model.getState().controlsRequestKey ?? "";
|
||||
expect(model.getState().controlsResolution).toBe("pending");
|
||||
|
||||
model.applyFeatures("stale-key", [
|
||||
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
|
||||
]);
|
||||
expect(model.getState().featureResolution).toBe("pending");
|
||||
expect(model.getState().features).toEqual([]);
|
||||
model.applyControls("stale-key", {
|
||||
controls: [{ type: "toggle", key: "webSearch", label: "Web search" }],
|
||||
options: { webSearch: false },
|
||||
});
|
||||
expect(model.getState().controlsResolution).toBe("pending");
|
||||
expect(model.getState().controls).toEqual([]);
|
||||
|
||||
model.applyFeatures(key, [
|
||||
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
|
||||
]);
|
||||
expect(model.getState().featureResolution).toBe("complete");
|
||||
expect(model.getState().disclosure.showFeaturesField).toBe(true);
|
||||
model.applyControls(key, {
|
||||
controls: [{ type: "toggle", key: "webSearch", label: "Web search" }],
|
||||
options: { webSearch: false },
|
||||
});
|
||||
expect(model.getState().controlsResolution).toBe("complete");
|
||||
expect(model.getState().disclosure.showControlsField).toBe(true);
|
||||
});
|
||||
|
||||
it("reflects a set feature value back into the rendered feature", () => {
|
||||
it("reflects a set option back into the rendered controls", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
model.setName("Searcher");
|
||||
selectClaude(model);
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS);
|
||||
|
||||
model.setOption("webSearch", true);
|
||||
|
||||
expect(model.getState().controls[0]).toMatchObject({ key: "webSearch" });
|
||||
expect(model.getState().controlOptions).toEqual({ webSearch: true });
|
||||
expect(model.getState().submitValue?.options).toEqual({ webSearch: true });
|
||||
});
|
||||
|
||||
it("keeps stored options while the controls are still pending", () => {
|
||||
const model = openAgentProfileForm({
|
||||
mode: "edit",
|
||||
profile: { id: "p1", name: "UI work", provider: "claude", options: { webSearch: true } },
|
||||
});
|
||||
model.applyProviderCatalog(ENTRIES);
|
||||
|
||||
expect(model.getState().controlsResolution).toBe("pending");
|
||||
expect(model.getState().submitValue?.options).toEqual({ webSearch: true });
|
||||
});
|
||||
|
||||
it("keeps options no control draws, so editing a profile does not drop them", () => {
|
||||
const model = openAgentProfileForm({
|
||||
mode: "edit",
|
||||
profile: {
|
||||
id: "p1",
|
||||
name: "UI work",
|
||||
provider: "claude",
|
||||
options: { webSearch: true, sandbox_mode: "read-only" },
|
||||
},
|
||||
});
|
||||
model.applyProviderCatalog(ENTRIES);
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS);
|
||||
model.setOption("webSearch", false);
|
||||
|
||||
expect(model.getState().submitValue?.options).toEqual({
|
||||
webSearch: false,
|
||||
sandbox_mode: "read-only",
|
||||
});
|
||||
});
|
||||
|
||||
it("draws the options the daemon resolved from the stored ones, then the edits", () => {
|
||||
const model = openAgentProfileForm({
|
||||
mode: "edit",
|
||||
profile: { id: "p1", name: "Fast", provider: "codex", options: { service_tier: "fast" } },
|
||||
});
|
||||
model.applyProviderCatalog(ENTRIES);
|
||||
|
||||
// The request carries the stored options so the daemon can resolve the legacy tier.
|
||||
expect(model.getState().controlsRequest?.providerOptions).toEqual({ service_tier: "fast" });
|
||||
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", {
|
||||
controls: [
|
||||
{
|
||||
type: "select",
|
||||
key: "service_tier",
|
||||
label: "Speed",
|
||||
defaultValue: "default",
|
||||
choices: [
|
||||
{ value: "default", label: "Standard" },
|
||||
{ value: "priority", label: "Fast" },
|
||||
],
|
||||
},
|
||||
],
|
||||
options: { service_tier: "priority" },
|
||||
});
|
||||
|
||||
expect(model.getState().controlOptions).toEqual({ service_tier: "priority" });
|
||||
// Nothing was edited, so the stored value is saved as it was.
|
||||
expect(model.getState().submitValue?.options).toEqual({ service_tier: "fast" });
|
||||
});
|
||||
|
||||
it("sends an edit to the daemon with the stored options, and draws the answer", () => {
|
||||
const model = openAgentProfileForm({
|
||||
mode: "edit",
|
||||
profile: {
|
||||
id: "p1",
|
||||
name: "Fast",
|
||||
provider: "codex",
|
||||
options: { service_tier: "fast", sandbox_mode: "read-only" },
|
||||
},
|
||||
});
|
||||
model.applyProviderCatalog(ENTRIES);
|
||||
const speed = {
|
||||
controls: [
|
||||
{
|
||||
type: "select" as const,
|
||||
key: "service_tier",
|
||||
label: "Speed",
|
||||
defaultValue: "default",
|
||||
choices: [
|
||||
{ value: "default", label: "Standard" },
|
||||
{ value: "priority", label: "Fast" },
|
||||
],
|
||||
},
|
||||
],
|
||||
options: { service_tier: "priority", sandbox_mode: "read-only" },
|
||||
};
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", speed);
|
||||
|
||||
model.setOption("service_tier", "default");
|
||||
|
||||
// The controls stay up while the daemon answers, with the toggle laid over the last answer.
|
||||
expect(model.getState().controls).toHaveLength(1);
|
||||
expect(model.getState().controlOptions).toMatchObject({ service_tier: "default" });
|
||||
// The request carries everything the profile would save, so the daemon resolves it.
|
||||
expect(model.getState().controlsRequest?.providerOptions).toEqual({
|
||||
service_tier: "default",
|
||||
sandbox_mode: "read-only",
|
||||
});
|
||||
expect(model.getState().controlsResolution).toBe("pending");
|
||||
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", {
|
||||
...speed,
|
||||
options: { service_tier: "default", sandbox_mode: "read-only" },
|
||||
});
|
||||
expect(model.getState().controlsResolution).toBe("complete");
|
||||
expect(model.getState().controlOptions).toEqual({
|
||||
service_tier: "default",
|
||||
sandbox_mode: "read-only",
|
||||
});
|
||||
});
|
||||
|
||||
it("draws no controls for the previous model while another model's answer is pending", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
selectClaude(model);
|
||||
model.applyFeatures(model.getState().featureRequestKey ?? "", [
|
||||
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
|
||||
]);
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS);
|
||||
|
||||
model.setFeatureValue("webSearch", true);
|
||||
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
|
||||
|
||||
expect(model.getState().features[0]).toMatchObject({ id: "webSearch", value: true });
|
||||
expect(model.getState().featureValues).toEqual({ webSearch: true });
|
||||
expect(model.getState().controls).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps stored feature values while the feature list is still pending", () => {
|
||||
it("does not save a toggle once the model that drew it is replaced by one without it", () => {
|
||||
const model = toggledOnOpus();
|
||||
expect(model.getState().submitValue?.options).toEqual({ fast_mode: true });
|
||||
|
||||
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS);
|
||||
|
||||
expect(model.getState().submitValue?.options).toBeUndefined();
|
||||
expect(model.getState().controlsRequest?.providerOptions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("saves a toggle again when the model that draws it returns", () => {
|
||||
const model = toggledOnOpus();
|
||||
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS);
|
||||
|
||||
model.setModel("claude-opus-5", { label: "Opus 5" });
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", FAST);
|
||||
|
||||
expect(model.getState().submitValue?.options).toEqual({ fast_mode: true });
|
||||
});
|
||||
|
||||
it("does not save a stored toggle profile either", () => {
|
||||
const model = openAgentProfileForm({
|
||||
mode: "edit",
|
||||
profile: {
|
||||
id: "p1",
|
||||
name: "UI work",
|
||||
name: "Fast",
|
||||
provider: "claude",
|
||||
featureValues: { webSearch: true },
|
||||
model: "claude-opus-5",
|
||||
options: { fast_mode: true },
|
||||
},
|
||||
});
|
||||
model.applyProviderCatalog(ENTRIES);
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", FAST);
|
||||
|
||||
expect(model.getState().featureResolution).toBe("pending");
|
||||
expect(model.getState().submitValue?.featureValues).toEqual({ webSearch: true });
|
||||
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS);
|
||||
|
||||
expect(model.getState().submitValue?.options).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prunes stored values the provider no longer reports", () => {
|
||||
const model = openAgentProfileForm({
|
||||
mode: "edit",
|
||||
profile: {
|
||||
id: "p1",
|
||||
name: "UI work",
|
||||
provider: "claude",
|
||||
featureValues: { webSearch: true, retiredFlag: true },
|
||||
},
|
||||
});
|
||||
model.applyProviderCatalog(ENTRIES);
|
||||
model.applyFeatures(model.getState().featureRequestKey ?? "", [
|
||||
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
|
||||
]);
|
||||
it("never drops an option no control has drawn", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
model.setName("Fast");
|
||||
selectClaude(model);
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", FAST);
|
||||
model.setOption("token_budget", 8000);
|
||||
model.setOption("fast_mode", true);
|
||||
|
||||
expect(model.getState().featureValues).toEqual({ webSearch: true });
|
||||
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS);
|
||||
|
||||
expect(model.getState().submitValue?.options).toEqual({ token_budget: 8000 });
|
||||
});
|
||||
|
||||
it("keeps a toggle when the daemon cannot list controls", () => {
|
||||
const model = toggledOnOpus();
|
||||
|
||||
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
|
||||
model.applyControlsUnavailable(model.getState().controlsRequestKey ?? "");
|
||||
|
||||
expect(model.getState().submitValue?.options).toEqual({ fast_mode: true });
|
||||
});
|
||||
|
||||
it("resolves an unavailable feature listing without showing the field", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
selectClaude(model);
|
||||
|
||||
model.applyFeaturesUnavailable(model.getState().featureRequestKey ?? "");
|
||||
model.applyControlsUnavailable(model.getState().controlsRequestKey ?? "");
|
||||
|
||||
expect(model.getState().featureResolution).toBe("complete");
|
||||
expect(model.getState().disclosure.showFeaturesField).toBe(false);
|
||||
expect(model.getState().controlsResolution).toBe("complete");
|
||||
expect(model.getState().disclosure.showControlsField).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -464,15 +642,15 @@ describe("openAgentProfileForm", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFeatureRequestKey", () => {
|
||||
describe("buildControlsRequestKey", () => {
|
||||
it("is null without a provider", () => {
|
||||
expect(buildFeatureRequestKey(null)).toBeNull();
|
||||
expect(buildControlsRequestKey(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("separates the fields so a value cannot bleed across positions", () => {
|
||||
expect(buildFeatureRequestKey({ provider: "claude", model: "a|b" })).toBe("claude|a|b||");
|
||||
expect(buildFeatureRequestKey({ provider: "claude", modeId: "a" })).not.toBe(
|
||||
buildFeatureRequestKey({ provider: "claude", model: "a" }),
|
||||
expect(buildControlsRequestKey({ provider: "claude", model: "a|b" })).toBe("claude|a|b||");
|
||||
expect(buildControlsRequestKey({ provider: "claude", modeId: "a" })).not.toBe(
|
||||
buildControlsRequestKey({ provider: "claude", model: "a" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type {
|
||||
AgentFeature,
|
||||
AgentOptionControl,
|
||||
ProviderOptions,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentSelectOption,
|
||||
ProviderSnapshotEntry,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||
import { withoutLostControls } from "@/agent-controls/lost-controls";
|
||||
import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels";
|
||||
import { applyFeatureValues, pruneFeatureValues } from "@/hooks/feature-preferences";
|
||||
import { filterSelectableModels } from "@/provider-selection/model-catalog";
|
||||
|
||||
/**
|
||||
@@ -57,11 +58,15 @@ export interface AgentProfileFormSnapshot {
|
||||
}
|
||||
|
||||
/**
|
||||
* The inputs a feature listing needs. Features are provider-scoped and change
|
||||
* with model/mode/thinking, so every one of those is part of the request.
|
||||
* The inputs a controls listing needs. Controls are provider-scoped and change
|
||||
* with model/mode/thinking, so every one of those is part of the request. The
|
||||
* profile's own options ride along so the daemon can resolve them (a legacy
|
||||
* `service_tier: "fast"` is drawn as the model's Fast tier); they are the same
|
||||
* for the life of one provider, so they are not part of the key.
|
||||
*/
|
||||
export interface AgentProfileFeatureRequest {
|
||||
export interface AgentProfileControlsRequest {
|
||||
provider: string;
|
||||
providerOptions?: ProviderOptions;
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
thinkingOptionId?: string;
|
||||
@@ -73,7 +78,7 @@ export interface AgentProfileFormDisclosure {
|
||||
showModelField: boolean;
|
||||
showModeField: boolean;
|
||||
showThinkingField: boolean;
|
||||
showFeaturesField: boolean;
|
||||
showControlsField: boolean;
|
||||
}
|
||||
|
||||
export interface AgentProfileFormState {
|
||||
@@ -86,13 +91,22 @@ export interface AgentProfileFormState {
|
||||
modelId: string;
|
||||
modeId: string;
|
||||
thinkingOptionId: string;
|
||||
featureValues: Record<string, unknown>;
|
||||
/** The options the profile was opened with, including ones no control draws. */
|
||||
options: ProviderOptions;
|
||||
/** Changes made in this form, over `options`. */
|
||||
optionEdits: ProviderOptions;
|
||||
|
||||
providerOptions: AgentProfileFormOption[];
|
||||
modelOptions: AgentProfileFormOption[];
|
||||
modeOptions: AgentProfileFormOption[];
|
||||
thinkingOptions: AgentProfileFormOption[];
|
||||
features: AgentFeature[];
|
||||
controls: AgentOptionControl[];
|
||||
/** What the controls show: the options the daemon resolved from `options`, then the edits. */
|
||||
controlOptions: ProviderOptions;
|
||||
/** Every key an answer for this provider has drawn as a control. */
|
||||
drawnControlKeys: ReadonlySet<string>;
|
||||
/** The keys the latest answer draws as controls. */
|
||||
answeredControlKeys: ReadonlySet<string>;
|
||||
|
||||
providerDisplay: AgentProfileFormDisplay | null;
|
||||
modelDisplay: AgentProfileFormDisplay | null;
|
||||
@@ -100,9 +114,9 @@ export interface AgentProfileFormState {
|
||||
thinkingDisplay: AgentProfileFormDisplay | null;
|
||||
|
||||
catalogResolution: AgentProfileResolutionStatus;
|
||||
featureResolution: AgentProfileResolutionStatus;
|
||||
featureRequest: AgentProfileFeatureRequest | null;
|
||||
featureRequestKey: string | null;
|
||||
controlsResolution: AgentProfileResolutionStatus;
|
||||
controlsRequest: AgentProfileControlsRequest | null;
|
||||
controlsRequestKey: string | null;
|
||||
|
||||
disclosure: AgentProfileFormDisclosure;
|
||||
isSubmitting: boolean;
|
||||
@@ -118,9 +132,12 @@ export interface AgentProfileFormModel {
|
||||
/** Late input: the host-scoped provider catalog. Never touches selections. */
|
||||
applyProviderCatalog: (entries: readonly ProviderSnapshotEntry[]) => void;
|
||||
/** Late input: the feature list for one request. Stale keys are ignored. */
|
||||
applyFeatures: (requestKey: string, features: readonly AgentFeature[]) => void;
|
||||
applyControls: (
|
||||
requestKey: string,
|
||||
resolved: { controls: readonly AgentOptionControl[]; options: ProviderOptions },
|
||||
) => void;
|
||||
/** Resolve a request that produced no usable features (provider error). */
|
||||
applyFeaturesUnavailable: (requestKey: string) => void;
|
||||
applyControlsUnavailable: (requestKey: string) => void;
|
||||
setName: (value: string) => void;
|
||||
setAppearance: (value: { icon: string; color: string }) => void;
|
||||
setNotes: (value: string) => void;
|
||||
@@ -128,7 +145,7 @@ export interface AgentProfileFormModel {
|
||||
setModel: (modelId: string, display: AgentProfileFormDisplay | null) => void;
|
||||
setMode: (modeId: string, display: AgentProfileFormDisplay | null) => void;
|
||||
setThinking: (thinkingOptionId: string, display: AgentProfileFormDisplay | null) => void;
|
||||
setFeatureValue: (featureId: string, value: unknown) => void;
|
||||
setOption: (key: string, value: unknown) => void;
|
||||
setSubmitting: (value: boolean) => void;
|
||||
setSubmitError: (value: string | null) => void;
|
||||
}
|
||||
@@ -312,19 +329,39 @@ function refineDisplay(
|
||||
return current ?? { label: selectedId };
|
||||
}
|
||||
|
||||
function buildFeatureRequest(state: AgentProfileFormState): AgentProfileFeatureRequest | null {
|
||||
function buildControlsRequest(state: AgentProfileFormState): AgentProfileControlsRequest | null {
|
||||
if (!state.provider) {
|
||||
return null;
|
||||
}
|
||||
const ownOptions = ownOptionsOf(state);
|
||||
return {
|
||||
provider: state.provider,
|
||||
...(state.modelId ? { model: state.modelId } : {}),
|
||||
...(state.modeId ? { modeId: state.modeId } : {}),
|
||||
...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}),
|
||||
...(Object.keys(ownOptions).length > 0 ? { providerOptions: ownOptions } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFeatureRequestKey(request: AgentProfileFeatureRequest | null): string | null {
|
||||
/**
|
||||
* Everything the profile would save as its options; the daemon resolves them for the form. A
|
||||
* toggle the model in use does not draw is not saved (see `withoutLostControls`).
|
||||
*/
|
||||
function ownOptionsOf(
|
||||
state: Pick<
|
||||
AgentProfileFormState,
|
||||
"options" | "optionEdits" | "drawnControlKeys" | "answeredControlKeys"
|
||||
>,
|
||||
): ProviderOptions {
|
||||
return withoutLostControls(
|
||||
{ ...state.options, ...state.optionEdits },
|
||||
state.drawnControlKeys,
|
||||
state.answeredControlKeys,
|
||||
);
|
||||
}
|
||||
|
||||
/** The part of a request that decides which controls exist; options only change their values. */
|
||||
function buildControlsScopeKey(request: AgentProfileControlsRequest | null): string | null {
|
||||
if (!request) {
|
||||
return null;
|
||||
}
|
||||
@@ -336,12 +373,25 @@ export function buildFeatureRequestKey(request: AgentProfileFeatureRequest | nul
|
||||
].join("|");
|
||||
}
|
||||
|
||||
export function buildControlsRequestKey(
|
||||
request: AgentProfileControlsRequest | null,
|
||||
): string | null {
|
||||
if (!request) {
|
||||
return null;
|
||||
}
|
||||
const scope = buildControlsScopeKey(request);
|
||||
return request.providerOptions
|
||||
? `${scope}|${JSON.stringify(Object.entries(request.providerOptions).sort(([a], [b]) => a.localeCompare(b)))}`
|
||||
: scope;
|
||||
}
|
||||
|
||||
function buildSubmitValue(state: AgentProfileFormState): AgentProfileValue | null {
|
||||
const name = state.name.trim();
|
||||
const notes = state.notes.trim();
|
||||
if (!name || !state.provider) {
|
||||
return null;
|
||||
}
|
||||
const options = ownOptionsOf(state);
|
||||
return {
|
||||
name,
|
||||
...(state.icon ? { icon: state.icon } : {}),
|
||||
@@ -350,16 +400,16 @@ function buildSubmitValue(state: AgentProfileFormState): AgentProfileValue | nul
|
||||
...(state.modelId ? { model: state.modelId } : {}),
|
||||
...(state.modeId ? { modeId: state.modeId } : {}),
|
||||
...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}),
|
||||
...(Object.keys(state.featureValues).length > 0 ? { featureValues: state.featureValues } : {}),
|
||||
...(Object.keys(options).length > 0 ? { options } : {}),
|
||||
...(notes ? { notes } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveFeatureStatus(
|
||||
featureRequestKey: string | null,
|
||||
function resolveControlsStatus(
|
||||
controlsRequestKey: string | null,
|
||||
featuresAreCurrent: boolean,
|
||||
): AgentProfileResolutionStatus {
|
||||
if (featureRequestKey === null) {
|
||||
if (controlsRequestKey === null) {
|
||||
return "idle";
|
||||
}
|
||||
return featuresAreCurrent ? "complete" : "pending";
|
||||
@@ -392,25 +442,29 @@ function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileForm
|
||||
modelId,
|
||||
modeId: profile.modeId ?? "",
|
||||
thinkingOptionId: profile.thinkingOptionId ?? "",
|
||||
featureValues: { ...profile.featureValues },
|
||||
options: { ...profile.options },
|
||||
optionEdits: {},
|
||||
providerOptions: [],
|
||||
modelOptions: [],
|
||||
modeOptions: [],
|
||||
thinkingOptions: [],
|
||||
features: [],
|
||||
controls: [],
|
||||
controlOptions: {},
|
||||
drawnControlKeys: new Set(),
|
||||
answeredControlKeys: new Set(),
|
||||
providerDisplay: seedDisplay(provider),
|
||||
modelDisplay: seedDisplay(modelId),
|
||||
modeDisplay: seedDisplay(profile.modeId),
|
||||
thinkingDisplay: seedDisplay(profile.thinkingOptionId),
|
||||
catalogResolution: "idle",
|
||||
featureResolution: "idle",
|
||||
featureRequest: null,
|
||||
featureRequestKey: null,
|
||||
controlsResolution: "idle",
|
||||
controlsRequest: null,
|
||||
controlsRequestKey: null,
|
||||
disclosure: {
|
||||
showModelField: false,
|
||||
showModeField: false,
|
||||
showThinkingField: false,
|
||||
showFeaturesField: false,
|
||||
showControlsField: false,
|
||||
},
|
||||
isSubmitting: false,
|
||||
submitError: null,
|
||||
@@ -422,28 +476,45 @@ function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileForm
|
||||
export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentProfileFormModel {
|
||||
let entries: readonly ProviderSnapshotEntry[] = [];
|
||||
let catalogResolution: AgentProfileResolutionStatus = "idle";
|
||||
let resolvedFeatureKey: string | null = null;
|
||||
let resolvedFeatures: AgentFeature[] = [];
|
||||
let resolvedControlsKey: string | null = null;
|
||||
let resolvedScopeKey: string | null = null;
|
||||
let resolvedControls: AgentOptionControl[] = [];
|
||||
let resolvedOptions: ProviderOptions = {};
|
||||
let drawnKeys: ReadonlySet<string> = new Set();
|
||||
let answeredKeys: ReadonlySet<string> = new Set();
|
||||
let listeners = new Set<() => void>();
|
||||
let closed = false;
|
||||
|
||||
function derive(incoming: AgentProfileFormState): AgentProfileFormState {
|
||||
const models = resolveModels(entries, incoming.provider);
|
||||
const modes = resolveModes(entries, incoming.provider);
|
||||
const next = seedSelections(incoming, {
|
||||
entry: findEntry(entries, incoming.provider),
|
||||
models,
|
||||
modes,
|
||||
});
|
||||
const next: AgentProfileFormState = {
|
||||
...seedSelections(incoming, {
|
||||
entry: findEntry(entries, incoming.provider),
|
||||
models,
|
||||
modes,
|
||||
}),
|
||||
drawnControlKeys: drawnKeys,
|
||||
answeredControlKeys: answeredKeys,
|
||||
};
|
||||
const thinking = resolveThinkingOptions(entries, next.provider, next.modelId);
|
||||
const featureRequest = buildFeatureRequest(next);
|
||||
const featureRequestKey = buildFeatureRequestKey(featureRequest);
|
||||
const controlsRequest = buildControlsRequest(next);
|
||||
const controlsRequestKey = buildControlsRequestKey(controlsRequest);
|
||||
const controlsScopeKey = buildControlsScopeKey(controlsRequest);
|
||||
const featuresAreCurrent =
|
||||
featureRequestKey !== null && featureRequestKey === resolvedFeatureKey;
|
||||
const features = featuresAreCurrent
|
||||
? applyFeatureValues(resolvedFeatures, next.featureValues)
|
||||
: [];
|
||||
const featureResolution = resolveFeatureStatus(featureRequestKey, featuresAreCurrent);
|
||||
controlsRequestKey !== null && controlsRequestKey === resolvedControlsKey;
|
||||
// A toggle changes only the options, so the controls stay up while the daemon answers and the
|
||||
// toggle shows over the last answer. A change of provider, model, mode or thinking draws none
|
||||
// until its own answer arrives.
|
||||
const controlsApply = controlsScopeKey !== null && controlsScopeKey === resolvedScopeKey;
|
||||
const controls = controlsApply ? resolvedControls : [];
|
||||
let controlOptions: ProviderOptions = next.options;
|
||||
if (controlsApply) {
|
||||
controlOptions = featuresAreCurrent
|
||||
? resolvedOptions
|
||||
: { ...resolvedOptions, ...next.optionEdits };
|
||||
}
|
||||
const controlsResolution = resolveControlsStatus(controlsRequestKey, featuresAreCurrent);
|
||||
|
||||
const withOptions: AgentProfileFormState = {
|
||||
...next,
|
||||
@@ -451,7 +522,8 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
|
||||
modelOptions: buildModelOptions(models),
|
||||
modeOptions: buildModeOptions(modes),
|
||||
thinkingOptions: buildThinkingOptions(thinking),
|
||||
features,
|
||||
controls,
|
||||
controlOptions,
|
||||
providerDisplay: refineDisplay(
|
||||
next.providerDisplay,
|
||||
next.provider,
|
||||
@@ -479,9 +551,9 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
|
||||
})(),
|
||||
),
|
||||
catalogResolution,
|
||||
featureResolution,
|
||||
featureRequest,
|
||||
featureRequestKey,
|
||||
controlsResolution,
|
||||
controlsRequest,
|
||||
controlsRequestKey,
|
||||
};
|
||||
|
||||
// A field appears once the catalog can populate it, and stays visible while
|
||||
@@ -492,7 +564,7 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
|
||||
showModeField: hasProvider && (modes.length > 0 || Boolean(withOptions.modeId)),
|
||||
showThinkingField:
|
||||
hasProvider && (thinking.length > 0 || Boolean(withOptions.thinkingOptionId)),
|
||||
showFeaturesField: hasProvider && features.length > 0,
|
||||
showControlsField: hasProvider && controls.length > 0,
|
||||
};
|
||||
const canSubmit =
|
||||
withOptions.name.trim().length > 0 &&
|
||||
@@ -531,23 +603,26 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
|
||||
catalogResolution = "complete";
|
||||
publish((current) => current);
|
||||
},
|
||||
applyFeatures: (requestKey, features) => {
|
||||
if (requestKey !== state.featureRequestKey) {
|
||||
applyControls: (requestKey, resolved) => {
|
||||
if (requestKey !== state.controlsRequestKey) {
|
||||
return;
|
||||
}
|
||||
resolvedFeatureKey = requestKey;
|
||||
resolvedFeatures = [...features];
|
||||
publish((current) => ({
|
||||
...current,
|
||||
featureValues: pruneFeatureValues(current.featureValues, resolvedFeatures),
|
||||
}));
|
||||
resolvedControlsKey = requestKey;
|
||||
resolvedScopeKey = buildControlsScopeKey(state.controlsRequest);
|
||||
resolvedControls = [...resolved.controls];
|
||||
resolvedOptions = resolved.options;
|
||||
answeredKeys = new Set(resolved.controls.map((control) => control.key));
|
||||
drawnKeys = new Set([...drawnKeys, ...answeredKeys]);
|
||||
publish((current) => current);
|
||||
},
|
||||
applyFeaturesUnavailable: (requestKey) => {
|
||||
if (requestKey !== state.featureRequestKey) {
|
||||
applyControlsUnavailable: (requestKey) => {
|
||||
if (requestKey !== state.controlsRequestKey) {
|
||||
return;
|
||||
}
|
||||
resolvedFeatureKey = requestKey;
|
||||
resolvedFeatures = [];
|
||||
resolvedControlsKey = requestKey;
|
||||
resolvedScopeKey = buildControlsScopeKey(state.controlsRequest);
|
||||
resolvedControls = [];
|
||||
resolvedOptions = {};
|
||||
publish((current) => current);
|
||||
},
|
||||
setName: (value) => publish((current) => ({ ...current, name: value })),
|
||||
@@ -562,6 +637,8 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
|
||||
// Everything below the provider is provider-scoped: a model id, mode id,
|
||||
// thinking id or feature id from the old provider means nothing here.
|
||||
// Clearing them is enough — `derive` seeds the new provider's defaults.
|
||||
drawnKeys = new Set();
|
||||
answeredKeys = new Set();
|
||||
return {
|
||||
...current,
|
||||
provider: providerId,
|
||||
@@ -572,7 +649,8 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
|
||||
modeDisplay: null,
|
||||
thinkingOptionId: "",
|
||||
thinkingDisplay: null,
|
||||
featureValues: {},
|
||||
options: {},
|
||||
optionEdits: {},
|
||||
};
|
||||
}),
|
||||
setModel: (modelId, display) =>
|
||||
@@ -598,10 +676,10 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
|
||||
publish((current) => ({ ...current, modeId, modeDisplay: display })),
|
||||
setThinking: (thinkingOptionId, display) =>
|
||||
publish((current) => ({ ...current, thinkingOptionId, thinkingDisplay: display })),
|
||||
setFeatureValue: (featureId, value) =>
|
||||
setOption: (key, value) =>
|
||||
publish((current) => ({
|
||||
...current,
|
||||
featureValues: { ...current.featureValues, [featureId]: value },
|
||||
optionEdits: { ...current.optionEdits, [key]: value },
|
||||
})),
|
||||
setSubmitting: (value) => publish((current) => ({ ...current, isSubmitting: value })),
|
||||
setSubmitError: (value) => publish((current) => ({ ...current, submitError: value })),
|
||||
|
||||
@@ -47,7 +47,7 @@ export function buildAgentProfileTags(input: {
|
||||
tags.push({ id: "thinking", label: formatThinkingOptionLabel({ id: thinkingOptionId }) });
|
||||
}
|
||||
|
||||
const featureCount = Object.keys(input.profile.featureValues ?? {}).length;
|
||||
const featureCount = Object.keys(input.profile.options ?? {}).length;
|
||||
if (featureCount > 0) {
|
||||
tags.push({ id: "features", label: input.formatFeatureCount(featureCount) });
|
||||
}
|
||||
|
||||
@@ -117,9 +117,7 @@ export function useAgentProfilePicker(
|
||||
modelId: resolved.modelId,
|
||||
modeId: resolved.modeId,
|
||||
thinkingOptionId: resolved.thinkingOptionId,
|
||||
...(Object.keys(resolved.featureValues).length > 0
|
||||
? { featureValues: resolved.featureValues }
|
||||
: {}),
|
||||
...(Object.keys(resolved.options).length > 0 ? { options: resolved.options } : {}),
|
||||
}),
|
||||
).catch((error) => {
|
||||
console.warn("[useAgentProfilePicker] persist profile selection failed", error);
|
||||
|
||||
@@ -27,7 +27,7 @@ export function useAgentProfileFormCatalog(input: {
|
||||
}, [entries, input.model]);
|
||||
}
|
||||
|
||||
export function useAgentProfileFormFeatures(input: {
|
||||
export function useAgentProfileFormControls(input: {
|
||||
serverId: string;
|
||||
model: AgentProfileFormModel;
|
||||
state: AgentProfileFormState;
|
||||
@@ -35,14 +35,14 @@ export function useAgentProfileFormFeatures(input: {
|
||||
const { t } = useTranslation();
|
||||
const client = useHostRuntimeClient(input.serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(input.serverId);
|
||||
const request = input.state.featureRequest;
|
||||
const requestKey = input.state.featureRequestKey;
|
||||
const request = input.state.controlsRequest;
|
||||
const requestKey = input.state.controlsRequestKey;
|
||||
|
||||
// `dataShape: "value"` and not `"list"`: a list shape keeps the previous
|
||||
// page's data while the next request is in flight, which would hand the
|
||||
// features of one provider/model combination to the key of another.
|
||||
const featuresQuery = useFetchQuery({
|
||||
queryKey: ["agentProfileFeatures", input.serverId, requestKey],
|
||||
const controlsQuery = useFetchQuery({
|
||||
queryKey: ["agentProfileControls", input.serverId, requestKey],
|
||||
dataShape: "value",
|
||||
staleTimeMs: 5 * 60 * 1000,
|
||||
enabled: Boolean(client && isConnected && request),
|
||||
@@ -51,28 +51,28 @@ export function useAgentProfileFormFeatures(input: {
|
||||
if (!client || !request) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
const payload = await client.listProviderFeatures({ ...request, cwd: HOME_SCOPE_CWD });
|
||||
const payload = await client.listProviderControls({ ...request, cwd: HOME_SCOPE_CWD });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.features ?? [];
|
||||
return { controls: payload.controls ?? [], options: payload.options ?? {} };
|
||||
},
|
||||
});
|
||||
|
||||
const { data, isError } = featuresQuery;
|
||||
const { data, isError } = controlsQuery;
|
||||
const { model } = input;
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestKey || !data) {
|
||||
return;
|
||||
}
|
||||
model.applyFeatures(requestKey, data);
|
||||
model.applyControls(requestKey, data);
|
||||
}, [data, model, requestKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestKey || !isError) {
|
||||
return;
|
||||
}
|
||||
model.applyFeaturesUnavailable(requestKey);
|
||||
model.applyControlsUnavailable(requestKey);
|
||||
}, [isError, model, requestKey]);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement }
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { AgentFeature } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentOptionControl, ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { readSelectOption, readToggleOption } from "@/agent-controls/option-controls";
|
||||
import type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -24,7 +25,7 @@ import type {
|
||||
} from "../internal/profile-form-model";
|
||||
import {
|
||||
useAgentProfileFormCatalog,
|
||||
useAgentProfileFormFeatures,
|
||||
useAgentProfileFormControls,
|
||||
} from "../internal/use-profile-form-inputs";
|
||||
import {
|
||||
useAgentProfileFormModel,
|
||||
@@ -140,7 +141,7 @@ function OpenAgentProfileEditModal({
|
||||
const model = useAgentProfileFormModel(snapshot);
|
||||
const state = useAgentProfileFormState(model);
|
||||
useAgentProfileFormCatalog({ serverId, model });
|
||||
useAgentProfileFormFeatures({ serverId, model, state });
|
||||
useAgentProfileFormControls({ serverId, model, state });
|
||||
|
||||
const sheetHeader = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
@@ -327,16 +328,17 @@ function OpenAgentProfileEditModal({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{state.disclosure.showFeaturesField ? (
|
||||
{state.disclosure.showControlsField ? (
|
||||
<Field
|
||||
label={t("settings.host.agentProfiles.featuresLabel")}
|
||||
testID="agent-profile-features-field"
|
||||
>
|
||||
<View style={styles.featureCard} testID="agent-profile-features-card">
|
||||
{state.features.map((feature, index) => (
|
||||
<AgentProfileFeatureRow
|
||||
key={feature.id}
|
||||
feature={feature}
|
||||
{state.controls.map((control, index) => (
|
||||
<AgentProfileControlRow
|
||||
key={control.key}
|
||||
control={control}
|
||||
options={state.controlOptions}
|
||||
isFirst={index === 0}
|
||||
disabled={state.isSubmitting}
|
||||
size={controlSize}
|
||||
@@ -399,14 +401,16 @@ function OpenAgentProfileEditModal({
|
||||
);
|
||||
}
|
||||
|
||||
function AgentProfileFeatureRow({
|
||||
feature,
|
||||
function AgentProfileControlRow({
|
||||
control,
|
||||
options,
|
||||
isFirst,
|
||||
disabled,
|
||||
size,
|
||||
model,
|
||||
}: {
|
||||
feature: AgentFeature;
|
||||
control: AgentOptionControl;
|
||||
options: ProviderOptions;
|
||||
isFirst: boolean;
|
||||
disabled: boolean;
|
||||
size: ControlSize;
|
||||
@@ -417,74 +421,75 @@ function AgentProfileFeatureRow({
|
||||
[isFirst],
|
||||
);
|
||||
const handleToggle = useCallback(
|
||||
(value: boolean) => model.setFeatureValue(feature.id, value),
|
||||
[feature.id, model],
|
||||
(value: boolean) => model.setOption(control.key, value),
|
||||
[control.key, model],
|
||||
);
|
||||
const handleSelect = useCallback(
|
||||
(value: string) => model.setFeatureValue(feature.id, value),
|
||||
[feature.id, model],
|
||||
(value: string) => model.setOption(control.key, value),
|
||||
[control.key, model],
|
||||
);
|
||||
const selectOptions = useMemo<SelectFieldOption<string>[]>(
|
||||
() =>
|
||||
feature.type === "select"
|
||||
? feature.options.map((option) => ({
|
||||
id: option.id,
|
||||
value: option.id,
|
||||
label: option.label,
|
||||
...(option.description ? { description: option.description } : {}),
|
||||
testID: `agent-profile-feature-option-${feature.id}-${option.id}`,
|
||||
control.type === "select"
|
||||
? control.choices.map((choice) => ({
|
||||
id: choice.value,
|
||||
value: choice.value,
|
||||
label: choice.label,
|
||||
...(choice.description ? { description: choice.description } : {}),
|
||||
testID: `agent-profile-feature-option-${control.key}-${choice.value}`,
|
||||
}))
|
||||
: [],
|
||||
[feature],
|
||||
[control],
|
||||
);
|
||||
const selectedValue = control.type === "select" ? readSelectOption(control, options) : null;
|
||||
const selectedDisplay = useMemo<SelectFieldDisplay | null>(() => {
|
||||
if (feature.type !== "select" || !feature.value) {
|
||||
if (control.type !== "select" || !selectedValue) {
|
||||
return null;
|
||||
}
|
||||
const option = feature.options.find((candidate) => candidate.id === feature.value);
|
||||
return { label: option?.label ?? feature.value };
|
||||
}, [feature]);
|
||||
const choice = control.choices.find((candidate) => candidate.value === selectedValue);
|
||||
return { label: choice?.label ?? selectedValue };
|
||||
}, [control, selectedValue]);
|
||||
|
||||
if (feature.type === "select") {
|
||||
if (control.type === "select") {
|
||||
return (
|
||||
<View style={rowStyle} testID={`agent-profile-feature-row-${feature.id}`}>
|
||||
<View style={rowStyle} testID={`agent-profile-feature-row-${control.key}`}>
|
||||
<SelectField
|
||||
label={feature.label}
|
||||
value={feature.value}
|
||||
label={control.label}
|
||||
value={selectedValue}
|
||||
selectedDisplay={selectedDisplay}
|
||||
options={selectOptions}
|
||||
onChange={handleSelect}
|
||||
placeholder={feature.label}
|
||||
emptyText={feature.label}
|
||||
placeholder={control.label}
|
||||
emptyText={control.label}
|
||||
disabled={disabled}
|
||||
{...(feature.description ? { hint: feature.description } : {})}
|
||||
title={feature.label}
|
||||
{...(control.description ? { hint: control.description } : {})}
|
||||
title={control.label}
|
||||
size={size}
|
||||
testID={`agent-profile-feature-field-${feature.id}`}
|
||||
triggerTestID={`agent-profile-feature-trigger-${feature.id}`}
|
||||
testID={`agent-profile-feature-field-${control.key}`}
|
||||
triggerTestID={`agent-profile-feature-trigger-${control.key}`}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={rowStyle} testID={`agent-profile-feature-row-${feature.id}`}>
|
||||
<View style={rowStyle} testID={`agent-profile-feature-row-${control.key}`}>
|
||||
<View style={styles.featureMeta}>
|
||||
<Text style={styles.featureLabel} numberOfLines={1}>
|
||||
{feature.label}
|
||||
{control.label}
|
||||
</Text>
|
||||
{feature.description ? (
|
||||
{control.description ? (
|
||||
<Text style={styles.featureDescription} numberOfLines={2}>
|
||||
{feature.description}
|
||||
{control.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Switch
|
||||
value={feature.value === true}
|
||||
value={readToggleOption(control, options)}
|
||||
onValueChange={handleToggle}
|
||||
disabled={disabled}
|
||||
accessibilityLabel={feature.label}
|
||||
testID={`agent-profile-feature-switch-${feature.id}`}
|
||||
accessibilityLabel={control.label}
|
||||
testID={`agent-profile-feature-switch-${control.key}`}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ import { PaneFind, findShortcutPlatform, isFindShortcut, type PaneFindHandle } f
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { usePaneFocus } from "@/panels/pane-context";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { hasActiveWebOverlay } from "@/lib/overlay-root";
|
||||
import { hasActiveOverlay } from "@/keyboard";
|
||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { planTimelinePromptJump } from "@/timeline/timeline-sync-plan";
|
||||
@@ -104,7 +104,7 @@ export function ChatFind({
|
||||
useEffect(() => {
|
||||
if (!active || !isInteractive) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event))
|
||||
if (event.defaultPrevented || hasActiveOverlay() || isImeComposingKeyboardEvent(event))
|
||||
return;
|
||||
if (isFindShortcut(event, findShortcutPlatform())) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -1304,7 +1304,8 @@ function collectAgentSetupDiffs(left: AgentScreenAgent, right: AgentScreenAgent)
|
||||
if (left.runtimeInfo?.thinkingOptionId !== right.runtimeInfo?.thinkingOptionId) {
|
||||
reasons.push("agent.runtimeInfo.thinkingOptionId");
|
||||
}
|
||||
if (left.features !== right.features) reasons.push("agent.features");
|
||||
if (left.options !== right.options) reasons.push("agent.options");
|
||||
if (left.controls !== right.controls) reasons.push("agent.controls");
|
||||
return reasons;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useHardwareKeyboardPresent } from "@/keyboard";
|
||||
import { AddHostProvider, AddHostFlowHost } from "@/hosts/add-host";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
@@ -90,7 +91,7 @@ import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-acti
|
||||
import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-action";
|
||||
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
|
||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useKeyboardShortcuts } from "@/keyboard/use-shortcuts";
|
||||
import { KeyboardShiftProvider } from "@/keyboard/shift";
|
||||
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
@@ -549,8 +550,12 @@ function SidebarChrome({
|
||||
const isMobileActive = useIsMobilePanelActive("agent-list");
|
||||
const isDesktopOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const active = visible && (isCompactLayout ? isMobileActive : isDesktopOpen);
|
||||
const hardwareKeyboardPresent = useHardwareKeyboardPresent();
|
||||
return (
|
||||
<SidebarModelProvider active={active}>
|
||||
<SidebarModelProvider
|
||||
active={active}
|
||||
keyboardNavigationActive={keyboardShortcutsEnabled && hardwareKeyboardPresent}
|
||||
>
|
||||
{mounted ? <LeftSidebar active={active} /> : null}
|
||||
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
|
||||
</SidebarModelProvider>
|
||||
|
||||
@@ -40,16 +40,7 @@ function createAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
cwd: "/repo",
|
||||
model: "agent-model",
|
||||
thinkingOptionId: "think-hard",
|
||||
features: [
|
||||
{ type: "toggle", id: "web-search", label: "Web search", value: true },
|
||||
{
|
||||
type: "select",
|
||||
id: "effort",
|
||||
label: "Effort",
|
||||
value: "high",
|
||||
options: [{ id: "high", label: "High" }],
|
||||
},
|
||||
],
|
||||
options: { "web-search": true, effort: "high" },
|
||||
parentAgentId: null,
|
||||
labels: {},
|
||||
...overrides,
|
||||
@@ -114,7 +105,7 @@ describe("buildDraftAgentSetup", () => {
|
||||
modeId: "mode-current",
|
||||
model: "agent-model",
|
||||
thinkingOptionId: "think-hard",
|
||||
featureValues: {
|
||||
providerOptions: {
|
||||
"web-search": true,
|
||||
effort: "high",
|
||||
},
|
||||
|
||||
@@ -65,17 +65,12 @@ export function resolveClientSlashCommand(input: {
|
||||
}
|
||||
|
||||
export function buildDraftAgentSetup(agent: Agent): WorkspaceDraftTabSetup {
|
||||
const featureValues: Record<string, unknown> = {};
|
||||
for (const feature of agent.features ?? []) {
|
||||
featureValues[feature.id] = feature.value;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: agent.provider,
|
||||
cwd: agent.cwd,
|
||||
modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null,
|
||||
model: agent.model ?? agent.runtimeInfo?.model ?? null,
|
||||
thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null,
|
||||
featureValues,
|
||||
providerOptions: { ...agent.options },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentFeature, AgentMode, AgentSelectOption } from "@getpaseo/protocol/agent-types";
|
||||
import type {
|
||||
AgentMode,
|
||||
AgentOptionControl,
|
||||
AgentSelectOption,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
||||
import type { CommandCenterContribution } from "./contributions";
|
||||
import {
|
||||
@@ -31,7 +35,7 @@ const ICONS: AgentControlContributionSource["icons"] = {
|
||||
thinking: TestIcon,
|
||||
planMode: TestIcon,
|
||||
mode: () => TestIcon,
|
||||
feature: () => TestIcon,
|
||||
control: () => TestIcon,
|
||||
};
|
||||
|
||||
function provider(input: {
|
||||
@@ -65,8 +69,8 @@ function provider(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function toggleFeature(id: string, value = false, icon?: string): AgentFeature {
|
||||
return { type: "toggle", id, label: id, value, icon };
|
||||
function toggleControl(key: string, icon?: string): AgentOptionControl {
|
||||
return { type: "toggle", key, label: key, icon };
|
||||
}
|
||||
|
||||
function makeSource(
|
||||
@@ -75,7 +79,7 @@ function makeSource(
|
||||
models?: Partial<AgentControlContributionSource["models"]>;
|
||||
thinking?: Partial<AgentControlContributionSource["thinking"]>;
|
||||
modes?: Partial<NonNullable<AgentControlContributionSource["modes"]>>;
|
||||
features?: Partial<AgentControlContributionSource["features"]>;
|
||||
optionControls?: Partial<AgentControlContributionSource["optionControls"]>;
|
||||
} = {},
|
||||
): AgentControlContributionSource {
|
||||
return {
|
||||
@@ -104,10 +108,11 @@ function makeSource(
|
||||
select: () => undefined,
|
||||
...overrides.modes,
|
||||
},
|
||||
features: {
|
||||
list: [],
|
||||
set: () => undefined,
|
||||
...overrides.features,
|
||||
optionControls: {
|
||||
controls: [],
|
||||
options: {},
|
||||
onSetOption: () => undefined,
|
||||
...overrides.optionControls,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -133,7 +138,7 @@ describe("Command Center agent-control contributions", () => {
|
||||
const contributions = buildAgentControlContributions({
|
||||
...makeSource({
|
||||
models: { providers: [provider({ id: "claude", label: "Claude" })] },
|
||||
features: { list: [toggleFeature("plan_mode", true)] },
|
||||
optionControls: { controls: [toggleControl("plan_mode")], options: { plan_mode: true } },
|
||||
}),
|
||||
modes: undefined,
|
||||
});
|
||||
@@ -323,9 +328,10 @@ describe("Command Center agent-control contributions", () => {
|
||||
selectedId: "default",
|
||||
defaultModeId: "default",
|
||||
},
|
||||
features: {
|
||||
list: [toggleFeature("plan_mode", true, "list-todo")],
|
||||
set: (id, value) => {
|
||||
optionControls: {
|
||||
controls: [toggleControl("plan_mode", "list-todo")],
|
||||
options: { plan_mode: true },
|
||||
onSetOption: (id, value) => {
|
||||
calls.push([id, value]);
|
||||
},
|
||||
},
|
||||
@@ -341,9 +347,10 @@ describe("Command Center agent-control contributions", () => {
|
||||
const calls: [string, unknown][] = [];
|
||||
const contributions = buildAgentControlContributions(
|
||||
makeSource({
|
||||
features: {
|
||||
list: [toggleFeature("fast_mode", false, "zap"), toggleFeature("auto_accept", true)],
|
||||
set: (id, value) => {
|
||||
optionControls: {
|
||||
controls: [toggleControl("fast_mode", "zap"), toggleControl("auto_accept")],
|
||||
options: { fast_mode: false, auto_accept: true },
|
||||
onSetOption: (id, value) => {
|
||||
calls.push([id, value]);
|
||||
},
|
||||
},
|
||||
@@ -376,7 +383,7 @@ describe("Command Center agent-control contributions", () => {
|
||||
selectedId: "default",
|
||||
defaultModeId: "default",
|
||||
},
|
||||
features: { list: [toggleFeature("fast_mode")] },
|
||||
optionControls: { controls: [toggleControl("fast_mode")], options: {} },
|
||||
}),
|
||||
);
|
||||
const ranks = new Map<string, number>();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type {
|
||||
AgentFeature,
|
||||
AgentMode,
|
||||
AgentOptionControl,
|
||||
AgentProvider,
|
||||
AgentSelectOption,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import { readToggleOption, type ComposerOptionControls } from "@/agent-controls/option-controls";
|
||||
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
||||
import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels";
|
||||
import {
|
||||
@@ -61,7 +62,7 @@ export interface AgentControlContributionIcons {
|
||||
thinking: CommandCenterIcon;
|
||||
planMode: CommandCenterIcon;
|
||||
mode(modeId: string): CommandCenterIcon;
|
||||
feature(feature: AgentFeature): CommandCenterIcon;
|
||||
control(control: AgentOptionControl): CommandCenterIcon;
|
||||
}
|
||||
|
||||
interface CommandCenterChoice {
|
||||
@@ -105,10 +106,7 @@ export interface AgentControlContributionSource {
|
||||
defaultModeId: string | null;
|
||||
select(modeId: string): void | Promise<void>;
|
||||
};
|
||||
features: {
|
||||
list: readonly AgentFeature[];
|
||||
set(featureId: string, value: unknown): void | Promise<void>;
|
||||
};
|
||||
optionControls: ComposerOptionControls;
|
||||
}
|
||||
|
||||
function buildContributions(
|
||||
@@ -242,18 +240,19 @@ function toggleChoices(input: {
|
||||
}
|
||||
|
||||
function buildPlanModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup {
|
||||
const planFeature = source.features.list.find(
|
||||
(feature) => feature.id === PLAN_MODE_FEATURE_ID && feature.type === "toggle",
|
||||
const { controls, options, onSetOption } = source.optionControls;
|
||||
const planControl = controls.find(
|
||||
(control) => control.key === PLAN_MODE_FEATURE_ID && control.type === "toggle",
|
||||
);
|
||||
let choices: CommandCenterChoice[] = [];
|
||||
if (planFeature?.type === "toggle") {
|
||||
if (planControl?.type === "toggle") {
|
||||
choices = toggleChoices({
|
||||
source,
|
||||
key: "plan",
|
||||
icon: source.icons.feature(planFeature),
|
||||
isOn: planFeature.value,
|
||||
turnOn: () => source.features.set(PLAN_MODE_FEATURE_ID, true),
|
||||
turnOff: () => source.features.set(PLAN_MODE_FEATURE_ID, false),
|
||||
icon: source.icons.control(planControl),
|
||||
isOn: readToggleOption(planControl, options),
|
||||
turnOn: () => onSetOption(PLAN_MODE_FEATURE_ID, true),
|
||||
turnOff: () => onSetOption(PLAN_MODE_FEATURE_ID, false),
|
||||
});
|
||||
} else if (source.modes) {
|
||||
const modes = source.modes;
|
||||
@@ -280,18 +279,19 @@ function buildPlanModeGroup(source: AgentControlContributionSource): CommandCent
|
||||
}
|
||||
|
||||
function buildFastModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup {
|
||||
const fastFeature = source.features.list.find(
|
||||
(feature) => feature.id === FAST_MODE_FEATURE_ID && feature.type === "toggle",
|
||||
const { controls, options, onSetOption } = source.optionControls;
|
||||
const fastControl = controls.find(
|
||||
(control) => control.key === FAST_MODE_FEATURE_ID && control.type === "toggle",
|
||||
);
|
||||
const choices =
|
||||
fastFeature?.type === "toggle"
|
||||
fastControl?.type === "toggle"
|
||||
? toggleChoices({
|
||||
source,
|
||||
key: "fast",
|
||||
icon: source.icons.feature(fastFeature),
|
||||
isOn: fastFeature.value,
|
||||
turnOn: () => source.features.set(FAST_MODE_FEATURE_ID, true),
|
||||
turnOff: () => source.features.set(FAST_MODE_FEATURE_ID, false),
|
||||
icon: source.icons.control(fastControl),
|
||||
isOn: readToggleOption(fastControl, options),
|
||||
turnOn: () => onSetOption(FAST_MODE_FEATURE_ID, true),
|
||||
turnOff: () => onSetOption(FAST_MODE_FEATURE_ID, false),
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
AgentFeature,
|
||||
AgentMode,
|
||||
AgentProvider,
|
||||
AgentSelectOption,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentMode, AgentProvider, AgentSelectOption } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
import {
|
||||
getAgentFeatureIcon,
|
||||
@@ -14,6 +9,7 @@ import {
|
||||
ThinkingIcon,
|
||||
} from "@/agent-controls/icons";
|
||||
import { useProviderIcons } from "@/components/provider-icons";
|
||||
import type { ComposerOptionControls } from "@/agent-controls/option-controls";
|
||||
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
||||
import {
|
||||
buildAgentControlContributions,
|
||||
@@ -44,15 +40,15 @@ export interface AgentControlCommandCenterSource {
|
||||
selectedId: string | null | undefined;
|
||||
select(modeId: string): void | Promise<void>;
|
||||
};
|
||||
features: {
|
||||
list: readonly AgentFeature[] | undefined;
|
||||
set?: (featureId: string, value: unknown) => void | Promise<void>;
|
||||
};
|
||||
/** Absent when the surface cannot change options (the agent has no client). */
|
||||
optionControls: ComposerOptionControls | null;
|
||||
}
|
||||
|
||||
function ignoreAgentFeatureChange(): void {
|
||||
return;
|
||||
}
|
||||
const NO_OPTION_CONTROLS: ComposerOptionControls = {
|
||||
controls: [],
|
||||
options: {},
|
||||
onSetOption: () => undefined,
|
||||
};
|
||||
|
||||
function resolveDefaultModeId(
|
||||
provider: string | null | undefined,
|
||||
@@ -87,7 +83,7 @@ export function useAgentControlCommandCenterActions(input: {
|
||||
}): void {
|
||||
const { t } = useTranslation();
|
||||
const { controls } = input;
|
||||
const { features, models, modes, thinking } = controls;
|
||||
const { optionControls, models, modes, thinking } = controls;
|
||||
const getProviderIcon = useProviderIcons(controls.serverId);
|
||||
const actions = useMemo(
|
||||
() =>
|
||||
@@ -104,7 +100,7 @@ export function useAgentControlCommandCenterActions(input: {
|
||||
getCommandCenterIcon(
|
||||
getAgentModeIcon(controls.provider ?? "", modeId, controls.providerDefinitions),
|
||||
),
|
||||
feature: (feature) => getCommandCenterIcon(getAgentFeatureIcon(feature.icon)),
|
||||
control: (control) => getCommandCenterIcon(getAgentFeatureIcon(control.icon)),
|
||||
},
|
||||
models: {
|
||||
providers: models.providers,
|
||||
@@ -123,10 +119,7 @@ export function useAgentControlCommandCenterActions(input: {
|
||||
modes?.select,
|
||||
resolveDefaultModeId(controls.provider, controls.providerDefinitions),
|
||||
),
|
||||
features: {
|
||||
list: features.set ? (features.list ?? []) : [],
|
||||
set: features.set ?? ignoreAgentFeatureChange,
|
||||
},
|
||||
optionControls: optionControls ?? NO_OPTION_CONTROLS,
|
||||
}),
|
||||
[
|
||||
getProviderIcon,
|
||||
@@ -134,8 +127,7 @@ export function useAgentControlCommandCenterActions(input: {
|
||||
controls.provider,
|
||||
controls.providerDefinitions,
|
||||
controls.serverId,
|
||||
features.list,
|
||||
features.set,
|
||||
optionControls,
|
||||
models.providers,
|
||||
models.select,
|
||||
models.selectedModelId,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import equal from "fast-deep-equal";
|
||||
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
||||
@@ -52,11 +53,7 @@ import { useProjects } from "@/hooks/use-projects";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { createProjectIconTarget } from "@/projects/icon-target";
|
||||
import { useProjectIcons } from "@/projects/icons";
|
||||
import {
|
||||
OverlayLayerProvider,
|
||||
useGlobalWebOverlayLayer,
|
||||
useWebOverlayRegistration,
|
||||
} from "@/lib/overlay-root";
|
||||
import { OverlayLayerProvider, useGlobalOverlayLayer } from "@/lib/overlay-root";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import {
|
||||
useKeyboardShortcutsStore,
|
||||
@@ -763,6 +760,10 @@ function SectionRow({ row }: { row: Extract<CommandCenterListRow, { kind: "secti
|
||||
);
|
||||
}
|
||||
|
||||
// Escape arrives without asking; it is registered for `agent.interrupt` anyway.
|
||||
// Backspace stays off deliberately — the palette pops its scope with it on web,
|
||||
// but a registered command would stop the search field deleting text.
|
||||
|
||||
export function CommandCenter() {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
@@ -780,7 +781,7 @@ export function CommandCenter() {
|
||||
interaction,
|
||||
);
|
||||
const showBottomSheet = isCompact && isNative;
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet);
|
||||
const modalLayer = useGlobalOverlayLayer("modal", state.open);
|
||||
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
|
||||
visible: state.open,
|
||||
isEnabled: showBottomSheet,
|
||||
@@ -874,23 +875,20 @@ export function CommandCenter() {
|
||||
: {}),
|
||||
scrollEventThrottle: 16,
|
||||
};
|
||||
const keyPress = useCallback(
|
||||
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => state.key(key),
|
||||
[state],
|
||||
);
|
||||
const submit = useCallback(() => state.key("Enter"), [state]);
|
||||
const handleWebOverlayKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
const handleOverlayKeyDown = useCallback(
|
||||
(event: KeyboardInputEvent) => {
|
||||
if (!state.key(event.key)) return false;
|
||||
event.preventDefault();
|
||||
return true;
|
||||
},
|
||||
[state],
|
||||
);
|
||||
const setWebOverlayScope = useWebOverlayRegistration({
|
||||
active: isWeb && state.open && !showBottomSheet,
|
||||
const setOverlayScope = useOverlayRegistration({
|
||||
keys: ["Escape", "Enter", "ArrowUp", "ArrowDown"],
|
||||
active: state.open,
|
||||
layer: modalLayer,
|
||||
onKeyDown: handleWebOverlayKeyDown,
|
||||
onKeyDown: handleOverlayKeyDown,
|
||||
});
|
||||
|
||||
if (showBottomSheet) {
|
||||
@@ -911,45 +909,46 @@ export function CommandCenter() {
|
||||
keyboardBlurBehavior="restore"
|
||||
accessible={false}
|
||||
>
|
||||
<View style={[styles.bottomSheetHeader, styles.searchRow]} testID="command-center-header">
|
||||
{state.scope === "files" ? (
|
||||
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
|
||||
) : null}
|
||||
<ThemedBottomSheetTextInput
|
||||
testID="command-center-input"
|
||||
ref={bottomSheetInputRef}
|
||||
initialValue={state.query}
|
||||
variant="bottom-sheet"
|
||||
onChangeText={state.setQuery}
|
||||
onKeyPress={keyPress}
|
||||
onSubmitEditing={submit}
|
||||
placeholder={
|
||||
state.scope === "files"
|
||||
? t("shell.commandCenter.filePlaceholder")
|
||||
: t("shell.commandCenter.placeholder")
|
||||
}
|
||||
style={[styles.input, styles.growingInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
<FileSearchLoadingIndicator
|
||||
loading={state.fileSearchLoading}
|
||||
label={t("shell.commandCenter.searchingFiles")}
|
||||
/>
|
||||
</View>
|
||||
{fileSearchError}
|
||||
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
|
||||
<OverlayLayerProvider layer={modalLayer}>
|
||||
<View style={[styles.bottomSheetHeader, styles.searchRow]} testID="command-center-header">
|
||||
{state.scope === "files" ? (
|
||||
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
|
||||
) : null}
|
||||
<ThemedBottomSheetTextInput
|
||||
testID="command-center-input"
|
||||
ref={bottomSheetInputRef}
|
||||
initialValue={state.query}
|
||||
variant="bottom-sheet"
|
||||
onChangeText={state.setQuery}
|
||||
onSubmitEditing={submit}
|
||||
placeholder={
|
||||
state.scope === "files"
|
||||
? t("shell.commandCenter.filePlaceholder")
|
||||
: t("shell.commandCenter.placeholder")
|
||||
}
|
||||
style={[styles.input, styles.growingInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
<FileSearchLoadingIndicator
|
||||
loading={state.fileSearchLoading}
|
||||
label={t("shell.commandCenter.searchingFiles")}
|
||||
/>
|
||||
</View>
|
||||
{fileSearchError}
|
||||
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
|
||||
</OverlayLayerProvider>
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
if (!state.open) return null;
|
||||
return (
|
||||
<OverlayLayerProvider layer={isWeb ? modalLayer : 0}>
|
||||
<OverlayLayerProvider layer={modalLayer}>
|
||||
<Modal visible transparent animationType="fade" onRequestClose={state.close}>
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={styles.backdrop} onPress={state.close} />
|
||||
<View ref={setWebOverlayScope} testID="command-center-panel" style={styles.panel}>
|
||||
<View ref={setOverlayScope} testID="command-center-panel" style={styles.panel}>
|
||||
<View style={[styles.header, styles.searchRow]} testID="command-center-header">
|
||||
{state.scope === "files" ? (
|
||||
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { usesDesktopBindings } from "@/keyboard";
|
||||
import { useMemo } from "react";
|
||||
import { router, type Href } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -15,12 +16,12 @@ import {
|
||||
Settings,
|
||||
} from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { useImportSession } from "@/hooks/use-import-session";
|
||||
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { useHardwareKeyboardPresent } from "@/keyboard";
|
||||
import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
@@ -111,7 +112,7 @@ export function CommandCenterRootActions() {
|
||||
const keyboardActionDispatcher = useKeyboardActionDispatcher();
|
||||
const { t } = useTranslation();
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
||||
const shortcutsAvailable = useHardwareKeyboardPresent();
|
||||
const openAddProject = useOpenAddProject();
|
||||
const { open: openImportSession, sheet: importSessionSheet } = useImportSession();
|
||||
const settingsRoute = useMemo<Href>(() => buildSettingsRoute(), []);
|
||||
@@ -128,7 +129,7 @@ export function CommandCenterRootActions() {
|
||||
const toggleDesktopAgentList = usePanelStore((state) => state.toggleDesktopAgentList);
|
||||
const toggleAgentList = isCompact ? toggleMobileAgentList : toggleDesktopAgentList;
|
||||
const shortcutPlatform = useMemo(
|
||||
() => ({ isMac: getShortcutOs() === "mac", isDesktop: getIsElectronRuntime() }),
|
||||
() => ({ isMac: getShortcutOs() === "mac", usesDesktopBindings }),
|
||||
[],
|
||||
);
|
||||
const actions = useMemo<CommandCenterContribution[]>(() => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { usesDesktopBindings } from "@/keyboard";
|
||||
import { useCallback, useMemo, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -105,7 +106,7 @@ function staticIcon(element: ReactElement | undefined): CommandCenterIcon | unde
|
||||
}
|
||||
|
||||
function resolveWorkspaceShortcuts(overrides: ShortcutOverrides): WorkspaceCommandCenterShortcuts {
|
||||
const platform = { isMac: getShortcutOs() === "mac", isDesktop: getIsElectron() };
|
||||
const platform = { isMac: getShortcutOs() === "mac", usesDesktopBindings };
|
||||
return resolveWorkspaceCommandCenterShortcuts({ overrides, platform });
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ describe("resolveWorkspaceCommandCenterShortcuts", () => {
|
||||
expect(
|
||||
resolveWorkspaceCommandCenterShortcuts({
|
||||
overrides: {},
|
||||
platform: { isMac: true, isDesktop: true },
|
||||
platform: { isMac: true, usesDesktopBindings: true },
|
||||
}).newAgent,
|
||||
).toEqual([["mod", "shift", "A"]]);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { WorkspaceCommandCenterShortcuts } from "./workspace-contributions"
|
||||
|
||||
interface ResolveWorkspaceCommandCenterShortcutsInput {
|
||||
overrides: ShortcutOverrides;
|
||||
platform: { isMac: boolean; isDesktop: boolean };
|
||||
platform: { isMac: boolean; usesDesktopBindings: boolean };
|
||||
}
|
||||
|
||||
export function resolveWorkspaceCommandCenterShortcuts({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
@@ -6,12 +7,7 @@ import { Keyboard, Pressable, Text, View } from "react-native";
|
||||
import type { DimensionValue, StyleProp, ViewProps, ViewStyle } from "react-native";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
getOverlayRoot,
|
||||
OverlayLayerProvider,
|
||||
useGlobalWebOverlayLayer,
|
||||
useWebOverlayRegistration,
|
||||
} from "../lib/overlay-root";
|
||||
import { getOverlayRoot, OverlayLayerProvider, useGlobalOverlayLayer } from "../lib/overlay-root";
|
||||
import {
|
||||
BottomSheetHandle,
|
||||
useBottomSheetGestureHandlers,
|
||||
@@ -655,7 +651,7 @@ export function AdaptiveModalSheet({
|
||||
});
|
||||
const [shouldRenderWeb, setShouldRenderWeb] = useState(visible);
|
||||
const [isWebClosing, setIsWebClosing] = useState(false);
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && !isMobile && shouldRenderWeb);
|
||||
const modalLayer = useGlobalOverlayLayer("modal", visible || shouldRenderWeb);
|
||||
const handleDismiss = useCallback(() => {
|
||||
handleSheetDismiss();
|
||||
onDismiss?.();
|
||||
@@ -683,8 +679,8 @@ export function AdaptiveModalSheet({
|
||||
[isWebClosing, modalLayer],
|
||||
);
|
||||
|
||||
const handleWebOverlayKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
const handleOverlayKeyDown = useCallback(
|
||||
(event: KeyboardInputEvent) => {
|
||||
if (event.key !== "Escape") return false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -693,10 +689,11 @@ export function AdaptiveModalSheet({
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
const setWebOverlayScope = useWebOverlayRegistration({
|
||||
active: isWeb && !isMobile && visible,
|
||||
const setOverlayScope = useOverlayRegistration({
|
||||
keys: ["Escape"],
|
||||
active: visible,
|
||||
layer: modalLayer,
|
||||
onKeyDown: handleWebOverlayKeyDown,
|
||||
onKeyDown: handleOverlayKeyDown,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -735,18 +732,20 @@ export function AdaptiveModalSheet({
|
||||
accessible={false}
|
||||
presentation={presentation}
|
||||
>
|
||||
<CompactSheetBody
|
||||
header={header}
|
||||
onClose={onClose}
|
||||
testID={testID}
|
||||
footer={footerView}
|
||||
isKeyboardVisible={isKeyboardVisible}
|
||||
scrollable={scrollable}
|
||||
bodyStyle={bodyStyle}
|
||||
contentStyle={contentStyle}
|
||||
>
|
||||
{children}
|
||||
</CompactSheetBody>
|
||||
<OverlayLayerProvider layer={modalLayer}>
|
||||
<CompactSheetBody
|
||||
header={header}
|
||||
onClose={onClose}
|
||||
testID={testID}
|
||||
footer={footerView}
|
||||
isKeyboardVisible={isKeyboardVisible}
|
||||
scrollable={scrollable}
|
||||
bodyStyle={bodyStyle}
|
||||
contentStyle={contentStyle}
|
||||
>
|
||||
{children}
|
||||
</CompactSheetBody>
|
||||
</OverlayLayerProvider>
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
@@ -790,13 +789,7 @@ export function AdaptiveModalSheet({
|
||||
style={ABSOLUTE_FILL_STYLE}
|
||||
onPress={onClose}
|
||||
/>
|
||||
<View
|
||||
ref={setWebOverlayScope}
|
||||
style={desktopCardStyle}
|
||||
role="dialog"
|
||||
aria-modal
|
||||
tabIndex={-1}
|
||||
>
|
||||
<View ref={setOverlayScope} style={desktopCardStyle} role="dialog" aria-modal tabIndex={-1}>
|
||||
{cardInner}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard";
|
||||
import { useAddHost } from "@/hosts/add-host";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
@@ -69,19 +70,15 @@ import {
|
||||
} from "@/components/project-picker-options";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useRevealActiveItem } from "@/components/ui/scroll-reveal";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { useHardwareKeyboardPresent } from "@/keyboard";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { pickDirectory } from "@/desktop/pick-directory";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { getOpenProjectFailureReason, registerProjectDescriptor } from "@/hooks/open-project";
|
||||
import { useIsLocalDaemon, useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||
import { useCloneGithubProject, useOpenProject } from "@/hooks/use-open-project";
|
||||
import {
|
||||
OverlayLayerProvider,
|
||||
useGlobalWebOverlayLayer,
|
||||
useWebOverlayRegistration,
|
||||
} from "@/lib/overlay-root";
|
||||
import { OverlayLayerProvider, useGlobalOverlayLayer } from "@/lib/overlay-root";
|
||||
import {
|
||||
useHosts,
|
||||
useHostRuntimeClient,
|
||||
@@ -302,7 +299,7 @@ function FlowRow({
|
||||
}
|
||||
|
||||
function FlowHint({ keys, action }: { keys: string[]; action: string }) {
|
||||
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
||||
const shortcutsAvailable = useHardwareKeyboardPresent();
|
||||
if (!shortcutsAvailable) return null;
|
||||
|
||||
return (
|
||||
@@ -460,13 +457,10 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
});
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
setState((current) => {
|
||||
const previous = backAddProjectPage(current);
|
||||
if (previous) return previous;
|
||||
onClose();
|
||||
return current;
|
||||
});
|
||||
}, [onClose]);
|
||||
const previous = backAddProjectPage(state);
|
||||
if (previous) setState(previous);
|
||||
else onClose();
|
||||
}, [onClose, state]);
|
||||
|
||||
const openNewWorkspaceForProject = useCallback(
|
||||
(serverId: string, project: WorkspaceProjectDescriptorPayload) => {
|
||||
@@ -815,30 +809,22 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
[activeIndex, handleBack, rows, submitActive],
|
||||
);
|
||||
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb);
|
||||
const handleWebOverlayKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
const modalLayer = useGlobalOverlayLayer("modal", true);
|
||||
const handleOverlayKeyDown = useCallback(
|
||||
(event: KeyboardInputEvent) => {
|
||||
if (!handleKey(event.key)) return false;
|
||||
event.preventDefault();
|
||||
return true;
|
||||
},
|
||||
[handleKey],
|
||||
);
|
||||
const setWebOverlayScope = useWebOverlayRegistration({
|
||||
active: isWeb,
|
||||
const setOverlayScope = useOverlayRegistration({
|
||||
keys: ["Escape", "Enter", "ArrowUp", "ArrowDown"],
|
||||
active: true,
|
||||
layer: modalLayer,
|
||||
onKeyDown: handleWebOverlayKeyDown,
|
||||
onKeyDown: handleOverlayKeyDown,
|
||||
});
|
||||
|
||||
const handleNativeKeyPress = useCallback(
|
||||
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => {
|
||||
if (key === "ArrowDown" || key === "ArrowUp" || key === "Escape") {
|
||||
handleKey(key);
|
||||
}
|
||||
},
|
||||
[handleKey],
|
||||
);
|
||||
|
||||
const handleInputChange = useCallback((value: string) => {
|
||||
setState((current) =>
|
||||
currentAddProjectPage(current).kind === "new-directory-name"
|
||||
@@ -873,7 +859,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
<View style={styles.overlay} testID="add-project-flow">
|
||||
<Pressable style={styles.backdrop} onPress={onClose} testID="add-project-flow-backdrop" />
|
||||
<View
|
||||
ref={setWebOverlayScope}
|
||||
ref={setOverlayScope}
|
||||
style={styles.panel}
|
||||
testID={`add-project-flow-page-${page.kind}`}
|
||||
accessibilityLabel={`Add project: ${page.kind}`}
|
||||
@@ -892,29 +878,11 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
{page.kind === "method" && isNative ? (
|
||||
// Native hardware-keyboard events need a focused responder even without a visible field.
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
onKeyPress={handleNativeKeyPress}
|
||||
onSubmitEditing={submitActive}
|
||||
showSoftInputOnFocus={false}
|
||||
caretHidden
|
||||
contextMenuHidden
|
||||
accessible={false}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
pointerEvents="none"
|
||||
style={styles.keyboardCapture}
|
||||
testID="add-project-flow-keyboard-capture"
|
||||
/>
|
||||
) : null}
|
||||
{page.kind !== "method" ? (
|
||||
<ThemedTextInput
|
||||
ref={inputRef}
|
||||
initialValue={pageInput(page)}
|
||||
onChangeText={handleInputChange}
|
||||
onKeyPress={isWeb ? undefined : handleNativeKeyPress}
|
||||
onSubmitEditing={isWeb ? undefined : submitActive}
|
||||
placeholder={pagePlaceholder(page)}
|
||||
style={styles.input}
|
||||
@@ -996,7 +964,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
</Modal>
|
||||
);
|
||||
|
||||
return createElement(OverlayLayerProvider, { layer: isWeb ? modalLayer : 0 }, modal);
|
||||
return createElement(OverlayLayerProvider, { layer: modalLayer }, modal);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
@@ -1070,12 +1038,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: theme.spacing[1],
|
||||
outlineStyle: "none",
|
||||
} as object,
|
||||
keyboardCapture: {
|
||||
position: "absolute",
|
||||
width: 1,
|
||||
height: 1,
|
||||
opacity: 0,
|
||||
},
|
||||
results: { flexGrow: 0, flexShrink: 1, minHeight: 0 },
|
||||
resultsContent: { paddingVertical: theme.spacing[2] },
|
||||
row: {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
@@ -8,12 +9,12 @@ import { X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
import { WindowChromeRootRegion } from "@/utils/desktop-window";
|
||||
import { ZoomableImage } from "@/components/zoomable-viewport/image";
|
||||
import type { ViewportSize } from "@/components/zoomable-viewport/geometry";
|
||||
import { useGlobalWebOverlayLayer, useWebOverlayRegistration } from "@/lib/overlay-root";
|
||||
import { useGlobalOverlayLayer } from "@/lib/overlay-root";
|
||||
|
||||
export type ImageLightboxSource =
|
||||
| { type: "attachment"; metadata: AttachmentMetadata }
|
||||
@@ -34,14 +35,14 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
const url = source?.type === "uri" ? source.uri : attachmentUrl;
|
||||
const contentSize = source?.type === "uri" ? source.contentSize : undefined;
|
||||
const [errored, setErrored] = useState(false);
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && source !== null);
|
||||
const modalLayer = useGlobalOverlayLayer("modal", source !== null);
|
||||
|
||||
useEffect(() => {
|
||||
setErrored(false);
|
||||
}, [metadata?.id, url]);
|
||||
|
||||
const handleWebOverlayKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
const handleOverlayKeyDown = useCallback(
|
||||
(event: KeyboardInputEvent) => {
|
||||
if (event.key !== "Escape") return false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -50,10 +51,11 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
const setWebOverlayScope = useWebOverlayRegistration({
|
||||
active: isWeb && source !== null,
|
||||
const setOverlayScope = useOverlayRegistration({
|
||||
keys: ["Escape"],
|
||||
active: source !== null,
|
||||
layer: modalLayer,
|
||||
onKeyDown: handleWebOverlayKeyDown,
|
||||
onKeyDown: handleOverlayKeyDown,
|
||||
});
|
||||
|
||||
const actions = useMemo(
|
||||
@@ -83,7 +85,7 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
image was opened (inside a sheet tab, the bottom one is already spent). */}
|
||||
<SafeAreaProvider>
|
||||
<WindowChromeRootRegion corners="both">
|
||||
<View ref={setWebOverlayScope} style={styles.root}>
|
||||
<View ref={setOverlayScope} style={styles.root}>
|
||||
<Pressable
|
||||
testID="attachment-lightbox-backdrop"
|
||||
accessibilityRole="button"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { usesDesktopBindings } from "@/keyboard";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
@@ -24,14 +24,13 @@ export function KeyboardShortcutsDialog() {
|
||||
|
||||
const shortcutOs = getShortcutOs();
|
||||
const isMac = shortcutOs === "mac";
|
||||
const isDesktopApp = getIsElectronRuntime();
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
// Effective bindings, so a shortcut the user unassigned lists no keys here
|
||||
// instead of advertising a default that no longer fires.
|
||||
const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]);
|
||||
const sections = useMemo(
|
||||
() => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }, bindings),
|
||||
[bindings, isDesktopApp, isMac],
|
||||
() => buildKeyboardShortcutHelpSections({ isMac, usesDesktopBindings }, bindings),
|
||||
[bindings, isMac],
|
||||
);
|
||||
const visibleSections = useMemo(
|
||||
() => filterShortcutHelpSections({ sections, query, translate: t, shortcutOs }),
|
||||
|
||||
@@ -12,12 +12,13 @@ import { useTranslation } from "react-i18next";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet as RNStyleSheet,
|
||||
Text,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import { Gesture, ScrollView as GHScrollView } from "react-native-gesture-handler";
|
||||
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -41,6 +42,7 @@ import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-heade
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative as platformIsNative } from "@/constants/platform";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { useImportSession } from "@/hooks/use-import-session";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
@@ -555,6 +557,14 @@ function MobileSidebar({
|
||||
[insetsTop, insetsBottom, theme.colors.surfaceSidebar],
|
||||
);
|
||||
|
||||
const navRows = (
|
||||
<SidebarNavRows
|
||||
style={styles.mobileNavRowsGroup}
|
||||
onBeforeNavigate={closeSidebar}
|
||||
firstRowTrailing={closeButton}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<MobilePanelOverlay
|
||||
panel="agent-list"
|
||||
@@ -563,11 +573,29 @@ function MobileSidebar({
|
||||
>
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
<WindowChromeSafeArea placement="below" />
|
||||
<SidebarNavRows
|
||||
style={styles.sidebarHeaderGroup}
|
||||
onBeforeNavigate={closeSidebar}
|
||||
firstRowTrailing={closeButton}
|
||||
/>
|
||||
{platformIsNative ? (
|
||||
<GHScrollView
|
||||
// Same contract as the workspace list: vertical drags scroll the nav
|
||||
// block, a clear horizontal intent still activates the close pan.
|
||||
simultaneousHandlers={closeGestureRef}
|
||||
style={styles.mobileNavScroll}
|
||||
showsVerticalScrollIndicator={false}
|
||||
alwaysBounceVertical={false}
|
||||
testID="sidebar-nav-scroll"
|
||||
>
|
||||
{navRows}
|
||||
</GHScrollView>
|
||||
) : (
|
||||
<ScrollView
|
||||
style={styles.mobileNavScroll}
|
||||
showsVerticalScrollIndicator={false}
|
||||
alwaysBounceVertical={false}
|
||||
testID="sidebar-nav-scroll"
|
||||
>
|
||||
{navRows}
|
||||
</ScrollView>
|
||||
)}
|
||||
<SidebarSeparator />
|
||||
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
@@ -839,6 +867,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
sidebarHeaderGroupBelowChrome: {
|
||||
paddingTop: 0,
|
||||
},
|
||||
// The nav block caps at half the sidebar; a long item list scrolls inside the
|
||||
// cap instead of crowding the workspace list off a short screen.
|
||||
mobileNavScroll: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
maxHeight: "50%",
|
||||
},
|
||||
mobileNavRowsGroup: {
|
||||
paddingTop: theme.spacing[2],
|
||||
gap: 2,
|
||||
paddingBottom: theme.spacing[1.5],
|
||||
},
|
||||
sidebarContent: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
|
||||
@@ -18,7 +18,7 @@ import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { SidebarFooterIconHighlight } from "./sidebar-footer-icon-highlight";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useAppDiagnosticStore } from "@/diagnostics/store";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { useHardwareKeyboardPresent } from "@/keyboard";
|
||||
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -79,7 +79,7 @@ function HostVersionHint({ host }: { host: HostProfile }) {
|
||||
|
||||
export function SidebarHelpMenu() {
|
||||
const { t } = useTranslation();
|
||||
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
||||
const shortcutsAvailable = useHardwareKeyboardPresent();
|
||||
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
|
||||
const setShortcutsDialogOpen = useKeyboardShortcutsStore((state) => state.setShortcutsDialogOpen);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -50,13 +50,16 @@ const SidebarModelContext = createContext<SidebarModel | null>(null);
|
||||
|
||||
export function SidebarModelProvider({
|
||||
active,
|
||||
keyboardNavigationActive = false,
|
||||
children,
|
||||
}: {
|
||||
active?: boolean;
|
||||
keyboardNavigationActive?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const list = useSidebarWorkspacesList({ enabled: active });
|
||||
const modelActive = active !== false || keyboardNavigationActive;
|
||||
const list = useSidebarWorkspacesList({ enabled: modelActive });
|
||||
const showBackground = useSidebarViewStore((state) => state.showBackground);
|
||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
||||
const labelFilter = useSidebarViewStore((state) => state.labelFilter);
|
||||
@@ -98,10 +101,11 @@ export function SidebarModelProvider({
|
||||
// anything; the label filter reads `labels`, which only exists on an entry. Hydration opens a
|
||||
// live session-store subscription over every workspace on every visible host, so widening this
|
||||
// for a filter that does not need it costs a retained-but-inactive sidebar real work.
|
||||
// Hardware keyboard navigation needs the same filtered targets while the sidebar is hidden.
|
||||
const needsWorkspaceEntries = groupMode !== "project" || hasActiveLabelFilter;
|
||||
const workspaceEntriesByKey = useSidebarWorkspaceEntries(
|
||||
list.workspacePlacements,
|
||||
active !== false || needsWorkspaceEntries,
|
||||
modelActive || needsWorkspaceEntries,
|
||||
);
|
||||
const filteredWorkspaceEntriesByKey = useMemo(() => {
|
||||
const byProject = filterWorkspacesByProjects({
|
||||
|
||||
@@ -84,6 +84,7 @@ import {
|
||||
} from "./terminal-resize-debouncer";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useKeyboardFocusSurface } from "@/keyboard";
|
||||
import {
|
||||
applyTerminalRendererReadyChange,
|
||||
resolveTerminalStreamTarget,
|
||||
@@ -458,6 +459,8 @@ export function TerminalPane({
|
||||
}
|
||||
}, [isMobile, isPaneFocused, isWorkspaceFocused, requestTerminalFocus, scopeKey, terminalId]);
|
||||
|
||||
useKeyboardFocusSurface("terminal", isTerminalPresented && isPaneFocused && terminalId != null);
|
||||
|
||||
useEffect(() => {
|
||||
const canRequest = canRequestFocusClaim({
|
||||
isWorkspaceFocused: isTerminalPresented,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import {
|
||||
useCallback,
|
||||
@@ -62,12 +63,7 @@ import {
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import { FloatingSurface } from "@/components/ui/floating";
|
||||
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
|
||||
import {
|
||||
getOverlayRoot,
|
||||
OverlayLayerProvider,
|
||||
useOverlayLayer,
|
||||
useWebOverlayRegistration,
|
||||
} from "@/lib/overlay-root";
|
||||
import { getOverlayRoot, OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root";
|
||||
import { buildDesktopFrameStyle } from "./combobox-frame-style";
|
||||
|
||||
export { buildDesktopFrameStyle } from "./combobox-frame-style";
|
||||
@@ -526,7 +522,6 @@ type DesktopKey = "ArrowDown" | "ArrowUp" | "Enter" | "Escape";
|
||||
|
||||
interface DesktopKeyHandlerInput {
|
||||
isOpen: boolean;
|
||||
isMobile: boolean;
|
||||
orderedVisibleOptions: ComboboxOption[];
|
||||
activeIndex: number;
|
||||
setActiveIndex: React.Dispatch<React.SetStateAction<number>>;
|
||||
@@ -871,11 +866,12 @@ function isDesktopKey(key: string): key is DesktopKey {
|
||||
function dispatchDesktopKey(
|
||||
input: DesktopKeyHandlerInput,
|
||||
key: DesktopKey,
|
||||
event?: KeyboardEvent,
|
||||
event?: KeyboardInputEvent,
|
||||
): boolean {
|
||||
if (!input.isOpen) return false;
|
||||
if (!IS_WEB && input.isMobile) return false;
|
||||
|
||||
// A native mobile bail used to live here because native had no key source, so
|
||||
// nothing could call this. It has one now, and the sheet highlights an active
|
||||
// row like the popover does, so a hardware keyboard drives both.
|
||||
if (key === "ArrowDown" || key === "ArrowUp") {
|
||||
event?.preventDefault();
|
||||
handleDesktopArrowKey(input, key);
|
||||
@@ -912,6 +908,7 @@ function resolveInitialActiveIndex(
|
||||
}
|
||||
|
||||
interface MobileBodyProps {
|
||||
overlayLayer: number;
|
||||
isOpen: boolean;
|
||||
snapPoints: string[];
|
||||
handleIndicatorStyle: { backgroundColor: string };
|
||||
@@ -990,45 +987,47 @@ function MobileComboboxSheet(props: MobileBodyProps): ReactElement {
|
||||
keyboardBlurBehavior="none"
|
||||
presentation={props.presentation}
|
||||
>
|
||||
<ComboboxSheetFrame footer={props.footer}>
|
||||
{props.header ? (
|
||||
<SheetHeaderView header={props.header} onClose={props.onClose} />
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text key={props.titleColor} style={comboboxTitleStyle}>
|
||||
{props.title}
|
||||
</Text>
|
||||
</View>
|
||||
{props.stickyHeader}
|
||||
{!props.hasChildren && props.searchable ? (
|
||||
<SearchInput
|
||||
placeholder={props.searchPlaceholder}
|
||||
onChangeText={props.setSearchQueryWithCallback}
|
||||
onSubmitEditing={props.handleSubmitSearch}
|
||||
autoFocus={false}
|
||||
useBottomSheetInput
|
||||
resetKey={props.searchResetKey}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
|
||||
body
|
||||
) : (
|
||||
<BottomSheetScrollView
|
||||
style={styles.mobileSheetBody}
|
||||
contentContainerStyle={[
|
||||
styles.comboboxScrollContent,
|
||||
props.mobileChildrenContentContainerStyle,
|
||||
]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{body}
|
||||
</BottomSheetScrollView>
|
||||
)}
|
||||
</ComboboxSheetFrame>
|
||||
<OverlayLayerProvider layer={props.overlayLayer}>
|
||||
<ComboboxSheetFrame footer={props.footer}>
|
||||
{props.header ? (
|
||||
<SheetHeaderView header={props.header} onClose={props.onClose} />
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text key={props.titleColor} style={comboboxTitleStyle}>
|
||||
{props.title}
|
||||
</Text>
|
||||
</View>
|
||||
{props.stickyHeader}
|
||||
{!props.hasChildren && props.searchable ? (
|
||||
<SearchInput
|
||||
placeholder={props.searchPlaceholder}
|
||||
onChangeText={props.setSearchQueryWithCallback}
|
||||
onSubmitEditing={props.handleSubmitSearch}
|
||||
autoFocus={false}
|
||||
useBottomSheetInput
|
||||
resetKey={props.searchResetKey}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
|
||||
body
|
||||
) : (
|
||||
<BottomSheetScrollView
|
||||
style={styles.mobileSheetBody}
|
||||
contentContainerStyle={[
|
||||
styles.comboboxScrollContent,
|
||||
props.mobileChildrenContentContainerStyle,
|
||||
]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{body}
|
||||
</BottomSheetScrollView>
|
||||
)}
|
||||
</ComboboxSheetFrame>
|
||||
</OverlayLayerProvider>
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
@@ -1062,7 +1061,8 @@ interface DesktopBodyProps {
|
||||
overlayLayer: number;
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
handleDesktopKey: (key: DesktopKey, event?: KeyboardEvent) => boolean;
|
||||
setOverlayScope: (node: unknown) => void;
|
||||
handleDesktopKey: (key: DesktopKey, event?: KeyboardInputEvent) => boolean;
|
||||
refs: ReturnType<typeof useFloating>["refs"];
|
||||
shouldUseDesktopFade: boolean;
|
||||
desktopFrameStyle: StyleProp<ViewStyle>;
|
||||
@@ -1189,25 +1189,13 @@ function DesktopComboboxOptionsBody(props: {
|
||||
|
||||
function DesktopComboboxBody(props: DesktopBodyProps): ReactElement | null {
|
||||
const active = useRetainedPanelActive();
|
||||
const handleDesktopKey = props.handleDesktopKey;
|
||||
const handleWebOverlayKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (!isDesktopKey(event.key)) return false;
|
||||
return handleDesktopKey(event.key, event);
|
||||
},
|
||||
[handleDesktopKey],
|
||||
);
|
||||
const setWebOverlayScope = useWebOverlayRegistration({
|
||||
active: isWeb && props.isOpen,
|
||||
layer: props.overlayLayer,
|
||||
onKeyDown: handleWebOverlayKeyDown,
|
||||
});
|
||||
const setOverlayScope = props.setOverlayScope;
|
||||
const setFloatingRef = useCallback(
|
||||
(node: View | null) => {
|
||||
props.refs.setFloating(node);
|
||||
setWebOverlayScope(node);
|
||||
setOverlayScope(node);
|
||||
},
|
||||
[props.refs, setWebOverlayScope],
|
||||
[props.refs, setOverlayScope],
|
||||
);
|
||||
|
||||
// Remove the portal before a retained chat can freeze its closing update.
|
||||
@@ -1531,11 +1519,10 @@ export function Combobox({
|
||||
);
|
||||
|
||||
const handleDesktopKey = useCallback(
|
||||
(key: DesktopKey, event?: KeyboardEvent) => {
|
||||
(key: DesktopKey, event?: KeyboardInputEvent) => {
|
||||
return dispatchDesktopKey(
|
||||
{
|
||||
isOpen,
|
||||
isMobile,
|
||||
orderedVisibleOptions,
|
||||
activeIndex,
|
||||
setActiveIndex,
|
||||
@@ -1546,7 +1533,7 @@ export function Combobox({
|
||||
event,
|
||||
);
|
||||
},
|
||||
[activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions],
|
||||
[activeIndex, handleClose, handleSelect, isOpen, orderedVisibleOptions],
|
||||
);
|
||||
|
||||
useDismissKeyboardOnOpen(isOpen, isMobile);
|
||||
@@ -1583,12 +1570,20 @@ export function Combobox({
|
||||
[],
|
||||
);
|
||||
|
||||
const setOverlayScope = useOverlayRegistration({
|
||||
active: isOpen,
|
||||
layer: floatingLayer,
|
||||
keys: ["Escape", "Enter", "ArrowUp", "ArrowDown"],
|
||||
onKeyDown: (event) => (isDesktopKey(event.key) ? handleDesktopKey(event.key, event) : false),
|
||||
});
|
||||
|
||||
const effectiveSearchPlaceholder = searchPlaceholder ?? resolvedPlaceholder;
|
||||
const hasChildren = Boolean(children);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileComboboxBody
|
||||
overlayLayer={floatingLayer}
|
||||
isOpen={isOpen}
|
||||
snapPoints={snapPoints}
|
||||
handleIndicatorStyle={handleIndicatorStyle}
|
||||
@@ -1628,6 +1623,7 @@ export function Combobox({
|
||||
isOpen={isOpen}
|
||||
handleClose={handleClose}
|
||||
handleDesktopKey={handleDesktopKey}
|
||||
setOverlayScope={setOverlayScope}
|
||||
refs={refs}
|
||||
shouldUseDesktopFade={shouldUseDesktopFade}
|
||||
desktopFrameStyle={desktopFrameStyle}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
@@ -14,11 +15,7 @@ import { Dimensions, View, type Role, type StyleProp, type ViewStyle } from "rea
|
||||
import { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import {
|
||||
OverlayLayerProvider,
|
||||
useOverlayLayer,
|
||||
useWebOverlayRegistration,
|
||||
} from "@/lib/overlay-root";
|
||||
import { OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root";
|
||||
import { FloatingSurface } from "@/components/ui/floating";
|
||||
import {
|
||||
measureFloatingPanelPortalHost,
|
||||
@@ -127,7 +124,7 @@ function WebHoverCard({
|
||||
}, [clearGraceTimer, disabled]);
|
||||
|
||||
const keyPressed = useCallback(
|
||||
(event: KeyboardEvent): boolean => {
|
||||
(event: KeyboardInputEvent): boolean => {
|
||||
if (!isWeb) return false;
|
||||
const trigger = triggerRef.current as unknown as HTMLElement | null;
|
||||
if (!trigger) return false;
|
||||
@@ -139,7 +136,7 @@ function WebHoverCard({
|
||||
return true;
|
||||
} else if (
|
||||
focusInside() &&
|
||||
event.target === trigger &&
|
||||
document.activeElement === trigger &&
|
||||
["ArrowDown", "Enter", " "].includes(event.key)
|
||||
) {
|
||||
event.preventDefault();
|
||||
@@ -158,7 +155,8 @@ function WebHoverCard({
|
||||
[clearGraceTimer, focusInside, open, openNow],
|
||||
);
|
||||
|
||||
const setOverlayScope = useWebOverlayRegistration({
|
||||
const setOverlayScope = useOverlayRegistration({
|
||||
keys: ["Escape"],
|
||||
active: !disabled && open,
|
||||
layer,
|
||||
onKeyDown: keyPressed,
|
||||
@@ -210,9 +208,9 @@ function WebHoverCard({
|
||||
)
|
||||
keyPressed(event);
|
||||
};
|
||||
// Only a scroll that moves the trigger detaches the card. A streaming chat beside it does not.
|
||||
const scrolled = (event: Event) => {
|
||||
if (!(contentRef.current as unknown as HTMLElement | null)?.contains(event.target as Node))
|
||||
close();
|
||||
if ((event.target as Node).contains(trigger)) close();
|
||||
};
|
||||
document.addEventListener("focusin", focusEntered);
|
||||
document.addEventListener("focusout", focusLeft);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { KeyboardInputEvent } from "@/keyboard";
|
||||
export function navigateMenuKeyboard(_event: KeyboardInputEvent): boolean {
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { KeyboardInputEvent } from "@/keyboard";
|
||||
export function navigateMenuKeyboard(event: KeyboardInputEvent): boolean {
|
||||
const target = document.activeElement;
|
||||
const surface = target?.closest<HTMLElement>('[data-menu-surface="true"]');
|
||||
if (!surface) return false;
|
||||
const items = Array.from(
|
||||
surface.querySelectorAll<HTMLElement>(
|
||||
'[data-menu-item="true"]:not([data-menu-disabled="true"])',
|
||||
),
|
||||
);
|
||||
if (items.length === 0) return false;
|
||||
const currentIndex = items.findIndex((item) => item === document.activeElement);
|
||||
let nextIndex: number | null = null;
|
||||
if (event.key === "ArrowDown") {
|
||||
if (currentIndex < 0) nextIndex = 0;
|
||||
else nextIndex = (currentIndex + 1) % items.length;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
if (currentIndex < 0) nextIndex = items.length - 1;
|
||||
else nextIndex = (currentIndex - 1 + items.length) % items.length;
|
||||
}
|
||||
if (event.key === "Home") nextIndex = 0;
|
||||
if (event.key === "End") nextIndex = items.length - 1;
|
||||
if (nextIndex !== null) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
items[nextIndex]?.focus();
|
||||
return true;
|
||||
}
|
||||
if ((event.key === "Enter" || event.key === " ") && currentIndex >= 0) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
items[currentIndex]?.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { navigateMenuKeyboard } from "./keyboard-navigation";
|
||||
import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import {
|
||||
useCallback,
|
||||
@@ -24,12 +26,7 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { KeyboardFocusScope } from "@/keyboard/actions";
|
||||
import {
|
||||
getOverlayRoot,
|
||||
OverlayLayerProvider,
|
||||
useOverlayLayer,
|
||||
useWebOverlayRegistration,
|
||||
} from "@/lib/overlay-root";
|
||||
import { getOverlayRoot, OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root";
|
||||
import {
|
||||
computePosition,
|
||||
getTransformOrigin,
|
||||
@@ -405,8 +402,8 @@ export function MenuOverlay({
|
||||
}): ReactElement | null {
|
||||
const floatingLayer = useOverlayLayer("floating");
|
||||
|
||||
const handleWebOverlayKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
const handleOverlayKeyDown = useCallback(
|
||||
(event: KeyboardInputEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -414,44 +411,15 @@ export function MenuOverlay({
|
||||
return true;
|
||||
}
|
||||
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
const surface = target?.closest<HTMLElement>('[data-menu-surface="true"]');
|
||||
if (!surface) return false;
|
||||
const items = Array.from(
|
||||
surface.querySelectorAll<HTMLElement>(
|
||||
'[data-menu-item="true"]:not([data-menu-disabled="true"])',
|
||||
),
|
||||
);
|
||||
if (items.length === 0) return false;
|
||||
const currentIndex = items.findIndex((item) => item === document.activeElement);
|
||||
let nextIndex: number | null = null;
|
||||
if (event.key === "ArrowDown")
|
||||
nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length;
|
||||
if (event.key === "ArrowUp")
|
||||
nextIndex =
|
||||
currentIndex < 0 ? items.length - 1 : (currentIndex - 1 + items.length) % items.length;
|
||||
if (event.key === "Home") nextIndex = 0;
|
||||
if (event.key === "End") nextIndex = items.length - 1;
|
||||
if (nextIndex !== null) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
items[nextIndex]?.focus();
|
||||
return true;
|
||||
}
|
||||
if ((event.key === "Enter" || event.key === " ") && currentIndex >= 0) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
items[currentIndex]?.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return navigateMenuKeyboard(event);
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
const setWebOverlayScope = useWebOverlayRegistration({
|
||||
active: isWeb && visible,
|
||||
const setOverlayScope = useOverlayRegistration({
|
||||
keys: ["Escape"],
|
||||
active: visible,
|
||||
layer: floatingLayer,
|
||||
onKeyDown: handleWebOverlayKeyDown,
|
||||
onKeyDown: handleOverlayKeyDown,
|
||||
restoreFocusRef,
|
||||
});
|
||||
|
||||
@@ -463,7 +431,7 @@ export function MenuOverlay({
|
||||
{...{
|
||||
onContextMenu: (event: { preventDefault?: () => void }) => event.preventDefault?.(),
|
||||
}}
|
||||
ref={setWebOverlayScope}
|
||||
ref={setOverlayScope}
|
||||
collapsable={false}
|
||||
style={[
|
||||
styles.overlay,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useMemo, type ReactElement } from "react";
|
||||
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { useHardwareKeyboardPresent } from "@/keyboard";
|
||||
import { normalizeDisplayChord } from "@/components/ui/normalize-display-chord";
|
||||
import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
@@ -17,7 +17,7 @@ export function Shortcut({
|
||||
style?: StyleProp<ViewStyle>;
|
||||
textStyle?: StyleProp<TextStyle>;
|
||||
}): ReactElement | null {
|
||||
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
||||
const shortcutsAvailable = useHardwareKeyboardPresent();
|
||||
const displayChord = normalizeDisplayChord(chord, keys);
|
||||
const shortcutOs = getShortcutOs();
|
||||
|
||||
|
||||
@@ -125,6 +125,20 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps keyboard navigation populated when the sidebar starts hidden", async () => {
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<SidebarModelProvider active={false} keyboardNavigationActive>
|
||||
<WorkspaceShortcutTargetsSubscriber enabled />
|
||||
</SidebarModelProvider>,
|
||||
);
|
||||
});
|
||||
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
|
||||
{ serverId: "srv", workspaceId: "ws-1" },
|
||||
{ serverId: "srv", workspaceId: "ws-2" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("publishes workspace shortcut targets without rendering the sidebar", async () => {
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
|
||||
@@ -25,6 +25,13 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { Settings2 } from "lucide-react-native";
|
||||
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 { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||
@@ -47,7 +54,8 @@ import {
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type {
|
||||
AgentFeature,
|
||||
AgentOptionControl,
|
||||
ProviderOptions,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
@@ -55,8 +63,6 @@ import type {
|
||||
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
import {
|
||||
getFeatureHighlightColor,
|
||||
isFeatureActive,
|
||||
getFeatureTooltip,
|
||||
getAgentControlHintKey,
|
||||
resolveAgentModelSelection,
|
||||
} from "@/composer/agent-controls/utils";
|
||||
@@ -101,6 +107,7 @@ interface AgentControlOption {
|
||||
type AgentControlSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`;
|
||||
|
||||
const EMPTY_AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [];
|
||||
const EMPTY_OPTIONS: ProviderOptions = {};
|
||||
|
||||
interface ControlledAgentControlsProps {
|
||||
provider: string;
|
||||
@@ -122,8 +129,7 @@ interface ControlledAgentControlsProps {
|
||||
onEditAgentProfiles?: () => void;
|
||||
onCreateAgentProfile?: (seed: AgentProfileSeed) => void;
|
||||
onEditAgentProfile?: (profileId: string) => void;
|
||||
features?: AgentFeature[];
|
||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
||||
optionControls?: ComposerOptionControls;
|
||||
onDropdownClose?: () => void;
|
||||
onModelSelectorOpen?: () => void;
|
||||
onRetryModelProvider?: (provider: AgentProvider) => void;
|
||||
@@ -150,8 +156,7 @@ export interface DraftAgentControlsProps {
|
||||
selectedThinkingOptionId: string;
|
||||
onSelectThinkingOption: (thinkingOptionId: string) => void;
|
||||
onApplyAgentProfile: DraftAgentProfileControls["applyProfile"];
|
||||
features?: AgentFeature[];
|
||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
||||
optionControls?: ComposerOptionControls;
|
||||
onDropdownClose?: () => void;
|
||||
onModelSelectorOpen?: () => void;
|
||||
onRetryModelProvider?: (provider: AgentProvider) => void;
|
||||
@@ -212,7 +217,8 @@ function getModeProviderDefinitions(modeControl: AgentModeControlValue | null) {
|
||||
}
|
||||
|
||||
function getFeatureIconColor(
|
||||
feature: AgentFeature,
|
||||
control: AgentOptionControl,
|
||||
options: ProviderOptions,
|
||||
palette: {
|
||||
blue: { 400: string };
|
||||
green: { 400: string };
|
||||
@@ -220,11 +226,11 @@ function getFeatureIconColor(
|
||||
},
|
||||
foregroundMuted: string,
|
||||
): string {
|
||||
if (!isFeatureActive(feature)) {
|
||||
if (!isControlActive(control, options)) {
|
||||
return foregroundMuted;
|
||||
}
|
||||
|
||||
switch (getFeatureHighlightColor(feature.id)) {
|
||||
switch (getFeatureHighlightColor(control.key)) {
|
||||
case "blue":
|
||||
return palette.blue[400];
|
||||
case "green":
|
||||
@@ -242,20 +248,20 @@ function resolveHasAnyControl({
|
||||
providerOptions,
|
||||
canSelectModel,
|
||||
thinkingOptions,
|
||||
features,
|
||||
controls,
|
||||
hasMode,
|
||||
}: {
|
||||
providerOptions: AgentControlOption[] | undefined;
|
||||
canSelectModel: boolean;
|
||||
thinkingOptions: AgentControlOption[] | undefined;
|
||||
features: AgentFeature[] | undefined;
|
||||
controls: readonly AgentOptionControl[] | undefined;
|
||||
hasMode: boolean;
|
||||
}) {
|
||||
return (
|
||||
Boolean(providerOptions?.length) ||
|
||||
canSelectModel ||
|
||||
Boolean(thinkingOptions?.length) ||
|
||||
Boolean(features?.length) ||
|
||||
Boolean(controls?.length) ||
|
||||
hasMode
|
||||
);
|
||||
}
|
||||
@@ -393,7 +399,8 @@ type AgentControlsSlice = {
|
||||
cwd: string | null;
|
||||
runtimeModelId: string | null;
|
||||
model: string | null | undefined;
|
||||
features: AgentFeature[] | undefined;
|
||||
options: ProviderOptions | undefined;
|
||||
controls: AgentOptionControl[] | undefined;
|
||||
runtimeThinkingOptionId: string | null;
|
||||
thinkingOptionId: string | null | undefined;
|
||||
lastUsage: unknown;
|
||||
@@ -413,7 +420,8 @@ function selectAgentControlsSlice(
|
||||
cwd: currentAgent.cwd,
|
||||
runtimeModelId: currentAgent.runtimeInfo?.model ?? null,
|
||||
model: currentAgent.model,
|
||||
features: currentAgent.features,
|
||||
options: currentAgent.options,
|
||||
controls: currentAgent.controls,
|
||||
runtimeThinkingOptionId: currentAgent.runtimeInfo?.thinkingOptionId ?? null,
|
||||
thinkingOptionId: currentAgent.thinkingOptionId,
|
||||
lastUsage: currentAgent.lastUsage,
|
||||
@@ -493,8 +501,7 @@ function ControlledAgentControls({
|
||||
onEditAgentProfiles,
|
||||
onCreateAgentProfile,
|
||||
onEditAgentProfile,
|
||||
features,
|
||||
onSetFeature,
|
||||
optionControls,
|
||||
onDropdownClose,
|
||||
onModelSelectorOpen,
|
||||
onRetryModelProvider,
|
||||
@@ -546,20 +553,21 @@ function ControlledAgentControls({
|
||||
providerOptions,
|
||||
canSelectModel,
|
||||
thinkingOptions,
|
||||
features,
|
||||
controls: optionControls?.controls,
|
||||
hasMode: modeControl !== null && modeControl !== undefined,
|
||||
});
|
||||
const featureControls = useMemo(
|
||||
() =>
|
||||
(features ?? []).map((feature) => {
|
||||
if (feature.type === "toggle") return { type: "toggle" as const };
|
||||
const selectedOption = feature.options.find((option) => option.id === feature.value);
|
||||
(optionControls?.controls ?? []).map((control) => {
|
||||
if (control.type === "toggle") return { type: "toggle" as const };
|
||||
const selected = readSelectOption(control, optionControls?.options ?? {});
|
||||
const choice = control.choices.find((candidate) => candidate.value === selected);
|
||||
return {
|
||||
type: "select" as const,
|
||||
label: selectedOption?.label ?? feature.label,
|
||||
label: choice?.label ?? control.label,
|
||||
};
|
||||
}),
|
||||
[features],
|
||||
[optionControls],
|
||||
);
|
||||
const controlPresence = useMemo(
|
||||
() => ({
|
||||
@@ -743,8 +751,7 @@ function ControlledAgentControls({
|
||||
selectedModelId={selectedModelId}
|
||||
thinkingOptions={formattedThinkingOptions}
|
||||
selectedThinkingOptionId={selectedThinkingOptionId}
|
||||
features={features}
|
||||
onSetFeature={onSetFeature}
|
||||
optionControls={optionControls}
|
||||
onApplyAgentProfile={onApplyAgentProfile}
|
||||
onEditAgentProfiles={onEditAgentProfiles}
|
||||
onCreateAgentProfile={onCreateAgentProfile}
|
||||
@@ -792,8 +799,7 @@ function ControlledAgentControls({
|
||||
provider={provider}
|
||||
selectedModelId={selectedModelId}
|
||||
selectedThinkingOptionId={selectedThinkingOptionId}
|
||||
features={features}
|
||||
onSetFeature={onSetFeature}
|
||||
optionControls={optionControls}
|
||||
onApplyAgentProfile={onApplyAgentProfile}
|
||||
onEditAgentProfiles={onEditAgentProfiles}
|
||||
onCreateAgentProfile={onCreateAgentProfile}
|
||||
@@ -838,8 +844,7 @@ interface DesktopAgentControlsContentProps {
|
||||
selectedModelId?: string;
|
||||
thinkingOptions?: AgentControlOption[];
|
||||
selectedThinkingOptionId?: string;
|
||||
features?: AgentFeature[];
|
||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
||||
optionControls?: ComposerOptionControls;
|
||||
onApplyAgentProfile?: (profileId: string) => void;
|
||||
onEditAgentProfiles?: () => void;
|
||||
onCreateAgentProfile?: (seed: AgentProfileSeed) => void;
|
||||
@@ -900,8 +905,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
|
||||
selectedModelId,
|
||||
thinkingOptions,
|
||||
selectedThinkingOptionId,
|
||||
features,
|
||||
onSetFeature,
|
||||
optionControls,
|
||||
onApplyAgentProfile,
|
||||
onEditAgentProfiles,
|
||||
onCreateAgentProfile,
|
||||
@@ -1057,7 +1061,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
|
||||
|
||||
{modeControl ? <AgentModeControl {...modeControl} onClose={onDropdownClose} /> : null}
|
||||
|
||||
{presentation.aggregateFeatures && features?.length ? (
|
||||
{presentation.aggregateFeatures && optionControls?.controls.length ? (
|
||||
<>
|
||||
<Pressable
|
||||
onPress={handleOpenFeatures}
|
||||
@@ -1077,27 +1081,29 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
|
||||
onClose={handleCloseSheet}
|
||||
testID="agent-features-sheet"
|
||||
>
|
||||
{features.map((feature) => (
|
||||
{optionControls.controls.map((control) => (
|
||||
<SheetFeatureItem
|
||||
key={`feature-${feature.id}`}
|
||||
feature={feature}
|
||||
key={`feature-${control.key}`}
|
||||
control={control}
|
||||
options={optionControls.options}
|
||||
disabled={disabled}
|
||||
openSelector={openSelector}
|
||||
handleOpenChange={handleNestedOpenChange}
|
||||
onSetFeature={onSetFeature}
|
||||
onSetOption={optionControls.onSetOption}
|
||||
/>
|
||||
))}
|
||||
</AdaptiveModalSheet>
|
||||
</>
|
||||
) : (
|
||||
features?.map((feature) => (
|
||||
optionControls?.controls.map((control) => (
|
||||
<DesktopFeatureItem
|
||||
key={`feature-${feature.id}`}
|
||||
feature={feature}
|
||||
key={`feature-${control.key}`}
|
||||
control={control}
|
||||
options={optionControls.options}
|
||||
disabled={disabled}
|
||||
openSelector={openSelector}
|
||||
handleOpenChange={handleOpenChange}
|
||||
onSetFeature={onSetFeature}
|
||||
onSetOption={optionControls.onSetOption}
|
||||
onActionComplete={onDropdownClose}
|
||||
/>
|
||||
))
|
||||
@@ -1110,8 +1116,7 @@ interface SheetAgentControlsContentProps {
|
||||
provider: string;
|
||||
selectedModelId?: string;
|
||||
selectedThinkingOptionId?: string;
|
||||
features?: AgentFeature[];
|
||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
||||
optionControls?: ComposerOptionControls;
|
||||
onApplyAgentProfile?: (profileId: string) => void;
|
||||
onEditAgentProfiles?: () => void;
|
||||
onCreateAgentProfile?: (seed: AgentProfileSeed) => void;
|
||||
@@ -1154,8 +1159,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
||||
provider,
|
||||
selectedModelId,
|
||||
selectedThinkingOptionId,
|
||||
features,
|
||||
onSetFeature,
|
||||
optionControls,
|
||||
onApplyAgentProfile,
|
||||
onEditAgentProfiles,
|
||||
onCreateAgentProfile,
|
||||
@@ -1188,6 +1192,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
||||
} = props;
|
||||
|
||||
const thinkingAnchorRef = useRef<View | null>(null);
|
||||
const sheetOptions = optionControls?.options ?? EMPTY_OPTIONS;
|
||||
|
||||
const hasThinking = comboboxThinkingOptions.length > 0;
|
||||
|
||||
@@ -1238,14 +1243,15 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
||||
|
||||
{modeControl ? <AgentModeControl {...modeControl} surface="sheet" /> : null}
|
||||
|
||||
{(features ?? []).map((feature) => (
|
||||
{(optionControls?.controls ?? []).map((control) => (
|
||||
<SheetFeatureItem
|
||||
key={`feature-${feature.id}`}
|
||||
feature={feature}
|
||||
key={`feature-${control.key}`}
|
||||
control={control}
|
||||
options={sheetOptions}
|
||||
disabled={disabled}
|
||||
openSelector={openSelector}
|
||||
handleOpenChange={handleOpenChange}
|
||||
onSetFeature={onSetFeature}
|
||||
onSetOption={optionControls?.onSetOption}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
@@ -1279,22 +1285,24 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
||||
}
|
||||
|
||||
function DesktopFeatureItem({
|
||||
feature,
|
||||
control,
|
||||
options,
|
||||
disabled,
|
||||
openSelector,
|
||||
handleOpenChange,
|
||||
onSetFeature,
|
||||
onSetOption,
|
||||
onActionComplete,
|
||||
}: {
|
||||
feature: AgentFeature;
|
||||
control: AgentOptionControl;
|
||||
options: ProviderOptions;
|
||||
disabled: boolean;
|
||||
openSelector: AgentControlSelector | null;
|
||||
handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void;
|
||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
||||
onSetOption?: (key: string, value: unknown) => void;
|
||||
onActionComplete?: () => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const featureSelector: AgentControlSelector = `feature-${feature.id}`;
|
||||
const featureSelector: AgentControlSelector = `feature-${control.key}`;
|
||||
const featureAnchorRef = useRef<View>(null);
|
||||
|
||||
const handleFeatureOpenChange = useMemo(
|
||||
@@ -1307,120 +1315,117 @@ function DesktopFeatureItem({
|
||||
);
|
||||
|
||||
const handleTogglePress = useCallback(() => {
|
||||
if (feature.type === "toggle") {
|
||||
onSetFeature?.(feature.id, !feature.value);
|
||||
if (control.type === "toggle") {
|
||||
onSetOption?.(control.key, !readToggleOption(control, options));
|
||||
onActionComplete?.();
|
||||
}
|
||||
}, [feature, onActionComplete, onSetFeature]);
|
||||
}, [control, onActionComplete, onSetOption, options]);
|
||||
|
||||
const handleSelectOption = useCallback(
|
||||
(optionId: string) => {
|
||||
onSetFeature?.(feature.id, optionId);
|
||||
(choiceValue: string) => {
|
||||
onSetOption?.(control.key, choiceValue);
|
||||
},
|
||||
[feature.id, onSetFeature],
|
||||
[control.key, onSetOption],
|
||||
);
|
||||
const comboboxOptions = useMemo<ComboboxOption[]>(
|
||||
() =>
|
||||
feature.type === "select"
|
||||
? feature.options.map((option) => ({ id: option.id, label: option.label }))
|
||||
control.type === "select"
|
||||
? control.choices.map((choice) => ({ id: choice.value, label: choice.label }))
|
||||
: [],
|
||||
[feature],
|
||||
[control],
|
||||
);
|
||||
|
||||
if (feature.type === "toggle") {
|
||||
const FeatureIcon = getAgentFeatureIcon(feature.icon);
|
||||
const FeatureIcon = getAgentFeatureIcon(control.icon);
|
||||
const iconColor = getFeatureIconColor(
|
||||
control,
|
||||
options,
|
||||
theme.colors.palette,
|
||||
theme.colors.foregroundMuted,
|
||||
);
|
||||
|
||||
if (control.type === "toggle") {
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<AgentControlTrigger
|
||||
icon={FeatureIcon}
|
||||
iconColor={getFeatureIconColor(
|
||||
feature,
|
||||
theme.colors.palette,
|
||||
theme.colors.foregroundMuted,
|
||||
)}
|
||||
iconColor={iconColor}
|
||||
surface="toolbar"
|
||||
label={feature.label}
|
||||
label={control.label}
|
||||
showToolbarLabel={false}
|
||||
disabled={disabled}
|
||||
onPress={handleTogglePress}
|
||||
accessibilityLabel={getFeatureTooltip(feature)}
|
||||
testID={`agent-feature-${feature.id}`}
|
||||
accessibilityLabel={getControlTooltip(control)}
|
||||
testID={`agent-feature-${control.key}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
|
||||
<Text style={styles.tooltipText}>{getControlTooltip(control)}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (feature.type === "select") {
|
||||
const FeatureIcon = getAgentFeatureIcon(feature.icon);
|
||||
const selectedOption = feature.options.find((o) => o.id === feature.value);
|
||||
const iconOnly = feature.desktopTrigger === "icon";
|
||||
const tooltip = iconOnly
|
||||
? `${feature.label}: ${selectedOption?.label ?? feature.label}`
|
||||
: getFeatureTooltip(feature);
|
||||
return (
|
||||
<>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<AgentControlTrigger
|
||||
ref={featureAnchorRef}
|
||||
icon={FeatureIcon}
|
||||
iconColor={getFeatureIconColor(
|
||||
feature,
|
||||
theme.colors.palette,
|
||||
theme.colors.foregroundMuted,
|
||||
)}
|
||||
surface="toolbar"
|
||||
label={feature.label}
|
||||
value={selectedOption?.label ?? feature.label}
|
||||
showToolbarLabel={!iconOnly}
|
||||
open={openSelector === featureSelector}
|
||||
disabled={disabled}
|
||||
onPress={handleSelectPress}
|
||||
accessibilityLabel={tooltip}
|
||||
testID={`agent-feature-${feature.id}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{tooltip}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Combobox
|
||||
options={comboboxOptions}
|
||||
value={String(feature.value)}
|
||||
onSelect={handleSelectOption}
|
||||
open={openSelector === featureSelector}
|
||||
onOpenChange={handleFeatureOpenChange}
|
||||
anchorRef={featureAnchorRef}
|
||||
desktopPlacement="top-start"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
const selectedValue = readSelectOption(control, options);
|
||||
const selectedChoice = control.choices.find((choice) => choice.value === selectedValue);
|
||||
const iconOnly = control.desktopTrigger === "icon";
|
||||
const tooltip = iconOnly
|
||||
? `${control.label}: ${selectedChoice?.label ?? control.label}`
|
||||
: getControlTooltip(control);
|
||||
return (
|
||||
<>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<AgentControlTrigger
|
||||
ref={featureAnchorRef}
|
||||
icon={FeatureIcon}
|
||||
iconColor={iconColor}
|
||||
surface="toolbar"
|
||||
label={control.label}
|
||||
value={selectedChoice?.label ?? control.label}
|
||||
showToolbarLabel={!iconOnly}
|
||||
open={openSelector === featureSelector}
|
||||
disabled={disabled}
|
||||
onPress={handleSelectPress}
|
||||
accessibilityLabel={tooltip}
|
||||
testID={`agent-feature-${control.key}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{tooltip}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Combobox
|
||||
options={comboboxOptions}
|
||||
value={String(selectedValue)}
|
||||
onSelect={handleSelectOption}
|
||||
open={openSelector === featureSelector}
|
||||
onOpenChange={handleFeatureOpenChange}
|
||||
anchorRef={featureAnchorRef}
|
||||
desktopPlacement="top-start"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetFeatureItem({
|
||||
feature,
|
||||
control,
|
||||
options,
|
||||
disabled,
|
||||
openSelector,
|
||||
handleOpenChange,
|
||||
onSetFeature,
|
||||
onSetOption,
|
||||
}: {
|
||||
feature: AgentFeature;
|
||||
control: AgentOptionControl;
|
||||
options: ProviderOptions;
|
||||
disabled: boolean;
|
||||
openSelector: AgentControlSelector | null;
|
||||
handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void;
|
||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
||||
onSetOption?: (key: string, value: unknown) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const featureSelector: AgentControlSelector = `feature-${feature.id}`;
|
||||
const featureSelector: AgentControlSelector = `feature-${control.key}`;
|
||||
const featureAnchorRef = useRef<View>(null);
|
||||
|
||||
const handleFeatureOpenChange = useMemo(
|
||||
@@ -1431,97 +1436,71 @@ function SheetFeatureItem({
|
||||
() => handleFeatureOpenChange(openSelector !== featureSelector),
|
||||
[featureSelector, handleFeatureOpenChange, openSelector],
|
||||
);
|
||||
const sheetHeader = useMemo<SheetHeader>(() => ({ title: feature.label }), [feature.label]);
|
||||
const sheetHeader = useMemo<SheetHeader>(() => ({ title: control.label }), [control.label]);
|
||||
|
||||
const handleSelectOption = useCallback(
|
||||
(optionId: string) => {
|
||||
onSetFeature?.(feature.id, feature.type === "toggle" ? optionId === "true" : optionId);
|
||||
(choiceValue: string) => {
|
||||
onSetOption?.(control.key, control.type === "toggle" ? choiceValue === "true" : choiceValue);
|
||||
},
|
||||
[feature.id, feature.type, onSetFeature],
|
||||
[control.key, control.type, onSetOption],
|
||||
);
|
||||
const comboboxOptions = useMemo<ComboboxOption[]>(() => {
|
||||
if (feature.type === "select") {
|
||||
return feature.options.map((option) => ({ id: option.id, label: option.label }));
|
||||
if (control.type === "select") {
|
||||
return control.choices.map((choice) => ({ id: choice.value, label: choice.label }));
|
||||
}
|
||||
return [
|
||||
{ id: "true", label: t("agentControls.features.on") },
|
||||
{ id: "false", label: t("agentControls.features.off") },
|
||||
];
|
||||
}, [feature, t]);
|
||||
}, [control, t]);
|
||||
|
||||
if (feature.type === "toggle") {
|
||||
const FeatureIcon = getAgentFeatureIcon(feature.icon);
|
||||
return (
|
||||
<>
|
||||
<AgentControlTrigger
|
||||
ref={featureAnchorRef}
|
||||
icon={FeatureIcon}
|
||||
iconColor={getFeatureIconColor(
|
||||
feature,
|
||||
theme.colors.palette,
|
||||
theme.colors.foregroundMuted,
|
||||
)}
|
||||
surface="sheet"
|
||||
label={feature.label}
|
||||
value={feature.value ? t("agentControls.features.on") : t("agentControls.features.off")}
|
||||
open={openSelector === featureSelector}
|
||||
disabled={disabled}
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
const FeatureIcon = getAgentFeatureIcon(control.icon);
|
||||
const iconColor = getFeatureIconColor(
|
||||
control,
|
||||
options,
|
||||
theme.colors.palette,
|
||||
theme.colors.foregroundMuted,
|
||||
);
|
||||
let selectedValue: string;
|
||||
let displayValue: string;
|
||||
if (control.type === "toggle") {
|
||||
const isOn = readToggleOption(control, options);
|
||||
selectedValue = String(isOn);
|
||||
displayValue = isOn ? t("agentControls.features.on") : t("agentControls.features.off");
|
||||
} else {
|
||||
const selected = readSelectOption(control, options);
|
||||
selectedValue = String(selected);
|
||||
displayValue =
|
||||
control.choices.find((choice) => choice.value === selected)?.label ?? control.label;
|
||||
}
|
||||
|
||||
if (feature.type === "select") {
|
||||
const FeatureIcon = getAgentFeatureIcon(feature.icon);
|
||||
const selectedOption = feature.options.find((o) => o.id === feature.value);
|
||||
return (
|
||||
<>
|
||||
<AgentControlTrigger
|
||||
ref={featureAnchorRef}
|
||||
icon={FeatureIcon}
|
||||
iconColor={getFeatureIconColor(
|
||||
feature,
|
||||
theme.colors.palette,
|
||||
theme.colors.foregroundMuted,
|
||||
)}
|
||||
surface="sheet"
|
||||
label={feature.label}
|
||||
value={selectedOption?.label ?? feature.label}
|
||||
open={openSelector === featureSelector}
|
||||
disabled={disabled}
|
||||
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"
|
||||
header={sheetHeader}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
return (
|
||||
<>
|
||||
<AgentControlTrigger
|
||||
ref={featureAnchorRef}
|
||||
icon={FeatureIcon}
|
||||
iconColor={iconColor}
|
||||
surface="sheet"
|
||||
label={control.label}
|
||||
value={displayValue}
|
||||
open={openSelector === featureSelector}
|
||||
disabled={disabled}
|
||||
onPress={handleSelectPress}
|
||||
accessibilityLabel={getControlTooltip(control)}
|
||||
testID={`agent-feature-${control.key}`}
|
||||
/>
|
||||
<Combobox
|
||||
options={comboboxOptions}
|
||||
value={selectedValue}
|
||||
onSelect={handleSelectOption}
|
||||
open={openSelector === featureSelector}
|
||||
onOpenChange={handleFeatureOpenChange}
|
||||
anchorRef={featureAnchorRef}
|
||||
presentation="push"
|
||||
searchable={control.type === "toggle" ? false : undefined}
|
||||
header={sheetHeader}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ThinkingComboboxOption({
|
||||
@@ -1549,6 +1528,18 @@ 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({
|
||||
agentId,
|
||||
serverId,
|
||||
@@ -1700,8 +1691,8 @@ export const AgentControls = memo(function AgentControls({
|
||||
[activeModelId, agentId, agentProvider, client, toast, updatePreferences],
|
||||
);
|
||||
|
||||
const handleSetFeature = useCallback(
|
||||
(featureId: string, value: unknown) => {
|
||||
const handleSetOption = useCallback(
|
||||
(key: string, value: unknown) => {
|
||||
if (!client || !agentProvider) {
|
||||
return;
|
||||
}
|
||||
@@ -1710,22 +1701,24 @@ export const AgentControls = memo(function AgentControls({
|
||||
preferences: current,
|
||||
provider: agentProvider,
|
||||
updates: {
|
||||
featureValues: {
|
||||
[featureId]: value,
|
||||
options: {
|
||||
[key]: value,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).catch((error) => {
|
||||
console.warn("[AgentControls] persist feature preference failed", error);
|
||||
console.warn("[AgentControls] persist option preference failed", error);
|
||||
});
|
||||
void client.setAgentFeature(agentId, featureId, value).catch((error) => {
|
||||
console.warn("[AgentControls] setAgentFeature failed", error);
|
||||
void client.setAgentOptions(agentId, { [key]: value }).catch((error) => {
|
||||
console.warn("[AgentControls] setAgentOptions failed", error);
|
||||
toast.error(toErrorMessage(error));
|
||||
});
|
||||
},
|
||||
[agentId, agentProvider, client, toast, updatePreferences],
|
||||
);
|
||||
|
||||
const agentOptionControls = useAgentOptionControls(agent, handleSetOption);
|
||||
|
||||
const commandCenterControls = useMemo<AgentControlCommandCenterSource>(
|
||||
() => ({
|
||||
serverId,
|
||||
@@ -1744,21 +1737,18 @@ export const AgentControls = memo(function AgentControls({
|
||||
select: handleSelectThinkingOption,
|
||||
},
|
||||
modes: commandCenterModes,
|
||||
features: {
|
||||
list: agent?.features,
|
||||
set: handleSetFeature,
|
||||
},
|
||||
optionControls: client ? agentOptionControls : null,
|
||||
}),
|
||||
[
|
||||
activeModelId,
|
||||
agent?.features,
|
||||
agentOptionControls,
|
||||
client,
|
||||
agentId,
|
||||
agentModelSelectorProviders,
|
||||
agentProvider,
|
||||
commandCenterModes,
|
||||
handleSelectCommandCenterModel,
|
||||
handleSelectThinkingOption,
|
||||
handleSetFeature,
|
||||
modeProviderDefinitions,
|
||||
modelSelection.selectedThinkingId,
|
||||
modelSelection.thinkingOptions,
|
||||
@@ -1807,8 +1797,7 @@ export const AgentControls = memo(function AgentControls({
|
||||
thinkingOptions={thinkingOptions.length > 0 ? thinkingOptions : undefined}
|
||||
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
|
||||
onSelectThinkingOption={handleSelectThinkingOption}
|
||||
features={agent.features}
|
||||
onSetFeature={handleSetFeature}
|
||||
optionControls={agentOptionControls}
|
||||
isModelLoading={snapshotIsLoading || selectedProviderIsLoading}
|
||||
onModelSelectorOpen={handleModelSelectorOpen}
|
||||
onRetryModelProvider={handleRetryModelProvider}
|
||||
@@ -1840,8 +1829,7 @@ export function DraftAgentControls({
|
||||
selectedThinkingOptionId,
|
||||
onSelectThinkingOption,
|
||||
onApplyAgentProfile,
|
||||
features,
|
||||
onSetFeature,
|
||||
optionControls,
|
||||
onDropdownClose,
|
||||
onModelSelectorOpen,
|
||||
onRetryModelProvider,
|
||||
@@ -1927,8 +1915,7 @@ export function DraftAgentControls({
|
||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||
onSelectThinkingOption={onSelectThinkingOption}
|
||||
features={features}
|
||||
onSetFeature={onSetFeature}
|
||||
optionControls={optionControls}
|
||||
onDropdownClose={onDropdownClose}
|
||||
onModelSelectorOpen={onModelSelectorOpen}
|
||||
onRetryModelProvider={onRetryModelProvider}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getFeatureHighlightColor,
|
||||
getFeatureTooltip,
|
||||
getAgentControlHintKey,
|
||||
isFeatureActive,
|
||||
normalizeModelId,
|
||||
resolveAgentModelSelection,
|
||||
} from "./utils";
|
||||
@@ -17,51 +15,12 @@ describe("getAgentControlHintKey", () => {
|
||||
});
|
||||
|
||||
describe("feature metadata helpers", () => {
|
||||
it("prefers explicit feature tooltip copy", () => {
|
||||
expect(
|
||||
getFeatureTooltip({
|
||||
label: "Plan",
|
||||
tooltip: "Toggle plan mode",
|
||||
}),
|
||||
).toBe("Toggle plan mode");
|
||||
});
|
||||
|
||||
it("falls back to the feature label when no tooltip is provided", () => {
|
||||
expect(
|
||||
getFeatureTooltip({
|
||||
label: "Custom",
|
||||
}),
|
||||
).toBe("Custom");
|
||||
});
|
||||
|
||||
it("maps feature highlight colors by feature id", () => {
|
||||
expect(getFeatureHighlightColor("fast_mode")).toBe("yellow");
|
||||
expect(getFeatureHighlightColor("service_tier")).toBe("yellow");
|
||||
expect(getFeatureHighlightColor("plan_mode")).toBe("blue");
|
||||
expect(getFeatureHighlightColor("other")).toBe("default");
|
||||
});
|
||||
|
||||
it("treats a select feature as active when a non-default option is selected", () => {
|
||||
const speed = {
|
||||
type: "select" as const,
|
||||
id: "service_tier",
|
||||
label: "Speed",
|
||||
options: [
|
||||
{ id: "default", label: "Normal", isDefault: true },
|
||||
{ id: "fast", label: "Fast" },
|
||||
],
|
||||
};
|
||||
expect(isFeatureActive({ ...speed, value: "default" })).toBe(false);
|
||||
expect(isFeatureActive({ ...speed, value: "fast" })).toBe(true);
|
||||
expect(
|
||||
isFeatureActive({
|
||||
type: "toggle",
|
||||
id: "fast_mode",
|
||||
label: "Fast",
|
||||
value: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeModelId", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentFeature, AgentModelDefinition } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { formatThinkingOptionLabel } from "@/agent-controls/labels";
|
||||
import {
|
||||
@@ -35,16 +35,6 @@ export function normalizeModelId(modelId: string | null | undefined): string | n
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function getFeatureTooltip(feature: Pick<AgentFeature, "label" | "tooltip">): string {
|
||||
return feature.tooltip ?? feature.label;
|
||||
}
|
||||
|
||||
export function isFeatureActive(feature: AgentFeature): boolean {
|
||||
if (feature.type === "toggle") return feature.value;
|
||||
const selectedOption = feature.options.find((option) => option.id === feature.value);
|
||||
return selectedOption !== undefined && !selectedOption.isDefault;
|
||||
}
|
||||
|
||||
export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor {
|
||||
switch (featureId) {
|
||||
case FAST_MODE_FEATURE_ID:
|
||||
|
||||
@@ -20,12 +20,11 @@ export function resolveDraftKey(input: {
|
||||
|
||||
export function buildDraftAgentControls(input: {
|
||||
formState: UseAgentFormStateResult;
|
||||
features?: DraftAgentControlsProps["features"];
|
||||
onSetFeature?: DraftAgentControlsProps["onSetFeature"];
|
||||
optionControls?: DraftAgentControlsProps["optionControls"];
|
||||
onApplyAgentProfile: DraftAgentControlsProps["onApplyAgentProfile"];
|
||||
onDropdownClose?: DraftAgentControlsProps["onDropdownClose"];
|
||||
}): DraftAgentControlsProps {
|
||||
const { formState, features, onSetFeature, onApplyAgentProfile, onDropdownClose } = input;
|
||||
const { formState, optionControls, onApplyAgentProfile, onDropdownClose } = input;
|
||||
return {
|
||||
providerDefinitions: formState.providerDefinitions,
|
||||
selectedProvider: formState.selectedProvider,
|
||||
@@ -43,8 +42,7 @@ export function buildDraftAgentControls(input: {
|
||||
selectedThinkingOptionId: formState.selectedThinkingOptionId,
|
||||
onSelectThinkingOption: formState.setThinkingOptionFromUser,
|
||||
onApplyAgentProfile,
|
||||
features,
|
||||
onSetFeature,
|
||||
optionControls,
|
||||
onDropdownClose,
|
||||
onModelSelectorOpen: formState.refetchProviderModelsIfStale,
|
||||
onRetryModelProvider: formState.refreshProviderModels,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type CreateAgentInitialValues,
|
||||
type UseAgentFormStateResult,
|
||||
} from "@/hooks/use-agent-form-state";
|
||||
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
||||
import { useDraftProviderOptions } from "@/hooks/use-draft-provider-options";
|
||||
import {
|
||||
buildDraftAgentControls,
|
||||
hasDraftContent,
|
||||
@@ -35,7 +35,7 @@ type AttachmentUpdater =
|
||||
interface AgentInputDraftComposerOptions {
|
||||
initialServerId: string | null;
|
||||
initialValues?: CreateAgentInitialValues;
|
||||
initialFeatureValues?: Record<string, unknown>;
|
||||
initialProviderOptions?: Record<string, unknown>;
|
||||
isVisible?: boolean;
|
||||
lockedWorkingDir?: string;
|
||||
}
|
||||
@@ -50,7 +50,7 @@ type DraftComposerState = UseAgentFormStateResult & {
|
||||
effectiveModeId: string;
|
||||
effectiveModelId: string;
|
||||
effectiveThinkingOptionId: string;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
providerOptions: Record<string, unknown> | undefined;
|
||||
agentControls: DraftAgentControlsProps;
|
||||
commandDraft: DraftCommandTarget;
|
||||
};
|
||||
@@ -274,26 +274,25 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
);
|
||||
|
||||
const {
|
||||
features: draftFeatures,
|
||||
featureValues: draftFeatureValues,
|
||||
setFeatureValue: setDraftFeatureValue,
|
||||
applyProfileFeatureValues,
|
||||
} = useDraftAgentFeatures({
|
||||
optionControls: draftOptionControls,
|
||||
options: draftOptions,
|
||||
applyProfile: applyProfileOptions,
|
||||
} = useDraftProviderOptions({
|
||||
serverId: formState.selectedServerId,
|
||||
provider: formState.selectedProvider,
|
||||
cwd: workingDir,
|
||||
modeId: formState.selectedMode,
|
||||
modelId: effectiveModelId,
|
||||
thinkingOptionId: effectiveThinkingOptionId,
|
||||
initialFeatureValues: composerOptions?.initialFeatureValues,
|
||||
initialOptions: composerOptions?.initialProviderOptions,
|
||||
});
|
||||
|
||||
const applyDraftAgentProfile = useCallback(
|
||||
(profile: Parameters<typeof formState.applyProfileFromUser>[0]) => {
|
||||
formState.applyProfileFromUser(profile);
|
||||
applyProfileFeatureValues(profile.featureValues);
|
||||
applyProfileOptions({ provider: profile.provider, options: profile.options });
|
||||
},
|
||||
[applyProfileFeatureValues, formState],
|
||||
[applyProfileOptions, formState],
|
||||
);
|
||||
|
||||
const commandDraft = useMemo(
|
||||
@@ -303,15 +302,9 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
cwd: workingDir,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
featureValues: draftFeatureValues,
|
||||
providerOptions: draftOptions,
|
||||
}),
|
||||
[
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
draftFeatureValues,
|
||||
providerSelection,
|
||||
workingDir,
|
||||
],
|
||||
[effectiveModelId, effectiveThinkingOptionId, draftOptions, providerSelection, workingDir],
|
||||
);
|
||||
|
||||
const composerState = useMemo<DraftComposerState | null>(() => {
|
||||
@@ -325,11 +318,10 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
effectiveModeId,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
featureValues: draftFeatureValues,
|
||||
providerOptions: draftOptions,
|
||||
agentControls: buildDraftAgentControls({
|
||||
formState,
|
||||
features: draftFeatures,
|
||||
onSetFeature: setDraftFeatureValue,
|
||||
optionControls: draftOptionControls,
|
||||
onApplyAgentProfile: applyDraftAgentProfile,
|
||||
}),
|
||||
commandDraft,
|
||||
@@ -340,11 +332,10 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
effectiveModeId,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
draftFeatures,
|
||||
draftFeatureValues,
|
||||
draftOptionControls,
|
||||
draftOptions,
|
||||
applyDraftAgentProfile,
|
||||
formState,
|
||||
setDraftFeatureValue,
|
||||
workingDir,
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ComposerOptionControls } from "@/agent-controls/option-controls";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -65,7 +66,7 @@ interface AutoSubmitConfig {
|
||||
modeId: string | null;
|
||||
model: string | null;
|
||||
thinkingOptionId: string | null;
|
||||
featureValues: Record<string, unknown>;
|
||||
providerOptions: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function resolveAutoSubmitConfig(
|
||||
@@ -74,7 +75,7 @@ function resolveAutoSubmitConfig(
|
||||
modeId?: string | null;
|
||||
model?: string | null;
|
||||
thinkingOptionId?: string | null;
|
||||
featureValues?: Record<string, unknown>;
|
||||
providerOptions?: Record<string, unknown>;
|
||||
} | null,
|
||||
): AutoSubmitConfig | null {
|
||||
if (!pending) return null;
|
||||
@@ -83,7 +84,7 @@ function resolveAutoSubmitConfig(
|
||||
modeId: pending.modeId ?? null,
|
||||
model: pending.model ?? null,
|
||||
thinkingOptionId: pending.thinkingOptionId ?? null,
|
||||
featureValues: pending.featureValues ?? {},
|
||||
providerOptions: pending.providerOptions ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,7 +132,7 @@ async function submitDraftCreateRequest(input: {
|
||||
effectiveModeId: string;
|
||||
effectiveModelId: string | null;
|
||||
effectiveThinkingOptionId: string | null;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
providerOptions: Record<string, unknown> | undefined;
|
||||
};
|
||||
hostDisconnectedMessage: string;
|
||||
selectModelMessage: string;
|
||||
@@ -170,7 +171,7 @@ async function submitDraftCreateRequest(input: {
|
||||
model: autoSubmitConfig?.model ?? (composerState.effectiveModelId || undefined),
|
||||
thinkingOptionId:
|
||||
autoSubmitConfig?.thinkingOptionId ?? (composerState.effectiveThinkingOptionId || undefined),
|
||||
featureValues: autoSubmitConfig?.featureValues ?? composerState.featureValues,
|
||||
providerOptions: autoSubmitConfig?.providerOptions ?? composerState.providerOptions,
|
||||
});
|
||||
|
||||
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
|
||||
@@ -204,7 +205,7 @@ function buildDraftAgentSnapshot(input: {
|
||||
effectiveThinkingOptionId: string | null;
|
||||
effectiveModeId: string;
|
||||
selectedProvider: string | null;
|
||||
agentControls: { features?: Agent["features"] };
|
||||
agentControls: { optionControls?: ComposerOptionControls };
|
||||
};
|
||||
selectModelMessage: string;
|
||||
}): Agent {
|
||||
@@ -241,7 +242,8 @@ function buildDraftAgentSnapshot(input: {
|
||||
title: "Agent",
|
||||
cwd: workspaceDirectory,
|
||||
model,
|
||||
features: composerState.agentControls.features,
|
||||
options: composerState.agentControls.optionControls?.options,
|
||||
controls: composerState.agentControls.optionControls?.controls,
|
||||
thinkingOptionId,
|
||||
parentAgentId: null,
|
||||
labels: {},
|
||||
@@ -333,7 +335,7 @@ export function WorkspaceDraftAgentTab({
|
||||
composer: {
|
||||
initialServerId: serverId,
|
||||
initialValues: draftInitialValues,
|
||||
initialFeatureValues: draftSetup?.featureValues,
|
||||
initialProviderOptions: draftSetup?.providerOptions,
|
||||
isVisible: true,
|
||||
lockedWorkingDir: draftWorkingDirectory ?? undefined,
|
||||
},
|
||||
@@ -351,8 +353,7 @@ export function WorkspaceDraftAgentTab({
|
||||
const draftModeOptions = composerState.modeOptions;
|
||||
const draftSelectedMode = composerState.selectedMode;
|
||||
const draftSetMode = composerState.setModeFromUser;
|
||||
const draftFeatures = composerState.agentControls.features;
|
||||
const draftOnSetFeature = composerState.agentControls.onSetFeature;
|
||||
const draftOptionControls = composerState.agentControls.optionControls;
|
||||
|
||||
const clearDraftInput = draftInput.clear;
|
||||
const replaceDraftText = draftInput.replaceText;
|
||||
@@ -521,10 +522,7 @@ export function WorkspaceDraftAgentTab({
|
||||
selectedId: draftSelectedMode,
|
||||
select: draftSetMode,
|
||||
},
|
||||
features: {
|
||||
list: draftFeatures,
|
||||
set: draftOnSetFeature,
|
||||
},
|
||||
optionControls: draftOptionControls ?? null,
|
||||
},
|
||||
});
|
||||
const isReadyForPendingAutoSubmit = Boolean(
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { expect, it } from "vitest";
|
||||
import { dispatchTopOverlayKeyDown } from "@/keyboard";
|
||||
import { useComposerFullscreenExit } from "./fullscreen-exit";
|
||||
|
||||
it("the native entry claims Escape until fullscreen closes", () => {
|
||||
let exits = 0;
|
||||
function Harness({ active }: { active: boolean }) {
|
||||
useComposerFullscreenExit(active, () => exits++);
|
||||
return null;
|
||||
}
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
flushSync(() => root.render(<Harness active />));
|
||||
expect(dispatchTopOverlayKeyDown(new KeyboardEvent("keydown", { key: "Escape" }))).toBe(true);
|
||||
expect(exits).toBe(1);
|
||||
flushSync(() => root.render(<Harness active={false} />));
|
||||
expect(dispatchTopOverlayKeyDown(new KeyboardEvent("keydown", { key: "Escape" }))).toBe(false);
|
||||
expect(exits).toBe(1);
|
||||
} finally {
|
||||
root.unmount();
|
||||
container.remove();
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useOverlayLayer, useWebOverlayRegistration } from "@/lib/overlay-root";
|
||||
import { useComposerHardwareBackExit } from "./hardware-back-exit";
|
||||
import { useOverlayRegistration } from "@/keyboard";
|
||||
import { useOverlayLayer } from "@/lib/overlay-root";
|
||||
|
||||
/**
|
||||
* Escape leaves fullscreen before app shortcuts see it. The registration only claims
|
||||
@@ -8,8 +10,10 @@ export function useComposerFullscreenExit(
|
||||
active: boolean,
|
||||
onExit: () => void,
|
||||
): ((node: unknown) => void) | undefined {
|
||||
useComposerHardwareBackExit(active, onExit);
|
||||
const layer = useOverlayLayer("modal");
|
||||
return useWebOverlayRegistration({
|
||||
return useOverlayRegistration({
|
||||
keys: ["Escape"],
|
||||
active,
|
||||
layer,
|
||||
manageFocus: false,
|
||||
|
||||
+1
-5
@@ -2,10 +2,7 @@ import { useEffect } from "react";
|
||||
import { BackHandler } from "react-native";
|
||||
|
||||
/** The hardware back button leaves fullscreen instead of the screen. */
|
||||
export function useComposerFullscreenExit(
|
||||
active: boolean,
|
||||
onExit: () => void,
|
||||
): ((node: unknown) => void) | undefined {
|
||||
export function useComposerHardwareBackExit(active: boolean, onExit: () => void): void {
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
|
||||
@@ -14,5 +11,4 @@ export function useComposerFullscreenExit(
|
||||
});
|
||||
return () => subscription.remove();
|
||||
}, [active, onExit]);
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export function useComposerHardwareBackExit(_active: boolean, _onExit: () => void): void {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user