Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andThePharmer 4062bc5fe1 test(relay): reproduce password rejection lost on hosted relay close
Preserve the failing client-path reproduction for #7929 and a close-timing control. The hosted relay loses the rejection after the daemon send completes; the internal relay cause and proposed upstream correction still require verification.

Co-authored-by: ThePharmer <77949461+ThePharmer@users.noreply.github.com>
2026-10-26 12:45:48 +00:00
Mohamed Boudra 42d74c2546 fix(ci): retry transient Apple staple connection failures (#7933) 2026-10-26 19:20:17 +07:00
Mohamed Boudra 678fe308db chore(release): reconcile 0.13.2 metadata with main (#7931)
* docs(release): add 0.13.2 native patch notes

(cherry picked from commit 870c969fdb)

* chore(release): cut 0.13.2

(cherry picked from commit d2941157a8)

* chore(release): refresh Nix dependency hash for 0.13.2
2026-10-26 18:35:29 +07:00
Mohamed Boudra 287ca54749 fix(app): enlarge sidebar kebab tap targets on compact layouts (#7928)
On phones the project and workspace row kebabs were 24px and 18px boxes
with a 14px glyph, too small to hit reliably. Compact layouts now use a
32px box and the density-sized glyph (18px); desktop sizes are unchanged.
Negative margins keep the dots on the existing right rail and the rows at
their 44px compact height.
2026-10-26 18:12:56 +07:00
paseo-ai[bot] 902b0c7a16 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-26 08:55:29 +00:00
Mohamed Boudra 428ef707ad fix(app): demand the host directory from the workspace route (#7918)
A workspace restored at launch with no open chat had nothing demanding its host's directory on a phone, so the deck waited on hydration that never started. The workspace route now keeps its host's directory demanded while mounted, through the same hook the agent route uses.
2026-10-26 15:46:10 +07:00
Mohamed Boudra e277656cbd fix(app): clean up native animation and layout lifecycles (#7914)
Worklets 0.10.4 and Reanimated 4.5.5 while keeping the stale-view guard from #7907; backport the Enriched Markdown settled-row measurement fix (upstream #924); release each bottom sheet's Dimensions subscription on unmount.
2026-10-26 15:46:04 +07:00
Mohamed Boudra e958540b0a fix(app): remove SVG prop and nested button warnings on web (#7915)
* fix(app): prevent web SVG prop and nested button warnings

Consume Unistyles mapping props at the wrapper boundary and use web accessibility attributes for decorative rings. Register composer track rows as menu items so nested actions stay valid and keyboard selection remains available.

* test(app): select archive actions by their menu role
2026-10-26 15:06:02 +07:00
Mohamed Boudra 033b2cdfc2 fix(app): keep command suggestions visible as the composer grows (#7910)
Long command descriptions exhausted the available space above a tall draft, leaving no complete selectable row. Bound the preview and reserve list space. Let the popover own its composer anchor so layout changes refresh its position while suggestions remain open.
2026-10-26 14:24:38 +07:00
Mohamed Boudra 74a7e5803b Choose voice providers and local speech models from a Voice settings page (#7295)
* feat(voice): add a Voice settings page with provider choice and a local model catalog

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Language belongs to every STT use site. Preserve legacy local language settings through config loading and saving, resolve them without provider gating, and expose them in the provider form.
2026-10-26 14:19:02 +07:00
Mohamed Boudraandbjspi 88bd1cf05a feat(app): long-press Send to queue or send now on touch screens (#7897)
Touch users had no way to reach what Cmd/Ctrl+Enter does: queue when
Default send is Steer or Interrupt, send now when it is Queue. Holding the
Send button now runs that same action on every platform, with a haptic on
native and an accessibility hint naming it. Tapping is unchanged.

Both paths go through runAlternateSendAction, which now falls back to the
default send when no turn is running, so holding Send while idle still
sends and the keyboard path no longer carries its own gate.

Co-authored-by: bjspi <43368639+bjspi@users.noreply.github.com>
2026-10-26 14:04:30 +07:00
Mohamed Boudra 95c8491e2e test(server): await finish cleanup and expose completion persistence errors (#7909) 2026-10-26 14:03:51 +07:00
5e53bb78c2 fix(app): keep your place in the Settings sidebar (#7899)
Keep sidebar scroll position for the Settings visit across app and host section navigation. Reject stale scroll callbacks from replaced sidebars and reset the offset when leaving Settings.

Preserve the existing sibling routes and host-selection behavior. Consolidate the restoration work and broader regression coverage from the predecessor pull requests.

Closes #1532.

Co-authored-by: bingqilinweimaotai <111987281+bingqilinweimaotai@users.noreply.github.com>
Co-authored-by: shampoo <wn990916@gmail.com>
2026-10-26 13:53:01 +07:00
Mohamed BoudraandClaude Fable 5.1 ff31b6e5b9 fix(android): skip Reanimated sync prop updates for unmounted views (#7907)
Backport of software-mansion/react-native-reanimated#10435 (shipped in Reanimated 4.7.1) to the 4.5.1 that Expo 57 pins. On React Native 0.86 Reanimated applies synchronous prop updates by reflection and skips the view-existence guard, so every view tag that unmounted with buffered updates threw RetryableMountingLayerException on each event dispatch and printed a full stack on the UI thread. The guard checks MountingManager.getViewExists first and skips writes to missing native views.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 13:50:46 +07:00
Mohamed Boudra 78a3083fea perf(app): avoid stream updates in hidden chats (#7901)
Retained chats already preserve their last visible transcript. Keep their stream-tail selection stable while inactive so incoming chunks do not schedule React work; reveal reads the current model tail.
2026-10-26 13:49:56 +07:00
paseo-ai[bot] 443f1501e4 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-26 06:49:41 +00:00
97ed96bdf6 release: prepare 0.13.1 sidebar startup hotfix from stable (#7902)
* fix(app): keep the sidebar alive when a workspace entry has not hydrated (#7896)

ProjectBlock asserted that every placement in a project has an entry in
workspaceEntriesByKey. A retained sidebar that is inactive (compact layout
with the sidebar closed) or still waiting for sessions keeps an empty map,
and Show background stops the project list from being pruned to hydrated
entries. From the eighth workspace on, selectSidebarGroupVisibility asked
for the status of a missing entry and the app crashed on start.

Count a missing entry as Done. It renders no row, so it should not claim a
slot past the limit.

Closes #7869

Co-authored-by: brucey0x <11062155+brucey0x@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 6bc4b73c42)

* docs(release): add 0.13.1 hotfix notes

* chore(release): cut 0.13.1

---------

Co-authored-by: Budi <11062155+budivoogt@users.noreply.github.com>
Co-authored-by: brucey0x <11062155+brucey0x@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:40:19 +07:00
Mohamed BoudraandKino Roy 27d6b3b7ce feat(desktop): remember project editors and support Xcode (#7898)
Keep editor choices local to each project and shared across its worktrees. Reuse the Xcode target contribution while preserving explicit editor selection.

Co-authored-by: Kino Roy <kinoroy@me.com>
2026-10-26 13:36:00 +07:00
180 changed files with 9012 additions and 1530 deletions
+28
View File
@@ -169,6 +169,34 @@ jobs:
NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }}
run: |
set -euo pipefail
export PASEO_REAL_XCRUN="$(command -v xcrun)"
stapler_bin="$(mktemp -d)"
trap 'rm -rf "$stapler_bin"' EXIT
cat > "$stapler_bin/xcrun" <<'STAPLER'
#!/bin/bash
if [[ "${1:-}" != stapler || "${2:-}" != staple ]]; then
exec "$PASEO_REAL_XCRUN" "$@"
fi
output="$(mktemp)"
trap 'rm -f "$output"' EXIT
for attempt in 1 2 3 4; do
if "$PASEO_REAL_XCRUN" "$@" >"$output" 2>&1; then
cat "$output"
exit 0
else
status=$?
fi
cat "$output"
if [[ "$status" != 68 || "$attempt" == 4 ]] ||
! grep -Fq 'NSURLErrorDomain Code=-1005' "$output"; then
exit "$status"
fi
echo "::warning::Apple staple connection lost; retrying the same signed app ($attempt/4)" >&2
sleep 30
done
STAPLER
chmod +x "$stapler_bin/xcrun"
export PATH="$stapler_bin:$PATH"
build_args=(-- --publish never --mac --${{ matrix.electron_arch }})
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
+15
View File
@@ -1,5 +1,20 @@
# Changelog
## 0.13.2 - 2026-10-26
### Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted ([#7907](https://github.com/getpaseo/paseo/pull/7907))
- Fixed the Android animation run loop continuing while the app is in the background ([#7914](https://github.com/getpaseo/paseo/pull/7914))
- Fixed finished Android chat messages being remeasured during streaming updates ([#7914](https://github.com/getpaseo/paseo/pull/7914))
- Fixed bottom sheets retaining window-size listeners after closing ([#7914](https://github.com/getpaseo/paseo/pull/7914))
## 0.13.1 - 2026-10-26
### Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project ([#7896](https://github.com/getpaseo/paseo/pull/7896) by [@budivoogt](https://github.com/budivoogt))
## 0.13.0 - 2026-10-24
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
+15
View File
@@ -1,5 +1,20 @@
# 更新日志
## 0.13.2 - 2026-10-26
### 修复
- 修复 Android 对已卸载视图反复抛出动画异常的问题 ([#7907](https://github.com/getpaseo/paseo/pull/7907))
- 修复 Android 应用进入后台后动画运行循环仍持续执行的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914))
- 修复 Android 在流式更新时反复测量已完成聊天消息的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914))
- 修复底部面板关闭后仍保留窗口尺寸监听器的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914))
## 0.13.1 - 2026-10-26
### 修复
- 修复开启“显示后台工作区”且项目包含至少八个工作区时的启动崩溃([#7896](https://github.com/getpaseo/paseo/pull/7896),由 [@budivoogt](https://github.com/budivoogt) 贡献)
## 0.13.0 - 2026-10-24
折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。
+20 -41
View File
@@ -221,12 +221,6 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
}
},
providers: {
openai: {
apiKey?: string,
baseUrl?: string,
stt?: { apiKey?: string, baseUrl?: string },
tts?: { apiKey?: string, baseUrl?: string }
},
local: { modelsDir: string }
},
agents: {
@@ -244,8 +238,8 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
pluginsEnabled: boolean,
plugins: Record<pluginId, { source: "directory", path: string, enabled?: boolean }>,
features: {
dictation: { enabled, stt: { provider, model, language, confidenceThreshold } },
voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } }
dictation: { enabled, stt: SpeechSttConfig },
voiceMode: { enabled, llm, stt: SpeechSttConfig, turnDetection, tts: SpeechTtsConfig }
},
log: {
level, format,
@@ -353,42 +347,27 @@ Environment variables override `config.json`:
after changing `config.json`. Environment changes require a daemon restart; the launch environment
remains authoritative during reload.
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
### Speech configuration
Set these to select OpenAI instead of local speech:
Each use site owns its provider settings: `features.dictation.stt`,
`features.voiceMode.stt`, and `features.voiceMode.tts`. `provider` selects a strict
`local` or `openai` variant. Both STT variants carry `language`; normalization preserves
it regardless of provider. `providers.local.modelsDir` remains shared storage.
See [Voice configuration](../public-docs/voice.md) for fields, examples and env precedence.
| Env var | Applies to |
| ------------------------------ | ------------------------------- |
| `PASEO_VOICE_STT_PROVIDER` | Voice mode STT provider |
| `PASEO_DICTATION_STT_PROVIDER` | Composer dictation STT provider |
| `PASEO_VOICE_TTS_PROVIDER` | Voice mode TTS provider |
On load, `persisted-config.ts` folds legacy `providers.openai` credentials and URLs
into OpenAI sites that lack their own values, using endpoint settings before shared
settings. A provider-less site becomes local; provider names are lower-cased, and
fields ignored by the selected speech provider are discarded. The next save writes only the
inline shape. A legacy key with no site to receive it is discarded with a warning
that names its path, without logging its value.
OpenAI speech can be configured under `providers.openai`. STT and TTS resolve independently, so they can point at different endpoints:
```json
{
"providers": {
"openai": {
"stt": {
"apiKey": "sk-...",
"baseUrl": "https://stt.example.com/v1"
},
"tts": {
"apiKey": "sk-...",
"baseUrl": "https://api.openai.com/v1"
}
}
}
}
```
`providers.openai.stt` is used for both composer dictation and voice mode speech-to-text; `providers.openai.tts` is used for voice mode text-to-speech. The equivalent env vars are `OPENAI_STT_API_KEY`/`OPENAI_STT_BASE_URL` and `OPENAI_TTS_API_KEY`/`OPENAI_TTS_BASE_URL`. Each feature falls back to `providers.openai.apiKey`/`providers.openai.baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when its own fields are unset. These settings apply only to Paseo OpenAI speech features, not to Codex or other OpenAI-backed tools.
Paseo uses these paths under the configured OpenAI base URL:
- dictation STT: `/v1/audio/transcriptions`
- voice mode STT: `/v1/audio/transcriptions`
- voice mode TTS: `/v1/audio/speech`
`speech-settings.ts` owns mutable config reads and patches. The wire uses the same
`features` structure. Reads replace OpenAI keys with `hasApiKey` (saved in config)
and report effective env sources in `envOverrides`. A site patch that changes
`provider` replaces the entire object. The same or omitted `provider` merges fields
into the current variant, preserving the write-only key when a form re-saves it. `null` clears a value. Switching providers discards credentials and
options from the previous variant.
---
+3 -1
View File
@@ -70,7 +70,7 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.sm` labels, `sm` = 32px with `fontSize.base`, `md`/`lg` = 44px with `fontSize.base`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and corner radius. Its segments run one padding step tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. Thin chrome such as the file toolbar uses `xs`; settings rows use `sm`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.sm` labels, `sm` = 32px with `fontSize.base`, `md`/`lg` = 44px with `fontSize.base`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and corner radius. Its segments run one padding step tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. Text fields take `xs`, `sm` and `md`. Thin chrome such as the file toolbar uses `xs`. A settings row sizes the controls in its trailing slot itself, `sm` in a card and `xs` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
@@ -126,6 +126,8 @@ Cards inside a section sit closer than sections. Rows inside a card touch — on
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
Rows revealed under a `<SettingsCollapsibleRow>` are nested rows, one step smaller than card rows: 8px of vertical padding, `fontSize.sm` text, `xs` controls, and one fixed height so rows with and without a trailing button line up. The collapsible row sets this for everything under it (`packages/app/src/components/settings/row-density.tsx`). Never pass a density to a row, and never pad or resize a nested row by hand.
The whitespace is the design.
---
+6
View File
@@ -94,6 +94,12 @@ and its Claude provider available. Set `PASEO_COMPOSER_KEYBOARD_SERVER_ID`,
`_AGENT_ID` (an idle mock chat), and `_WORKSPACE_ID` (its workspace); `_DEVICE`,
`_SESSION`, and `_DRAFT_ID` are optional. Artifacts go to
`.dev/agent-device-artifacts/composer-keyboard-ios`.
With a Codex draft selected, set `_SCENARIO=growth` on a 402pt-wide iPhone to
check live wrapping, shrinking, the 12pt anchor gap, and command insertion. Set
`_SCENARIO=previews` on an SE-sized iPhone to check long and short descriptions
under an eight-line draft, selection with the keyboard shown and hidden, and
file mentions. These scenarios only need `_SERVER_ID` and `_WORKSPACE_ID`;
`_UDID` selects an exact simulator and `_ARTIFACTS_DIR` overrides the output path.
The Android sidebar scroll-retention flow requires a connected host with enough
workspaces to overflow the compact sidebar. Pass `TOP_WORKSPACE` and
@@ -0,0 +1,4 @@
Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,7 @@
Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted
- Fixed the Android animation run loop continuing while the app is in the background
- Fixed finished Android chat messages being remeasured during streaming updates
- Fixed bottom sheets retaining window-size listeners after closing
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,7 @@
Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted
- Fixed the Android animation run loop continuing while the app is in the background
- Fixed finished Android chat messages being remeasured during streaming updates
- Fixed bottom sheets retaining window-size listeners after closing
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,7 @@
Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted
- Fixed the Android animation run loop continuing while the app is in the background
- Fixed finished Android chat messages being remeasured during streaming updates
- Fixed bottom sheets retaining window-size listeners after closing
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,7 @@
Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted
- Fixed the Android animation run loop continuing while the app is in the background
- Fixed finished Android chat messages being remeasured during streaming updates
- Fixed bottom sheets retaining window-size listeners after closing
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-DZOPOf81PkUxdD65BrfkTZhs6/DkfDWCFZ/SU+QrZbg=
sha256-eNiKmiZ3XXZMRZRSoGxUbMn2RtY6AX079vUatBYM+qE=
+37 -70
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.13.0",
"version": "0.13.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.13.0",
"version": "0.13.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -30269,9 +30269,9 @@
}
},
"node_modules/react-native-reanimated": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.1.tgz",
"integrity": "sha512-RnMvtDuR+68ig864gAvZCOdZehqhC5rFmMo0kn+ARfgVSTvFeF6IFLBVgMPUu0KwihaapEyW24WRi6nEyy1kSA==",
"version": "4.5.5",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.5.tgz",
"integrity": "sha512-xtJXZRZ1vkec1AIUVG02St2sbyZS5jc7227TlDu/HonqUYPeZAfiC4RI2jqL1dVMNBu1+7oBx/c9QkjF2kg6Lg==",
"license": "MIT",
"dependencies": {
"react-native-is-edge-to-edge": "^1.3.1",
@@ -30280,7 +30280,7 @@
"peerDependencies": {
"react": "*",
"react-native": "0.83 - 0.86",
"react-native-worklets": "0.10.x"
"react-native-worklets": "0.10.x - 0.11.x"
}
},
"node_modules/react-native-safe-area-context": {
@@ -30406,9 +30406,9 @@
}
},
"node_modules/react-native-worklets": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.1.tgz",
"integrity": "sha512-62mRM19bDpfpdI8HLkEErcdOsrAPDtE9lA/sw+5lLRpzBHNhxaoj9QyY2KjXqUmirelxkX4zuPGTC3VdA0feJA==",
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.4.tgz",
"integrity": "sha512-gbk0CxMrGywEp3SJAHO+2JVaCWtl4WyAYBIxLZy6Y113+mHHuq3FuT6iV+VcaBuJzuuN34mtdZ4j5JIlBDzmeA==",
"license": "MIT",
"dependencies": {
"@babel/plugin-transform-arrow-functions": "^7.27.1",
@@ -35695,7 +35695,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.13.0",
"version": "0.13.2",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -35780,14 +35780,14 @@
"react-native-gesture-handler": "~2.32.0",
"react-native-keyboard-controller": "1.21.12",
"react-native-nitro-modules": "0.35.5",
"react-native-reanimated": "4.5.1",
"react-native-reanimated": "4.5.5",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-svg": "15.15.4",
"react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0",
"react-native-webview": "13.16.1",
"react-native-worklets": "0.10.1",
"react-native-worklets": "0.10.4",
"tiny-invariant": "^1.3.3",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2",
@@ -37961,12 +37961,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.13.0",
"version": "0.13.2",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/server": "0.13.0",
"@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"@getpaseo/server": "0.13.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -38213,10 +38213,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.13.0",
"version": "0.13.2",
"dependencies": {
"@getpaseo/protocol": "0.13.0",
"@getpaseo/relay": "0.13.0",
"@getpaseo/protocol": "0.13.2",
"@getpaseo/relay": "0.13.2",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -38228,7 +38228,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.13.0",
"version": "0.13.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -38472,7 +38472,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.13.0",
"version": "0.13.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -41012,39 +41012,6 @@
}
}
},
"packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/react-native-worklets": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.13.0.tgz",
"integrity": "sha512-1OrhAeAKaZF4XrBa4YbPQbKzNOpjh52HDlId8e6ete1OCHNdAlrQHzG4gznJ225EAj3dgQXHViJvK51M9B52jQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@babel/generator": "^7.27.1",
"@babel/plugin-transform-arrow-functions": "^7.27.1",
"@babel/plugin-transform-class-properties": "^7.28.6",
"@babel/plugin-transform-classes": "^7.28.6",
"@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6",
"@babel/plugin-transform-optional-chaining": "^7.28.6",
"@babel/plugin-transform-shorthand-properties": "^7.27.1",
"@babel/plugin-transform-template-literals": "^7.27.1",
"@babel/plugin-transform-unicode-regex": "^7.27.1",
"@babel/preset-typescript": "^7.28.5",
"@babel/traverse": "^7.27.1",
"@babel/types": "^7.27.1",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"semver": "^7.7.4",
"whatwg-fetch": "^3.6.20"
},
"peerDependencies": {
"@babel/core": "*",
"@react-native/metro-config": "*",
"react": "*",
"react-native": "0.86 - 0.88"
}
},
"packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/react-native/node_modules/pretty-format": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
@@ -42124,7 +42091,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.13.0",
"version": "0.13.2",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -42153,15 +42120,15 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.13.0",
"version": "0.13.2",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -42171,8 +42138,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -42194,7 +42161,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.13.0",
"version": "0.13.2",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -42221,7 +42188,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.13.0",
"version": "0.13.2",
"dependencies": {
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
@@ -42442,16 +42409,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.13.0",
"version": "0.13.2",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.13.0",
"@getpaseo/highlight": "0.13.0",
"@getpaseo/plugin": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/relay": "0.13.0",
"@getpaseo/client": "0.13.2",
"@getpaseo/highlight": "0.13.2",
"@getpaseo/plugin": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"@getpaseo/relay": "0.13.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -43372,7 +43339,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.13.0",
"version": "0.13.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -44982,7 +44949,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.13.0",
"version": "0.13.2",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.13.0",
"version": "0.13.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -154,7 +154,7 @@
"react-dom": "19.2.3",
"react-native": "0.86.3",
"react-native-gesture-handler": "~2.32.0",
"react-native-reanimated": "4.5.1",
"react-native-worklets": "0.10.1"
"react-native-reanimated": "4.5.5",
"react-native-worklets": "0.10.4"
}
}
@@ -18,7 +18,9 @@ import {
expectComposerVisible,
cancelAgent,
composerLocator,
expectQueuedMessageButton,
fillComposerDraft,
holdSendButton,
sendDraftToQueue,
startRunningMockAgent,
submitMessage,
@@ -1290,6 +1292,49 @@ test.describe("Agent message submission", () => {
}
});
test("queues a draft by holding Send while a tap still steers", async ({ page }, testInfo) => {
test.setTimeout(120_000);
const gate = await gateNextAgentMessage(page);
const agent = await startRunningMockAgent(page, {
prefix: `hold-send-queue-${testInfo.workerIndex}-`,
model: "one-minute-stream",
prompt: "Keep this turn active while the user holds Send.",
});
try {
await expectComposerVisible(page);
await expectAgentReadyToInterrupt(page);
const queuedPrompt = "Queue this follow-up by holding Send.";
await test.step("holding Send queues the draft", async () => {
await fillComposerDraft(page, queuedPrompt);
await holdSendButton(page, "Send and steer");
await expectQueuedMessageButton(page);
await expectComposerDraft(page, "");
await expectAgentReadyToInterrupt(page);
await expect(
page.getByTestId("user-message").filter({ hasText: queuedPrompt }),
).toHaveCount(0);
});
await test.step("tapping Send still steers", async () => {
const steeredPrompt = "Steer this turn with a tap.";
await fillComposerDraft(page, steeredPrompt);
await page.getByRole("button", { name: "Send and steer", exact: true }).click();
const request = await gate.waitForRequest();
expect(request.text).toBe(steeredPrompt);
expect(request.activeTurnBehavior).toBe("steer");
gate.accept();
await expect(
page.getByTestId("user-message").filter({ hasText: steeredPrompt }),
).toHaveCount(1);
await expectQueuedMessageButton(page);
});
} finally {
await agent.cleanup();
}
});
test("replays Claude-shaped steering inside one active turn", async ({ page }, testInfo) => {
test.setTimeout(120_000);
await replaySteeredSleepTurnInBrowser(page, testInfo, "claude");
@@ -42,6 +42,14 @@ const test = base.extend<{ agent: MockAgentSession }>({
},
});
// The outer meter owns the accessible usage label; its SVG is decorative.
async function expectDecorativeContextRing(meter: Locator): Promise<void> {
const ring = meter.locator("svg");
await expect(ring).toHaveAttribute("aria-hidden", "true");
await expect(ring).not.toHaveAttribute("accessibilityElementsHidden");
await expect(ring).not.toHaveAttribute("importantForAccessibility");
}
// Where the progress arc is painted, as its centroid relative to the ring's centre in pixels.
// Reads the rendered pixels, so any rotation that does not reach the screen counts as none.
async function progressArcCentroid(meter: Locator): Promise<{ x: number; y: number }> {
@@ -100,6 +108,7 @@ test.describe("context window meter", () => {
const meter = page.getByTestId("context-window-meter");
await expect(meter).toHaveAccessibleName(/25%/, { timeout: 30_000 });
await expectDecorativeContextRing(meter);
const centroid = await progressArcCentroid(meter);
expect(centroid.x).toBeGreaterThan(1);
expect(centroid.y).toBeLessThan(-1);
@@ -10,6 +10,15 @@ import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import {
closeCompactSettings,
scrollSettingsSidebarToEnd,
revealSettingsSidebarSection,
expectSettingsSidebarScrollSettled,
scrollSettingsSidebarUp,
changeSettingsInterfaceFontSize,
selectSettingsHostSectionsRapidly,
expectSettingsSidebarScrollOffset,
scrollSettingsDetailDown,
expectSettingsDetailScrollOffset,
openSettingsSection,
expectSettingsHeader,
openAddHostFlow,
@@ -54,6 +63,98 @@ async function openWorkspace(
await expect(page.getByTestId("menu-button")).toBeVisible();
}
test.describe("Settings sidebar scroll position", () => {
test.use({ viewport: { width: 1000, height: 500 } });
test("clamps after resizing and starts a fresh visit at the top", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await scrollSettingsSidebarToEnd(page);
await page.setViewportSize({ width: 1000, height: 1100 });
await expectSettingsSidebarScrollOffset(page, 0);
await page.setViewportSize({ width: 1000, height: 500 });
await openSettingsSection(page, "appearance");
await expectSettingsSidebarScrollOffset(page, 0);
await scrollSettingsSidebarToEnd(page);
await clickSettingsBackToWorkspace(page);
await openSettings(page);
await expectSettingsSidebarScrollOffset(page, 0);
});
test("keeps the sidebar position through interface font changes", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsSection(page, "appearance");
const offset = await scrollSettingsSidebarToEnd(page);
await changeSettingsInterfaceFontSize(page, 20);
await expectSettingsSidebarScrollOffset(page, offset);
await openSettingsHostSection(page, getServerId(), "providers");
await expectSettingsSidebarScrollOffset(page, offset);
});
test("scrolls the sidebar and detail pane independently", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
const sidebarOffset = await scrollSettingsSidebarToEnd(page);
await openSettingsHostSection(page, getServerId(), "providers");
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
const detailOffset = await scrollSettingsDetailDown(page);
await expectSettingsSidebarScrollOffset(page, sidebarOffset);
await scrollSettingsSidebarUp(page, 60);
await expectSettingsSidebarScrollOffset(page, sidebarOffset - 60);
await expectSettingsDetailScrollOffset(page, detailOffset);
});
test("keeps the sidebar position after rapid consecutive selections", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
const scrollTop = await scrollSettingsSidebarToEnd(page);
await selectSettingsHostSectionsRapidly(page);
await expectSettingsSidebarScrollOffset(page, scrollTop);
await expectSettingsSidebarScrollSettled(page, scrollTop);
});
test("keeps the sidebar position when switching between app and host sections", async ({
page,
}) => {
await gotoAppShell(page);
await openSettings(page);
const hostScrollTop = await scrollSettingsSidebarToEnd(page);
await expect(page.getByTestId("settings-host-section-providers")).toBeInViewport();
for (const section of [
"providers",
"terminals",
"providers",
"terminals",
"providers",
] as const) {
await openSettingsHostSection(page, getServerId(), section);
await expect(page.getByTestId("page-title")).toHaveText(
section === "providers" ? "Providers" : "Terminals",
);
await expectSettingsSidebarScrollOffset(page, hostScrollTop);
}
const sidebar = page.getByTestId("settings-sidebar");
const appScrollTop = await revealSettingsSidebarSection(page, "diagnostics");
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeInViewport();
await openSettingsSection(page, "about");
await expectAboutContent(page);
await expectSettingsSidebarScrollOffset(page, appScrollTop);
await openSettingsSection(page, "diagnostics");
await expectDiagnosticsContent(page);
await expectSettingsSidebarScrollOffset(page, appScrollTop);
});
});
test.describe("Settings sidebar navigation", () => {
test("opening Settings closes the composer model selector until it is opened again", async ({
page,
@@ -0,0 +1,93 @@
import { expect, test } from "../support/fixtures";
import {
apiKeyInput,
baseUrlInput,
expectSheetClosed,
expectSpeechSiteSummary,
openSpeechSite,
openVoiceSettings,
providerOption,
readSpeechSite,
resetSpeechSite,
saveSpeechSite,
withUnwritableDaemonConfig,
} from "../support/helpers/voice-settings";
// E2E daemons pin every speech provider to OpenAI through env vars so no test starts a model
// download. That makes the provider picker read-only here; the env key covers the key fallback.
const ENV_API_KEY = "sk-e2e-env-fallback";
const SAVED_API_KEY = "sk-e2e-saved-key";
const BASE_URL = "http://127.0.0.1:9/v1";
test.use({ e2eDaemonEnvironment: { OPENAI_API_KEY: ENV_API_KEY } });
test("configures an env-pinned OpenAI site and keeps the saved key on later saves", async ({
page,
}) => {
try {
await openVoiceSettings(page);
await expectSpeechSiteSummary(page, "Listening", "OpenAI · whisper-1");
const sheet = await test.step("the env var decides the provider", async () => {
const opened = await openSpeechSite(page, "Listening");
await expect(opened.getByText("Set by PASEO_VOICE_STT_PROVIDER")).toBeVisible();
await expect(providerOption(opened, "Local")).toBeDisabled();
await expect(providerOption(opened, "OpenAI")).toHaveAttribute("aria-selected", "true");
await expect(opened.getByText("Set by OPENAI_API_KEY")).toBeVisible();
return opened;
});
await test.step("a saved key and base URL win over the env key", async () => {
await apiKeyInput(sheet).fill(SAVED_API_KEY);
await baseUrlInput(sheet).fill(BASE_URL);
await saveSpeechSite(sheet);
await expectSheetClosed(page);
await expect
.poll(() => readSpeechSite("voiceMode.stt"))
.toMatchObject({ provider: "openai", hasApiKey: true, baseUrl: BASE_URL });
});
await test.step("reopening shows the key as saved without revealing it", async () => {
const reopened = await openSpeechSite(page, "Listening");
await expect(apiKeyInput(reopened)).toHaveValue("");
await expect(apiKeyInput(reopened)).toHaveAttribute("placeholder", "Saved");
await expect(reopened.getByText("Set by OPENAI_API_KEY")).toHaveCount(0);
await expect(baseUrlInput(reopened)).toHaveValue(BASE_URL);
await expect(reopened.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
});
await test.step("clearing the base URL keeps the saved key", async () => {
const sheetAgain = page.getByRole("dialog");
await baseUrlInput(sheetAgain).fill("");
await saveSpeechSite(sheetAgain);
await expectSheetClosed(page);
const site = await readSpeechSite("voiceMode.stt");
expect(site).toMatchObject({ provider: "openai", hasApiKey: true });
expect(site).not.toHaveProperty("baseUrl");
});
} finally {
await resetSpeechSite("voiceMode.stt");
}
});
test("a failed save keeps the sheet open with the user's input", async ({ page }) => {
try {
await openVoiceSettings(page);
const sheet = await openSpeechSite(page, "Speaking");
await baseUrlInput(sheet).fill(BASE_URL);
await withUnwritableDaemonConfig(async () => {
await saveSpeechSite(sheet);
await expect(page.getByText(/^Unable to update voice settings: /)).toBeVisible();
});
await expect(baseUrlInput(sheet)).toHaveValue(BASE_URL);
await saveSpeechSite(sheet);
await expectSheetClosed(page);
await expect
.poll(() => readSpeechSite("voiceMode.tts"))
.toMatchObject({ provider: "openai", baseUrl: BASE_URL });
} finally {
await resetSpeechSite("voiceMode.tts");
}
});
@@ -28,7 +28,10 @@ function contains(outer, inner) {
* while suggestions are open, so its callers read the composer from `reference`: a
* snapshot in the same keyboard state with an empty input.
*/
export function resolveAutocompletePlacement(snapshot, { expected, keyboardTop, reference }) {
export function resolveAutocompletePlacement(
snapshot,
{ expected, keyboardTop, reference, anchorGap },
) {
if (!snapshot.success || !snapshot.data?.nodes?.length) throw new Error("Empty device snapshot");
const nodes = snapshot.data.nodes;
const popover = findById(nodes, "composer-autocomplete-popover").rect;
@@ -40,6 +43,10 @@ export function resolveAutocompletePlacement(snapshot, { expected, keyboardTop,
const keyboardY =
keyboardTop ?? nodes.find((node) => node.type === "Keyboard" && node.rect)?.rect.y;
const popoverBottom = bottomOf(popover);
const gap = composer.y - popoverBottom;
if (anchorGap !== undefined && Math.abs(gap - anchorGap) > 1) {
throw new Error(`Autocomplete lost its anchor gap: gap=${gap}, expected=${anchorGap}`);
}
if (popoverBottom > composer.y + 1) {
throw new Error(
`Autocomplete overlaps the composer: popover bottom=${popoverBottom}, composer top=${composer.y}`,
+136 -25
View File
@@ -14,7 +14,8 @@ const env = (name, fallback) => process.env[`PASEO_COMPOSER_KEYBOARD_${name}`] ?
const serverId = env("SERVER_ID");
const agentId = env("AGENT_ID");
const workspaceId = env("WORKSPACE_ID");
if (!serverId || !agentId || !workspaceId) {
const scenario = env("SCENARIO", "all");
if (!serverId || !workspaceId || (scenario === "all" && !agentId)) {
throw new Error(
"Set PASEO_COMPOSER_KEYBOARD_SERVER_ID, _AGENT_ID (an idle chat) and _WORKSPACE_ID (its workspace)",
);
@@ -22,7 +23,12 @@ if (!serverId || !agentId || !workspaceId) {
const draftId = env("DRAFT_ID", "composer-keyboard");
const session = env("SESSION", "composer-keyboard-ios");
const device = env("DEVICE", "iPhone 16 Pro");
const artifactsDir = path.join(repoRoot, ".dev/agent-device-artifacts/composer-keyboard-ios");
const udid = env("UDID");
const deviceArgs = udid ? ["--udid", udid] : ["--device", device];
const artifactsDir = env(
"ARTIFACTS_DIR",
path.join(repoRoot, ".dev/agent-device-artifacts/composer-keyboard-ios"),
);
const grownPrefix = "grown line one\ngrown line two\ngrown line three\n";
// Partial queries with known best matches. `inserted` is the composer text after
// tapping the best match.
@@ -35,7 +41,7 @@ const chatCommandCase = { text: "/mo", expected: "/mock", inserted: "/mock " };
fs.mkdirSync(artifactsDir, { recursive: true });
function ad(...args) {
return execFileSync("agent-device", [...args, "--session", session], {
return execFileSync("agent-device", [args[0], "--session", session, ...args.slice(1)], {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
@@ -77,7 +83,7 @@ async function settledPlacement(name, keyboard) {
}
function assertPlacement(name, snap, expected) {
const placement = resolveAutocompletePlacement(snap, { expected });
const placement = resolveAutocompletePlacement(snap, { expected, anchorGap: 12 });
process.stdout.write(
`PASS ${name}: popover ${Math.round(placement.popover.y)}-${Math.round(
placement.popover.y + placement.popover.height,
@@ -123,21 +129,34 @@ async function clearInput(name) {
)?.rect;
if (!deleteKey) throw new Error("Missing keyboard delete key");
const length = composerText(keyboard).length;
ad(
"press",
String(Math.round(deleteKey.x + deleteKey.width / 2)),
String(Math.round(deleteKey.y + deleteKey.height / 2)),
"--count",
String(length + 2),
"--interval-ms",
"40",
);
for (let remaining = length + 2; remaining > 0; remaining -= 100) {
ad(
"press",
String(Math.round(deleteKey.x + deleteKey.width / 2)),
String(Math.round(deleteKey.y + deleteKey.height / 2)),
"--count",
String(Math.min(remaining, 100)),
"--interval-ms",
"40",
);
}
}
// The accessibility value drops a trailing space.
const sameText = (actual, expected) => actual.trimEnd() === expected.trimEnd();
async function runCase(host, kind, { text, expected, inserted }) {
async function selectRow(name, row, inserted) {
ad("press", String(row.x), String(row.y));
const selected = await waitFor(`${name}-selected`, (snap) =>
sameText(composerText(snap), inserted),
);
if (!keyboardShown(selected)) throw new Error(`Selecting ${row.label} closed the keyboard`);
process.stdout.write(
`PASS ${name}: tapped ${row.label}, composer now ${JSON.stringify(inserted)}\n`,
);
}
async function runCase(host, kind, { text, expected, inserted, selectionKeyboard = "reopened" }) {
const name = `${host}-${kind}`;
await clearInput(name);
await dismissKeyboard(snapshot(`${name}-empty`));
@@ -151,31 +170,32 @@ async function runCase(host, kind, { text, expected, inserted }) {
await clearInput(`${name}-settled`);
await waitFor(`${name}-settled-empty`, (snap) => composerText(snap) === "");
ad("type", text);
assertPlacement(
const opened = assertPlacement(
`${name} opened after keyboard settled`,
await settledPlacement(`${name}-open`, true),
expected,
);
if (selectionKeyboard === "shown") {
await selectRow(name, opened, inserted);
return;
}
await dismissKeyboard(snapshot(`${name}-before-close`));
assertPlacement(
const closed = assertPlacement(
`${name} keyboard closed`,
await settledPlacement(`${name}-closed`, false),
expected,
);
if (selectionKeyboard === "hidden") {
await selectRow(name, closed, inserted);
return;
}
focusAtEnd(snapshot(`${name}-before-reopen`));
const reopened = assertPlacement(
`${name} keyboard opened under open suggestions`,
await settledPlacement(`${name}-reopened`, true),
expected,
);
ad("press", String(reopened.x), String(reopened.y));
const selected = await waitFor(`${name}-selected`, (snap) =>
sameText(composerText(snap), inserted),
);
if (!keyboardShown(selected)) throw new Error(`Selecting ${expected} closed the keyboard`);
process.stdout.write(
`PASS ${name}: tapped ${expected}, composer now ${JSON.stringify(inserted)}\n`,
);
await selectRow(name, reopened, inserted);
}
// Drafts list commands for their selected provider, and a fresh install selects none.
@@ -216,13 +236,104 @@ async function runHost(host) {
await dismissKeyboard(snapshot(`${host}-done`));
}
// These cases use the selected draft provider's real skills. Run on a 402pt-wide
// iPhone for growth and a small phone (SE) for previews, with Codex selected.
async function runLiveGrowth() {
await clearInput("live-growth");
ad("type", "Please do the following now /paseo");
const before = await settledPlacement("live-growth-before", true);
assertPlacement("before growth", before, "/paseo");
const inputHeight = (snap) =>
snap.data.nodes.find((node) => node.type === "TextView").rect.height;
ad("type", "--", "-committee");
const grown = await settledPlacement("live-growth-after", true);
if (inputHeight(grown) <= inputHeight(before))
throw new Error("Draft did not wrap onto a new line");
assertPlacement("live growth without reopening", grown, "/paseo-committee");
const deleteKey = grown.data.nodes.find(
(node) => node.type === "Key" && node.label === "delete",
).rect;
ad(
"press",
String(deleteKey.x + deleteKey.width / 2),
String(deleteKey.y + deleteKey.height / 2),
"--count",
"10",
"--interval-ms",
"40",
);
const shrunk = await settledPlacement("live-growth-shrunk", true);
if (inputHeight(shrunk) !== inputHeight(before))
throw new Error("Draft did not shrink back to one line");
assertPlacement("live shrink without reopening", shrunk, "/paseo");
ad("type", "--", "-committ");
const partial = await settledPlacement("live-growth-partial", true);
if (!sameText(composerText(partial), "Please do the following now /paseo-committ")) {
throw new Error("Could not prepare a partial command for selection");
}
const row = assertPlacement("partial command after growth", partial, "/paseo-committee");
ad("press", String(row.x), String(row.y));
await waitFor("live-growth-selected", (snap) =>
sameText(composerText(snap), "Please do the following now /paseo-committee "),
);
await clearInput("live-growth-done");
}
async function runBoundedPreview() {
const prefix =
"Please do the following / testing inline selection.\n" +
"Line two with details.\nLine three with details.\nLine four with details.\n" +
"Line five with details.\nLine six with details.\nLine seven with details.\nPlease use ";
await runCase("preview", "long", {
text: `${prefix}/openai`,
expected: "/openai-docs",
inserted: `${prefix}/openai-docs `,
selectionKeyboard: "shown",
});
await runCase("preview", "long-hidden", {
text: `${prefix}/openai`,
expected: "/openai-docs",
inserted: `${prefix}/openai-docs `,
selectionKeyboard: "hidden",
});
await runCase("preview", "longest", {
text: `${prefix}/paseo-plug`,
expected: "/paseo-plugin",
inserted: `${prefix}/paseo-plugin `,
selectionKeyboard: "shown",
});
await runCase("preview", "short", {
text: `${prefix}/com`,
expected: "/commit",
inserted: `${prefix}/commit `,
selectionKeyboard: "shown",
});
await runCase("preview", "files", fileCase);
}
if (scenario !== "all") {
ad(
"open",
`paseo://h/${serverId}/workspace/${workspaceId}?open=draft%3A${draftId}`,
"--platform",
"ios",
...deviceArgs,
);
await waitFor("draft", (snap) => Boolean(nodeById(snap, "message-input-root")), 60_000);
if (scenario === "growth") await runLiveGrowth();
else if (scenario === "previews") await runBoundedPreview();
else throw new Error(`Unknown scenario: ${scenario}`);
process.stdout.write(`Composer ${scenario} regression passed. Artifacts: ${artifactsDir}\n`);
process.exit(0);
}
const hosts = [
["chat", `paseo://h/${serverId}/agent/${agentId}`],
["workspace-draft", `paseo://h/${serverId}/workspace/${workspaceId}?open=draft%3A${draftId}`],
["new-workspace", `paseo://new?serverId=${serverId}`],
];
for (const [host, url] of hosts) {
ad("open", url, "--platform", "ios", "--device", device);
ad("open", url, "--platform", "ios", ...deviceArgs);
await waitFor(host, (snap) => Boolean(nodeById(snap, "message-input-root")), 60_000);
await runHost(host);
}
@@ -87,6 +87,14 @@ export async function sendDraftToQueue(page: Page): Promise<void> {
await composerInput(page).press("Control+Enter");
}
/** Long-press the Send button: the composer takes the draft while the pointer is still down. */
export async function holdSendButton(page: Page, label: string): Promise<void> {
await page.getByRole("button", { name: label, exact: true }).hover();
await page.mouse.down();
await expect(composerInput(page)).toHaveValue("", { timeout: 5_000 });
await page.mouse.up();
}
export async function expectQueuedMessageButton(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Send queued message now" })).toBeVisible({
timeout: 10_000,
@@ -42,6 +42,7 @@ type HostSection =
| "pair-device"
| "agents"
| "metadata"
| "voice"
| "workspaces"
| "providers"
| "usage"
@@ -57,6 +58,92 @@ export async function openSettingsSection(page: Page, section: SettingsSection):
await expectAppRoute(page, buildSettingsSectionRoute(section));
}
export async function revealSettingsSidebarSection(
page: Page,
section: SettingsSection,
): Promise<number> {
await page
.getByRole("navigation", { name: "Settings" })
.getByRole("button", { name: SECTION_LABELS[section], exact: true })
.scrollIntoViewIfNeeded();
const offset = await page
.getByTestId("settings-sidebar-scroll-body")
.evaluate((node) => node.scrollTop);
expect(offset).toBeGreaterThan(0);
return offset;
}
export async function expectSettingsSidebarScrollSettled(
page: Page,
offset: number,
): Promise<void> {
// RN Web emits its final scroll callback 100 ms after scrolling stops.
await page.waitForTimeout(200);
await expectSettingsSidebarScrollOffset(page, offset);
}
export async function selectSettingsHostSectionsRapidly(page: Page): Promise<void> {
for (let selection = 0; selection < 5; selection += 1) {
await page
.getByRole("navigation", { name: "Settings" })
.getByRole("button", { name: "Providers", exact: true })
.click();
await page
.getByRole("navigation", { name: "Settings" })
.getByRole("button", { name: "Terminals", exact: true })
.click();
await expectSettingsHeader(page, "Terminals");
}
}
export async function scrollSettingsSidebarUp(page: Page, pixels: number): Promise<void> {
await page.getByTestId("settings-sidebar-scroll-body").hover();
await page.mouse.wheel(0, -pixels);
}
export async function changeSettingsInterfaceFontSize(page: Page, size: number): Promise<void> {
const input = page.getByRole("textbox", { name: "Interface font size" });
await input.fill(String(size));
await input.press("Tab");
await expect(input).toHaveValue(String(size));
}
export async function scrollSettingsSidebarToEnd(page: Page): Promise<number> {
const scrollBody = page.locator('[data-testid="settings-sidebar-scroll-body"]:visible');
await scrollBody.hover();
await page.mouse.wheel(0, 2000);
await expect
.poll(() =>
scrollBody.evaluate((node) => node.scrollHeight - node.clientHeight - node.scrollTop),
)
.toBe(0);
const offset = await scrollBody.evaluate((node) => node.scrollTop);
expect(offset).toBeGreaterThan(0);
return offset;
}
export async function expectSettingsSidebarScrollOffset(page: Page, offset: number): Promise<void> {
const scrollBody = page.locator('[data-testid="settings-sidebar-scroll-body"]:visible');
await expect.poll(() => scrollBody.evaluate((node) => node.scrollTop)).toBe(offset);
}
export async function scrollSettingsDetailDown(page: Page): Promise<number> {
const scrollBody = page.getByTestId("settings-detail-scroll-body");
await expect
.poll(() => scrollBody.evaluate((node) => node.scrollHeight - node.clientHeight))
.toBeGreaterThan(0);
await scrollBody.hover();
await page.mouse.wheel(0, 1000);
await expect.poll(() => scrollBody.evaluate((node) => node.scrollTop)).toBeGreaterThan(0);
const offset = await scrollBody.evaluate((node) => node.scrollTop);
return offset;
}
export async function expectSettingsDetailScrollOffset(page: Page, offset: number): Promise<void> {
const scrollBody = page.getByTestId("settings-detail-scroll-body");
await expect.poll(() => scrollBody.evaluate((node) => node.scrollTop)).toBe(offset);
}
export async function openSettingsHost(page: Page, serverId: string): Promise<void> {
// Host sections are now flat top-level rows under the Host group. Navigate by
// clicking the Connections section row; the picker only matters when >1 host.
@@ -188,7 +188,7 @@ export async function expectManagedSubagentArchived(
}
export async function archiveFinishedSubagents(page: Page): Promise<void> {
await page.getByRole("button", { name: "Archive finished subagents" }).click();
await page.getByRole("menuitem", { name: "Archive finished subagents" }).click();
}
export async function expectArchiveFinishedInProgress(
@@ -196,7 +196,7 @@ export async function expectArchiveFinishedInProgress(
completed: number,
total: number,
): Promise<void> {
await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeDisabled();
await expect(page.getByRole("menuitem", { name: "Archive finished subagents" })).toBeDisabled();
await expect(page.getByText(`${completed}/${total}`, { exact: true })).toBeVisible();
}
@@ -206,7 +206,7 @@ export async function expectArchiveFinishedRetry(
total: number,
): Promise<void> {
await expect(page.getByText(`Retry (${failed}/${total})`, { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeEnabled();
await expect(page.getByRole("menuitem", { name: "Archive finished subagents" })).toBeEnabled();
}
export async function holdManagedSubagentArchiveRequest(
@@ -0,0 +1,99 @@
import { rename, mkdir, rmdir } from "node:fs/promises";
import path from "node:path";
import { expect, type Locator, type Page } from "@playwright/test";
import { gotoAppShell, openSettings } from "./app";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { getServerId } from "./server-id";
import { expectSettingsHeader, openHostSection, openSettingsHost } from "./settings";
export type SpeechSiteLabel = "Speech-to-text" | "Listening" | "Speaking";
type SpeechSiteId = "dictation.stt" | "voiceMode.stt" | "voiceMode.tts";
export async function openVoiceSettings(page: Page): Promise<void> {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "voice");
await expectSettingsHeader(page, "Voice");
}
function speechSiteRow(page: Page, label: SpeechSiteLabel): Locator {
return page.getByRole("button", { name: new RegExp(`^${label}, `) });
}
export async function expectSpeechSiteSummary(
page: Page,
label: SpeechSiteLabel,
summary: string,
): Promise<void> {
await expect(speechSiteRow(page, label)).toHaveAccessibleName(`${label}, ${summary}`);
}
/** Opens the provider sheet for one use site and returns it. */
export async function openSpeechSite(page: Page, label: SpeechSiteLabel): Promise<Locator> {
await speechSiteRow(page, label).click();
const sheet = page.getByRole("dialog");
await expect(sheet.getByText(label, { exact: true })).toBeVisible();
return sheet;
}
export function providerOption(sheet: Locator, provider: "Local" | "OpenAI"): Locator {
return sheet.getByRole("button", { name: provider, exact: true });
}
export function apiKeyInput(sheet: Locator): Locator {
return sheet.getByRole("textbox", { name: "API key" });
}
export function baseUrlInput(sheet: Locator): Locator {
return sheet.getByRole("textbox", { name: "Base URL" });
}
export async function saveSpeechSite(sheet: Locator): Promise<void> {
await sheet.getByRole("button", { name: "Save", exact: true }).click();
}
export async function expectSheetClosed(page: Page): Promise<void> {
await expect(page.getByRole("dialog")).toHaveCount(0);
}
/** The speech site as the daemon reports it; the API key itself never leaves the daemon. */
export async function readSpeechSite(siteId: SpeechSiteId): Promise<Record<string, unknown>> {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
try {
const { config } = await client.getDaemonConfig();
const features = config.features as Record<string, Record<string, unknown>>;
const [feature, site] = siteId.split(".");
return features[feature][site] as Record<string, unknown>;
} finally {
await client.close().catch(() => undefined);
}
}
/** Puts a speech site back to the default provider so later tests start from a known state. */
export async function resetSpeechSite(siteId: SpeechSiteId): Promise<void> {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
try {
const [feature, site] = siteId.split(".");
await client.patchDaemonConfig({ features: { [feature]: { [site]: { provider: "local" } } } });
} finally {
await client.close().catch(() => undefined);
}
}
/** Makes the daemon's config file unreadable for the duration of `run`. */
export async function withUnwritableDaemonConfig(run: () => Promise<void>): Promise<void> {
const paseoHome = process.env.E2E_PASEO_HOME;
if (!paseoHome) throw new Error("E2E_PASEO_HOME is required to break the daemon config");
const configPath = path.join(paseoHome, "config.json");
const asidePath = `${configPath}.aside`;
await rename(configPath, asidePath);
await mkdir(configPath);
try {
await run();
} finally {
await rmdir(configPath);
await rename(asidePath, configPath);
}
}
+11 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.13.0",
"version": "0.13.2",
"private": true,
"main": "index.ts",
"scripts": {
@@ -126,14 +126,14 @@
"react-native-gesture-handler": "~2.32.0",
"react-native-keyboard-controller": "1.21.12",
"react-native-nitro-modules": "0.35.5",
"react-native-reanimated": "4.5.1",
"react-native-reanimated": "4.5.5",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-svg": "15.15.4",
"react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0",
"react-native-webview": "13.16.1",
"react-native-worklets": "0.10.1",
"react-native-worklets": "0.10.4",
"tiny-invariant": "^1.3.3",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2",
@@ -198,6 +198,14 @@
],
"enableVideo": false
},
"expo": {
"install": {
"exclude": [
"react-native-reanimated",
"react-native-worklets"
]
}
},
"reanimated": {
"staticFeatureFlags": {
"FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS": false
@@ -0,0 +1,12 @@
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useSessionStore } from "@/stores/session-store";
// The view retains its last visible transcript. Selecting a stable value here
// also prevents incoming chunks from scheduling React work in a hidden panel.
// Revealing the panel reads the current tail directly from the model.
export function useAgentStreamTail(serverId: string, agentId: string | undefined) {
const active = useRetainedPanelActive();
return useSessionStore((state) =>
active && agentId ? state.sessions[serverId]?.agentStreamTail.get(agentId) : undefined,
);
}
+1 -1
View File
@@ -72,7 +72,7 @@ import {
type StartupBlocker,
} from "@/navigation/host-runtime-bootstrap";
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
import { useSettingsRouteEntry } from "@/navigation/settings-selection";
import { useSettingsRouteEntry } from "@/navigation/settings-presentation";
import { ThemedStack } from "@/navigation/themed-stack";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { AgentNavigationListener } from "@/desktop/agent-navigation";
@@ -5,6 +5,7 @@ import { useFetchQuery } from "@/data/query";
import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution";
import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view";
import { useHasHydratedWorkspaces } from "@/stores/session-store-hooks";
import { useDirectoryDemand } from "@/hooks/use-directory-demand";
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
import { useSessionStore } from "@/stores/session-store";
import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
@@ -87,11 +88,7 @@ function HostAgentReadyRouteContent() {
]);
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
const hasHydratedLayout = useWorkspaceLayoutStoreHydrated();
useEffect(() => {
if (!serverId) return;
// A compact app can open this route before its sidebar has demanded the directory.
return getHostRuntimeStore().acquireDirectoryDemand(serverId);
}, [serverId]);
useDirectoryDemand(serverId);
const resolution = resolveAgentRoute({
hasHydratedWorkspaces,
hasHydratedLayout,
@@ -9,6 +9,7 @@ import {
useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
import { useDirectoryDemand } from "@/hooks/use-directory-demand";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
@@ -110,6 +111,10 @@ function HostWorkspaceRouteContent() {
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: "";
const openValue = getParamValue(params.open);
// A restored workspace with no open chat has nothing else demanding its host's directory on a
// phone, where the sidebar only demands while its panel is active. Without demand the deck
// waits on workspace hydration that never starts.
useDirectoryDemand(serverId);
useEffect(() => {
if (!serverId || !workspaceId) {
return;
@@ -112,8 +112,9 @@ const ContextWindowRing = withUnistyles(function ContextWindowRing({
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
{...(isNative
? { accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants" }
: { "aria-hidden": true })}
>
<Circle
cx={center}
@@ -77,6 +77,7 @@ import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-dra
import { confirmDialog } from "@/utils/confirm-dialog";
import { useToast } from "@/contexts/toast-context";
import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { useOpenDirectoryInEditor } from "@/workspace/open-in-editor/directory";
const SORT_OPTIONS: { value: SortOption }[] = [
@@ -450,9 +451,15 @@ export function FileExplorerPane({
isLocalExecution: isLocalDaemon,
});
const fileManagerTarget = desktopOpenTargets.find((target) => target.kind === "file-manager");
const projectId = useWorkspaceFields(
serverId,
workspaceId ?? null,
(workspace) => workspace.projectId,
);
const openDirectoryInEditor = useOpenDirectoryInEditor({
serverId,
workspaceDirectory: normalizedWorkspaceRoot,
projectId,
});
// COMPAT(fsEntryOps): added in v0.3.0, remove gate after 2027-02-08.
const fsEntryOpsEnabled = useSessionStore(
@@ -1,20 +1,18 @@
import { ChevronRight } from "lucide-react-native";
import { Children, isValidElement, useCallback, useMemo, useState, type ReactNode } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { mutedIconColorMapping } from "@/components/ui/icon-color";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { isWeb } from "@/constants/platform";
import { settingsStyles } from "@/styles/settings";
import { ICON_SIZE, type Theme } from "@/styles/theme";
const ThemedChevron = withUnistyles(ChevronRight);
const hoveredIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
import { NestedSettingsRows } from "./row-density";
import { SettingsRowHeader } from "./row-header";
interface SettingsCollapsibleRowProps {
label: string;
/** A short status shown before the chevron, so the row says something while collapsed. */
summary?: string;
defaultExpanded?: boolean;
testID?: string;
/** The rows revealed underneath, separated like the rows of a `SettingsCard`. */
/** The rows revealed underneath, separated like the rows of a `SettingsCard` and laid out as nested rows. */
children: ReactNode;
}
@@ -25,6 +23,7 @@ interface SettingsCollapsibleRowProps {
*/
export function SettingsCollapsibleRow({
label,
summary,
defaultExpanded = false,
testID,
children,
@@ -37,66 +36,39 @@ export function SettingsCollapsibleRow({
() => (isWeb ? ({ "aria-expanded": expanded } as const) : null),
[expanded],
);
const headerStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
settingsStyles.row,
hovered && styles.headerHovered,
pressed && styles.headerPressed,
],
[],
);
const chevronStyle = useMemo(
() => [styles.chevron, expanded && styles.chevronExpanded],
[expanded],
);
return (
<View testID={testID}>
<Pressable
{...webExpandedProps}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={accessibilityState}
<SettingsRowHeader
label={label}
summary={summary}
onPress={toggle}
style={headerStyle}
expanded={expanded}
accessibilityState={accessibilityState}
webAccessibilityProps={webExpandedProps}
testID={testID ? `${testID}-toggle` : undefined}
>
{({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => (
<>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{label}</Text>
</View>
<View style={chevronStyle}>
<ThemedChevron
size={ICON_SIZE.sm}
uniProps={hovered ? hoveredIconColorMapping : mutedIconColorMapping}
/>
</View>
</>
)}
</Pressable>
{expanded
? Children.toArray(children).map((child, index) => (
<View key={isValidElement(child) ? child.key : index} style={settingsStyles.rowBorder}>
{child}
</View>
))
: null}
/>
{expanded ? (
<View style={styles.children}>
<NestedSettingsRows>
{Children.toArray(children).map((child, index) => (
<View
key={isValidElement(child) ? child.key : index}
style={settingsStyles.rowBorder}
>
{child}
</View>
))}
</NestedSettingsRows>
</View>
) : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
headerHovered: {
backgroundColor: theme.colors.surface2,
},
headerPressed: {
backgroundColor: theme.colors.surface3,
},
// Optical: the chevron's ink sits about 4px inside its box whether it points right or down; this
// puts the ink on the card's trailing rail, where the switch and the reports' refresh buttons end.
chevron: { marginRight: -theme.spacing[1] },
chevronExpanded: {
transform: [{ rotate: "90deg" }],
// The revealed rows keep the card's rails; the tint is what groups them under their header.
children: {
backgroundColor: theme.colors.surface0,
},
}));
@@ -0,0 +1,33 @@
import { SettingsRowHeader } from "./row-header";
interface SettingsDetailRowProps {
label: string;
/** What the detail is set to, so the row says it without being opened. */
summary?: string;
onPress: () => void;
/** A row that cannot be opened keeps its summary and drops the chevron. */
disabled?: boolean;
testID?: string;
}
/**
* A settings row that opens a detail, such as a sheet, for one setting. The label, a one-line
* summary of the current value and a chevron pointing right. Sits inside a `SettingsCard`.
*/
export function SettingsDetailRow({
label,
summary,
onPress,
disabled,
testID,
}: SettingsDetailRowProps) {
return (
<SettingsRowHeader
label={label}
summary={summary}
onPress={onPress}
disabled={disabled}
testID={testID}
/>
);
}
@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import type { ComponentType, ReactNode } from "react";
import { Pressable, Text } from "react-native";
import { useTranslation } from "react-i18next";
import { Info } from "lucide-react-native";
@@ -6,7 +6,13 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { ICON_SIZE, type Theme } from "@/styles/theme";
const ThemedInfo = withUnistyles(Info);
type TipIconComponent = ComponentType<{ size: number; color: string }>;
function TipIcon({ icon: Icon, color }: { icon: TipIconComponent; color: string }) {
return <Icon size={ICON_SIZE.sm} color={color} />;
}
const ThemedTipIcon = withUnistyles(TipIcon);
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -14,27 +20,37 @@ interface SettingsInfoTipProps {
/** Header the tip belongs to; used for the accessibility label. */
title: string;
info: ReactNode;
/** Defaults to the info icon. */
icon?: TipIconComponent;
/** Defaults to "About {title}". */
accessibilityLabel?: string;
testID?: string;
}
/**
* The one way a settings group or section explains itself: an info icon on the
* header that opens a tooltip. Never a paragraph under the header — see
* docs/design.md §7.
* docs/design.md §7. Rows use it the same way to keep secondary details on one line.
*/
export function SettingsInfoTip({ title, info, testID }: SettingsInfoTipProps) {
export function SettingsInfoTip({
title,
info,
icon = Info,
accessibilityLabel,
testID,
}: SettingsInfoTipProps) {
const { t } = useTranslation();
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={t("settings.groupInfo", { title })}
accessibilityLabel={accessibilityLabel ?? t("settings.groupInfo", { title })}
testID={testID}
hitSlop={8}
style={styles.button}
>
<ThemedInfo size={ICON_SIZE.sm} uniProps={mutedIconMapping} />
<ThemedTipIcon icon={icon} uniProps={mutedIconMapping} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="start" offset={8}>
+25 -11
View File
@@ -24,15 +24,23 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/component
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
import { useIsCompactFormFactor } from "@/constants/layout";
import { settingsStyles } from "@/styles/settings";
import { ControlSizeProvider } from "@/components/ui/control-size";
import { useSettingsRowLayout } from "./row-density";
export { SettingsGroup } from "./headings/settings-group";
export { SettingsSection } from "./headings/settings-section";
export { SettingsCollapsibleRow } from "./collapsible-row";
export { SettingsDetailRow } from "./detail-row";
export { SettingsInfoTip } from "./headings/settings-info-tip";
interface AppSettingsRowProps extends Omit<SettingsRowProps, "hint"> {
hint?: ReactNode;
/** What app screens can add to a plugin-facing row: an accessory beside the label, such as an info tip. */
interface AppRowExtras {
labelAccessory?: ReactNode;
}
interface AppSettingsRowProps extends Omit<SettingsRowProps, "hint">, AppRowExtras {
hint?: ReactNode;
}
export function SettingsCard({ children, testID }: { children: ReactNode; testID?: string }) {
return (
<View style={settingsStyles.card} testID={testID}>
@@ -57,26 +65,32 @@ export function SettingsRow({
testID,
}: AppSettingsRowProps) {
const compact = useIsCompactFormFactor();
const rowStyle = useMemo(() => [settingsStyles.row, compact && styles.compactRow], [compact]);
const layout = useSettingsRowLayout();
const rowStyle = useMemo(() => [layout.row, compact && styles.compactRow], [compact, layout]);
const accessoryTitleStyle = useMemo(() => [layout.title, styles.accessoryLabel], [layout]);
return (
<View style={rowStyle} testID={testID}>
<View style={styles.label}>
{labelAccessory ? (
<View style={styles.labelRow}>
<Text style={[settingsStyles.rowTitle, styles.accessoryLabel]}>{label}</Text>
<Text style={accessoryTitleStyle}>{label}</Text>
{labelAccessory}
</View>
) : (
<Text style={settingsStyles.rowTitle}>{label}</Text>
<Text style={layout.title}>{label}</Text>
)}
{typeof hint === "string" ? <Text style={settingsStyles.rowHint}>{hint}</Text> : hint}
{typeof hint === "string" ? <Text style={layout.hint}>{hint}</Text> : hint}
{error ? (
<Text accessibilityRole="alert" style={settingsStyles.rowError}>
{error}
</Text>
) : null}
</View>
{children ? <View style={styles.control}>{children}</View> : null}
{children ? (
<View style={styles.control}>
<ControlSizeProvider size={layout.controlSize}>{children}</ControlSizeProvider>
</View>
) : null}
</View>
);
}
@@ -117,7 +131,7 @@ export function SettingsSelect<Value extends string>({
onValueChange,
disabled,
...row
}: SettingsSelectProps<Value>) {
}: SettingsSelectProps<Value> & AppRowExtras) {
const selectedLabel = options.find((option) => option.value === value)?.label ?? value;
return (
<SettingsRow {...row}>
@@ -152,7 +166,7 @@ export function SettingsInput({
secureTextEntry,
ref,
...row
}: SettingsInputProps) {
}: SettingsInputProps & AppRowExtras) {
const compact = useIsCompactFormFactor();
const input = useRef<EditingTextInputHandle>(null);
useImperativeHandle(
@@ -175,7 +189,7 @@ export function SettingsInput({
editable={!disabled}
secureTextEntry={secureTextEntry}
accessibilityLabel={row.label}
size={compact ? "md" : "sm"}
size={compact ? "md" : undefined}
style={styles.input}
/>
</SettingsRow>
@@ -185,7 +199,7 @@ export function SettingsInput({
export function SettingsAction({ actionLabel, onPress, disabled, ...row }: SettingsActionProps) {
return (
<SettingsRow {...row}>
<Button variant="outline" size="sm" onPress={onPress} disabled={disabled}>
<Button variant="outline" onPress={onPress} disabled={disabled}>
{actionLabel}
</Button>
</SettingsRow>
@@ -0,0 +1,77 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { Button } from "@/components/ui/button";
import { SettingsCard, SettingsCollapsibleRow, SettingsRow } from "@/components/settings";
let root: Root | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
});
function mountRows() {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() =>
root?.render(
<SettingsCard>
<SettingsRow label="Card row" testID="card-row">
<Button variant="outline" testID="card-button">
Download
</Button>
</SettingsRow>
<SettingsCollapsibleRow label="Group" defaultExpanded testID="group">
<SettingsRow label="Nested with a button" testID="nested-with-button">
<Button variant="outline" testID="nested-button">
Download
</Button>
</SettingsRow>
<SettingsRow label="Nested without a button" testID="nested-without-button" />
</SettingsCollapsibleRow>
</SettingsCard>,
),
);
}
function element(testID: string): HTMLElement {
const found = document.querySelector(`[data-testid="${testID}"]`);
if (!(found instanceof HTMLElement)) throw new Error(`Nothing rendered as ${testID}`);
return found;
}
function heightOf(testID: string): number {
return element(testID).getBoundingClientRect().height;
}
function titleFontSize(rowTestID: string, label: string): number {
const title = [...element(rowTestID).querySelectorAll("div")].find(
(node) => node.textContent === label && node.children.length === 0,
);
if (!title) throw new Error(`No title ${label}`);
return Number.parseFloat(getComputedStyle(title).fontSize);
}
describe("settings row density", () => {
it("lines up nested rows with and without a button, tighter than card rows", () => {
mountRows();
expect(heightOf("nested-with-button")).toBe(heightOf("nested-without-button"));
expect(heightOf("nested-with-button")).toBeLessThan(heightOf("card-row"));
expect(heightOf("group-toggle")).toBeGreaterThan(heightOf("nested-without-button"));
});
it("sizes nested text and controls one step below card rows", () => {
mountRows();
expect(heightOf("nested-button")).toBeLessThan(heightOf("card-button"));
expect(titleFontSize("nested-with-button", "Nested with a button")).toBeLessThan(
titleFontSize("card-row", "Card row"),
);
});
});
@@ -0,0 +1,57 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import type { StyleProp, TextStyle, ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry";
import type { ContainedControlSize } from "@/components/ui/control-size";
import { settingsStyles } from "@/styles/settings";
// Rows revealed under a collapsible row belong to it, so they sit one step smaller than the rows
// of the card: tighter padding, smaller text, smaller controls. The container decides; a row
// never knows on its own that it is nested.
const NestedRowsContext = createContext(false);
export function NestedSettingsRows({ children }: { children: ReactNode }) {
return <NestedRowsContext.Provider value>{children}</NestedRowsContext.Provider>;
}
export interface SettingsRowLayout {
row: StyleProp<ViewStyle>;
title: StyleProp<TextStyle>;
hint: StyleProp<TextStyle>;
/** The size of the controls in the row's trailing slot. */
controlSize: ContainedControlSize;
}
/** How a row lays out where it is rendered: in a card, or nested under a collapsible row. */
export function useSettingsRowLayout(): SettingsRowLayout {
const nested = useContext(NestedRowsContext);
return useMemo(
() =>
nested
? {
row: [settingsStyles.row, styles.nestedRow],
title: [settingsStyles.rowTitle, styles.nestedText],
hint: [settingsStyles.rowHint, styles.nestedText],
controlSize: "xs",
}
: {
row: settingsStyles.row,
title: settingsStyles.rowTitle,
hint: settingsStyles.rowHint,
controlSize: "sm",
},
[nested],
);
}
const styles = StyleSheet.create((theme) => ({
// Every nested row is as tall as one holding an `xs` control, so rows with and without a
// trailing button line up.
nestedRow: {
paddingVertical: theme.spacing[2],
minHeight: CONTROL_HEIGHTS.tight + theme.spacing[2] * 2,
},
nestedText: {
fontSize: theme.fontSize.sm,
},
}));
@@ -0,0 +1,113 @@
import { ChevronRight } from "lucide-react-native";
import { useCallback, useMemo } from "react";
import { Text, View, type AccessibilityState, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { mutedIconColorMapping } from "@/components/ui/icon-color";
import { PressHighlight } from "@/components/ui/press-highlight";
import { settingsStyles } from "@/styles/settings";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { useSettingsRowLayout } from "./row-density";
const ThemedChevron = withUnistyles(ChevronRight);
const hoveredIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
type HoverState = PressableStateCallbackType & { hovered?: boolean };
export interface SettingsRowHeaderProps {
label: string;
/** A short status shown before the chevron. */
summary?: string;
onPress: () => void;
disabled?: boolean;
/** Turns the chevron down, for a row whose children are revealed in place. */
expanded?: boolean;
accessibilityState?: AccessibilityState;
/** Props React Native Web does not derive from `accessibilityState`, such as `aria-expanded`. */
webAccessibilityProps?: Record<string, unknown> | null;
testID?: string;
}
/**
* The pressable line shared by settings rows that lead somewhere: a label, an optional summary and
* a trailing chevron. A collapsible row turns the chevron down; a row that opens a sheet keeps it
* pointing right.
*/
export function SettingsRowHeader({
label,
summary,
onPress,
disabled,
expanded = false,
accessibilityState,
webAccessibilityProps,
testID,
}: SettingsRowHeaderProps) {
const layout = useSettingsRowLayout();
const rowStyle = useCallback(
({ hovered, pressed }: HoverState) => [
layout.row,
!disabled && hovered && styles.hovered,
!disabled && pressed && styles.pressed,
],
[disabled, layout],
);
const chevronStyle = useMemo(
() => [styles.chevron, expanded && styles.chevronExpanded],
[expanded],
);
return (
<PressHighlight
{...webAccessibilityProps}
accessibilityRole="button"
accessibilityLabel={summary ? `${label}, ${summary}` : label}
accessibilityState={accessibilityState}
disabled={disabled}
onPress={onPress}
style={rowStyle}
highlightStyle={styles.pressed}
testID={testID}
>
{({ hovered }: HoverState) => (
<>
<View style={settingsStyles.rowContent}>
<Text style={layout.title}>{label}</Text>
</View>
{summary ? (
<Text style={styles.summary} numberOfLines={1}>
{summary}
</Text>
) : null}
{disabled ? null : (
<View style={chevronStyle}>
<ThemedChevron
size={ICON_SIZE.sm}
uniProps={hovered ? hoveredIconColorMapping : mutedIconColorMapping}
/>
</View>
)}
</>
)}
</PressHighlight>
);
}
const styles = StyleSheet.create((theme) => ({
summary: {
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
marginRight: theme.spacing[2],
},
hovered: {
backgroundColor: theme.colors.surface2,
},
pressed: {
backgroundColor: theme.colors.surface3,
},
// Optical: the chevron's ink sits about 4px inside its box whether it points right or down; this
// puts the ink on the card's trailing rail, where the switch and the reports' refresh buttons end.
chevron: { marginRight: -theme.spacing[1] },
chevronExpanded: {
transform: [{ rotate: "90deg" }],
},
}));
@@ -30,7 +30,7 @@ import {
type ActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { density, densityIconBox } from "@/styles/density";
import { density, densityIconBox, densityIconProps } from "@/styles/density";
import type { Theme } from "@/styles/theme";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
@@ -463,12 +463,12 @@ const openInNewWindowLeadingIcon = (
<ThemedExternalLink size={14} uniProps={foregroundMutedColorMapping} />
);
const kebabTriggerIconProps = densityIconProps("sm", foregroundMutedColorMapping);
const kebabTriggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping);
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
return (
<ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
<ThemedMoreVertical uniProps={hovered ? kebabTriggerIconHoveredProps : kebabTriggerIconProps} />
);
}
@@ -2705,8 +2705,12 @@ const styles = StyleSheet.create((theme) => ({
marginRight: -6,
},
projectKebabButton: {
width: 24,
height: 24,
// Larger on compact so the kebab is an easy tap target. The negative margins keep its
// dots on the same rail as the 24px desktop box and the row at its 44px height.
width: { xs: 32, md: 24 },
height: { xs: 32, md: 24 },
marginRight: { xs: -4, md: 0 },
marginVertical: { xs: -4, md: 0 },
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
@@ -17,6 +17,7 @@ import { isWeb } from "@/constants/platform";
import { getForgePresentation, normalizeForge } from "@/git/forge";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { useAppSettings } from "@/hooks/use-settings";
import { densityIconProps } from "@/styles/density";
import type { Theme } from "@/styles/theme";
import type { ShortcutKey } from "@/utils/format-shortcut";
import {
@@ -70,13 +71,11 @@ const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedCol
const pinLeadingIcon = <ThemedPin size={14} uniProps={foregroundMutedColorMapping} />;
const unpinLeadingIcon = <ThemedPinOff size={14} uniProps={foregroundMutedColorMapping} />;
const triggerIconProps = densityIconProps("sm", foregroundMutedColorMapping);
const triggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping);
function renderTriggerIcon({ hovered }: { hovered?: boolean }) {
return (
<ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
);
return <ThemedMoreVertical uniProps={hovered ? triggerIconHoveredProps : triggerIconProps} />;
}
export interface SidebarWorkspaceMenuProps {
@@ -439,12 +438,18 @@ function triggerStyle({ hovered = false }: PressableStateCallbackType & { hovere
const styles = StyleSheet.create((theme) => ({
trigger: {
padding: 2,
// Larger on compact so the kebab is an easy tap target.
width: { xs: 32, md: 18 },
height: { xs: 32, md: 18 },
alignItems: "center",
justifyContent: "center",
borderRadius: 4,
marginLeft: 2,
// Center the compact box on the 20px title line it is anchored to.
marginTop: { xs: -6, md: 0 },
// MoreVertical paints only around the center of its SVG. Keep the padded hit box, but
// pull the painted dots through that unused view-box space onto the trailing-content rail.
marginRight: -7,
marginRight: { xs: -14, md: -7 },
},
triggerHovered: {
backgroundColor: theme.colors.surface2,
@@ -5,9 +5,9 @@ import {
useRef,
useState,
type ReactElement,
type RefObject,
type ReactNode,
} from "react";
import { View, useWindowDimensions } from "react-native";
import { View, useWindowDimensions, type StyleProp, type ViewStyle } from "react-native";
import { Portal } from "@gorhom/portal";
import Animated, { useAnimatedReaction, useAnimatedStyle } from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
@@ -32,7 +32,8 @@ interface RelativeAnchorRect {
interface AutocompletePopoverProps {
visible: boolean;
anchorRef: RefObject<View | null>;
children: ReactNode;
anchorStyle?: StyleProp<ViewStyle>;
options: readonly AutocompleteOption[];
selectedIndex: number;
onSelect: (option: AutocompleteOption) => void;
@@ -44,7 +45,8 @@ interface AutocompletePopoverProps {
export function AutocompletePopover({
visible,
anchorRef,
children,
anchorStyle,
options,
selectedIndex,
onSelect,
@@ -55,6 +57,7 @@ export function AutocompletePopover({
}: AutocompletePopoverProps): ReactElement | null {
"use no memo";
// React Compiler memoizes effect captures by reading SharedValue.value during render.
const anchorRef = useRef<View>(null);
const [relativeAnchorRect, setRelativeAnchorRect] = useState<RelativeAnchorRect | null>(null);
const windowDimensions = useWindowDimensions();
const safeAreaInsets = useSafeAreaInsets();
@@ -137,25 +140,29 @@ export function AutocompletePopover({
[anchorY, reservedLift, safeAreaInsets.top],
);
if (!visible || !relativeAnchorRect || !baseStyle) return null;
if (options.length > 0 && selectedIndex < 0) return null;
const showPopover = canMeasure && relativeAnchorRect && baseStyle;
// The translate view is the popover's own frame, so it lifts only the popover.
return (
<Portal hostName={portalHostName}>
<KeyboardTranslateView {...anchor.translation} style={baseStyle}>
<Animated.View testID="composer-autocomplete-popover" style={heightStyle}>
<Autocomplete
options={options}
selectedIndex={selectedIndex}
onSelect={onSelect}
isLoading={isLoading}
errorMessage={errorMessage}
loadingText={loadingText}
emptyText={emptyText}
/>
</Animated.View>
</KeyboardTranslateView>
</Portal>
<View ref={anchorRef} onLayout={remeasure} style={anchorStyle} collapsable={false}>
{children}
{showPopover ? (
<Portal hostName={portalHostName}>
<KeyboardTranslateView {...anchor.translation} style={baseStyle}>
<Animated.View testID="composer-autocomplete-popover" style={heightStyle}>
<Autocomplete
options={options}
selectedIndex={selectedIndex}
onSelect={onSelect}
isLoading={isLoading}
errorMessage={errorMessage}
loadingText={loadingText}
emptyText={emptyText}
/>
</Animated.View>
</KeyboardTranslateView>
</Portal>
) : null}
</View>
);
}
@@ -247,14 +247,16 @@ export function Autocomplete({
<View style={styles.outerWrapper}>
{selectedOption?.kind === "command" && selectedOption.description ? (
<View style={styles.detailCard}>
<Text style={styles.detailLabel}>
<Text style={styles.detailLabel} numberOfLines={1}>
{removeBoltGlyphs(selectedOption.label) ?? selectedOption.label}
</Text>
<Text style={styles.detailDescription}>
<Text style={styles.detailDescription} numberOfLines={2} ellipsizeMode="tail">
{removeBoltGlyphs(selectedOption.description)}
</Text>
{selectedOption.detail ? (
<Text style={styles.detailHint}>{removeBoltGlyphs(selectedOption.detail)}</Text>
<Text style={styles.detailHint} numberOfLines={1}>
{removeBoltGlyphs(selectedOption.detail)}
</Text>
) : null}
</View>
) : null}
@@ -293,6 +295,9 @@ const styles = StyleSheet.create((theme: Theme) => ({
gap: theme.spacing[1],
},
detailCard: {
flexShrink: 1,
minHeight: 0,
overflow: "hidden",
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
@@ -318,7 +323,8 @@ const styles = StyleSheet.create((theme: Theme) => ({
},
container: {
flexShrink: 1,
minHeight: 0,
// Keep a complete selectable row (36), list padding (8), and borders (2).
minHeight: 46,
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
+4 -1
View File
@@ -1,4 +1,5 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useContainedControlSize } from "@/components/ui/control-size";
import {
default as React,
useCallback,
@@ -162,7 +163,7 @@ const styles = StyleSheet.create((theme) => {
export function Button({
children,
variant = "secondary",
size = "md",
size: sizeProp,
leftIcon,
trailing,
style,
@@ -185,6 +186,8 @@ export function Button({
}
>) {
const [hovered, setHovered] = useState(false);
const containedSize = useContainedControlSize();
const size = sizeProp ?? containedSize ?? "md";
const isDisabled = disabled || loading;
let variantStyle: ViewStyle;
@@ -81,6 +81,8 @@ describe("control geometry", () => {
it("keeps field text sizing tied to control size", () => {
const geometry = createControlGeometry(theme);
expect(geometry.fieldTextXs.fontSize).toBe(12);
expect(geometry.fieldTextXs.lineHeight).toBe(17);
expect(geometry.fieldTextSm.fontSize).toBe(14);
expect(geometry.fieldTextSm.lineHeight).toBe(20);
expect(geometry.fieldTextMd.fontSize).toBe(14);
@@ -95,6 +97,13 @@ describe("control geometry", () => {
const geometry = createControlGeometry(theme);
const borderWidth = theme.borderWidth[1];
expect(geometry.fieldControlXs.minHeight).toBe(28);
expect(
geometry.fieldTextXs.lineHeight +
geometry.fieldControlXs.paddingVertical * 2 +
borderWidth * 2,
).toBe(geometry.fieldControlXs.minHeight);
expect(geometry.fieldControlSm.minHeight).toBe(32);
expect(geometry.fieldControlSm.paddingVertical).toBe(5);
expect(
@@ -2,7 +2,7 @@ import type { StyleProp, ViewStyle } from "react-native";
import { ICON_SIZE, type Theme } from "@/styles/theme";
export type ButtonControlSize = "xs" | "sm" | "md" | "lg";
export type FieldControlSize = "sm" | "md";
export type FieldControlSize = "xs" | "sm" | "md";
export type SegmentedControlSize = "xs" | "sm" | "md";
export type ControlInteractionPhase = "rest" | "hover" | "active";
@@ -188,8 +188,19 @@ export function resolveControlInteractionStyles(
export function createControlGeometry(theme: Theme) {
const controlBorderWidth = theme.borderWidth[1];
const fieldTextXsLineHeight = fieldLineHeight(theme.fontSize.sm);
const fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.base);
const fieldTextMdLineHeight = fieldLineHeight(theme.fontSize.base);
const fieldControlXs = {
minHeight: CONTROL_HEIGHTS.tight,
paddingHorizontal: theme.spacing[2],
paddingVertical: fieldVerticalPadding(
CONTROL_HEIGHTS.tight,
fieldTextXsLineHeight,
controlBorderWidth,
),
borderRadius: theme.borderRadius.md,
};
const fieldControlSm = {
minHeight: CONTROL_HEIGHTS.compact,
paddingHorizontal: theme.spacing[3],
@@ -210,6 +221,10 @@ export function createControlGeometry(theme: Theme) {
),
borderRadius: theme.borderRadius.lg,
};
const fieldTextXs = {
fontSize: theme.fontSize.sm,
lineHeight: fieldTextXsLineHeight,
};
const fieldTextSm = {
fontSize: theme.fontSize.base,
lineHeight: fieldTextSmLineHeight,
@@ -252,6 +267,10 @@ export function createControlGeometry(theme: Theme) {
fontSize: theme.fontSize.sm,
},
alert,
formTextInputXs: {
...fieldControlXs,
...fieldTextXs,
},
formTextInputSm: {
...fieldControlSm,
...fieldTextSm,
@@ -264,8 +283,10 @@ export function createControlGeometry(theme: Theme) {
...fieldControlMd,
...fieldTextMd,
},
fieldControlXs,
fieldControlSm,
fieldControlMd,
fieldTextXs,
fieldTextSm,
fieldTextMd,
controlRest: {
@@ -0,0 +1,23 @@
import { createContext, useContext, type ReactNode } from "react";
/** The control sizes a container can ask of the controls placed in it. */
export type ContainedControlSize = "xs" | "sm";
// A row decides how big its controls are, so a button or field placed in it needs no size of
// its own. A size passed to the control still wins.
const ControlSizeContext = createContext<ContainedControlSize | null>(null);
export function ControlSizeProvider({
size,
children,
}: {
size: ContainedControlSize;
children: ReactNode;
}) {
return <ControlSizeContext.Provider value={size}>{children}</ControlSizeContext.Provider>;
}
/** The size the surrounding container asks for, or null outside one. */
export function useContainedControlSize(): ContainedControlSize | null {
return useContext(ControlSizeContext);
}
@@ -9,6 +9,7 @@ import {
} from "@/components/ui/control-geometry";
import type { Theme } from "@/styles/theme";
import { DropdownMenuTrigger, type DropdownMenuTriggerProps } from "@/components/ui/dropdown-menu";
import { useContainedControlSize } from "@/components/ui/control-size";
const ThemedChevronDown = withUnistyles(ChevronDown);
@@ -32,10 +33,12 @@ interface DropdownTriggerProps extends Omit<DropdownMenuTriggerProps, "children"
export function DropdownTrigger({
children,
leading,
size = "sm",
size: sizeProp,
disabled,
...props
}: DropdownTriggerProps): ReactElement {
const containedSize = useContainedControlSize();
const size = sizeProp ?? containedSize ?? "sm";
let sizeStyle: ViewStyle;
if (size === "xs") {
sizeStyle = styles.xs;
+21 -3
View File
@@ -23,6 +23,16 @@ import {
resolveControlInteractionStyles,
type FieldControlSize,
} from "@/components/ui/control-geometry";
import { useContainedControlSize } from "@/components/ui/control-size";
const FIELD_CHROME = { xs: "chromeXs", sm: "chromeSm", md: "chromeMd" } as const satisfies Record<
FieldControlSize,
string
>;
const FIELD_TEXT = { xs: "inputXs", sm: "inputSm", md: "inputMd" } as const satisfies Record<
FieldControlSize,
string
>;
interface FieldProps {
label: string;
@@ -159,11 +169,13 @@ function assignTextInputRef(
}
export const FormTextInput = forwardRef<EditingTextInputHandle, FormTextInputProps>(
function FormTextInput({ size = "md", style, onFocus, onBlur, editable, ...props }, ref) {
function FormTextInput({ size: sizeProp, style, onFocus, onBlur, editable, ...props }, ref) {
const [focused, setFocused] = useState(false);
const containedSize = useContainedControlSize();
const size = sizeProp ?? containedSize ?? "md";
const isDisabled = editable === false;
const chromeSizeStyle = size === "sm" ? formInputStyles.chromeSm : formInputStyles.chromeMd;
const inputSizeStyle = size === "sm" ? formInputStyles.inputSm : formInputStyles.inputMd;
const chromeSizeStyle = formInputStyles[FIELD_CHROME[size]];
const inputSizeStyle = formInputStyles[FIELD_TEXT[size]];
const splitStyle = useMemo(() => splitFormTextInputStyle(style), [style]);
const setInputRef = useCallback(
(node: EditingTextInputHandle | null) => {
@@ -262,6 +274,9 @@ const formInputStyles = StyleSheet.create((theme) => {
chrome: {
backgroundColor: theme.colors.surface2,
},
chromeXs: {
...geometry.fieldControlXs,
},
chromeSm: {
...geometry.fieldControlSm,
},
@@ -289,6 +304,9 @@ const formInputStyles = StyleSheet.create((theme) => {
outlineColor: "transparent",
outlineWidth: 0,
},
inputXs: {
...geometry.fieldTextXs,
},
inputSm: {
...geometry.fieldTextSm,
},
@@ -1,5 +1,9 @@
import type { Theme } from "@/styles/theme";
export const foregroundIconColorMapping = (theme: Theme) => ({
color: theme.colors.foreground,
});
export const mutedIconColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
@@ -58,6 +58,7 @@ vi.mock("@gorhom/bottom-sheet", async () => {
});
import { IsolatedBottomSheetModal, type ContextBridge } from ".";
import { useAnimatedLayout } from "@gorhom/bottom-sheet/src/hooks/useAnimatedLayout";
import { useIsInsideBottomSheet } from "@/components/ui/bottom-sheet-scope";
/** Renders whatever the portal is currently holding, from its own place in the tree. */
@@ -200,3 +201,35 @@ describe("IsolatedBottomSheetModal context bridging", () => {
).toEqual(["false", "true"]);
});
});
describe("bottom sheet layout subscription", () => {
it("stops updating a dismissed sheet when the window resizes", () => {
const container = document.createElement("div");
const root = createRoot(container);
let layout: ReturnType<typeof useAnimatedLayout> | undefined;
const viewport = document.documentElement;
const originalWidth = Object.getOwnPropertyDescriptor(viewport, "clientWidth");
function resize(width: number) {
Object.defineProperty(viewport, "clientWidth", { configurable: true, value: width });
act(() => window.dispatchEvent(new Event("resize")));
}
function LayoutProbe() {
layout = useAnimatedLayout(undefined, 0, 0);
return null;
}
try {
act(() => root.render(<LayoutProbe />));
if (!layout) throw new Error("Sheet layout did not mount");
const mountedLayout = layout;
resize(640);
expect(mountedLayout.get().window.width).toBe(640);
act(() => root.render(null));
resize(960);
expect(mountedLayout.get().window.width).toBe(640);
} finally {
act(() => root.unmount());
if (originalWidth) Object.defineProperty(viewport, "clientWidth", originalWidth);
else Reflect.deleteProperty(viewport, "clientWidth");
}
});
});
@@ -7,6 +7,7 @@ import {
segmentedIconSize,
type SegmentedControlSize,
} from "@/components/ui/control-geometry";
import { useContainedControlSize } from "@/components/ui/control-size";
import type { Theme } from "@/styles/theme";
type SegmentedControlIconRenderer = (props: { color: string; size: number }) => ReactNode;
@@ -48,11 +49,13 @@ export function SegmentedControl<T extends string>({
options,
value,
onValueChange,
size = "md",
size: sizeProp,
hideLabels = false,
style,
testID,
}: SegmentedControlProps<T>) {
const containedSize = useContainedControlSize();
const size = sizeProp ?? containedSize ?? "md";
const sizeStyles = {
xs: { container: styles.containerXs, segment: styles.segmentXs, label: styles.labelXs },
sm: { container: styles.containerSm, segment: styles.segmentSm, label: styles.labelSm },
+19 -17
View File
@@ -1003,7 +1003,8 @@ function ComposerAutocompleteBinding({
recall,
configuration,
inputRef,
anchorRef,
children,
anchorStyle,
show,
ref,
}: {
@@ -1015,7 +1016,8 @@ function ComposerAutocompleteBinding({
"userInput" | "cursorIndex" | "onAutocompleteApplied"
>;
inputRef: React.RefObject<MessageInputRef | null>;
anchorRef: React.RefObject<View | null>;
children: React.ReactNode;
anchorStyle: React.ComponentProps<typeof View>["style"];
show: boolean;
ref: React.Ref<ComposerAutocompleteHandle>;
}) {
@@ -1042,7 +1044,7 @@ function ComposerAutocompleteBinding({
return (
<ComposerAutocomplete
visible={autocomplete.isVisible && isShown}
anchorRef={anchorRef}
anchorStyle={anchorStyle}
options={autocomplete.options}
selectedIndex={autocomplete.selectedIndex}
onSelect={onSelect}
@@ -1050,7 +1052,9 @@ function ComposerAutocompleteBinding({
errorMessage={autocomplete.errorMessage}
loadingText={autocomplete.loadingText}
emptyText={autocomplete.emptyText}
/>
>
{children}
</ComposerAutocomplete>
);
}
@@ -2379,7 +2383,6 @@ function ComposerContentImpl({
pluginClientSlashCommands,
],
);
const messageInputContainerRef = useRef<View>(null);
const isSubmitLoadingVisible =
isProcessing || isSubmitLoading || isUploadingFile || pendingNativeImagePastes > 0;
@@ -2438,17 +2441,16 @@ function ComposerContentImpl({
{isFullscreen ? null : queueList}
{!isFullscreen && sendErrorNode}
<View ref={messageInputContainerRef} style={messageInputContainerStyle}>
<ComposerAutocompleteBinding
text={textSource}
cursor={cursor}
recall={recall}
inputRef={messageInputRef}
anchorRef={messageInputContainerRef}
show={mode.showAutocomplete && !isFullscreen}
ref={autocompleteRef}
configuration={autocompleteConfiguration}
/>
<ComposerAutocompleteBinding
text={textSource}
cursor={cursor}
recall={recall}
inputRef={messageInputRef}
anchorStyle={messageInputContainerStyle}
show={mode.showAutocomplete && !isFullscreen}
ref={autocompleteRef}
configuration={autocompleteConfiguration}
>
<ComposerForgeBinding
text={textSource}
configuration={forgeConfiguration}
@@ -2528,7 +2530,7 @@ function ComposerContentImpl({
renderOption={renderGithubPickerOption}
/>
{pluginAttachments.picker}
</View>
</ComposerAutocompleteBinding>
</View>
</View>
</View>
+35 -13
View File
@@ -27,6 +27,7 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
import * as Haptics from "expo-haptics";
import { useDictation } from "@/hooks/use-dictation";
import { hasFinePointer } from "@/hooks/use-fine-pointer";
import { DictationOverlay } from "@/components/dictation-controls";
@@ -57,7 +58,7 @@ import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut";
import { getShortcutOs } from "@/utils/shortcut-platform";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { isWeb } from "@/constants/platform";
import { isNative, isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { RenderProfile } from "@/utils/render-profiler";
@@ -76,6 +77,7 @@ const ComposerTextInput = withUnistyles(EditingTextInput, (theme) => ({
}));
import {
resolveSendTooltipLabel,
resolveSubmitAccessibilityHint,
resolveSubmitAccessibilityLabel,
resolveVoiceAccessibilityLabel,
resolveVoiceTooltipText,
@@ -83,6 +85,7 @@ import {
import {
applyDictationTranscript,
computeCanStartDictation,
resolveAlternateSendAction,
resolveComposerSurfacePresentation,
resolveComposerSurfaceText,
resolveRetainedSelection,
@@ -399,8 +402,6 @@ interface DesktopKeyPressContext {
onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined;
input: ComposerKeyPressEvent["input"];
submitOnEnter: boolean;
isAgentRunning: boolean;
onQueue: ((payload: MessagePayload) => void) | undefined;
isSubmitDisabled: boolean;
isSubmitLoading: boolean;
disabled: boolean;
@@ -430,15 +431,12 @@ function handleDesktopKeyPressImpl(
if (!ctx.submitOnEnter) return;
if (shiftKey) return;
if ((metaKey || ctrlKey) && ctx.isAgentRunning && ctx.onQueue) {
if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return;
event.preventDefault();
if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return;
event.preventDefault();
if (metaKey || ctrlKey) {
ctx.handleAlternateSendAction();
return;
}
if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return;
event.preventDefault();
ctx.handleDefaultSendAction();
}
@@ -769,8 +767,10 @@ function SendButtonTooltip({
canPressLoadingButton,
onSubmitLoadingPress,
onDefaultSendAction,
onLongPressSend,
isSendButtonDisabled,
submitAccessibilityLabel,
submitAccessibilityHint,
sendButtonCombinedStyle,
isSubmitLoading,
submitIcon,
@@ -784,8 +784,10 @@ function SendButtonTooltip({
canPressLoadingButton: boolean;
onSubmitLoadingPress: (() => void) | undefined;
onDefaultSendAction: () => void;
onLongPressSend: () => void;
isSendButtonDisabled: boolean;
submitAccessibilityLabel: string;
submitAccessibilityHint: string | undefined;
sendButtonCombinedStyle: React.ComponentProps<typeof TooltipTrigger>["style"];
isSubmitLoading: boolean;
submitIcon: "arrow" | "return";
@@ -800,8 +802,10 @@ function SendButtonTooltip({
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={canPressLoadingButton ? onSubmitLoadingPress : onDefaultSendAction}
onLongPress={canPressLoadingButton ? undefined : onLongPressSend}
disabled={isSendButtonDisabled}
accessibilityLabel={submitAccessibilityLabel}
accessibilityHint={submitAccessibilityHint}
accessibilityRole="button"
testID={submitButtonTestID}
style={sendButtonCombinedStyle}
@@ -1705,6 +1709,19 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
});
}, [defaultSendBehavior, isAgentRunning, handleSendMessage, handleQueueMessage, onQueue]);
const alternateSendAction = resolveAlternateSendAction({
defaultSendBehavior,
isAgentRunning,
onQueue,
});
const handleLongPressSend = useCallback(() => {
if (alternateSendAction && isNative) {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
}
handleAlternateSendAction();
}, [alternateSendAction, handleAlternateSendAction]);
usePasteImagesEffect({
textarea: webTextArea,
isConnected,
@@ -1738,8 +1755,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
selectionRef.current,
),
submitOnEnter: shouldSubmitOnEnter,
isAgentRunning,
onQueue,
isSubmitDisabled,
isSubmitLoading,
disabled,
@@ -1751,8 +1766,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
shouldHandleWebKeyPress,
onKeyPressCallback,
shouldSubmitOnEnter,
isAgentRunning,
onQueue,
isSubmitDisabled,
isSubmitLoading,
disabled,
@@ -1792,6 +1805,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isAgentRunning,
t,
});
const submitAccessibilityHint = resolveSubmitAccessibilityHint({
canPressLoadingButton,
alternateSendAction,
t,
});
const voiceButtonAccessibilityLabel = resolveVoiceAccessibilityLabel({
isRealtimeVoiceForCurrentAgent,
@@ -1952,8 +1970,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
canPressLoadingButton={canPressLoadingButton}
onSubmitLoadingPress={onSubmitLoadingPress}
onDefaultSendAction={handleDefaultSendAction}
onLongPressSend={handleLongPressSend}
isSendButtonDisabled={isPresentedSendDisabled}
submitAccessibilityLabel={submitAccessibilityLabel}
submitAccessibilityHint={submitAccessibilityHint}
sendButtonCombinedStyle={sendButtonCombinedStyle}
isSubmitLoading={isSubmitLoading}
submitIcon={submitIcon}
@@ -1971,8 +1991,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
canPressLoadingButton,
onSubmitLoadingPress,
handleDefaultSendAction,
handleLongPressSend,
isPresentedSendDisabled,
submitAccessibilityLabel,
submitAccessibilityHint,
sendButtonCombinedStyle,
isSubmitLoading,
submitIcon,
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
resolveSendTooltipLabel,
resolveSubmitAccessibilityHint,
resolveSubmitAccessibilityLabel,
resolveVoiceAccessibilityLabel,
resolveVoiceTooltipText,
@@ -12,6 +13,8 @@ const translations: Record<string, string> = {
"composer.input.sendAndInterrupt": "Send and interrupt",
"composer.input.sendAndSteer": "Send and steer",
"composer.input.sendMessage": "Send message",
"composer.input.longPressToQueue": "Long press to queue",
"composer.input.longPressToSendNow": "Long press to send now",
"composer.input.queue": "Queue",
"composer.input.send": "Send",
"composer.voice.unmuteVoiceMode": "Unmute Voice mode",
@@ -26,6 +29,37 @@ const translations: Record<string, string> = {
const t = ((key: string) => translations[key] ?? key) as never;
describe("composer input labels", () => {
it("names the long-press action in the submit hint only when one exists", () => {
expect(
resolveSubmitAccessibilityHint({
canPressLoadingButton: false,
alternateSendAction: "queue",
t,
}),
).toBe("Long press to queue");
expect(
resolveSubmitAccessibilityHint({
canPressLoadingButton: false,
alternateSendAction: "send",
t,
}),
).toBe("Long press to send now");
expect(
resolveSubmitAccessibilityHint({
canPressLoadingButton: false,
alternateSendAction: null,
t,
}),
).toBeUndefined();
expect(
resolveSubmitAccessibilityHint({
canPressLoadingButton: true,
alternateSendAction: "queue",
t,
}),
).toBeUndefined();
});
it("resolves submit accessibility labels from translations", () => {
expect(
resolveSubmitAccessibilityLabel({
+14
View File
@@ -1,5 +1,6 @@
import type { TFunction } from "i18next";
import type { SendBehavior } from "@/hooks/use-settings/storage";
import type { AlternateSendAction } from "./state";
export function resolveSubmitAccessibilityLabel(input: {
submitButtonAccessibilityLabel: string | undefined;
@@ -22,6 +23,19 @@ export function resolveSubmitAccessibilityLabel(input: {
return input.t("composer.input.sendMessage");
}
export function resolveSubmitAccessibilityHint(input: {
canPressLoadingButton: boolean;
alternateSendAction: AlternateSendAction | null;
t: TFunction;
}): string | undefined {
if (input.canPressLoadingButton || !input.alternateSendAction) return undefined;
return input.t(
input.alternateSendAction === "queue"
? "composer.input.longPressToQueue"
: "composer.input.longPressToSendNow",
);
}
export function resolveVoiceAccessibilityLabel(input: {
isRealtimeVoiceForCurrentAgent: boolean;
isMuted: boolean;
@@ -7,6 +7,7 @@ import {
resolveComposerSurfaceText,
resolveGlyphRailOffset,
resolveRetainedSelection,
resolveAlternateSendAction,
runAlternateSendAction,
runDefaultSendAction,
runMessageInputKeyboardAction,
@@ -276,6 +277,47 @@ describe("composer send behavior", () => {
expect(defaultAction.calls).toEqual(["queue"]);
expect(alternateAction.calls).toEqual(["send"]);
});
it("sends with the alternate action while no turn is running", () => {
for (const defaultSendBehavior of ["steer", "interrupt", "queue"] as const) {
const alternateAction = actions();
runAlternateSendAction({
defaultSendBehavior,
isAgentRunning: false,
onQueue: alternateAction.onQueue,
handleSendMessage: alternateAction.handleSendMessage,
handleQueueMessage: alternateAction.handleQueueMessage,
});
expect(alternateAction.calls).toEqual(["send"]);
}
});
it("offers an alternate action only while a turn runs and the composer can queue", () => {
const onQueue = () => undefined;
expect(
resolveAlternateSendAction({ defaultSendBehavior: "steer", isAgentRunning: true, onQueue }),
).toBe("queue");
expect(
resolveAlternateSendAction({
defaultSendBehavior: "interrupt",
isAgentRunning: true,
onQueue,
}),
).toBe("queue");
expect(
resolveAlternateSendAction({ defaultSendBehavior: "queue", isAgentRunning: true, onQueue }),
).toBe("send");
expect(
resolveAlternateSendAction({ defaultSendBehavior: "steer", isAgentRunning: false, onQueue }),
).toBeNull();
expect(
resolveAlternateSendAction({
defaultSendBehavior: "queue",
isAgentRunning: true,
onQueue: undefined,
}),
).toBeNull();
});
});
describe("stopRealtimeVoice", () => {
+20 -4
View File
@@ -125,14 +125,30 @@ export function runDefaultSendAction(ctx: SendActionContext): void {
ctx.handleSendMessage();
}
export type AlternateSendAction = "queue" | "send";
/**
* What Mod+Enter and a long-press on Send do instead of the default send. Null while
* no turn is running, where both fall back to the default send.
*/
export function resolveAlternateSendAction(
ctx: Pick<SendActionContext, "defaultSendBehavior" | "isAgentRunning" | "onQueue">,
): AlternateSendAction | null {
if (!ctx.isAgentRunning || !ctx.onQueue) return null;
return ctx.defaultSendBehavior === "queue" ? "send" : "queue";
}
export function runAlternateSendAction(ctx: SendActionContext): void {
if (ctx.defaultSendBehavior === "queue") {
const action = resolveAlternateSendAction(ctx);
if (action === "queue") {
ctx.handleQueueMessage();
return;
}
if (action === "send") {
ctx.handleSendMessage();
return;
}
if (ctx.isAgentRunning && ctx.onQueue) {
ctx.handleQueueMessage();
}
runDefaultSendAction(ctx);
}
export function runMessageInputKeyboardAction(
@@ -162,12 +162,34 @@ describe("composer track panel", () => {
);
openPanel();
expect(document.querySelector("button button")).toBeNull();
expect(row("row")?.getAttribute("role")).toBe("menuitem");
click(row("row-archive") as Element);
expect(onAction).toHaveBeenCalledTimes(1);
expect(onPress).not.toHaveBeenCalled();
expect(isPanelOpen()).toBe(true);
});
it.each(["Enter", " "])("selects a track row with %j", (key) => {
const select = vi.fn();
mount(
<ComposerTrackPill testID="pill" segments={SUBAGENT_SEGMENTS} panelTitle="Subagents">
<ComposerTrackRow accessibilityLabel="Subagent one" testID="row" onPress={select}>
<Text>Subagent one</Text>
</ComposerTrackRow>
</ComposerTrackPill>,
);
openPanel();
const target = row("row") as HTMLElement;
act(() => {
target.focus();
target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }));
target.dispatchEvent(new KeyboardEvent("keyup", { key, bubbles: true }));
});
expect(select).toHaveBeenCalledTimes(1);
expect(isPanelOpen()).toBe(false);
});
});
describe("composer track pill status mark", () => {
+7 -1
View File
@@ -233,6 +233,11 @@ export function ComposerTrackRow({
[closeOnSelect, onPress, selectItem],
);
const itemDataSet = useMemo(
() => ({ menuItem: "true", menuDisabled: disabled ? "true" : "false" }),
[disabled],
);
const renderRow = useCallback(
(active: boolean) => (
<View style={active ? styles.rowActive : styles.row}>
@@ -253,7 +258,8 @@ export function ComposerTrackRow({
return (
<View onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave}>
<Pressable
accessibilityRole="button"
accessibilityRole="menuitem"
dataSet={itemDataSet}
accessibilityLabel={accessibilityLabel}
testID={testID}
disabled={disabled}
+1 -1
View File
@@ -80,7 +80,7 @@ export interface DesktopEditorOpenTargetInput {
}
export interface DesktopEditorBridge {
listTargets?: () => Promise<DesktopEditorTargetDescriptor[]>;
listTargets?: (input?: { workspacePath?: string }) => Promise<DesktopEditorTargetDescriptor[]>;
openTarget?: (input: DesktopEditorOpenTargetInput) => Promise<void>;
}
@@ -1,3 +1,4 @@
import { isNative } from "@/constants/platform";
import Svg, { Circle } from "react-native-svg";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
@@ -75,8 +76,9 @@ function ChecksRingSvg({
height={size}
viewBox={`0 0 ${VIEW_BOX} ${VIEW_BOX}`}
style={ringStyles.svg}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
{...(isNative
? { accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants" }
: { "aria-hidden": true })}
>
<Circle
cx={CENTER}
@@ -0,0 +1,40 @@
/**
* @vitest-environment jsdom
*/
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useDirectoryDemand } from "./use-directory-demand";
function Probe({ serverId }: { serverId: string | null }) {
useDirectoryDemand(serverId);
return null;
}
describe("useDirectoryDemand", () => {
let root: Root | null = null;
afterEach(() => {
act(() => root?.unmount());
root = null;
vi.restoreAllMocks();
});
it("demands the host directory while mounted and releases it on unmount", () => {
const release = vi.fn();
const acquire = vi
.spyOn(getHostRuntimeStore(), "acquireDirectoryDemand")
.mockImplementation(() => release);
const container = document.createElement("div");
root = createRoot(container);
act(() => root?.render(<Probe serverId="srv_a" />));
expect(acquire).toHaveBeenCalledTimes(1);
expect(acquire).toHaveBeenCalledWith("srv_a");
expect(release).not.toHaveBeenCalled();
act(() => root?.render(<Probe serverId={null} />));
expect(release).toHaveBeenCalledTimes(1);
expect(acquire).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,16 @@
import { useEffect } from "react";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
/**
* Keeps a host's directory demanded while the caller is mounted.
*
* The sidebar only demands the directory while its panel is active, so a compact app that
* restores straight into a workspace or agent route has nothing else asking the host for
* workspaces and agents. Without demand the route waits on hydration that never starts.
*/
export function useDirectoryDemand(serverId: string | null | undefined): void {
useEffect(() => {
if (!serverId) return;
return getHostRuntimeStore().acquireDirectoryDemand(serverId);
}, [serverId]);
}
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import { resolvePreferredEditorId } from "./use-preferred-editor";
import { QueryClient } from "@tanstack/react-query";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { createPreferredEditorPreference, resolvePreferredEditorId } from "./use-preferred-editor";
describe("resolvePreferredEditorId", () => {
it("keeps the stored editor when it is still available", () => {
@@ -30,3 +32,78 @@ describe("resolvePreferredEditorId", () => {
expect(resolvePreferredEditorId([], "cursor")).toBeNull();
});
});
describe("project editor preferences", () => {
it("changing one project leaves another project's cached and persisted choice unchanged", async () => {
await AsyncStorage.clear();
await AsyncStorage.setItem("@paseo:preferred-editor", "vscode");
const queryClient = new QueryClient();
const projectA = createPreferredEditorPreference({
scope: { serverId: "host", projectId: "project-a" },
queryClient,
storage: AsyncStorage,
});
const projectB = createPreferredEditorPreference({
scope: { serverId: "host", projectId: "project-b" },
queryClient,
storage: AsyncStorage,
});
expect(await queryClient.fetchQuery(projectA.queryOptions)).toBe("vscode");
expect(await queryClient.fetchQuery(projectB.queryOptions)).toBe("vscode");
await projectA.update("cursor");
expect(await queryClient.fetchQuery(projectA.queryOptions)).toBe("cursor");
expect(await queryClient.fetchQuery(projectB.queryOptions)).toBe("vscode");
queryClient.clear();
expect(await queryClient.fetchQuery(projectA.queryOptions)).toBe("cursor");
expect(await queryClient.fetchQuery(projectB.queryOptions)).toBe("vscode");
expect(await AsyncStorage.getItem("@paseo:preferred-editor")).toBe("vscode");
queryClient.clear();
});
it("shares a choice across workspaces of the same project while isolating hosts", async () => {
await AsyncStorage.clear();
const queryClient = new QueryClient();
const root = createPreferredEditorPreference({
scope: { serverId: "host-a", projectId: "project" },
queryClient,
storage: AsyncStorage,
});
const worktree = createPreferredEditorPreference({
scope: { serverId: "host-a", projectId: "project" },
queryClient,
storage: AsyncStorage,
});
const otherHost = createPreferredEditorPreference({
scope: { serverId: "host-b", projectId: "project" },
queryClient,
storage: AsyncStorage,
});
await root.update("zed");
expect(await queryClient.fetchQuery(worktree.queryOptions)).toBe("zed");
expect(await queryClient.fetchQuery(otherHost.queryOptions)).toBeNull();
queryClient.clear();
expect(await queryClient.fetchQuery(worktree.queryOptions)).toBe("zed");
expect(await queryClient.fetchQuery(otherHost.queryOptions)).toBeNull();
queryClient.clear();
});
it("preserves the previous choice when saving fails", async () => {
await AsyncStorage.clear();
await AsyncStorage.setItem("@paseo:preferred-editor", "vscode");
const queryClient = new QueryClient();
const preference = createPreferredEditorPreference({
scope: { serverId: "host", projectId: "project" },
queryClient,
storage: {
getItem: AsyncStorage.getItem,
removeItem: AsyncStorage.removeItem,
async setItem() {
throw new Error("Storage is full");
},
},
});
expect(await queryClient.fetchQuery(preference.queryOptions)).toBe("vscode");
await expect(preference.update("cursor")).rejects.toThrow("Storage is full");
expect(await queryClient.fetchQuery(preference.queryOptions)).toBe("vscode");
queryClient.clear();
});
});
+62 -27
View File
@@ -1,16 +1,57 @@
import { useCallback } from "react";
import { useMemo } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { QueryClient, useQuery, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
import { readValidatedString } from "@/storage/validated-storage";
import { readValidatedString, type ValidatedStorage } from "@/storage/validated-storage";
type EditorTargetId = string;
const PREFERRED_EDITOR_STORAGE_KEY = "@paseo:preferred-editor";
const PREFERRED_EDITOR_QUERY_KEY = ["preferred-editor"];
async function loadPreferredEditor(): Promise<EditorTargetId | null> {
return readValidatedString(AsyncStorage, PREFERRED_EDITOR_STORAGE_KEY, z.string().trim().min(1));
interface EditorPreferenceStorage extends ValidatedStorage {
setItem(key: string, value: string): Promise<void>;
}
interface ProjectEditorScope {
serverId: string;
projectId: string | null;
}
export function createPreferredEditorPreference({
scope,
queryClient,
storage,
}: {
scope: ProjectEditorScope;
queryClient: QueryClient;
storage: EditorPreferenceStorage;
}) {
const queryKey = ["preferred-editor", scope.serverId, scope.projectId];
const storageKey = `${PREFERRED_EDITOR_STORAGE_KEY}:${JSON.stringify([scope.serverId, scope.projectId])}`;
async function load() {
if (scope.projectId === null) return null;
const projectEditor = await readValidatedString(storage, storageKey, z.string().trim().min(1));
if (projectEditor !== null) return projectEditor;
return readValidatedString(storage, PREFERRED_EDITOR_STORAGE_KEY, z.string().trim().min(1));
}
return {
queryOptions: {
queryKey,
queryFn: load,
enabled: scope.projectId !== null,
staleTime: Infinity,
gcTime: Infinity,
},
async update(editorId: EditorTargetId | null) {
if (scope.projectId === null) return;
if (editorId) {
await storage.setItem(storageKey, editorId);
} else {
await storage.removeItem(storageKey);
}
queryClient.setQueryData(queryKey, await load());
},
};
}
export function resolvePreferredEditorId(
@@ -29,30 +70,24 @@ export function resolvePreferredEditorId(
return availableEditorIds[0] ?? null;
}
export function usePreferredEditor() {
export function usePreferredEditor(scope: ProjectEditorScope) {
const queryClient = useQueryClient();
const { data, isPending } = useQuery({
queryKey: PREFERRED_EDITOR_QUERY_KEY,
queryFn: loadPreferredEditor,
staleTime: Infinity,
gcTime: Infinity,
});
const updatePreferredEditor = useCallback(
async (editorId: EditorTargetId | null) => {
queryClient.setQueryData(PREFERRED_EDITOR_QUERY_KEY, editorId);
if (editorId) {
await AsyncStorage.setItem(PREFERRED_EDITOR_STORAGE_KEY, editorId);
return;
}
await AsyncStorage.removeItem(PREFERRED_EDITOR_STORAGE_KEY);
},
[queryClient],
const { serverId, projectId } = scope;
const preference = useMemo(
() =>
createPreferredEditorPreference({
scope: { serverId, projectId },
queryClient,
storage: AsyncStorage,
}),
[queryClient, serverId, projectId],
);
const { data, isPending } = useQuery(preference.queryOptions);
const isLoading = projectId !== null && isPending;
return {
preferredEditorId: isPending ? undefined : (data ?? null),
isLoading: isPending,
updatePreferredEditor,
preferredEditorId: isLoading ? undefined : (data ?? null),
isLoading,
updatePreferredEditor: preference.update,
};
}
+66
View File
@@ -133,6 +133,8 @@ export const ar: TranslationResources = {
sendAndInterrupt: "إرسال ومقاطعة",
sendAndSteer: "إرسال وتوجيه",
sendMessage: "أرسل رسالة",
longPressToQueue: "اضغط مطولًا لإضافتها إلى الطابور",
longPressToSendNow: "اضغط مطولًا للإرسال الآن",
queue: "طابور",
send: "يرسل",
},
@@ -2258,6 +2260,7 @@ export const ar: TranslationResources = {
connections: "اتصالات",
agents: "Agents",
metadata: "البيانات الوصفية",
voice: "الصوت",
workspaces: "Workspaces",
providers: "مقدمي الخدمات",
usage: "الاستخدام",
@@ -2280,6 +2283,69 @@ export const ar: TranslationResources = {
docs: "الوثائق",
saveError: "تعذر تحديث إنشاء البيانات الوصفية",
},
voice: {
offline: "اتصل بهذا المضيف لضبط الصوت.",
updateHost: "حدّث المضيف لضبط الصوت.",
saveError: "تعذّر تحديث إعدادات الصوت",
provider: "المزوّد",
providers: {
local: "محلي",
openai: "OpenAI",
},
voice: "الصوت",
save: "حفظ",
retry: "إعادة المحاولة",
cancel: "إلغاء",
model: "النموذج",
language: "اللغة",
languagePlaceholder: "اكتشاف تلقائي",
setBy: "محدد بواسطة {{name}}",
unknownProvider: "حدّث التطبيق لإعداد {{provider}}",
sites: {
dictationStt: "تحويل الكلام إلى نص",
voiceModeStt: "الاستماع",
voiceModeTts: "التحدث",
},
dictation: {
title: "الإملاء",
enabled: "الإملاء",
enabledHint: "تحدّث في مربع الرسالة بدلًا من الكتابة",
},
voiceMode: {
title: "وضع الصوت",
info: "يحوّل وضع الصوت كلامك إلى نص ويقرأ ردود الوكيل بصوت عالٍ.",
enabled: "وضع الصوت",
enabledHint: "تحدّث مع وكلائك دون استخدام يديك",
},
openai: {
apiKey: "مفتاح API",
apiKeyHint: "يُحفظ على المضيف ولا يُعرض مرة أخرى.",
keyPlaceholder: "sk-...",
savedKeyPlaceholder: "محفوظ",
missingKey: "مفتاح API مفقود",
baseUrl: "عنوان URL الأساسي",
baseUrlHint: "للخوادم المتوافقة مع OpenAI. اتركه فارغًا لاستخدام OpenAI.",
},
models: {
loading: "جارٍ تحميل النماذج...",
loadError: "تعذّر تحميل النماذج المحلية",
none: "لا توجد نماذج متاحة",
downloaded: "منزّل",
inUse: "قيد الاستخدام",
recommended: "موصى به",
download: "تنزيل",
downloadAction: "تنزيل {{label}}",
downloading: "جارٍ التنزيل...",
downloadingPercent: "جارٍ التنزيل {{percent}}%",
extracting: "جارٍ الاستخراج...",
delete: "حذف",
deleteAction: "حذف {{label}}",
deleteTitle: "حذف {{label}}؟",
deleteMessage: "سيؤدي هذا إلى تحرير {{size}}. يمكنك تنزيله مرة أخرى لاحقًا.",
languages: "لغات {{label}}",
moreLanguages: "{{languages}} و{{count}} أخرى",
},
},
general: {
title: "عام",
sending: "الإرسال",
+66
View File
@@ -129,6 +129,8 @@ export const en = {
sendAndInterrupt: "Send and interrupt",
sendAndSteer: "Send and steer",
sendMessage: "Send message",
longPressToQueue: "Long press to queue",
longPressToSendNow: "Long press to send now",
queue: "Queue",
send: "Send",
},
@@ -2285,6 +2287,7 @@ export const en = {
connections: "Connections",
agents: "Agents",
metadata: "Metadata",
voice: "Voice",
workspaces: "Workspaces",
providers: "Providers",
usage: "Usage",
@@ -2369,6 +2372,69 @@ export const en = {
docs: "Docs",
saveError: "Unable to update metadata generation",
},
voice: {
offline: "Connect to this host to configure voice.",
updateHost: "Update the host to configure voice.",
saveError: "Unable to update voice settings",
provider: "Provider",
providers: {
local: "Local",
openai: "OpenAI",
},
voice: "Voice",
save: "Save",
retry: "Retry",
cancel: "Cancel",
model: "Model",
language: "Language",
languagePlaceholder: "Auto-detect",
setBy: "Set by {{name}}",
unknownProvider: "Update the app to configure {{provider}}",
sites: {
dictationStt: "Speech-to-text",
voiceModeStt: "Listening",
voiceModeTts: "Speaking",
},
dictation: {
title: "Dictation",
enabled: "Dictation",
enabledHint: "Speak into the message box instead of typing",
},
voiceMode: {
title: "Voice mode",
info: "Voice mode transcribes what you say and reads agent replies aloud.",
enabled: "Voice mode",
enabledHint: "Talk with your agents hands-free",
},
openai: {
apiKey: "API key",
apiKeyHint: "Saved on the host and never shown again.",
keyPlaceholder: "sk-...",
savedKeyPlaceholder: "Saved",
missingKey: "API key missing",
baseUrl: "Base URL",
baseUrlHint: "For OpenAI-compatible servers. Leave empty to use OpenAI.",
},
models: {
loading: "Loading models...",
loadError: "Couldn't load local models",
none: "No models available",
downloaded: "Downloaded",
inUse: "In use",
recommended: "Recommended",
download: "Download",
downloadAction: "Download {{label}}",
downloading: "Downloading...",
downloadingPercent: "Downloading {{percent}}%",
extracting: "Extracting...",
delete: "Delete",
deleteAction: "Delete {{label}}",
deleteTitle: "Delete {{label}}?",
deleteMessage: "This frees {{size}}. You can download it again later.",
languages: "{{label}} languages",
moreLanguages: "{{languages}} and {{count}} more",
},
},
general: {
title: "General",
sending: "Sending",
+66
View File
@@ -134,6 +134,8 @@ export const es: TranslationResources = {
sendAndInterrupt: "Enviar e interrumpir",
sendAndSteer: "Enviar y orientar",
sendMessage: "enviar mensaje",
longPressToQueue: "Mantén pulsado para poner en cola",
longPressToSendNow: "Mantén pulsado para enviar ahora",
queue: "Cola",
send: "Enviar",
},
@@ -2270,6 +2272,7 @@ export const es: TranslationResources = {
connections: "Conexiones",
agents: "Agents",
metadata: "Metadatos",
voice: "Voz",
workspaces: "Workspaces",
providers: "Proveedores",
usage: "Uso",
@@ -2292,6 +2295,69 @@ export const es: TranslationResources = {
docs: "Documentación",
saveError: "No se pudo actualizar la generación de metadatos",
},
voice: {
offline: "Conéctate a este host para configurar la voz.",
updateHost: "Actualiza el host para configurar la voz.",
saveError: "No se pudo actualizar la configuración de voz",
provider: "Proveedor",
providers: {
local: "Local",
openai: "OpenAI",
},
voice: "Voz",
save: "Guardar",
retry: "Reintentar",
cancel: "Cancelar",
model: "Modelo",
language: "Idioma",
languagePlaceholder: "Detección automática",
setBy: "Definido por {{name}}",
unknownProvider: "Actualiza la app para configurar {{provider}}",
sites: {
dictationStt: "Voz a texto",
voiceModeStt: "Escucha",
voiceModeTts: "Habla",
},
dictation: {
title: "Dictado",
enabled: "Dictado",
enabledHint: "Habla en el cuadro de mensaje en lugar de escribir",
},
voiceMode: {
title: "Modo de voz",
info: "El modo de voz transcribe lo que dices y lee en voz alta las respuestas del agente.",
enabled: "Modo de voz",
enabledHint: "Habla con tus agentes sin usar las manos",
},
openai: {
apiKey: "Clave de API",
apiKeyHint: "Se guarda en el host y no se vuelve a mostrar.",
keyPlaceholder: "sk-...",
savedKeyPlaceholder: "Guardada",
missingKey: "Falta la clave de API",
baseUrl: "URL base",
baseUrlHint: "Para servidores compatibles con OpenAI. Déjala vacía para usar OpenAI.",
},
models: {
loading: "Cargando modelos...",
loadError: "No se pudieron cargar los modelos locales",
none: "No hay modelos disponibles",
downloaded: "Descargado",
inUse: "En uso",
recommended: "Recomendado",
download: "Descargar",
downloadAction: "Descargar {{label}}",
downloading: "Descargando...",
downloadingPercent: "Descargando {{percent}} %",
extracting: "Extrayendo...",
delete: "Eliminar",
deleteAction: "Eliminar {{label}}",
deleteTitle: "¿Eliminar {{label}}?",
deleteMessage: "Esto libera {{size}}. Puedes volver a descargarlo más tarde.",
languages: "Idiomas de {{label}}",
moreLanguages: "{{languages}} y {{count}} más",
},
},
general: {
title: "General",
sending: "Envío",
+66
View File
@@ -135,6 +135,8 @@ export const fr: TranslationResources = {
sendAndInterrupt: "Envoyer et interrompre",
sendAndSteer: "Envoyer et réorienter",
sendMessage: "Envoyer le message",
longPressToQueue: "Appui long pour mettre en file d’attente",
longPressToSendNow: "Appui long pour envoyer maintenant",
queue: "Mettre en file d’attente",
send: "Envoyer",
},
@@ -2270,6 +2272,7 @@ export const fr: TranslationResources = {
connections: "Connexions",
agents: "Agents",
metadata: "Métadonnées",
voice: "Voix",
workspaces: "Espaces de travail",
providers: "Fournisseurs",
usage: "Utilisation",
@@ -2292,6 +2295,69 @@ export const fr: TranslationResources = {
docs: "Documentation",
saveError: "Impossible de mettre à jour la génération de métadonnées",
},
voice: {
offline: "Connectez-vous à cet hôte pour configurer la voix.",
updateHost: "Mettez à jour l'hôte pour configurer la voix.",
saveError: "Impossible de mettre à jour les réglages vocaux",
provider: "Fournisseur",
providers: {
local: "Local",
openai: "OpenAI",
},
voice: "Voix",
save: "Enregistrer",
retry: "Réessayer",
cancel: "Annuler",
model: "Modèle",
language: "Langue",
languagePlaceholder: "Détection automatique",
setBy: "Défini par {{name}}",
unknownProvider: "Mettez à jour l'app pour configurer {{provider}}",
sites: {
dictationStt: "Transcription",
voiceModeStt: "Écoute",
voiceModeTts: "Parole",
},
dictation: {
title: "Dictée",
enabled: "Dictée",
enabledHint: "Parlez dans la zone de message au lieu de taper",
},
voiceMode: {
title: "Mode vocal",
info: "Le mode vocal transcrit ce que vous dites et lit à voix haute les réponses de l'agent.",
enabled: "Mode vocal",
enabledHint: "Parlez à vos agents sans les mains",
},
openai: {
apiKey: "Clé d'API",
apiKeyHint: "Enregistrée sur l'hôte, jamais réaffichée.",
keyPlaceholder: "sk-...",
savedKeyPlaceholder: "Enregistrée",
missingKey: "Clé d'API manquante",
baseUrl: "URL de base",
baseUrlHint: "Pour les serveurs compatibles OpenAI. Laissez vide pour utiliser OpenAI.",
},
models: {
loading: "Chargement des modèles...",
loadError: "Impossible de charger les modèles locaux",
none: "Aucun modèle disponible",
downloaded: "Téléchargé",
inUse: "Utilisé",
recommended: "Recommandé",
download: "Télécharger",
downloadAction: "Télécharger {{label}}",
downloading: "Téléchargement...",
downloadingPercent: "Téléchargement {{percent}} %",
extracting: "Extraction...",
delete: "Supprimer",
deleteAction: "Supprimer {{label}}",
deleteTitle: "Supprimer {{label}} ?",
deleteMessage: "Cela libère {{size}}. Vous pourrez le retélécharger plus tard.",
languages: "Langues de {{label}}",
moreLanguages: "{{languages}} et {{count}} autres",
},
},
general: {
title: "Général",
sending: "Envoi",
+66
View File
@@ -134,6 +134,8 @@ export const ja: TranslationResources = {
sendAndInterrupt: "送信して中断",
sendAndSteer: "送信して指示を追加",
sendMessage: "メッセージを送信",
longPressToQueue: "長押しでキューに追加",
longPressToSendNow: "長押しで今すぐ送信",
queue: "キュー",
send: "送信",
},
@@ -2243,6 +2245,7 @@ export const ja: TranslationResources = {
connections: "接続",
agents: "エージェント",
metadata: "メタデータ",
voice: "音声",
workspaces: "ワークスペース",
providers: "プロバイダー",
usage: "使用状況",
@@ -2265,6 +2268,69 @@ export const ja: TranslationResources = {
docs: "ドキュメント",
saveError: "メタデータ生成を更新できません",
},
voice: {
offline: "音声を設定するには、このホストに接続してください。",
updateHost: "音声を設定するには、ホストを更新してください。",
saveError: "音声設定を更新できませんでした",
provider: "プロバイダー",
providers: {
local: "ローカル",
openai: "OpenAI",
},
voice: "ボイス",
save: "保存",
retry: "再試行",
cancel: "キャンセル",
model: "モデル",
language: "言語",
languagePlaceholder: "自動検出",
setBy: "{{name}} で設定",
unknownProvider: "{{provider}} を設定するにはアプリを更新してください",
sites: {
dictationStt: "音声入力",
voiceModeStt: "聞き取り",
voiceModeTts: "読み上げ",
},
dictation: {
title: "音声入力",
enabled: "音声入力",
enabledHint: "入力する代わりにメッセージ欄に話しかけます",
},
voiceMode: {
title: "ボイスモード",
info: "ボイスモードは話した内容を文字起こしし、エージェントの返答を読み上げます。",
enabled: "ボイスモード",
enabledHint: "ハンズフリーでエージェントと会話します",
},
openai: {
apiKey: "API キー",
apiKeyHint: "ホストに保存され、再表示されません。",
keyPlaceholder: "sk-...",
savedKeyPlaceholder: "保存済み",
missingKey: "API キーがありません",
baseUrl: "ベース URL",
baseUrlHint: "OpenAI 互換サーバー用です。OpenAI を使う場合は空欄のままにします。",
},
models: {
loading: "モデルを読み込み中...",
loadError: "ローカルモデルを読み込めませんでした",
none: "利用できるモデルがありません",
downloaded: "ダウンロード済み",
inUse: "使用中",
recommended: "おすすめ",
download: "ダウンロード",
downloadAction: "{{label}} をダウンロード",
downloading: "ダウンロード中...",
downloadingPercent: "ダウンロード中 {{percent}}%",
extracting: "展開中...",
delete: "削除",
deleteAction: "{{label}} を削除",
deleteTitle: "{{label}} を削除しますか?",
deleteMessage: "{{size}} が解放されます。後で再ダウンロードできます。",
languages: "{{label}} の対応言語",
moreLanguages: "{{languages}} ほか {{count}} 言語",
},
},
general: {
title: "一般",
sending: "送信",
+66
View File
@@ -133,6 +133,8 @@ export const ko: TranslationResources = {
sendAndInterrupt: "보내고 중단",
sendAndSteer: "보내고 지시 추가",
sendMessage: "메시지 보내기",
longPressToQueue: "길게 눌러 대기열에 추가",
longPressToSendNow: "길게 눌러 지금 보내기",
queue: "대기열",
send: "보내기",
},
@@ -2232,6 +2234,7 @@ export const ko: TranslationResources = {
connections: "연결",
agents: "에이전트",
metadata: "메타데이터",
voice: "음성",
workspaces: "워크스페이스",
providers: "프로바이더",
usage: "사용량",
@@ -2254,6 +2257,69 @@ export const ko: TranslationResources = {
docs: "문서",
saveError: "메타데이터 생성을 업데이트할 수 없습니다",
},
voice: {
offline: "음성을 설정하려면 이 호스트에 연결하세요.",
updateHost: "음성을 설정하려면 호스트를 업데이트하세요.",
saveError: "음성 설정을 업데이트할 수 없습니다",
provider: "제공자",
providers: {
local: "로컬",
openai: "OpenAI",
},
voice: "목소리",
save: "저장",
retry: "다시 시도",
cancel: "취소",
model: "모델",
language: "언어",
languagePlaceholder: "자동 감지",
setBy: "{{name}}에서 설정됨",
unknownProvider: "{{provider}}을(를) 설정하려면 앱을 업데이트하세요",
sites: {
dictationStt: "음성 텍스트 변환",
voiceModeStt: "듣기",
voiceModeTts: "말하기",
},
dictation: {
title: "받아쓰기",
enabled: "받아쓰기",
enabledHint: "입력하는 대신 메시지 상자에 말하세요",
},
voiceMode: {
title: "음성 모드",
info: "음성 모드는 말한 내용을 텍스트로 옮기고 에이전트의 답변을 소리 내어 읽어 줍니다.",
enabled: "음성 모드",
enabledHint: "손을 쓰지 않고 에이전트와 대화하세요",
},
openai: {
apiKey: "API 키",
apiKeyHint: "호스트에 저장되며 다시 표시되지 않습니다.",
keyPlaceholder: "sk-...",
savedKeyPlaceholder: "저장됨",
missingKey: "API 키 없음",
baseUrl: "기본 URL",
baseUrlHint: "OpenAI 호환 서버용입니다. OpenAI를 사용하려면 비워 두세요.",
},
models: {
loading: "모델을 불러오는 중...",
loadError: "로컬 모델을 불러올 수 없습니다",
none: "사용 가능한 모델 없음",
downloaded: "다운로드됨",
inUse: "사용 중",
recommended: "추천",
download: "다운로드",
downloadAction: "{{label}} 다운로드",
downloading: "다운로드 중...",
downloadingPercent: "다운로드 중 {{percent}}%",
extracting: "압축 해제 중...",
delete: "삭제",
deleteAction: "{{label}} 삭제",
deleteTitle: "{{label}}을(를) 삭제할까요?",
deleteMessage: "{{size}}가 확보됩니다. 나중에 다시 다운로드할 수 있습니다.",
languages: "{{label}} 지원 언어",
moreLanguages: "{{languages}} 외 {{count}}개",
},
},
general: {
title: "일반",
sending: "전송",
+66
View File
@@ -134,6 +134,8 @@ export const ptBR: TranslationResources = {
sendAndInterrupt: "Enviar e interromper",
sendAndSteer: "Enviar e orientar",
sendMessage: "Enviar mensagem",
longPressToQueue: "Mantenha pressionado para enfileirar",
longPressToSendNow: "Mantenha pressionado para enviar agora",
queue: "Fila",
send: "Enviar",
},
@@ -2255,6 +2257,7 @@ export const ptBR: TranslationResources = {
connections: "Conexões",
agents: "Agentes",
metadata: "Metadados",
voice: "Voz",
workspaces: "Workspaces",
providers: "Provedores",
usage: "Uso",
@@ -2277,6 +2280,69 @@ export const ptBR: TranslationResources = {
docs: "Documentação",
saveError: "Não foi possível atualizar a geração de metadados",
},
voice: {
offline: "Conecte-se a este host para configurar a voz.",
updateHost: "Atualize o host para configurar a voz.",
saveError: "Não foi possível atualizar as configurações de voz",
provider: "Provedor",
providers: {
local: "Local",
openai: "OpenAI",
},
voice: "Voz",
save: "Salvar",
retry: "Tentar novamente",
cancel: "Cancelar",
model: "Modelo",
language: "Idioma",
languagePlaceholder: "Detecção automática",
setBy: "Definido por {{name}}",
unknownProvider: "Atualize o app para configurar {{provider}}",
sites: {
dictationStt: "Fala para texto",
voiceModeStt: "Escuta",
voiceModeTts: "Fala",
},
dictation: {
title: "Ditado",
enabled: "Ditado",
enabledHint: "Fale na caixa de mensagem em vez de digitar",
},
voiceMode: {
title: "Modo de voz",
info: "O modo de voz transcreve o que você diz e lê em voz alta as respostas do agente.",
enabled: "Modo de voz",
enabledHint: "Converse com seus agentes sem usar as mãos",
},
openai: {
apiKey: "Chave de API",
apiKeyHint: "Salva no host e nunca mostrada novamente.",
keyPlaceholder: "sk-...",
savedKeyPlaceholder: "Salva",
missingKey: "Chave de API ausente",
baseUrl: "URL base",
baseUrlHint: "Para servidores compatíveis com a OpenAI. Deixe vazio para usar a OpenAI.",
},
models: {
loading: "Carregando modelos...",
loadError: "Não foi possível carregar os modelos locais",
none: "Nenhum modelo disponível",
downloaded: "Baixado",
inUse: "Em uso",
recommended: "Recomendado",
download: "Baixar",
downloadAction: "Baixar {{label}}",
downloading: "Baixando...",
downloadingPercent: "Baixando {{percent}}%",
extracting: "Extraindo...",
delete: "Excluir",
deleteAction: "Excluir {{label}}",
deleteTitle: "Excluir {{label}}?",
deleteMessage: "Isso libera {{size}}. Você pode baixá-lo de novo depois.",
languages: "Idiomas do {{label}}",
moreLanguages: "{{languages}} e mais {{count}}",
},
},
general: {
title: "Geral",
sending: "Envio",
+67
View File
@@ -134,6 +134,8 @@ export const ru: TranslationResources = {
sendAndInterrupt: "Отправить и прервать",
sendAndSteer: "Отправить и направить",
sendMessage: "Отправить сообщение",
longPressToQueue: "Удерживайте, чтобы поставить в очередь",
longPressToSendNow: "Удерживайте, чтобы отправить сейчас",
queue: "Очередь",
send: "Отправить",
},
@@ -2255,6 +2257,7 @@ export const ru: TranslationResources = {
connections: "Подключения",
agents: "Агенты",
metadata: "Метаданные",
voice: "Голос",
workspaces: "Рабочие пространства",
providers: "Провайдеры",
usage: "Использование",
@@ -2278,6 +2281,70 @@ export const ru: TranslationResources = {
docs: "Документация",
saveError: "Не удалось обновить настройки генерации метаданных",
},
voice: {
offline: "Подключитесь к этому хосту, чтобы настроить голос.",
updateHost: "Обновите хост, чтобы настроить голос.",
saveError: "Не удалось обновить голосовые настройки",
provider: "Провайдер",
providers: {
local: "Локально",
openai: "OpenAI",
},
voice: "Голос",
save: "Сохранить",
retry: "Повторить",
cancel: "Отмена",
model: "Модель",
language: "Язык",
languagePlaceholder: "Автоопределение",
setBy: "Задано через {{name}}",
unknownProvider: "Обновите приложение, чтобы настроить {{provider}}",
sites: {
dictationStt: "Распознавание речи",
voiceModeStt: "Прослушивание",
voiceModeTts: "Озвучивание",
},
dictation: {
title: "Диктовка",
enabled: "Диктовка",
enabledHint: "Говорите в поле сообщения вместо набора текста",
},
voiceMode: {
title: "Голосовой режим",
info: "Голосовой режим расшифровывает вашу речь и зачитывает ответы агента вслух.",
enabled: "Голосовой режим",
enabledHint: "Общайтесь с агентами без рук",
},
openai: {
apiKey: "API-ключ",
apiKeyHint: "Хранится на хосте и больше не показывается.",
keyPlaceholder: "sk-...",
savedKeyPlaceholder: "Сохранён",
missingKey: "Нет API-ключа",
baseUrl: "Базовый URL",
baseUrlHint:
"Для серверов, совместимых с OpenAI. Оставьте пустым, чтобы использовать OpenAI.",
},
models: {
loading: "Загрузка моделей...",
loadError: "Не удалось загрузить локальные модели",
none: "Нет доступных моделей",
downloaded: "Скачана",
inUse: "Используется",
recommended: "Рекомендуется",
download: "Скачать",
downloadAction: "Скачать {{label}}",
downloading: "Скачивание...",
downloadingPercent: "Скачивание {{percent}}%",
extracting: "Распаковка...",
delete: "Удалить",
deleteAction: "Удалить {{label}}",
deleteTitle: "Удалить {{label}}?",
deleteMessage: "Освободится {{size}}. Позже её можно скачать снова.",
languages: "Языки {{label}}",
moreLanguages: "{{languages}} и ещё {{count}}",
},
},
general: {
title: "Основные",
sending: "Отправка",
+66
View File
@@ -133,6 +133,8 @@ export const zhCN: TranslationResources = {
sendAndInterrupt: "发送并中断",
sendAndSteer: "发送并引导",
sendMessage: "发送消息",
longPressToQueue: "长按加入队列",
longPressToSendNow: "长按立即发送",
queue: "排队",
send: "发送",
},
@@ -2200,6 +2202,7 @@ export const zhCN: TranslationResources = {
connections: "连接",
agents: "Agents",
metadata: "元数据",
voice: "语音",
workspaces: "Workspaces",
providers: "Providers",
usage: "使用情况",
@@ -2221,6 +2224,69 @@ export const zhCN: TranslationResources = {
docs: "文档",
saveError: "无法更新元数据生成设置",
},
voice: {
offline: "连接到此主机以配置语音。",
updateHost: "请更新主机以配置语音。",
saveError: "无法更新语音设置",
provider: "提供方",
providers: {
local: "本地",
openai: "OpenAI",
},
voice: "音色",
save: "保存",
retry: "重试",
cancel: "取消",
model: "模型",
language: "语言",
languagePlaceholder: "自动检测",
setBy: "由 {{name}} 设置",
unknownProvider: "更新应用以配置 {{provider}}",
sites: {
dictationStt: "语音转文字",
voiceModeStt: "聆听",
voiceModeTts: "朗读",
},
dictation: {
title: "听写",
enabled: "听写",
enabledHint: "对着消息框说话,无需打字",
},
voiceMode: {
title: "语音模式",
info: "语音模式会转写你说的话,并朗读智能体的回复。",
enabled: "语音模式",
enabledHint: "免手动与智能体对话",
},
openai: {
apiKey: "API 密钥",
apiKeyHint: "保存在主机上,不会再次显示。",
keyPlaceholder: "sk-...",
savedKeyPlaceholder: "已保存",
missingKey: "缺少 API 密钥",
baseUrl: "基础 URL",
baseUrlHint: "用于兼容 OpenAI 的服务器。留空则使用 OpenAI。",
},
models: {
loading: "正在加载模型...",
loadError: "无法加载本地模型",
none: "没有可用的模型",
downloaded: "已下载",
inUse: "使用中",
recommended: "推荐",
download: "下载",
downloadAction: "下载 {{label}}",
downloading: "正在下载...",
downloadingPercent: "正在下载 {{percent}}%",
extracting: "正在解压...",
delete: "删除",
deleteAction: "删除 {{label}}",
deleteTitle: "删除 {{label}}?",
deleteMessage: "这将释放 {{size}}。之后可以重新下载。",
languages: "{{label}} 支持的语言",
moreLanguages: "{{languages}} 等另外 {{count}} 种",
},
},
general: {
title: "通用",
sending: "发送",
@@ -0,0 +1,57 @@
import { useCallback, useLayoutEffect, useRef } from "react";
import { ScrollView, type NativeScrollEvent, type NativeSyntheticEvent } from "react-native";
import { usePathname, useRootNavigationState } from "expo-router";
import { useStore } from "zustand";
import {
useIsLastWorkspaceSelectionHydrated,
useLastWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { createSettingsPresentation } from "./model";
const selection = createSettingsPresentation();
// Mount once above the sibling Settings routes so replacements and Back share
// one visit, and leaving Settings gives the next workspace entry a fresh default.
export function useSettingsRouteEntry(): void {
const pathname = usePathname();
const navigationState = useRootNavigationState();
const routeKey = navigationState?.routes[navigationState.index]?.key;
const workspace = useLastWorkspaceSelection();
const isWorkspaceHydrated = useIsLastWorkspaceSelectionHydrated();
const workspaceServerId = workspace?.serverId ?? null;
useLayoutEffect(() => {
if (isWorkspaceHydrated && routeKey) {
selection.enterRoute({ key: routeKey, pathname }, workspaceServerId);
}
}, [pathname, routeKey, workspaceServerId, isWorkspaceHydrated]);
}
export function useSettingsHostSelection(): string | null {
return useStore(selection.store, (state) => state.selectedServerId);
}
export const selectSettingsHost = selection.selectHost;
// Sibling routes remount the wide sidebar. Only the current mount owns its
// offset: RN Web can deliver its final scroll callback after unmount.
export function useSettingsSidebarScroll(enabled: boolean) {
const ref = useRef<ScrollView>(null);
const owner = useRef<ReturnType<typeof selection.attachSidebar> | null>(null);
const restore = useCallback(() => {
if (owner.current) ref.current?.scrollTo({ y: owner.current.offset, animated: false });
}, []);
useLayoutEffect(() => {
if (!enabled) return;
const sidebar = selection.attachSidebar();
owner.current = sidebar;
restore();
return () => {
sidebar.detach();
owner.current = null;
};
}, [enabled, restore]);
const onScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
owner.current?.remember(event.nativeEvent.contentOffset.y);
}, []);
return { ref, onScroll, onContentSizeChange: restore, scrollEventThrottle: 16 };
}
@@ -1,9 +1,9 @@
import { describe, expect, it } from "vitest";
import { createSettingsSelection } from "./model";
import { createSettingsPresentation } from "./model";
describe("Settings host selection", () => {
it("keeps picker choices across sections, replacement and Back", () => {
const settings = createSettingsSelection();
const settings = createSettingsPresentation();
settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host");
expect(settings.store.getState().selectedServerId).toBe("workspace-host");
settings.selectHost("chosen-host");
@@ -27,7 +27,7 @@ describe("Settings host selection", () => {
});
it("applies explicit host links once, including project and plugin screens", () => {
const settings = createSettingsSelection();
const settings = createSettingsPresentation();
settings.enterRoute(
{
key: "/settings/hosts/linked-host/projects/project",
@@ -56,7 +56,7 @@ describe("Settings host selection", () => {
});
it("seeds each new visit from the workspace, including a retained settings route", () => {
const settings = createSettingsSelection();
const settings = createSettingsPresentation();
settings.enterRoute(
{ key: "/settings/general", pathname: "/settings/general" },
"first-workspace-host",
@@ -77,7 +77,7 @@ describe("Settings host selection", () => {
});
it("does not replay a retained host route after a newer picker choice", () => {
const settings = createSettingsSelection();
const settings = createSettingsPresentation();
const oldRoute = { key: "old-host-screen", pathname: "/settings/hosts/first/host" };
settings.enterRoute(oldRoute, "workspace-host");
settings.selectHost("second");
@@ -93,10 +93,40 @@ describe("Settings host selection", () => {
});
it("does not reseed a visit when remembered workspace state changes", () => {
const settings = createSettingsSelection();
const settings = createSettingsPresentation();
settings.enterRoute({ key: "/settings", pathname: "/settings" }, null);
settings.selectHost("chosen-host");
settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host");
expect(settings.store.getState().selectedServerId).toBe("chosen-host");
});
});
describe("Settings sidebar presentation", () => {
it("restores across replacements and rejects callbacks from older sidebars", () => {
const settings = createSettingsPresentation();
settings.enterRoute({ key: "app", pathname: "/settings/general" }, "host");
const oldSidebar = settings.attachSidebar();
oldSidebar.remember(246);
settings.enterRoute({ key: "host", pathname: "/settings/hosts/host/providers" }, "host");
const currentSidebar = settings.attachSidebar();
expect(currentSidebar.offset).toBe(246);
oldSidebar.remember(0);
oldSidebar.detach();
expect(currentSidebar.offset).toBe(246);
currentSidebar.remember(190);
currentSidebar.detach();
currentSidebar.remember(0);
expect(settings.attachSidebar().offset).toBe(190);
});
it("resets on leaving Settings and rejects callbacks from a previous visit", () => {
const settings = createSettingsPresentation();
settings.enterRoute({ key: "app", pathname: "/settings/general" }, "host");
const previousVisit = settings.attachSidebar();
previousVisit.remember(246);
settings.enterRoute({ key: "workspace", pathname: "/h/host/workspace/workspace" }, "host");
settings.enterRoute({ key: "app", pathname: "/settings/general" }, "host");
previousVisit.remember(246);
expect(settings.attachSidebar().offset).toBe(0);
});
});
@@ -9,10 +9,28 @@ interface SettingsSelectionState {
selectedServerId: string | null;
}
export function createSettingsSelection() {
export function createSettingsPresentation() {
const store = createStore<SettingsSelectionState>(() => ({ selectedServerId: null }));
const enteredRoutes = new Map<string, string>();
let inSettings = false;
let sidebarOffset = 0;
let sidebarOwner: object | null = null;
function attachSidebar() {
const owner = {};
sidebarOwner = owner;
return {
get offset() {
return sidebarOffset;
},
remember(offset: number) {
if (sidebarOwner === owner) sidebarOffset = offset;
},
detach() {
if (sidebarOwner === owner) sidebarOwner = null;
},
};
}
function enterRoute(
{ key, pathname }: SettingsRouteEntry,
@@ -20,6 +38,8 @@ export function createSettingsSelection() {
): void {
const isSettings = pathname === "/settings" || pathname.startsWith("/settings/");
if (!isSettings) {
sidebarOffset = 0;
sidebarOwner = null;
inSettings = false;
enteredRoutes.clear();
return;
@@ -42,5 +62,5 @@ export function createSettingsSelection() {
store.setState({ selectedServerId: serverId });
}
return { store, enterRoute, selectHost };
return { store, enterRoute, selectHost, attachSidebar };
}
@@ -1,32 +0,0 @@
import { useLayoutEffect } from "react";
import { usePathname, useRootNavigationState } from "expo-router";
import { useStore } from "zustand";
import {
useIsLastWorkspaceSelectionHydrated,
useLastWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { createSettingsSelection } from "./model";
const selection = createSettingsSelection();
// Mount once above the sibling Settings routes so replacements and Back share
// one visit, and leaving Settings gives the next workspace entry a fresh default.
export function useSettingsRouteEntry(): void {
const pathname = usePathname();
const navigationState = useRootNavigationState();
const routeKey = navigationState?.routes[navigationState.index]?.key;
const workspace = useLastWorkspaceSelection();
const isWorkspaceHydrated = useIsLastWorkspaceSelectionHydrated();
const workspaceServerId = workspace?.serverId ?? null;
useLayoutEffect(() => {
if (isWorkspaceHydrated && routeKey) {
selection.enterRoute({ key: routeKey, pathname }, workspaceServerId);
}
}, [pathname, routeKey, workspaceServerId, isWorkspaceHydrated]);
}
export function useSettingsHostSelection(): string | null {
return useStore(selection.store, (state) => state.selectedServerId);
}
export const selectSettingsHost = selection.selectHost;
+2 -3
View File
@@ -20,6 +20,7 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
import invariant from "tiny-invariant";
import { shallow, useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useAgentStreamTail } from "@/agent-stream/use-agent-stream-tail";
import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view";
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
import { ComposerDock } from "@/composer/dock";
@@ -1365,9 +1366,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
const bottomOverlayControlClearance = hasVisibleComposerTracks
? resolveComposerTrackControlClearance(isCompactFormFactor)
: 0;
const streamItemsRaw = useSessionStore((state) =>
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
);
const streamItemsRaw = useAgentStreamTail(serverId, agentId);
const pendingMessageSubmissions = useSessionStore(
useShallow((state) =>
agentId
@@ -1,11 +1,15 @@
import React, {
act,
Profiler,
useCallback,
useEffect,
useLayoutEffect,
useState,
useSyncExternalStore,
} from "react";
import { useAgentStreamTail } from "@/agent-stream/use-agent-stream-tail";
import { useSessionStore } from "@/stores/session-store";
import type { StreamItem } from "@/types/stream";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { RetainedPanelActivity, useRetainedPanelActive } from "@/components/retained-panel";
@@ -220,3 +224,69 @@ describe("workspace sheet environment", () => {
source.unmount();
});
});
for (const [runtime, Chat] of [
["native", NativeChat],
["web", WebChat],
] as const) {
it(`${runtime} ignores hidden stream chunks and reads the latest transcript on reveal`, async () => {
const serverId = `hidden-stream-${runtime}`;
const agentId = "background-agent";
const store = useSessionStore.getState();
store.initializeSession(serverId, null);
const profile = {
commits: 0,
onRender() {
profile.commits++;
},
};
const publish = (text: string) => {
const item: StreamItem = {
id: "message",
kind: "assistant_message",
text,
timestamp: new Date(),
};
store.setAgentStreamTail(serverId, new Map([[agentId, [item]]]));
};
function Content() {
const items = useAgentStreamTail(serverId, agentId);
const [draft] = useState("unsent draft");
return (
<span>
{draft}:{items?.[0]?.kind === "assistant_message" ? items[0].text : ""}
</span>
);
}
const show = (active: boolean) =>
act(() =>
root.render(
<Profiler id="workspace" onRender={profile.onRender}>
<RetainedPanelActivity active={active}>
<Chat>
<Content />
</Chat>
</RetainedPanelActivity>
</Profiler>,
),
);
try {
publish("first chunk");
show(true);
expect(container.textContent).toBe("unsent draft:first chunk");
show(false);
const hiddenCommits = profile.commits;
for (let index = 0; index < 10; index++) {
await act(async () => publish(`chunk ${index}`));
}
expect(profile.commits).toBe(hiddenCommits);
show(true);
expect(container.textContent).toBe("unsent draft:chunk 9");
await act(async () => publish("live after reveal"));
expect(container.textContent).toBe("unsent draft:live after reveal");
} finally {
act(() => root.render(null));
store.clearSession(serverId);
}
});
}
+17 -2
View File
@@ -37,6 +37,7 @@ import {
Files,
Smartphone,
Sparkles,
Mic,
Blocks,
Globe,
PanelLeft,
@@ -118,6 +119,7 @@ import { PluginSettingsContent } from "@/plugins/settings";
import { useInstalledPlugins } from "@/plugins/registry";
import { HostPluginsPage } from "@/screens/settings/plugins-page";
import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page";
import { HostVoicePage } from "@/screens/settings/voice/voice-page";
import ProjectsScreen from "@/screens/projects-screen";
import ProjectSettingsScreen from "@/screens/project-settings-screen";
import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
@@ -133,7 +135,11 @@ import {
type HostSectionSlug,
type SettingsSectionSlug,
} from "@/utils/host-routes";
import { selectSettingsHost, useSettingsHostSelection } from "@/navigation/settings-selection";
import {
selectSettingsHost,
useSettingsHostSelection,
useSettingsSidebarScroll,
} from "@/navigation/settings-presentation";
import { returnFromSettings, type SettingsView } from "@/navigation/settings-navigation";
import { isWeb } from "@/constants/platform";
@@ -236,6 +242,7 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [
{ id: "pair-device", labelKey: "openProject.tiles.pairDevice.title", icon: Smartphone },
{ id: "agents", labelKey: "settings.hostSections.agents", icon: Bot },
{ id: "metadata", labelKey: "settings.hostSections.metadata", icon: Sparkles },
{ id: "voice", labelKey: "settings.hostSections.voice", icon: Mic },
{ id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 },
{ id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes },
{ id: "usage", labelKey: "settings.hostSections.usage", icon: Gauge },
@@ -258,6 +265,8 @@ function renderHostSettingsContent(
return <HostAgentsPage serverId={view.serverId} />;
case "metadata":
return <MetadataGenerationPage serverId={view.serverId} />;
case "voice":
return <HostVoicePage serverId={view.serverId} />;
case "workspaces":
return <HostWorkspacesPage serverId={view.serverId} />;
case "providers":
@@ -945,6 +954,7 @@ function SettingsSidebar({
const items = SIDEBAR_SECTION_ITEMS.filter((item) => isSectionAvailable(item, isDesktopApp));
const insets = useSafeAreaInsets();
const isDesktop = layout === "desktop";
const sidebarScroll = useSettingsSidebarScroll(isDesktop);
const outerContainerStyle = useMemo(
() => [isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer],
[isDesktop],
@@ -1057,6 +1067,7 @@ function SettingsSidebar({
style={sidebarStyles.scrollBody}
showsVerticalScrollIndicator={false}
testID="settings-sidebar-scroll-body"
{...sidebarScroll}
>
{sidebarBody}
</ScrollView>
@@ -1457,7 +1468,11 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
</WindowChromeRegion>
<WindowChromeRegion corners="top-right">
<View style={desktopStyles.contentPane} testID="settings-detail-pane">
<PageLayout title={detailTitle} titleTestID="settings-detail-header-title">
<PageLayout
title={detailTitle}
titleTestID="settings-detail-header-title"
testID="settings-detail-scroll-body"
>
{content}
</PageLayout>
</View>
@@ -0,0 +1,174 @@
import { describe, expect, it } from "vitest";
import type { SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas";
import { openSpeechProviderForm, type SpeechProviderFormSnapshot } from "./speech-provider-form";
function open(overrides: Partial<SpeechProviderFormSnapshot> = {}) {
return openSpeechProviderForm({
siteId: "voiceMode.stt",
site: { provider: "local" },
envOverrides: {},
...overrides,
});
}
async function savedPatch(
model: ReturnType<typeof open>,
): Promise<SpeechSettingsPatch | undefined> {
let sent: SpeechSettingsPatch | undefined;
const result = await model.save(async (patch) => {
sent = patch;
});
expect(result).toEqual({ status: "saved" });
return sent;
}
describe("speech provider form", () => {
it("edits and clears local STT language from the saved snapshot", async () => {
const form = open({ site: { provider: "local", language: "fr" } });
expect(form.getState()).toMatchObject({ language: "fr", canSave: false });
form.setLanguage(" de ");
expect(await savedPatch(form)).toEqual({
voiceMode: { stt: { provider: "local", language: "de" } },
});
const clear = open({ site: { provider: "local", language: "fr" } });
clear.setLanguage("");
expect(await savedPatch(clear)).toEqual({
voiceMode: { stt: { provider: "local", language: null } },
});
});
it("keeps STT language when switching either provider", async () => {
for (const provider of ["local", "openai"] as const) {
const form = open({
site:
provider === "local"
? { provider, language: "fr" }
: { provider, hasApiKey: false, language: "fr" },
});
form.setProvider(provider === "local" ? "openai" : "local");
expect(await savedPatch(form)).toEqual({
voiceMode: { stt: { provider: provider === "local" ? "openai" : "local", language: "fr" } },
});
}
});
it("keeps env-controlled language read-only after switching providers", async () => {
const form = open({
site: { provider: "local", language: "fr" },
envOverrides: { "features.voiceMode.stt.language": "PASEO_VOICE_LANGUAGE" },
});
form.setProvider("openai");
expect(form.getState().fields.language).toEqual({
envVar: "PASEO_VOICE_LANGUAGE",
readOnly: true,
});
expect(await savedPatch(form)).toEqual({ voiceMode: { stt: { provider: "openai" } } });
});
it("switches a site to OpenAI with a key and base URL", async () => {
const form = open();
expect(form.getState().canSave).toBe(false);
form.setProvider("openai");
form.setApiKey(" sk-test ");
form.setBaseUrl("http://127.0.0.1:9999/v1");
expect(await savedPatch(form)).toEqual({
voiceMode: {
stt: { provider: "openai", apiKey: "sk-test", baseUrl: "http://127.0.0.1:9999/v1" },
},
});
});
it("re-saves OpenAI settings without touching the saved key", async () => {
const form = open({
siteId: "dictation.stt",
site: { provider: "openai", hasApiKey: true, baseUrl: "https://a.example/v1" },
});
expect(form.getState()).toMatchObject({ apiKey: "", hasSavedApiKey: true, canSave: false });
form.setOpenAiModel("gpt-4o-transcribe");
form.setBaseUrl("");
expect(await savedPatch(form)).toEqual({
dictation: { stt: { provider: "openai", model: "gpt-4o-transcribe", baseUrl: null } },
});
});
it("selects a local model", async () => {
const form = open({
siteId: "dictation.stt",
site: { provider: "local", model: "whisper-tiny" },
});
form.setLocalModel("whisper-tiny");
expect(form.getState().canSave).toBe(false);
form.setLocalModel("whisper-base");
expect(await savedPatch(form)).toEqual({
dictation: { stt: { provider: "local", model: "whisper-base" } },
});
});
it("locks fields an env var decides and keeps fallback fields editable", async () => {
const form = open({
site: { provider: "openai", hasApiKey: false },
envOverrides: {
"features.voiceMode.stt.provider": "PASEO_VOICE_STT_PROVIDER",
"features.voiceMode.stt.model": "STT_MODEL",
"features.voiceMode.stt.apiKey": "OPENAI_API_KEY",
},
});
expect(form.getState().fields).toMatchObject({
provider: { envVar: "PASEO_VOICE_STT_PROVIDER", readOnly: true },
model: { envVar: "STT_MODEL", readOnly: true },
apiKey: { envVar: "OPENAI_API_KEY", readOnly: false },
baseUrl: { envVar: null, readOnly: false },
});
form.setProvider("local");
form.setOpenAiModel("gpt-4o-transcribe");
form.setApiKey("sk-saved");
expect(form.getState().provider).toBe("openai");
expect(await savedPatch(form)).toEqual({
voiceMode: { stt: { provider: "openai", apiKey: "sk-saved" } },
});
});
it("does not apply the saved provider's env vars to another provider", () => {
const form = open({
envOverrides: { "features.voiceMode.stt.model": "PASEO_VOICE_LOCAL_STT_MODEL" },
});
expect(form.getState().fields.model.readOnly).toBe(true);
form.setProvider("openai");
expect(form.getState().fields.model).toEqual({ envVar: null, readOnly: false });
});
it("keeps the input and allows a retry after a failed save", async () => {
const form = open({ siteId: "voiceMode.tts" });
form.setProvider("openai");
form.setApiKey("sk-test");
form.setVoice("nova");
const failure = new Error("disk full");
const result = await form.save(async () => {
expect(form.getState()).toMatchObject({ saving: true, canSave: false });
throw failure;
});
expect(result).toEqual({ status: "failed", error: failure });
expect(form.getState()).toMatchObject({
provider: "openai",
apiKey: "sk-test",
voice: "nova",
saving: false,
canSave: true,
});
expect(await savedPatch(form)).toEqual({
voiceMode: { tts: { provider: "openai", apiKey: "sk-test", voice: "nova" } },
});
});
});
@@ -0,0 +1,240 @@
import type { SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas";
import {
SPEECH_SITE_KIND,
buildSpeechSitePatch,
resolveOpenAiModel,
resolveOpenAiVoice,
speechSiteFieldPath,
type SpeechModelKind,
type SpeechProvider,
type SpeechSite,
type SpeechSiteId,
type SpeechSitePatch,
} from "./voice-settings";
// The provider sheet's form: one speech site, its provider and that provider's fields.
// Plain TypeScript so the save rules are testable without rendering (docs/forms.md).
export interface SpeechProviderFormSnapshot {
siteId: SpeechSiteId;
site: Extract<SpeechSite, { provider: SpeechProvider }>;
envOverrides: Record<string, string>;
}
export type SpeechProviderField =
| "provider"
| "model"
| "apiKey"
| "baseUrl"
| "language"
| "voice";
// An env var set for one of these is only a fallback: a value saved in the sheet wins,
// so the field stays editable. Every other field is decided by its env var.
const FALLBACK_FIELDS: ReadonlySet<SpeechProviderField> = new Set(["apiKey", "baseUrl"]);
export interface SpeechProviderFieldSource {
/** The env var that decides this field's effective value, when one does. */
envVar: string | null;
readOnly: boolean;
}
export interface SpeechProviderFormState {
kind: SpeechModelKind;
provider: SpeechProvider;
/** The local model the user picked in this sheet; null keeps the saved or default model. */
localModel: string | null;
savedLocalModel: string | undefined;
apiKey: string;
hasSavedApiKey: boolean;
baseUrl: string;
openAiModel: string;
language: string;
voice: string;
fields: Record<SpeechProviderField, SpeechProviderFieldSource>;
saving: boolean;
canSave: boolean;
}
export type SpeechProviderSaveResult =
| { status: "saved" }
| { status: "skipped" }
| { status: "failed"; error: unknown };
export interface SpeechProviderFormModel {
getState: () => SpeechProviderFormState;
subscribe: (listener: () => void) => () => void;
close: () => void;
setProvider: (provider: SpeechProvider) => void;
setLocalModel: (modelId: string) => void;
setApiKey: (value: string) => void;
setBaseUrl: (value: string) => void;
setOpenAiModel: (value: string) => void;
setLanguage: (value: string) => void;
setVoice: (value: string) => void;
/** Sends the changed fields through `save`; the inputs survive a failed save. */
save: (
save: (patch: SpeechSettingsPatch) => Promise<unknown>,
) => Promise<SpeechProviderSaveResult>;
}
interface OpenAiValues {
baseUrl: string;
openAiModel: string;
voice: string;
}
export function openSpeechProviderForm(
snapshot: SpeechProviderFormSnapshot,
): SpeechProviderFormModel {
const { siteId, site, envOverrides } = snapshot;
const kind = SPEECH_SITE_KIND[siteId];
const savedOpenAi = site.provider === "openai" ? site : undefined;
const initialOpenAi: OpenAiValues = {
baseUrl: savedOpenAi?.baseUrl ?? "",
openAiModel: resolveOpenAiModel(kind, savedOpenAi?.model),
voice: resolveOpenAiVoice(savedOpenAi?.voice),
};
const initialLanguage = site.language ?? "";
// The daemon reports env vars for the provider that is in effect, so they only describe the
// provider-specific fields while that provider is selected. Language applies to either STT provider.
const savedFields = buildFieldSources(siteId, envOverrides);
const switchedFields = {
...buildFieldSources(siteId, {}),
provider: savedFields.provider,
language: savedFields.language,
};
const listeners = new Set<() => void>();
let closed = false;
let state: SpeechProviderFormState = derive({
kind,
provider: site.provider,
localModel: null,
savedLocalModel: site.provider === "local" ? site.model : undefined,
apiKey: "",
hasSavedApiKey: savedOpenAi?.hasApiKey ?? false,
...initialOpenAi,
language: initialLanguage,
fields: savedFields,
saving: false,
canSave: false,
});
function derive(next: SpeechProviderFormState): SpeechProviderFormState {
return {
...next,
fields: next.provider === site.provider ? savedFields : switchedFields,
canSave: !next.saving && isChanged(next),
};
}
function isChanged(next: SpeechProviderFormState): boolean {
if (next.provider !== site.provider) return true;
if (kind === "stt" && next.language.trim() !== initialLanguage) return true;
if (next.provider === "local") {
return next.localModel !== null && next.localModel !== next.savedLocalModel;
}
return next.apiKey.trim().length > 0 || changedOpenAiFields(next).length > 0;
}
function changedOpenAiFields(next: SpeechProviderFormState): (keyof OpenAiValues)[] {
const keys: (keyof OpenAiValues)[] =
kind === "stt" ? ["baseUrl", "openAiModel"] : ["baseUrl", "openAiModel", "voice"];
return keys.filter((key) => next[key].trim() !== initialOpenAi[key]);
}
function buildSitePatch(next: SpeechProviderFormState): SpeechSitePatch {
const language = next.language.trim();
const languagePatch: Record<string, string | null> =
kind === "stt" &&
!next.fields.language.readOnly &&
(language !== initialLanguage || (next.provider !== site.provider && language.length > 0))
? { language: language || null }
: {};
if (next.provider === "local") {
return {
provider: "local",
...languagePatch,
...(next.localModel !== null && !next.fields.model.readOnly
? { model: next.localModel }
: {}),
};
}
const patch: Record<string, string | null> = { provider: "openai", ...languagePatch };
const apiKey = next.apiKey.trim();
if (apiKey) patch.apiKey = apiKey;
for (const key of changedOpenAiFields(next)) {
const field = key === "openAiModel" ? "model" : key;
if (next.fields[field].readOnly) continue;
patch[field] = next[key].trim() || null;
}
return patch as SpeechSitePatch;
}
function publish(next: SpeechProviderFormState): void {
if (closed) return;
state = derive(next);
for (const listener of listeners) listener();
}
function edit(change: Partial<SpeechProviderFormState>): void {
if (state.saving) return;
publish({ ...state, ...change });
}
return {
getState: () => state,
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
close() {
closed = true;
listeners.clear();
},
setProvider: (provider) => {
if (!state.fields.provider.readOnly) edit({ provider });
},
setLocalModel: (localModel) => {
if (!state.fields.model.readOnly) edit({ localModel });
},
setApiKey: (apiKey) => edit({ apiKey }),
setBaseUrl: (baseUrl) => edit({ baseUrl }),
setOpenAiModel: (openAiModel) => edit({ openAiModel }),
setLanguage: (language) => edit({ language }),
setVoice: (voice) => edit({ voice }),
async save(save) {
if (!state.canSave) return { status: "skipped" };
const patch = buildSpeechSitePatch(siteId, buildSitePatch(state));
publish({ ...state, saving: true });
try {
await save(patch);
return { status: "saved" };
} catch (error) {
publish({ ...state, saving: false });
return { status: "failed", error };
}
},
};
}
function buildFieldSources(
siteId: SpeechSiteId,
envOverrides: Record<string, string>,
): Record<SpeechProviderField, SpeechProviderFieldSource> {
const source = (field: SpeechProviderField): SpeechProviderFieldSource => {
const envVar = envOverrides[speechSiteFieldPath(siteId, field)] ?? null;
return { envVar, readOnly: envVar !== null && !FALLBACK_FIELDS.has(field) };
};
return {
provider: source("provider"),
model: source("model"),
apiKey: source("apiKey"),
baseUrl: source("baseUrl"),
language: source("language"),
voice: source("voice"),
};
}
@@ -0,0 +1,585 @@
import {
useCallback,
useEffect,
useMemo,
useState,
useSyncExternalStore,
type ReactNode,
} from "react";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Check, Download, Languages, Trash2 } from "lucide-react-native";
import type { LocalSpeechModel, SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import {
SettingsCard,
SettingsInfoTip,
SettingsInput,
SettingsRow,
SettingsSelect,
} from "@/components/settings";
import { Button } from "@/components/ui/button";
import { buttonControlHeight } from "@/components/ui/control-geometry";
import { useContainedControlSize } from "@/components/ui/control-size";
import { foregroundIconColorMapping } from "@/components/ui/icon-color";
import { PressHighlight } from "@/components/ui/press-highlight";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { StatusBadge } from "@/components/ui/status-badge";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-api-context";
import { ICON_SIZE } from "@/styles/theme";
import { confirmDialog } from "@/utils/confirm-dialog";
import type { LocalSpeechModels } from "./use-local-speech-models";
import {
openSpeechProviderForm,
type SpeechProviderFieldSource,
type SpeechProviderFormModel,
type SpeechProviderFormSnapshot,
type SpeechProviderFormState,
} from "./speech-provider-form";
import {
SPEECH_PROVIDERS,
buildOpenAiModelOptions,
buildOpenAiVoiceOptions,
formatLanguages,
formatModelSize,
getLocalModelState,
resolveLocalModel,
type LocalModelState,
type SpeechProvider,
} from "./voice-settings";
const ThemedCheck = withUnistyles(Check);
export interface SpeechProviderSheetProps extends SpeechProviderFormSnapshot {
title: string;
localModels: LocalSpeechModels;
visible: boolean;
onClose: () => void;
onDismiss: () => void;
save: (patch: SpeechSettingsPatch) => Promise<unknown>;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
// The form opens from the props of the first render; later config changes do not reset input.
function useSpeechProviderForm(snapshot: SpeechProviderFormSnapshot): SpeechProviderFormModel {
const [model] = useState(() => openSpeechProviderForm(snapshot));
useEffect(() => () => model.close(), [model]);
return model;
}
/** One speech site's provider and that provider's settings. Mount it fresh for each open. */
export function SpeechProviderSheet({
title,
siteId,
site,
envOverrides,
localModels,
visible,
onClose,
onDismiss,
save,
}: SpeechProviderSheetProps) {
const { t } = useTranslation();
const toast = useToast();
const model = useSpeechProviderForm({ siteId, site, envOverrides });
const state = useSyncExternalStore(model.subscribe, model.getState, model.getState);
const submit = useCallback(() => {
void model.save(save).then((result) => {
if (result.status === "saved") onClose();
if (result.status === "failed") {
toast.error(`${t("settings.voice.saveError")}: ${errorMessage(result.error)}`);
}
return undefined;
});
}, [model, onClose, save, t, toast]);
const header = useMemo<SheetHeader>(() => ({ title }), [title]);
const footer = useMemo(
() => (
<View style={styles.footer}>
<Button
style={styles.footerButton}
variant="secondary"
onPress={onClose}
disabled={state.saving}
>
{t("settings.voice.cancel")}
</Button>
<Button
style={styles.footerButton}
variant="default"
onPress={submit}
disabled={!state.canSave}
loading={state.saving}
testID="voice-provider-save"
>
{t("settings.voice.save")}
</Button>
</View>
),
[onClose, state.canSave, state.saving, submit, t],
);
return (
<AdaptiveModalSheet
header={header}
visible={visible}
onClose={onClose}
onDismiss={onDismiss}
footer={footer}
testID="voice-provider-sheet"
>
<SettingsCard>
<ProviderRow state={state} model={model} />
</SettingsCard>
{state.provider === "local" ? (
<>
{state.kind === "stt" ? (
<SettingsCard>
<LanguageField state={state} model={model} />
</SettingsCard>
) : null}
<LocalModelList state={state} model={model} localModels={localModels} />
</>
) : (
<OpenAiFields state={state} model={model} />
)}
</AdaptiveModalSheet>
);
}
interface FormProps {
state: SpeechProviderFormState;
model: SpeechProviderFormModel;
}
function setByHint(t: TFunction, source: SpeechProviderFieldSource): string | undefined {
return source.envVar ? t("settings.voice.setBy", { name: source.envVar }) : undefined;
}
function ProviderRow({ state, model }: FormProps) {
const { t } = useTranslation();
const source = state.fields.provider;
const options = useMemo(
() =>
SPEECH_PROVIDERS.map((provider) => ({
value: provider,
label: t(`settings.voice.providers.${provider}`),
disabled: source.readOnly,
})),
[source.readOnly, t],
);
return (
<SettingsRow label={t("settings.voice.provider")} hint={setByHint(t, source)}>
<SegmentedControl<SpeechProvider>
options={options}
value={state.provider}
onValueChange={model.setProvider}
testID="voice-provider-picker"
/>
</SettingsRow>
);
}
function LanguageField({ state, model }: FormProps) {
const { t } = useTranslation();
return (
<SettingsInput
label={t("settings.voice.language")}
hint={setByHint(t, state.fields.language)}
initialValue={state.language}
onChangeText={model.setLanguage}
placeholder={t("settings.voice.languagePlaceholder")}
disabled={state.fields.language.readOnly}
/>
);
}
// ---------------------------------------------------------------------------
// OpenAI
// ---------------------------------------------------------------------------
function OpenAiFields({ state, model }: FormProps) {
const { t } = useTranslation();
const modelOptions = useMemo(
() => buildOpenAiModelOptions(state.kind, state.openAiModel),
[state.kind, state.openAiModel],
);
const voiceOptions = useMemo(() => buildOpenAiVoiceOptions(state.voice), [state.voice]);
const apiKeyInfo = useMemo(
() => (
<SettingsInfoTip
title={t("settings.voice.openai.apiKey")}
info={t("settings.voice.openai.apiKeyHint")}
/>
),
[t],
);
const baseUrlInfo = useMemo(
() => (
<SettingsInfoTip
title={t("settings.voice.openai.baseUrl")}
info={t("settings.voice.openai.baseUrlHint")}
/>
),
[t],
);
return (
<SettingsCard>
<SettingsInput
label={t("settings.voice.openai.apiKey")}
labelAccessory={apiKeyInfo}
hint={setByHint(t, state.fields.apiKey)}
initialValue={state.apiKey}
onChangeText={model.setApiKey}
secureTextEntry
placeholder={
state.hasSavedApiKey
? t("settings.voice.openai.savedKeyPlaceholder")
: t("settings.voice.openai.keyPlaceholder")
}
/>
<SettingsInput
label={t("settings.voice.openai.baseUrl")}
labelAccessory={baseUrlInfo}
hint={setByHint(t, state.fields.baseUrl)}
initialValue={state.baseUrl}
onChangeText={model.setBaseUrl}
placeholder="https://api.openai.com/v1"
/>
<SettingsSelect
label={t("settings.voice.model")}
hint={setByHint(t, state.fields.model)}
value={state.openAiModel}
options={modelOptions}
onValueChange={model.setOpenAiModel}
disabled={state.fields.model.readOnly}
/>
{state.kind === "stt" ? (
<LanguageField state={state} model={model} />
) : (
<SettingsSelect
label={t("settings.voice.voice")}
hint={setByHint(t, state.fields.voice)}
value={state.voice}
options={voiceOptions}
onValueChange={model.setVoice}
disabled={state.fields.voice.readOnly}
/>
)}
</SettingsCard>
);
}
// ---------------------------------------------------------------------------
// Local: the catalog, with the selected model checked
// ---------------------------------------------------------------------------
function LocalModelList({
state,
model,
localModels,
}: FormProps & { localModels: LocalSpeechModels }) {
const { t } = useTranslation();
const { models, isLoading, error, refresh, download, remove } = localModels;
const kindModels = useMemo(
() => models.filter((entry) => entry.kind === state.kind),
[models, state.kind],
);
const selectedId =
state.localModel ??
resolveLocalModel({ kind: state.kind, model: state.savedLocalModel, localModels: models })?.id;
const source = state.fields.model;
if (kindModels.length === 0) {
if (error) {
return (
<SettingsCard>
<SettingsRow label={t("settings.voice.models.loadError")} hint={error.message}>
<Button variant="outline" onPress={refresh}>
{t("settings.voice.retry")}
</Button>
</SettingsRow>
</SettingsCard>
);
}
return (
<SettingsCard>
<SettingsRow
label={isLoading ? t("settings.voice.models.loading") : t("settings.voice.models.none")}
/>
</SettingsCard>
);
}
return (
<SettingsCard testID="voice-local-models">
{kindModels.map((entry) => (
<LocalModelRow
key={entry.id}
model={entry}
selected={entry.id === selectedId}
selectionLocked={source.readOnly}
hint={entry.id === selectedId ? setByHint(t, source) : undefined}
onSelect={model.setLocalModel}
onDownload={download}
onDelete={remove}
/>
))}
</SettingsCard>
);
}
function progressLabel(
t: TFunction,
state: Extract<LocalModelState, { kind: "downloading" | "extracting" }>,
): string {
if (state.kind === "extracting") return t("settings.voice.models.extracting");
if (state.percent === null) return t("settings.voice.models.downloading");
return t("settings.voice.models.downloadingPercent", { percent: state.percent });
}
type HoverState = PressableStateCallbackType & { hovered?: boolean };
function LocalModelRow({
model,
selected,
selectionLocked,
hint,
onSelect,
onDownload,
onDelete,
}: {
model: LocalSpeechModel;
selected: boolean;
selectionLocked: boolean;
/** Why the selection is fixed, shown on the selected row. */
hint: string | undefined;
onSelect: (modelId: string) => void;
onDownload: (modelId: string) => Promise<void>;
onDelete: (modelId: string) => Promise<void>;
}) {
const { t, i18n } = useTranslation();
const state = getLocalModelState(model);
const [actionError, setActionError] = useState<string | null>(null);
const select = useCallback(() => onSelect(model.id), [model.id, onSelect]);
const download = useCallback(() => {
setActionError(null);
onDownload(model.id).catch((error: unknown) => setActionError(errorMessage(error)));
}, [model.id, onDownload]);
const remove = useCallback(() => {
void confirmDialog({
title: t("settings.voice.models.deleteTitle", { label: model.label }),
message: t("settings.voice.models.deleteMessage", {
size: formatModelSize(model.downloadSizeBytes),
}),
confirmLabel: t("settings.voice.models.delete"),
destructive: true,
})
.then((confirmed) => {
if (!confirmed) return undefined;
setActionError(null);
return onDelete(model.id);
})
.catch((error: unknown) => setActionError(errorMessage(error)));
}, [model.downloadSizeBytes, model.id, model.label, onDelete, t]);
const languages = formatLanguages({
languages: model.languages,
locale: i18n.language,
formatMore: (listed, count) =>
t("settings.voice.models.moreLanguages", { languages: listed, count }),
});
const details = useMemo(
() => (
<View style={styles.details}>
<SettingsInfoTip
title={model.label}
info={model.description}
testID={`voice-model-${model.id}-description`}
/>
<SettingsInfoTip
title={model.label}
info={languages}
icon={Languages}
accessibilityLabel={t("settings.voice.models.languages", { label: model.label })}
testID={`voice-model-${model.id}-languages`}
/>
<Text style={styles.metaText}>{formatModelSize(model.downloadSizeBytes)}</Text>
{state.kind === "installed" ? (
<StatusBadge
size="xs"
variant="success"
label={
model.inUse ? t("settings.voice.models.inUse") : t("settings.voice.models.downloaded")
}
/>
) : null}
{hint ? <Text style={styles.metaText}>{hint}</Text> : null}
</View>
),
[
hint,
languages,
model.description,
model.downloadSizeBytes,
model.id,
model.inUse,
model.label,
state.kind,
t,
],
);
const recommended = useMemo(
() =>
model.isDefault ? (
<StatusBadge size="xs" label={t("settings.voice.models.recommended")} />
) : undefined,
[model.isDefault, t],
);
// Phone rows have no room beside the label for a labelled button, so actions show their icon.
const compact = useIsCompactFormFactor();
const downloadLabel =
state.kind === "failed" ? t("settings.voice.retry") : t("settings.voice.models.download");
let control = null;
if (state.kind === "downloading" || state.kind === "extracting") {
control = (
<View style={styles.progress}>
<LoadingSpinner size="small" color={styles.spinnerColor.color} />
<Text style={styles.metaText}>{progressLabel(t, state)}</Text>
</View>
);
} else if (state.kind === "installed") {
control = model.inUse ? null : (
<Button
variant="ghost"
leftIcon={Trash2}
onPress={remove}
accessibilityLabel={t("settings.voice.models.deleteAction", { label: model.label })}
testID={`voice-model-delete-${model.id}`}
>
{compact ? null : t("settings.voice.models.delete")}
</Button>
);
} else {
control = (
<Button
variant="outline"
leftIcon={Download}
onPress={download}
accessibilityLabel={t("settings.voice.models.downloadAction", { label: model.label })}
testID={`voice-model-download-${model.id}`}
>
{compact ? null : downloadLabel}
</Button>
);
}
const rowStyle = useCallback(
({ hovered, pressed }: HoverState) => [
!selectionLocked && hovered && styles.rowHovered,
!selectionLocked && pressed && styles.rowPressed,
],
[selectionLocked],
);
const accessibilityState = useMemo(
() => ({ checked: selected, disabled: selectionLocked }),
[selected, selectionLocked],
);
return (
<PressHighlight
accessibilityRole="radio"
accessibilityLabel={model.label}
accessibilityState={accessibilityState}
aria-checked={selected}
disabled={selectionLocked}
onPress={select}
style={rowStyle}
highlightStyle={styles.rowPressed}
testID={`voice-model-${model.id}`}
>
<SettingsRow
label={model.label}
labelAccessory={recommended}
hint={details}
error={actionError ?? (state.kind === "failed" ? state.error : null)}
>
<TrailingControls>
{control}
<View style={styles.checkSlot}>
{selected ? (
<ThemedCheck size={ICON_SIZE.sm} uniProps={foregroundIconColorMapping} />
) : null}
</View>
</TrailingControls>
</SettingsRow>
</PressHighlight>
);
}
// Every catalog row is as tall as one holding a button, so a row does not shrink when its
// download finishes and the button goes away.
function TrailingControls({ children }: { children: ReactNode }) {
const size = useContainedControlSize() ?? "sm";
const style = useMemo(() => [styles.trailing, { minHeight: buttonControlHeight[size] }], [size]);
return <View style={style}>{children}</View>;
}
const styles = StyleSheet.create((theme) => ({
footer: {
flex: 1,
flexDirection: "row",
gap: theme.spacing[3],
},
footerButton: {
flex: 1,
},
rowHovered: {
backgroundColor: theme.colors.surface2,
},
rowPressed: {
backgroundColor: theme.colors.surface3,
},
// The model's details sit on the hint line, so every catalog row has the same two lines.
details: {
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
gap: theme.spacing[2],
marginTop: theme.spacing[1],
},
metaText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontVariant: ["tabular-nums"],
},
trailing: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
checkSlot: {
width: ICON_SIZE.sm,
alignItems: "center",
},
progress: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
spinnerColor: {
color: theme.colors.foregroundMuted,
},
}));
@@ -0,0 +1,71 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas";
import { useFetchQuery } from "@/data/query";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { isLocalModelBusy } from "./voice-settings";
const DOWNLOAD_POLL_INTERVAL_MS = 1000;
const localSpeechModelsQueryKey = (serverId: string) => ["speech-local-models", serverId] as const;
export interface LocalSpeechModels {
models: LocalSpeechModel[];
isLoading: boolean;
error: Error | null;
refresh: () => void;
download: (modelId: string) => Promise<void>;
remove: (modelId: string) => Promise<void>;
}
// The daemon reports download progress through the list, so the list polls while any
// model is downloading or extracting.
export function useLocalSpeechModels(serverId: string): LocalSpeechModels {
const { t } = useTranslation();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const query = useFetchQuery({
queryKey: localSpeechModelsQueryKey(serverId),
enabled: Boolean(client && isConnected),
dataShape: "list",
staleTimeMs: 0,
queryFn: () => {
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
return client.listLocalSpeechModels();
},
refetchInterval: (current) =>
current.state.data?.some(isLocalModelBusy) ? DOWNLOAD_POLL_INTERVAL_MS : false,
});
const { refetch } = query;
const refresh = useCallback(() => {
void refetch();
}, [refetch]);
const download = useCallback(
async (modelId: string) => {
if (!client) return;
await client.downloadLocalSpeechModel(modelId);
await refetch();
},
[client, refetch],
);
const remove = useCallback(
async (modelId: string) => {
if (!client) return;
await client.deleteLocalSpeechModel(modelId);
await refetch();
},
[client, refetch],
);
return {
models: query.data ?? [],
isLoading: query.isLoading,
error: query.error,
refresh,
download,
remove,
};
}
@@ -0,0 +1,252 @@
import { useCallback, useState } from "react";
import { View } from "react-native";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { StyleSheet } from "react-native-unistyles";
import type { SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas";
import {
SettingsCard,
SettingsDetailRow,
SettingsRow,
SettingsSection,
} from "@/components/settings";
import { Alert } from "@/components/ui/alert";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Switch } from "@/components/ui/switch";
import { useToast } from "@/contexts/toast-api-context";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useHostFeature } from "@/runtime/host-features";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { SpeechProviderSheet } from "./speech-provider-sheet";
import { useLocalSpeechModels } from "./use-local-speech-models";
import {
getSpeechSite,
summarizeSpeechSite,
type SpeechSiteId,
type SpeechSiteSummary,
} from "./voice-settings";
type SavePatch = (patch: SpeechSettingsPatch) => Promise<unknown>;
const EMPTY_ENV_OVERRIDES: Record<string, string> = {};
// The sheet mounts fresh for every open (docs/forms.md); `openCount` keys each mount.
interface OpenSheet {
siteId: SpeechSiteId;
visible: boolean;
openCount: number;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function HostVoicePage({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const toast = useToast();
const isConnected = useHostRuntimeIsConnected(serverId);
const supported = useHostFeature(serverId, "speechSettings");
const { config, patchConfig } = useDaemonConfig(serverId);
const localModels = useLocalSpeechModels(serverId);
const { refresh: refreshModels } = localModels;
const [sheet, setSheet] = useState<OpenSheet | null>(null);
// Selecting a model changes which models are in use, so the catalog is re-read after a save.
const save = useCallback<SavePatch>(
async (patch) => {
await patchConfig({ features: patch });
refreshModels();
},
[patchConfig, refreshModels],
);
const saveSwitch = useCallback(
(patch: SpeechSettingsPatch) => {
save(patch).catch((error: unknown) => {
toast.error(`${t("settings.voice.saveError")}: ${errorMessage(error)}`);
});
},
[save, t, toast],
);
const setDictationEnabled = useCallback(
(enabled: boolean) => saveSwitch({ dictation: { enabled } }),
[saveSwitch],
);
const setVoiceModeEnabled = useCallback(
(enabled: boolean) => saveSwitch({ voiceMode: { enabled } }),
[saveSwitch],
);
const openSheet = useCallback((siteId: SpeechSiteId) => {
setSheet((current) => ({
siteId,
visible: true,
openCount: (current?.openCount ?? 0) + 1,
}));
}, []);
const closeSheet = useCallback(() => {
setSheet((current) => (current ? { ...current, visible: false } : null));
}, []);
const dismissSheet = useCallback(() => setSheet(null), []);
if (!isConnected) {
return <Alert size="md" variant="warning" title={t("settings.voice.offline")} />;
}
if (!supported) {
return <Alert size="md" variant="warning" title={t("settings.voice.updateHost")} />;
}
const features = config?.features;
if (!features) {
return (
<View style={styles.loading}>
<LoadingSpinner size="large" color={styles.spinnerColor.color} />
</View>
);
}
const envOverrides = config?.envOverrides ?? EMPTY_ENV_OVERRIDES;
const siteRow = (siteId: SpeechSiteId, label: string) => (
<SpeechSiteRow
siteId={siteId}
label={label}
summary={summarizeSpeechSite({
siteId,
site: getSpeechSite(features, siteId),
envOverrides,
localModels: localModels.models,
})}
onOpen={openSheet}
/>
);
const sheetSite = sheet ? getSpeechSite(features, sheet.siteId) : null;
return (
<View testID="voice-settings">
<SettingsSection title={t("settings.voice.dictation.title")} testID="voice-dictation">
<SettingsCard>
<FeatureSwitchRow
label={t("settings.voice.dictation.enabled")}
hint={t("settings.voice.dictation.enabledHint")}
envVar={envOverrides["features.dictation.enabled"]}
value={features.dictation.enabled}
onChange={setDictationEnabled}
/>
{siteRow("dictation.stt", t("settings.voice.sites.dictationStt"))}
</SettingsCard>
</SettingsSection>
<SettingsSection
title={t("settings.voice.voiceMode.title")}
info={t("settings.voice.voiceMode.info")}
testID="voice-voice-mode"
>
<SettingsCard>
<FeatureSwitchRow
label={t("settings.voice.voiceMode.enabled")}
hint={t("settings.voice.voiceMode.enabledHint")}
envVar={envOverrides["features.voiceMode.enabled"]}
value={features.voiceMode.enabled}
onChange={setVoiceModeEnabled}
/>
{siteRow("voiceMode.stt", t("settings.voice.sites.voiceModeStt"))}
{siteRow("voiceMode.tts", t("settings.voice.sites.voiceModeTts"))}
</SettingsCard>
</SettingsSection>
{sheet && sheetSite && sheetSite.provider !== "unknown" ? (
<SpeechProviderSheet
key={sheet.openCount}
title={siteLabel(t, sheet.siteId)}
siteId={sheet.siteId}
site={sheetSite}
envOverrides={envOverrides}
localModels={localModels}
visible={sheet.visible}
onClose={closeSheet}
onDismiss={dismissSheet}
save={save}
/>
) : null}
</View>
);
}
function siteLabel(t: TFunction, siteId: SpeechSiteId): string {
switch (siteId) {
case "dictation.stt":
return t("settings.voice.sites.dictationStt");
case "voiceMode.stt":
return t("settings.voice.sites.voiceModeStt");
case "voiceMode.tts":
return t("settings.voice.sites.voiceModeTts");
}
}
function FeatureSwitchRow({
label,
hint,
envVar,
value,
onChange,
}: {
label: string;
hint: string;
envVar: string | undefined;
value: boolean;
onChange: (value: boolean) => void;
}) {
const { t } = useTranslation();
return (
<SettingsRow label={label} hint={envVar ? t("settings.voice.setBy", { name: envVar }) : hint}>
<Switch
value={value}
onValueChange={onChange}
disabled={Boolean(envVar)}
accessibilityLabel={label}
/>
</SettingsRow>
);
}
function SpeechSiteRow({
siteId,
label,
summary,
onOpen,
}: {
siteId: SpeechSiteId;
label: string;
summary: SpeechSiteSummary;
onOpen: (siteId: SpeechSiteId) => void;
}) {
const { t } = useTranslation();
const open = useCallback(() => onOpen(siteId), [onOpen, siteId]);
return (
<SettingsDetailRow
label={label}
summary={summaryLabel(t, summary)}
onPress={open}
disabled={summary.kind === "unknown"}
testID={`voice-site-${siteId}`}
/>
);
}
function summaryLabel(t: TFunction, summary: SpeechSiteSummary): string {
switch (summary.kind) {
case "local":
return summary.model
? `${t("settings.voice.providers.local")} · ${summary.model}`
: t("settings.voice.providers.local");
case "openai":
return `${t("settings.voice.providers.openai")} · ${
summary.missingKey ? t("settings.voice.openai.missingKey") : summary.model
}`;
case "unknown":
return t("settings.voice.unknownProvider", { provider: summary.provider });
}
}
const styles = StyleSheet.create((theme) => ({
loading: {
paddingVertical: theme.spacing[8],
alignItems: "center",
},
spinnerColor: {
color: theme.colors.foregroundMuted,
},
}));
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas";
import {
buildOpenAiModelOptions,
getSpeechSite,
formatLanguages,
formatModelSize,
getLocalModelState,
summarizeSpeechSite,
} from "./voice-settings";
function model(overrides: Partial<LocalSpeechModel>): LocalSpeechModel {
return {
id: "parakeet-tdt-0.6b-v2-int8",
kind: "stt",
label: "Parakeet v2",
description: "Accurate English transcription.",
languages: ["en"],
downloadSizeBytes: 482_468_385,
isDefault: true,
inUse: true,
status: "installed",
...overrides,
};
}
const catalog = [
model({}),
model({
id: "parakeet-tdt-110m-en-int8",
label: "Parakeet Small",
isDefault: false,
status: "not_installed",
inUse: false,
}),
model({ id: "kokoro-en-v0_19", kind: "tts", label: "Kokoro", inUse: false }),
];
describe("voice settings", () => {
it("keeps local STT language from the wire when opening a provider sheet", () => {
const features = {
dictation: { enabled: true, stt: { provider: "local", language: "fr" } },
voiceMode: {
enabled: false,
stt: { provider: "local", language: "de" },
tts: { provider: "local" },
},
};
expect(getSpeechSite(features, "dictation.stt")).toMatchObject({
provider: "local",
language: "fr",
});
expect(getSpeechSite(features, "voiceMode.stt")).toMatchObject({
provider: "local",
language: "de",
});
});
it("summarizes a local site by its model, falling back to the catalog default", () => {
const summary = (modelId?: string) =>
summarizeSpeechSite({
siteId: "dictation.stt",
site: { provider: "local", model: modelId },
envOverrides: {},
localModels: catalog,
});
expect(summary()).toEqual({ kind: "local", model: "Parakeet v2" });
expect(summary("parakeet-tdt-110m-en-int8")).toEqual({
kind: "local",
model: "Parakeet Small",
});
});
it("flags an OpenAI site with no saved key and no env key", () => {
const summary = (envOverrides: Record<string, string>) =>
summarizeSpeechSite({
siteId: "voiceMode.tts",
site: { provider: "openai", hasApiKey: false },
envOverrides,
localModels: catalog,
});
expect(summary({})).toEqual({ kind: "openai", model: "tts-1", missingKey: true });
expect(summary({ "features.voiceMode.tts.apiKey": "OPENAI_API_KEY" })).toEqual({
kind: "openai",
model: "tts-1",
missingKey: false,
});
});
it("keeps a custom OpenAI-compatible model id selectable", () => {
expect(buildOpenAiModelOptions("stt", "my-whisper").at(-1)).toEqual({
value: "my-whisper",
label: "my-whisper",
});
});
it("reports download progress as a percentage", () => {
expect(
getLocalModelState(model({ status: "downloading", receivedBytes: 241, totalBytes: 482 })),
).toEqual({ kind: "downloading", percent: 50 });
expect(getLocalModelState(model({ status: "downloading" }))).toEqual({
kind: "downloading",
percent: null,
});
});
it("formats download sizes", () => {
expect(formatModelSize(482_468_385)).toBe("482 MB");
expect(formatModelSize(1_250_000_000)).toBe("1.3 GB");
});
it("names a short language list and counts the rest of a long one", () => {
const formatMore = (languages: string, count: number) => `${languages} and ${count} more`;
expect(formatLanguages({ languages: ["en", "de"], locale: "en", formatMore })).toBe(
"English, German",
);
const whisper = ["en", "zh", "de", "es", "ru", "ko", "fr", "ja", "pt", "tr", "pl", "ca"];
expect(formatLanguages({ languages: whisper, locale: "en", formatMore })).toBe(
"English, Chinese, German, Spanish, Russian, Korean, French, Japanese, Portuguese, Turkish and 2 more",
);
});
});
@@ -0,0 +1,240 @@
import type {
LocalSpeechModel,
SpeechSettings,
SpeechSettingsPatch,
} from "@getpaseo/protocol/speech/rpc-schemas";
// What the Voice page needs to know about speech providers and models, kept free of React
// so the option lists and model states are testable on their own.
export type SpeechProvider = "local" | "openai";
export type SpeechModelKind = "stt" | "tts";
export const SPEECH_PROVIDERS: readonly SpeechProvider[] = ["local", "openai"];
// The three places that use speech. Each carries its own provider settings in config.json
// under `features.<site>`.
export type SpeechSiteId = "dictation.stt" | "voiceMode.stt" | "voiceMode.tts";
export const SPEECH_SITE_KIND: Record<SpeechSiteId, SpeechModelKind> = {
"dictation.stt": "stt",
"voiceMode.stt": "stt",
"voiceMode.tts": "tts",
};
/** The config path the daemon names in `envOverrides` for a field of a site. */
export function speechSiteFieldPath(siteId: SpeechSiteId, field: string): string {
return `features.${siteId}.${field}`;
}
/** One site's settings as the daemon reports them, narrowed to what the app can edit. */
export type SpeechSite =
| { provider: "local"; model?: string; language?: string }
| {
provider: "openai";
hasApiKey: boolean;
baseUrl?: string;
model?: string;
language?: string;
voice?: string;
}
| { provider: "unknown"; id: string };
type SpeechSiteSettings = SpeechSettings["dictation"]["stt"] | SpeechSettings["voiceMode"]["tts"];
export function getSpeechSite(features: SpeechSettings, siteId: SpeechSiteId): SpeechSite {
switch (siteId) {
case "dictation.stt":
return toSpeechSite(features.dictation.stt);
case "voiceMode.stt":
return toSpeechSite(features.voiceMode.stt);
case "voiceMode.tts":
return toSpeechSite(features.voiceMode.tts);
}
}
function toSpeechSite(settings: SpeechSiteSettings): SpeechSite {
const text = (value: unknown) => (typeof value === "string" && value ? value : undefined);
if (settings.provider === "local")
return { provider: "local", model: text(settings.model), language: text(settings.language) };
if (settings.provider === "openai") {
return {
provider: "openai",
hasApiKey: settings.hasApiKey === true,
baseUrl: text(settings.baseUrl),
model: text(settings.model),
language: text(settings.language),
voice: text(settings.voice),
};
}
return { provider: "unknown", id: settings.provider };
}
export type SpeechSitePatch = NonNullable<
| NonNullable<SpeechSettingsPatch["voiceMode"]>["stt"]
| NonNullable<SpeechSettingsPatch["voiceMode"]>["tts"]
>;
export function buildSpeechSitePatch(
siteId: SpeechSiteId,
patch: SpeechSitePatch,
): SpeechSettingsPatch {
switch (siteId) {
case "dictation.stt":
return { dictation: { stt: patch } };
case "voiceMode.stt":
return { voiceMode: { stt: patch } };
case "voiceMode.tts":
return { voiceMode: { tts: patch } };
}
}
// OpenAI accepts free-form ids so self-hosted servers can use their own; these are the
// ones the page offers. A saved id outside the list is still shown as an option.
const OPENAI_MODELS: Record<SpeechModelKind, { ids: readonly string[]; defaultId: string }> = {
stt: {
ids: ["whisper-1", "gpt-4o-transcribe", "gpt-4o-mini-transcribe"],
defaultId: "whisper-1",
},
tts: { ids: ["tts-1", "tts-1-hd", "gpt-4o-mini-tts"], defaultId: "tts-1" },
};
const OPENAI_VOICES = ["alloy", "ash", "coral", "echo", "fable", "nova", "onyx", "sage", "shimmer"];
const DEFAULT_OPENAI_VOICE = "alloy";
export interface SpeechModelOption {
value: string;
label: string;
}
export function resolveOpenAiModel(kind: SpeechModelKind, model: string | undefined): string {
return model ?? OPENAI_MODELS[kind].defaultId;
}
export function resolveLocalModel(input: {
kind: SpeechModelKind;
model: string | undefined;
localModels: readonly LocalSpeechModel[];
}): LocalSpeechModel | undefined {
const kindModels = input.localModels.filter((model) => model.kind === input.kind);
if (input.model) return kindModels.find((model) => model.id === input.model);
return kindModels.find((model) => model.isDefault);
}
export function buildOpenAiModelOptions(
kind: SpeechModelKind,
selected: string,
): SpeechModelOption[] {
return withSelected(
OPENAI_MODELS[kind].ids.map((id) => ({ value: id, label: id })),
selected,
);
}
export function resolveOpenAiVoice(voice: string | undefined): string {
return voice ?? DEFAULT_OPENAI_VOICE;
}
export function buildOpenAiVoiceOptions(selected: string): SpeechModelOption[] {
return withSelected(
OPENAI_VOICES.map((voice) => ({ value: voice, label: voice })),
selected,
);
}
function withSelected(options: SpeechModelOption[], selected: string): SpeechModelOption[] {
if (!selected || options.some((option) => option.value === selected)) return options;
return [...options, { value: selected, label: selected }];
}
// What a page row says about a site before it is opened.
export type SpeechSiteSummary =
| { kind: "local"; model: string | null }
| { kind: "openai"; model: string; missingKey: boolean }
| { kind: "unknown"; provider: string };
export function summarizeSpeechSite(input: {
siteId: SpeechSiteId;
site: SpeechSite;
envOverrides: Record<string, string>;
localModels: readonly LocalSpeechModel[];
}): SpeechSiteSummary {
const { site, siteId } = input;
const kind = SPEECH_SITE_KIND[siteId];
switch (site.provider) {
case "local": {
const model = resolveLocalModel({ kind, model: site.model, localModels: input.localModels });
return { kind: "local", model: model?.label ?? site.model ?? null };
}
case "openai":
return {
kind: "openai",
model: resolveOpenAiModel(kind, site.model),
missingKey: !site.hasApiKey && !input.envOverrides[speechSiteFieldPath(siteId, "apiKey")],
};
case "unknown":
return { kind: "unknown", provider: site.id };
}
}
export type LocalModelState =
| { kind: "installed" }
| { kind: "not_installed" }
| { kind: "downloading"; percent: number | null }
| { kind: "extracting" }
| { kind: "failed"; error: string };
export function getLocalModelState(model: LocalSpeechModel): LocalModelState {
switch (model.status) {
case "installed":
return { kind: "installed" };
case "downloading": {
const total = model.totalBytes ?? 0;
const received = model.receivedBytes ?? 0;
return {
kind: "downloading",
percent: total > 0 ? Math.min(100, Math.floor((received / total) * 100)) : null,
};
}
case "extracting":
return { kind: "extracting" };
case "failed":
return { kind: "failed", error: model.error ?? "" };
default:
return { kind: "not_installed" };
}
}
export function isLocalModelBusy(model: LocalSpeechModel): boolean {
return model.status === "downloading" || model.status === "extracting";
}
export function formatModelSize(bytes: number): string {
const megabytes = bytes / 1_000_000;
if (megabytes >= 1000) return `${(megabytes / 1000).toFixed(1)} GB`;
return `${Math.round(megabytes)} MB`;
}
// Multilingual models list close to a hundred languages; past this many the tooltip names
// the first ones and counts the rest.
const MAX_LISTED_LANGUAGES = 10;
export function formatLanguages(input: {
languages: readonly string[];
locale: string;
formatMore: (languages: string, count: number) => string;
}): string {
const names = languageNames(input.locale);
const listed = input.languages
.slice(0, MAX_LISTED_LANGUAGES)
.map((code) => names?.of(code) ?? code.toUpperCase())
.join(", ");
const hidden = input.languages.length - MAX_LISTED_LANGUAGES;
return hidden > 0 ? input.formatMore(listed, hidden) : listed;
}
// Hermes ships without Intl.DisplayNames; the language code is the fallback label there.
function languageNames(locale: string): Intl.DisplayNames | null {
if (typeof Intl.DisplayNames !== "function") return null;
return new Intl.DisplayNames([locale], { type: "language" });
}
@@ -3187,6 +3187,7 @@ function WorkspaceScreenContent({
<WorkspaceOpenInEditorButton
serverId={normalizedServerId}
cwd={workspaceDirectory}
projectId={workspaceDescriptor?.projectId ?? null}
activeFile={activeFileLocation}
hideLabels
/>
@@ -0,0 +1,86 @@
import { spawnSync } from "node:child_process";
import path from "node:path";
import { build } from "esbuild";
import { describe, expect, it } from "vitest";
// Bypass the app test aliases: they replace all three libraries involved in the leak.
async function renderWebProbe(contents: string) {
const { outputFiles } = await build({
stdin: {
contents,
resolveDir: path.resolve(import.meta.dirname, "../.."),
loader: "jsx",
},
bundle: true,
platform: "node",
format: "cjs",
write: false,
mainFields: ["browser", "module", "main"],
conditions: ["browser"],
resolveExtensions: [".web.tsx", ".web.ts", ".web.js", ".tsx", ".ts", ".js", ".json"],
alias: { "react-native": "react-native-web", "@": path.resolve(import.meta.dirname, "..") },
jsx: "automatic",
define: { __DEV__: "true", "process.env.NODE_ENV": '"development"' },
});
return spawnSync(process.execPath, ["-"], {
input: outputFiles[0].text,
encoding: "utf8",
});
}
describe("Unistyles web component props", () => {
it("consumes uniProps while preserving mapped colors and explicit overrides", async () => {
const result = await renderWebProbe(`
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { StyleSheet, UnistylesRuntime, withUnistyles } from 'react-native-unistyles';
import { Server } from 'lucide-react-native';
StyleSheet.configure({
themes: { light: { color: 'red' }, dark: { color: 'blue' } },
settings: { initialTheme: 'light' },
});
const Icon = withUnistyles(Server);
const mapping = theme => ({ color: theme.color });
const render = props => renderToStaticMarkup(React.createElement(Icon, { uniProps: mapping, ...props }));
const light = render();
UnistylesRuntime.setTheme('dark');
const dark = render();
const overridden = render({ color: 'green' });
const received = [];
const Probe = withUnistyles(props => { received.push(props); return null; });
renderToStaticMarkup(React.createElement(Probe, { uniProps: mapping, id: 'probe' }));
console.log(JSON.stringify({ light, dark, overridden, keys: Object.keys(received[0]).sort() }));
`);
expect(result.status, result.stderr).toBe(0);
expect(result.stderr).not.toContain("React does not recognize");
const rendered = JSON.parse(result.stdout);
expect(rendered.keys).toEqual(["color", "id", "ref"]);
expect(rendered.light).toContain('stroke="red"');
expect(rendered.dark).toContain('stroke="blue"');
expect(rendered.overridden).toContain('stroke="green"');
});
});
describe("decorative checks ring", () => {
it("hides its SVG from web accessibility without forwarding native props", async () => {
const result = await renderWebProbe(`
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { StyleSheet } from 'react-native-unistyles';
import { ChecksRing } from './src/git/pull-request-panel/checks-ring';
StyleSheet.configure({
themes: { light: { colors: { surface3: 'gray', statusSuccess: 'green' } } },
settings: { initialTheme: 'light' },
});
console.log(renderToStaticMarkup(React.createElement(ChecksRing, {
size: 20, summary: { total: 1, parts: [{ status: 'success', count: 1 }] },
})));
`);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain('aria-hidden="true"');
expect(result.stdout).not.toContain("importantForAccessibility");
expect(result.stdout).not.toContain("accessibilityElementsHidden");
expect(result.stdout).toContain('stroke="green"');
expect(result.stderr).not.toContain("React does not recognize");
});
});
+1
View File
@@ -518,6 +518,7 @@ export const HOST_SECTION_SLUGS = [
"pair-device",
"agents",
"metadata",
"voice",
"workspaces",
"providers",
"usage",
@@ -56,12 +56,14 @@ export function hasDesktopOpenTargetsBridge(): boolean {
return getDesktopEditorBridge() !== null;
}
export async function listDesktopOpenTargets(): Promise<DesktopOpenTarget[]> {
export async function listDesktopOpenTargets(
input: { workspacePath?: string } = {},
): Promise<DesktopOpenTarget[]> {
const bridge = getDesktopEditorBridge();
if (!bridge) {
return [];
}
return await bridge.listTargets();
return await bridge.listTargets(input);
}
export async function openDesktopTarget(input: OpenDesktopTargetInput): Promise<void> {
@@ -72,15 +74,24 @@ export async function openDesktopTarget(input: OpenDesktopTargetInput): Promise<
await bridge.openTarget(input);
}
export function useDesktopOpenTargets(input: { isLocalExecution: boolean }) {
/**
* Pass `workspacePath` whenever it is known. Project-specific targets such as
* Xcode are only listed for workspaces they can actually open, so callers that
* omit it get the workspace-agnostic targets only.
*/
export function useDesktopOpenTargets(input: {
isLocalExecution: boolean;
workspacePath?: string;
}) {
const hasBridge = hasDesktopOpenTargetsBridge();
const canListTargets = hasBridge && input.isLocalExecution;
const workspacePath = input.workspacePath?.trim() ?? "";
const query = useQuery({
queryKey: ["desktop-open-targets"],
queryKey: ["desktop-open-targets", workspacePath],
enabled: canListTargets,
staleTime: 60_000,
retry: false,
queryFn: listDesktopOpenTargets,
queryFn: () => listDesktopOpenTargets(workspacePath ? { workspacePath } : {}),
});
const targets = selectDesktopOpenTargets({
canListTargets,
@@ -33,6 +33,7 @@ import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
interface WorkspaceOpenInEditorButtonProps {
serverId: string;
cwd: string;
projectId: string | null;
activeFile?: WorkspaceFileLocation | null;
hideLabels?: boolean;
}
@@ -83,6 +84,7 @@ function OpenTargetMenuItem({ target, isPreferred, onSelect }: OpenTargetMenuIte
export function WorkspaceOpenInEditorButton({
serverId,
cwd,
projectId,
activeFile,
hideLabels,
}: WorkspaceOpenInEditorButtonProps) {
@@ -90,10 +92,11 @@ export function WorkspaceOpenInEditorButton({
const toast = useToast();
const isConnected = useHostRuntimeIsConnected(serverId);
const isLocalDaemon = useIsLocalDaemon(serverId);
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor({ serverId, projectId });
const { targets: desktopOpenTargets, isAvailable: isDesktopOpenAvailable } =
useDesktopOpenTargets({
isLocalExecution: isLocalDaemon,
workspacePath: cwd,
});
const resolvedFile = useMemo(
@@ -185,9 +188,11 @@ export function WorkspaceOpenInEditorButton({
const handleSelectTarget = useCallback(
(target: OpenTarget) => {
void updatePreferredEditor(target.id).catch(() => undefined);
void updatePreferredEditor(target.id).catch((error: unknown) => {
toast.error(error instanceof Error ? error.message : t("common.errors.unableToSave"));
});
},
[updatePreferredEditor],
[updatePreferredEditor, toast, t],
);
const primaryPressableStyle = useCallback(
@@ -9,6 +9,7 @@ import { planWorkspaceOpenTargets } from "@/workspace/open-in-editor/planner";
interface UseOpenDirectoryInEditorInput {
serverId: string;
workspaceDirectory: string;
projectId: string | null;
}
interface OpenDirectoryInEditorAction {
@@ -19,11 +20,12 @@ interface OpenDirectoryInEditorAction {
export function useOpenDirectoryInEditor({
serverId,
workspaceDirectory,
projectId,
}: UseOpenDirectoryInEditorInput): OpenDirectoryInEditorAction | null {
const { t } = useTranslation();
const toast = useToast();
const isLocalExecution = useIsLocalDaemon(serverId);
const { preferredEditorId } = usePreferredEditor();
const { preferredEditorId } = usePreferredEditor({ serverId, projectId });
const { targets, isAvailable } = useDesktopOpenTargets({ isLocalExecution });
const editorTargets = useMemo(
() => targets.filter((target) => target.kind === "editor"),
+3
View File
@@ -161,6 +161,7 @@ export default defineConfig({
"i18next",
"@marijn/find-cluster-break",
"tiny-invariant",
"react/jsx-dev-runtime",
"react-native-gesture-handler > hoist-non-react-statics",
"react-native-gesture-handler > invariant",
],
@@ -171,6 +172,8 @@ export default defineConfig({
"react-native-keyboard-controller",
],
},
// babel-preset-expo compiles JSX with the automatic runtime, so app modules don't import React.
esbuild: { jsx: "automatic" },
// The globals a React Native bundler defines, which esbuild is no longer there to supply for
// the package excluded above.
define: {
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.13.0",
"version": "0.13.2",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/server": "0.13.0",
"@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"@getpaseo/server": "0.13.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.13.0",
"version": "0.13.2",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -47,8 +47,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.13.0",
"@getpaseo/relay": "0.13.0",
"@getpaseo/protocol": "0.13.2",
"@getpaseo/relay": "0.13.2",
"ws": "^8.20.0",
"zod": "^4.4.3"
},

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