Compare commits

..

86 Commits

Author SHA1 Message Date
Mohamed Boudra
745f2e2768 chore(release): cut 0.1.28 2026-03-15 13:53:47 +07:00
Mohamed Boudra
ef55fecfe2 docs(changelog): prepare 0.1.28 release notes 2026-03-15 13:53:03 +07:00
Mohamed Boudra
cc675da49c Stabilize git action menu 2026-03-15 13:51:04 +07:00
Mohamed Boudra
a9d5502fad fix(app): route assistant file links to workspace files 2026-03-15 13:50:52 +07:00
Mohamed Boudra
849f49b622 fix: settings screen design tweaks for mobile
- Center settings cog icon vertically in host card
- Normalize icon sizes (relay/host/settings all use iconSize.sm)
- Hide theme picker labels on mobile (icon-only)
- Simplify audio test copy, remove verbose description
2026-03-15 13:50:50 +07:00
Mohamed Boudra
d89f1ea663 fix: align mobile workspace header icons with desktop
Use same explorer toggle icons (SourceControlPanelIcon/PanelRight) on
mobile as desktop, use vertical kebab icon for mobile header menu.
2026-03-15 13:50:42 +07:00
Mohamed Boudra
5701543746 refactor: move startup logic to app-support, improve welcome screen UI 2026-03-15 12:52:29 +07:00
Mohamed Boudra
57db31c662 feat(website): update hero copy, add sponsor CTA section
Update hero title to "All your coding agents, from anywhere" with
responsive line break. Add sponsor CTA section with personal messaging.
Update meta title for SEO. Fix GitHub sponsors link.
2026-03-15 12:07:14 +07:00
Mohamed Boudra
eadca4a96b Remove fix-tests script 2026-03-15 11:27:23 +07:00
Mohamed Boudra
4a111099c4 docs: fix license to AGPL-3.0 in README 2026-03-15 11:25:35 +07:00
Mohamed Boudra
6f34699e4b Add OpenCode build and plan modes 2026-03-15 11:17:23 +07:00
Mohamed Boudra
aa6c521394 feat(website): add SEO landing pages for Claude Code, Codex, and OpenCode
Extract homepage into reusable LandingPage component with configurable
hero. Add /claude-code, /codex, and /opencode routes targeting
agent-specific mobile app search terms. Replace single-link footer
with four-column layout (Product, Agents, Community, Download).
2026-03-15 11:16:28 +07:00
Mohamed Boudra
e80db346da fix(deploy): force production deployment on Cloudflare Pages
Add --branch main to wrangler pages deploy so tag-triggered deploys
go to production instead of preview. Add app-v* tag for retriggering.
2026-03-15 11:08:53 +07:00
Mohamed Boudra
26c7f3f5f3 docs: add GitHub releases link to getting started 2026-03-15 10:57:56 +07:00
Mohamed Boudra
0057ee7b72 docs: update README to match current homepage messaging
- Lead with desktop app download instead of CLI install
- Move CLI install under headless/server mode section
- Remove early development warning
- Add feature highlights from homepage
2026-03-15 10:56:12 +07:00
Mohamed Boudra
0a3e55734b docs(skill): document --wait-timeout option and clarify wait behavior 2026-03-15 10:47:07 +07:00
Mohamed Boudra
0b88383b01 chore: remove stale e2e specs and fix-tests scripts
Delete all 34 Playwright e2e specs — they test against an outdated UI
and will be rewritten from scratch with a clean DSL-based approach.
Remove scripts/fix-tests/ overnight loop infrastructure.
2026-03-15 10:45:36 +07:00
Mohamed Boudra
a364edde17 chore: update fix-tests prompts with known issues and pre-existing failures 2026-03-15 10:41:09 +07:00
Mohamed Boudra
22c6df3f85 feat(cli): add --wait-timeout option to run command
Replace hardcoded 10-minute timeout with configurable --wait-timeout.
Default is no limit, matching the wait command behavior. Extracts
parseDuration to shared utils so both run and wait can use it.

Fixes OUTPUT_SCHEMA_FAILED errors when agents take >10 minutes.
2026-03-15 10:41:06 +07:00
Mohamed Boudra
93a3a742ac Merge branch 'quality-gate-verify-tests-standards' 2026-03-15 10:40:20 +07:00
Mohamed Boudra
0abca5e0c7 fix(server): clean up test suite — remove mocks, delete redundant tests, fix races
- Remove vi.mock() from provider-launch-config.test.ts, use dependency injection instead
- Delete codex-app-server-agent.test.ts integration infrastructure (1900 lines) —
  real codex coverage lives in daemon e2e tests (agent-basics, permissions-codex, etc.)
- Delete claude-agent-commands.test.ts — redundant with daemon e2e suite
- Delete 16-agent-update.test.ts — tested removed functionality
- Fix getAvailablePort race condition with port 0 in bootstrap.ts
- Extract large integration test blocks from unit test files into daemon e2e
- Clean up speech-runtime TTS manager test to use real dependencies
- Net -2258 lines across 17 files, all tests passing
2026-03-15 10:30:44 +07:00
Mohamed Boudra
f275474fec Serialize workspace registry persists 2026-03-14 22:48:46 +07:00
Mohamed Boudra
f12dc6127b feat: add --prompt and --prompt-file options to agent send command 2026-03-14 22:38:13 +07:00
Mohamed Boudra
e526f375c8 Merge branch 'fix-tests-suite-loop'
# Conflicts:
#	package-lock.json
#	packages/server/src/server/speech/speech-config-resolver.ts
#	packages/server/src/shared/tool-call-display.ts
2026-03-14 22:06:42 +07:00
Mohamed Boudra
3ae1102d13 feat: add agent ID/name resolution and provider/model syntax support 2026-03-14 22:04:09 +07:00
Mohamed Boudra
d1632d80e3 refactor(voice): defer speech confirmation and add diagnostics 2026-03-14 20:05:48 +07:00
Mohamed Boudra
cad4110e24 feat: add expo-two-way-audio module to workspace 2026-03-14 16:46:35 +07:00
Mohamed Boudra
fc7369384b feat(voice): implement turn-detection and real-time audio streaming 2026-03-14 13:46:10 +07:00
Mohamed Boudra
c45b414476 chore(release): cut 0.1.27 2026-03-13 21:09:06 +07:00
Mohamed Boudra
73bd0c1dba docs: add 0.1.27 changelog 2026-03-13 21:08:53 +07:00
Mohamed Boudra
502dd2d2e2 refactor(app): remove debug logging from voice and UI components 2026-03-13 21:07:18 +07:00
Mohamed Boudra
80a2f8052c refactor(app): replace voice hooks with voice runtime and audio engine architecture 2026-03-13 20:53:55 +07:00
Mohamed Boudra
a495138374 refactor: simplify connection selection with linear scan 2026-03-13 13:44:25 +07:00
Mohamed Boudra
7f69166e97 refactor: extract detail display logic into helper functions 2026-03-13 13:44:25 +07:00
Mohamed Boudra
2268790656 refactor: consolidate speech provider config resolution logic 2026-03-13 13:44:25 +07:00
Mohamed Boudra
3a02d1cdd6 refactor: extract createShortcutTarget and simplify shortcut logic 2026-03-13 13:44:25 +07:00
Mohamed Boudra
2e560677e7 fix(tests): iteration 9 2026-03-13 13:44:25 +07:00
Mohamed Boudra
b4a6b098a4 fix(tests): iteration 8 2026-03-13 13:44:25 +07:00
Mohamed Boudra
12f8c0db96 fix(tests): iteration 7 2026-03-13 13:44:25 +07:00
Mohamed Boudra
d39c95e0e7 fix(tests): iteration 6 2026-03-13 13:44:25 +07:00
Mohamed Boudra
f74031b50d fix(tests): iteration 4 2026-03-13 13:44:25 +07:00
Mohamed Boudra
8e67f5a707 fix(tests): iteration 1 2026-03-13 13:44:03 +07:00
Mohamed Boudra
a194a901b4 test: remove unimplemented worktree archival tests and unused code 2026-03-13 13:44:03 +07:00
Mohamed Boudra
049edbfcaf fix(e2e): parametrize reply line count and improve scroll behavior 2026-03-13 13:43:34 +07:00
Mohamed Boudra
7c18616160 fix(tests): iteration 20 2026-03-13 13:43:34 +07:00
Mohamed Boudra
70191ceaa8 fix(tests): iteration 19 2026-03-13 13:43:34 +07:00
Mohamed Boudra
99bac0fd27 fix(tests): iteration 18 2026-03-13 13:43:34 +07:00
Mohamed Boudra
e8b55eccab fix(tests): iteration 16 2026-03-13 13:43:34 +07:00
Mohamed Boudra
40ebd1b730 fix(tests): iteration 15 2026-03-13 13:43:34 +07:00
Mohamed Boudra
b5102e7d7e fix(tests): iteration 14 2026-03-13 13:42:51 +07:00
Mohamed Boudra
f3036ad2c9 fix(tests): iteration 13 2026-03-13 13:42:51 +07:00
Mohamed Boudra
4a01b9ec90 fix(tests): iteration 12 2026-03-13 13:42:51 +07:00
Mohamed Boudra
2f13806a2c fix(tests): iteration 11 2026-03-13 13:42:51 +07:00
Mohamed Boudra
5f5599a1a8 fix(tests): iteration 10 2026-03-13 13:42:51 +07:00
Mohamed Boudra
f2680563bd fix(tests): iteration 9 2026-03-13 13:42:51 +07:00
Mohamed Boudra
79d658243d fix(tests): iteration 8 2026-03-13 13:42:51 +07:00
Mohamed Boudra
3994fa65c0 fix(tests): iteration 7 2026-03-13 13:42:51 +07:00
Mohamed Boudra
10671cf999 fix(tests): iteration 6 2026-03-13 13:42:51 +07:00
Mohamed Boudra
40b9b4672e fix(tests): iteration 5 2026-03-13 13:42:51 +07:00
Mohamed Boudra
44fc4bfeff fix(tests): iteration 3 2026-03-13 13:42:51 +07:00
Mohamed Boudra
402f3e32ae fix(tests): iteration 2 2026-03-13 13:42:51 +07:00
Mohamed Boudra
436a595839 fix(tests): iteration 1 2026-03-13 13:42:40 +07:00
Mohamed Boudra
8a360281c6 Add test automation loop and CI secrets for test fixes 2026-03-13 13:42:08 +07:00
Mohamed Boudra
730f34753a fix(app): use platform-specific components for markdown links 2026-03-13 13:27:24 +07:00
Mohamed Boudra
013f573cff refactor: migrate to shell-env and custom desktop notifications bridge 2026-03-13 12:26:14 +07:00
Mohamed Boudra
ee8a81d2e1 feat(server): support Grep tool in Claude tool-call mapping 2026-03-13 10:32:33 +07:00
Mohamed Boudra
bed03be332 feat(app): support file:// protocol URLs for opening workspace files 2026-03-13 10:24:01 +07:00
Mohamed Boudra
debe4b543b fix(desktop): resolve linux appimage cli resources 2026-03-13 09:58:01 +07:00
Mohamed Boudra
1c601e09b6 fix(release): repair linux appimage symlinks 2026-03-13 09:38:59 +07:00
Mohamed Boudra
ba54aae206 fix(server): pin sherpa runtime deps 2026-03-13 08:48:28 +07:00
Mohamed Boudra
36961f83bf fix(release): recover desktop r8 failures 2026-03-12 23:49:07 +07:00
Mohamed Boudra
6db99c03cb feat(app): allow agents to open workspace files from chat 2026-03-12 23:43:59 +07:00
Mohamed Boudra
999d100464 fix(release): recover desktop runtime packaging 2026-03-12 23:33:50 +07:00
Mohamed Boudra
9190d86148 feat(app): improve image picker, markdown rendering, and UI interactions 2026-03-12 23:29:26 +07:00
Mohamed Boudra
4b5cb5a3e2 fix(release): recover desktop packaging artifacts 2026-03-12 23:20:26 +07:00
Mohamed Boudra
847e1d2c60 fix(app): avoid metro exclusionList breakage on windows 2026-03-12 23:06:36 +07:00
Mohamed Boudra
970df6478b fix(app): make metro blocklist regex windows-safe 2026-03-12 22:56:46 +07:00
Mohamed Boudra
3371f17606 fix(release): add manual linux appimage fallback 2026-03-12 22:55:18 +07:00
Mohamed Boudra
0f65fe894c fix(release): preload metro patch inline on windows 2026-03-12 22:52:15 +07:00
Mohamed Boudra
2ac27a3e3e fix(release): support iterative desktop recovery tags 2026-03-12 22:48:26 +07:00
Mohamed Boudra
defcc54af2 fix(release): harden desktop recovery workflows 2026-03-12 22:41:34 +07:00
Mohamed Boudra
fe84c5c9a3 Match explorer files pane sidebar background 2026-03-12 22:34:42 +07:00
Mohamed Boudra
2ff8041e15 Fix hidden sidebar workspace shortcuts 2026-03-12 22:33:27 +07:00
Mohamed Boudra
1d3e551e7f fix(release): recover desktop release builds 2026-03-12 22:26:44 +07:00
Mohamed Boudra
79795b6f49 Update outdated Paseo skill CLI options 2026-03-12 22:26:10 +07:00
Mohamed Boudra
16da3300e3 fix: prevent Codex replacement stream from being killed by stale turn_completed notification
The turn_completed notification handler called eventQueue.end(), which
could terminate a replacement stream's queue if the old interrupted
turn's completion arrived after the new stream had set up its queue.
The streamInternal loop already breaks on terminal events, making the
end() call redundant and unsafe during replaceAgentRun.
2026-03-12 22:24:41 +07:00
322 changed files with 60625 additions and 16242 deletions

View File

@@ -4,6 +4,7 @@ on:
push:
tags:
- 'v*'
- 'app-v*'
workflow_dispatch:
jobs:

View File

@@ -59,13 +59,11 @@ jobs:
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
release_tag="$source_tag"
for prefix in desktop-windows-v desktop-linux-v desktop-macos-v desktop-v; do
if [[ "$source_tag" == ${prefix}* ]]; then
release_tag="v${source_tag#${prefix}}"
break
fi
done
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[3]}"
else
release_tag="$source_tag"
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
version="${release_tag#v}"
@@ -253,13 +251,11 @@ jobs:
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
release_tag="$source_tag"
for prefix in desktop-windows-v desktop-linux-v desktop-macos-v desktop-v; do
if [[ "$source_tag" == ${prefix}* ]]; then
release_tag="v${source_tag#${prefix}}"
break
fi
done
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[3]}"
else
release_tag="$source_tag"
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
version="${release_tag#v}"
@@ -369,23 +365,87 @@ jobs:
fi
echo "RELEASE_DRAFT=$release_draft" >> "$GITHUB_ENV"
- name: Build and publish Linux Tauri release
- name: Build Linux Tauri release
if: env.IS_SMOKE_TAG != 'true'
uses: tauri-apps/tauri-action@v0
id: linux_tauri
continue-on-error: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
NO_STRIP: "true"
NO_STRIP: "1"
APPIMAGE_EXTRACT_AND_RUN: "1"
with:
projectPath: packages/desktop
tagName: ${{ env.RELEASE_TAG }}
releaseName: Paseo ${{ env.RELEASE_TAG }}
releaseBody: See the assets to download and install this version.
releaseDraft: ${{ env.RELEASE_DRAFT }}
prerelease: false
args: --bundles appimage
shell: bash
run: |
set -euo pipefail
npm run tauri --workspace=@getpaseo/desktop build -- --bundles appimage
- name: Attempt manual Linux AppImage fallback
if: env.IS_SMOKE_TAG != 'true' && steps.linux_tauri.outcome == 'failure'
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
shell: bash
run: |
set -euxo pipefail
appimage_dir="packages/desktop/src-tauri/target/release/bundle/appimage"
appdir_path="$appimage_dir/Paseo.AppDir"
canonical_appimage="$appimage_dir/Paseo_${DESKTOP_VERSION}_amd64.AppImage"
existing_appimage="$(find "$appimage_dir" -maxdepth 1 -type f -name '*.AppImage' | head -n 1)"
if [ -n "$existing_appimage" ]; then
if [ "$existing_appimage" != "$canonical_appimage" ]; then
mv "$existing_appimage" "$canonical_appimage"
fi
if [ ! -f "$canonical_appimage.sig" ]; then
npx tauri signer sign "$canonical_appimage"
fi
exit 0
fi
if [ ! -d "$appdir_path" ]; then
echo "::error::AppDir was not generated at $appdir_path"
exit 1
fi
cp --remove-destination "$appdir_path/usr/share/applications/Paseo.desktop" "$appdir_path/Paseo.desktop"
cp --remove-destination "$appdir_path/Paseo.png" "$appdir_path/.DirIcon"
env | sort | grep -E '^(APPIMAGE|DESKTOP_VERSION|NO_STRIP|RELEASE_TAG|SOURCE_TAG|TAURI_)' || true
tools_dir="$(mktemp -d)"
curl -fsSL https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -o "$tools_dir/appimagetool-x86_64.AppImage"
chmod +x "$tools_dir/appimagetool-x86_64.AppImage"
ARCH=x86_64 APPIMAGE_EXTRACT_AND_RUN=1 "$tools_dir/appimagetool-x86_64.AppImage" "$appdir_path" "$canonical_appimage"
npx tauri signer sign "$canonical_appimage"
- name: Fail Linux release when AppImage bundling fails
if: env.IS_SMOKE_TAG != 'true' && steps.linux_tauri.outcome == 'failure'
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
assets=(
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage.sig
)
if [ "${#assets[@]}" -eq 0 ]; then
echo "::error::Linux AppImage assets are still missing after the manual fallback."
exit 1
fi
- name: Upload Linux release assets
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
assets=(
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage
packages/desktop/src-tauri/target/release/bundle/appimage/*.AppImage.sig
)
if [ "${#assets[@]}" -eq 0 ]; then
echo "::error::No Linux AppImage assets were produced."
exit 1
fi
printf 'Uploading Linux assets:\n%s\n' "${assets[@]}"
gh release upload "$RELEASE_TAG" "${assets[@]}" --repo "${{ github.repository }}" --clobber
- name: Build Linux app (smoke only)
if: env.IS_SMOKE_TAG == 'true'
@@ -409,13 +469,11 @@ jobs:
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
release_tag="$source_tag"
for prefix in desktop-windows-v desktop-linux-v desktop-macos-v desktop-v; do
if [[ "$source_tag" == ${prefix}* ]]; then
release_tag="v${source_tag#${prefix}}"
break
fi
done
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[3]}"
else
release_tag="$source_tag"
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
version="${release_tag#v}"
@@ -485,7 +543,11 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build web app for Tauri
run: npm run build:web --workspace=@getpaseo/app
shell: pwsh
run: |
$patchPath = (Get-Item "$env:GITHUB_WORKSPACE/scripts/metro-config-windows-loader-patch.cjs").FullName
$env:NODE_OPTIONS = "--require=$patchPath"
npm run build:web --workspace=@getpaseo/app
- name: Build managed runtime
run: npm run prepare:managed-runtime --workspace=@getpaseo/desktop

View File

@@ -44,3 +44,6 @@ jobs:
- name: Test
run: npm run test --workspace=@getpaseo/server
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

View File

@@ -1,5 +1,36 @@
# Changelog
## 0.1.28 - 2026-03-15
### Added
- Added OpenCode build and plan modes.
- Added website landing pages for Claude Code, Codex, and OpenCode.
### Improved
- Improved the git action menu for more reliable repository actions.
- Improved the mobile settings screen, workspace header actions, and welcome screen presentation.
- Updated the website hero copy and added a sponsor callout section.
### Fixed
- Fixed assistant file links so they open the correct workspace files from chat.
## 0.1.27 - 2026-03-13
### Added
- Added voice runtime with new audio engine architecture for voice interactions.
- Added Grep tool support in Claude tool-call mapping.
- Added ability to open workspace files directly from agent chat messages.
- Added desktop notifications via a custom native bridge.
### Improved
- Improved image picker, markdown rendering, and UI interactions.
- Improved shell environment detection using shell-env.
### Fixed
- Fixed platform-specific markdown link rendering.
- Fixed Linux AppImage CLI resource paths.
- Fixed Codex replacement stream being killed by stale turn notifications.
## 0.1.26 - 2026-03-12
### Added

View File

@@ -4,7 +4,7 @@
<h1 align="center">Paseo</h1>
<p align="center">Manage coding agents from your phone and desktop.</p>
<p align="center">One interface for all your coding agents.</p>
<p align="center">
<img src="https://paseo.sh/paseo-mockup.png" alt="Paseo app screenshot" width="100%">
@@ -12,19 +12,27 @@
---
> [!WARNING]
> **Early development** — Features may break or change without notice. Use at your own risk.
Run agents in parallel on your own machines. Ship from your phone or your desk.
Paseo is a self-hosted daemon for Claude Code, Codex, and OpenCode. Agents run on your machine with your full dev environment. Connect from phone, desktop, or web.
- **Self-hosted** — Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
- **Multi-provider** — Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
- **Voice control** — Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
- **Cross-device** — iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
## Getting Started
Download the desktop app from [paseo.sh](https://paseo.sh) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases) — it bundles the daemon so there's nothing else to install.
### Headless / server mode
To run the daemon on a remote or headless machine:
```bash
npm install -g @getpaseo/cli
paseo
```
Then open the app and connect to your daemon.
Then connect from the desktop app, mobile app, or CLI.
For full setup and configuration, see:
- [Docs](https://paseo.sh/docs)
@@ -57,4 +65,4 @@ npm run typecheck
## License
MIT
AGPL-3.0

View File

@@ -20,7 +20,7 @@ From repo root:
```bash
npm run android:development # Debug build
npm run android:production # Release build
npm run android:clean # Clean native project
npm run android:clear # Remove generated Android project
```
Or from `packages/app`:
@@ -34,8 +34,8 @@ APP_VARIANT=development npx expo run:android --variant=debug
APP_VARIANT=production npx expo prebuild --platform android --non-interactive
APP_VARIANT=production npx expo run:android --variant=release
# Clean
npx expo prebuild --platform android --clean --non-interactive
# Clear generated Android project
rm -rf android
```
## Screenshots

4272
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,9 @@
{
"name": "paseo",
"version": "0.1.26",
"version": "0.1.28",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
"packages/server",
"packages/app",
"packages/relay",
@@ -28,6 +29,7 @@
"android:development": "npm run android:development --workspace=@getpaseo/app",
"android:production": "npm run android:production --workspace=@getpaseo/app",
"android:release": "npm run android:production --workspace=@getpaseo/app",
"android:clear": "npm run android:clear --workspace=@getpaseo/app",
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
"ios": "npm run ios --workspace=@getpaseo/app",
"web": "npm run web --workspace=@getpaseo/app",
@@ -59,6 +61,8 @@
"get-port-cli": "^3.0.0",
"knip": "^5.82.1",
"patch-package": "^8.0.1",
"react": "19.1.4",
"react-dom": "19.1.4",
"typescript": "^5.9.3"
},
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",

View File

@@ -100,6 +100,9 @@ export default {
output: "single",
favicon: "./assets/images/favicon.png",
},
autolinking: {
searchPaths: ["../../node_modules", "./node_modules"],
},
plugins: [
"expo-router",
[
@@ -143,6 +146,7 @@ export default {
experiments: {
typedRoutes: true,
reactCompiler: true,
autolinkingModuleResolution: true,
},
extra: {
router: {},

Binary file not shown.

View File

@@ -1,183 +0,0 @@
import { test, expect, type Page } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import {
connectDaemonClient,
createReplyTurn,
expectDetachedFromBottom,
expectNearBottom,
getChatContainerKey,
readScrollMetrics,
scrollUpFromBottom,
seedBottomAnchorAgent,
waitForAgentReady,
waitForContentGrowth,
} from "./helpers/agent-bottom-anchor";
test.describe.configure({ timeout: 180000 });
async function openWorkspaceAgentTab(page: Page, agentId: string) {
const tab = page.getByTestId(`workspace-tab-agent_${agentId}`).first();
await expect(tab).toBeVisible({ timeout: 30000 });
await tab.click();
}
test("direct load and refresh land at the bottom for history-backed chats", async ({
page,
}) => {
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-direct-");
const client = await connectDaemonClient();
try {
const agent = await seedBottomAnchorAgent({
client,
cwd: repo.path,
title: `bottom-anchor-direct-${Date.now()}`,
turnCount: 4,
});
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
await openWorkspaceAgentTab(page, agent.id);
await waitForAgentReady(page, agent.expectedTailText);
await expectNearBottom(page);
await page.reload({ waitUntil: "commit" });
await openWorkspaceAgentTab(page, agent.id);
await waitForAgentReady(page, agent.expectedTailText);
await expectNearBottom(page);
} finally {
await client.close().catch(() => undefined);
await repo.cleanup();
}
});
test("revisiting a loaded chat restores bottom anchoring", async ({
page,
}) => {
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-switch-");
const client = await connectDaemonClient();
try {
const agent = await seedBottomAnchorAgent({
client,
cwd: repo.path,
title: `bottom-anchor-switch-${Date.now()}`,
turnCount: 4,
});
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
await openWorkspaceAgentTab(page, agent.id);
await waitForAgentReady(page, agent.expectedTailText);
await expectNearBottom(page);
await page.getByTestId("sidebar-new-agent").first().click();
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
timeout: 30000,
});
await openWorkspaceAgentTab(page, agent.id);
await waitForAgentReady(page, agent.expectedTailText);
await expectNearBottom(page);
} finally {
await client.close().catch(() => undefined);
await repo.cleanup();
}
});
test("sticky mode stays pinned through composer growth and viewport resize, but detached mode does not fight streamed updates", async ({
page,
}) => {
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-sticky-");
const client = await connectDaemonClient();
try {
const agent = await seedBottomAnchorAgent({
client,
cwd: repo.path,
title: `bottom-anchor-sticky-${Date.now()}`,
turnCount: 10,
});
await page.setViewportSize({ width: 1320, height: 920 });
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
await openWorkspaceAgentTab(page, agent.id);
await waitForAgentReady(page, agent.expectedTailText);
await expectNearBottom(page);
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
await composer.click();
for (let index = 0; index < 6; index += 1) {
await composer.pressSequentially(`composer growth line ${index + 1}`);
if (index < 5) {
await page.keyboard.press("Shift+Enter");
}
}
await expectNearBottom(page);
await expect(page.getByTestId("scroll-to-bottom-button")).toHaveCount(0);
await page.setViewportSize({ width: 820, height: 760 });
await expectNearBottom(page);
await scrollUpFromBottom(page, 720);
await expectDetachedFromBottom(page);
const beforeExternalUpdate = await readScrollMetrics(page);
const externalTurn = createReplyTurn(`external-stream-${Date.now()}`);
await client.sendAgentMessage(agent.id, externalTurn.message);
await waitForContentGrowth(page, beforeExternalUpdate.contentHeight);
const finish = await client.waitForFinish(agent.id, 120000);
expect(finish.status).toBe("idle");
await expectDetachedFromBottom(page);
} finally {
await client.close().catch(() => undefined);
await repo.cleanup();
}
});
test("web partial virtualization keeps bottom anchoring stable across direct load, refresh, and resize", async ({
page,
}) => {
await page.addInitScript(() => {
(window as typeof window & {
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: number;
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: number;
}).__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 6;
(window as typeof window & {
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: number;
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: number;
}).__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = 4;
});
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-virtualized-");
const client = await connectDaemonClient();
try {
const agent = await seedBottomAnchorAgent({
client,
cwd: repo.path,
title: `bottom-anchor-virtualized-${Date.now()}`,
turnCount: 4,
});
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
await openWorkspaceAgentTab(page, agent.id);
await waitForAgentReady(page, agent.expectedTailText);
await expect
.poll(async () => await getChatContainerKey(page))
.toBe("web-partial-virtualized");
await expectNearBottom(page);
await page.reload({ waitUntil: "commit" });
await openWorkspaceAgentTab(page, agent.id);
await waitForAgentReady(page, agent.expectedTailText);
await expect
.poll(async () => await getChatContainerKey(page))
.toBe("web-partial-virtualized");
await expectNearBottom(page);
await page.setViewportSize({ width: 780, height: 720 });
await expectNearBottom(page);
} finally {
await client.close().catch(() => undefined);
await repo.cleanup();
}
});

View File

@@ -1,35 +0,0 @@
import { test, expect } from "./fixtures";
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from "./helpers/app";
import { createTempGitRepo } from "./helpers/workspace";
test("agent details sheet shows IDs and copy toast", async ({ page }) => {
test.setTimeout(120_000);
const repo = await createTempGitRepo();
const prompt = "Respond with exactly: Hello";
try {
await gotoHome(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, prompt);
await page.getByTestId("agent-overflow-menu").click();
await expect(page.getByTestId("agent-details-sheet")).toBeVisible();
await expect(page.getByTestId("agent-details-agent-id")).toBeVisible();
await expect(page.getByTestId("agent-details-agent-id-value")).not.toHaveText(
"Not available"
);
await expect(page.getByTestId("agent-details-persistence-session-id")).toBeVisible();
await expect(
page.getByTestId("agent-details-persistence-session-id-value")
).not.toHaveText("Not available", { timeout: 90_000 });
await page.getByTestId("agent-details-agent-id").click();
await expect(page.getByTestId("app-toast")).toBeVisible();
await expect(page.getByTestId("app-toast-message")).toHaveText(/copied/i);
} finally {
await repo.cleanup();
}
});

View File

@@ -1,197 +0,0 @@
import { expect, test, type Page } from "@playwright/test";
const SERVER_ID =
process.env.PLAYWRIGHT_REPRO_SERVER_ID ?? "srv_ETXtcjYRGrCI";
const AGENT_ID =
process.env.PLAYWRIGHT_REPRO_AGENT_ID ??
"3533e6c3-c0b3-4310-b85c-eb07cbb501a0";
const APP_BASE_URL = process.env.PLAYWRIGHT_REPRO_BASE_URL ?? "http://localhost:8081";
const DAEMON_ENDPOINT =
process.env.PLAYWRIGHT_REPRO_DAEMON_ENDPOINT ?? "127.0.0.1:6767";
const SUBMIT_TEXT = process.env.PLAYWRIGHT_REPRO_MESSAGE ?? "hello";
const AGENT_URL = `${APP_BASE_URL}/h/${SERVER_ID}/agent/${AGENT_ID}`;
const NEAR_BOTTOM_THRESHOLD_PX = 64;
type ScrollMetrics = {
offsetY: number;
contentHeight: number;
viewportHeight: number;
distanceFromBottom: number;
};
test.use({ browserName: "firefox" });
function seedDaemonRegistryScript(params: {
serverId: string;
endpoint: string;
nowIso: string;
}) {
const daemon = {
serverId: params.serverId,
label: "localhost",
connections: [
{
id: `direct:${params.endpoint}`,
type: "direct",
endpoint: params.endpoint,
},
],
preferredConnectionId: `direct:${params.endpoint}`,
createdAt: params.nowIso,
updatedAt: params.nowIso,
};
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.setItem(
"@paseo:create-agent-preferences",
JSON.stringify({
serverId: params.serverId,
provider: "codex",
providerPreferences: {
claude: { model: "haiku" },
codex: { model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
},
})
);
}
async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
return page.getByTestId("agent-chat-scroll").evaluate((root: Element) => {
const rootElement = root as HTMLElement;
const candidates = [rootElement, ...Array.from(rootElement.querySelectorAll("*"))];
const scrollElement =
candidates.find(
(element) =>
element instanceof HTMLElement &&
element.scrollHeight - element.clientHeight > 1
) ?? rootElement;
const offsetY = Math.max(0, scrollElement.scrollTop);
const contentHeight = Math.max(0, scrollElement.scrollHeight);
const viewportHeight = Math.max(0, scrollElement.clientHeight);
const distanceFromBottom = Math.max(
0,
contentHeight - (offsetY + viewportHeight)
);
return {
offsetY,
contentHeight,
viewportHeight,
distanceFromBottom,
};
});
}
async function scrollUpFromBottom(
page: Page,
pixels: number
): Promise<void> {
await page.getByTestId("agent-chat-scroll").evaluate(
(root: Element, amount: number) => {
const rootElement = root as HTMLElement;
const candidates = [
rootElement,
...Array.from(rootElement.querySelectorAll("*")),
];
const scrollElement =
candidates.find(
(element) =>
element instanceof HTMLElement &&
element.scrollHeight - element.clientHeight > 1
) ?? rootElement;
const bottomOffset = Math.max(
0,
scrollElement.scrollHeight - scrollElement.clientHeight
);
scrollElement.scrollTop = Math.max(0, bottomOffset - amount);
},
pixels
);
}
test("repro: submit while scrolled up should stay anchored to bottom (Firefox)", async ({
page,
}, testInfo) => {
const userAgent = await page.evaluate(() => navigator.userAgent);
expect(userAgent).toContain("Firefox");
await page.addInitScript(seedDaemonRegistryScript, {
serverId: SERVER_ID,
endpoint: DAEMON_ENDPOINT,
nowIso: new Date().toISOString(),
});
await page.goto(AGENT_URL, { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("agent-chat-scroll")).toBeVisible({
timeout: 60_000,
});
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible({
timeout: 60_000,
});
// Require enough history so the repro actually scrolls away from bottom.
await expect
.poll(async () => {
const metrics = await readScrollMetrics(page);
return Math.max(0, metrics.contentHeight - metrics.viewportHeight);
})
.toBeGreaterThan(300);
await scrollUpFromBottom(page, 900);
await page.waitForTimeout(250);
const beforeSubmit = await readScrollMetrics(page);
const beforePath = testInfo.outputPath("before-submit.png");
await page.screenshot({ path: beforePath, fullPage: true });
await testInfo.attach("before-submit", {
path: beforePath,
contentType: "image/png",
});
await page.getByRole("textbox", { name: "Message agent..." }).fill(SUBMIT_TEXT);
await page.getByRole("textbox", { name: "Message agent..." }).press("Enter");
await page.waitForTimeout(1200);
const afterSubmit = await readScrollMetrics(page);
const afterPath = testInfo.outputPath("after-submit.png");
await page.screenshot({ path: afterPath, fullPage: true });
await testInfo.attach("after-submit", {
path: afterPath,
contentType: "image/png",
});
await testInfo.attach("firefox-scroll-metrics", {
body: JSON.stringify(
{
url: AGENT_URL,
submitText: SUBMIT_TEXT,
thresholdPx: NEAR_BOTTOM_THRESHOLD_PX,
beforeScreenshot: beforePath,
afterScreenshot: afterPath,
beforeSubmit,
afterSubmit,
},
null,
2
),
contentType: "application/json",
});
console.log(
`[firefox-scroll-repro] ${JSON.stringify(
{
submitText: SUBMIT_TEXT,
beforeSubmit,
afterSubmit,
},
null,
2
)}`
);
expect(beforeSubmit.distanceFromBottom).toBeGreaterThan(NEAR_BOTTOM_THRESHOLD_PX);
expect(afterSubmit.distanceFromBottom).toBeLessThanOrEqual(
NEAR_BOTTOM_THRESHOLD_PX
);
});

View File

@@ -1,27 +0,0 @@
import { test, expect } from './fixtures';
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
test('agent timeline hydrates after reload via fetch_agent_timeline_request', async ({ page }) => {
const repo = await createTempGitRepo();
const prompt = 'Respond with exactly: TIMELINE_HYDRATION_OK';
try {
await gotoHome(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, prompt);
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({
timeout: 30000,
});
await page.reload({ waitUntil: 'commit' });
await expect(page).toHaveURL(/\/agent\//);
await expect(page.getByTestId('agent-loading')).toHaveCount(0, { timeout: 30000 });
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({
timeout: 30000,
});
} finally {
await repo.cleanup();
}
});

View File

@@ -1,440 +0,0 @@
import path from 'node:path';
import { appendFile, mkdtemp, rm, writeFile, realpath } from 'node:fs/promises';
import { execSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { test, expect, type Page } from './fixtures';
import {
createAgent,
ensureHostSelected,
gotoHome,
setWorkingDirectory,
} from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
test.describe.configure({ mode: 'serial', timeout: 120000 });
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function getChangesScope(page: Page) {
return page.locator('[data-testid="explorer-content-area"]:visible').first();
}
function getChangesHeader(page: Page) {
return getChangesScope(page).getByTestId('changes-header');
}
async function ensureExplorerTabsVisible(page: Page) {
const changesTab = page.getByTestId('explorer-tab-changes').first();
if (await changesTab.isVisible().catch(() => false)) {
return;
}
const toggle = page
.getByRole('button', { name: /open explorer|close explorer|toggle explorer/i })
.first();
await expect(toggle).toBeVisible({ timeout: 10000 });
for (let attempt = 0; attempt < 4; attempt += 1) {
if (await changesTab.isVisible().catch(() => false)) {
return;
}
await toggle.click();
await page.waitForTimeout(200);
}
await expect(changesTab).toBeVisible({ timeout: 30000 });
}
async function selectChangesView(page: Page, view: 'working' | 'base') {
// Defensive: close any open dropdown menus (their backdrops intercept clicks).
const primaryBackdrop = page.getByTestId('changes-primary-cta-menu-backdrop');
if (await primaryBackdrop.isVisible().catch(() => false)) {
await primaryBackdrop.click({ force: true });
await expect(primaryBackdrop).toHaveCount(0);
}
const overflowBackdrop = page.getByTestId('changes-overflow-content-backdrop');
if (await overflowBackdrop.isVisible().catch(() => false)) {
await overflowBackdrop.click({ force: true });
await expect(overflowBackdrop).toHaveCount(0);
}
const diffModeBackdrop = page.getByTestId('changes-diff-status-menu-backdrop');
if (await diffModeBackdrop.isVisible().catch(() => false)) {
await diffModeBackdrop.click({ force: true });
await expect(diffModeBackdrop).toHaveCount(0);
}
const scope = getChangesScope(page);
const modeToggle = scope.getByTestId('changes-diff-status').first();
const expected = view === 'working' ? 'Uncommitted' : 'Committed';
if (!(await modeToggle.isVisible().catch(() => false))) {
return;
}
const current = ((await modeToggle.innerText().catch(() => '')) ?? '').trim();
if (current !== expected) {
await modeToggle.click();
const menu = page.getByTestId('changes-diff-status-menu');
await expect(menu).toBeVisible({ timeout: 10000 });
const optionTestId =
view === 'working' ? 'changes-diff-mode-uncommitted' : 'changes-diff-mode-committed';
await page.getByTestId(optionTestId).click({ force: true });
}
await expect(modeToggle).toContainText(expected, { timeout: 10000 });
}
async function openChangesPrimaryMenu(page: Page) {
const scope = getChangesScope(page);
const caret = scope.getByTestId('changes-primary-cta-caret').first();
await expect(caret).toBeVisible();
await caret.click();
// Menu content is rendered via a portal, so don't scope it to the explorer content area.
await expect(page.getByTestId('changes-primary-cta-menu')).toBeVisible();
}
async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
await ensureExplorerTabsVisible(page);
const changesHeader = getChangesHeader(page);
if (!(await changesHeader.isVisible())) {
await page.getByTestId('explorer-tab-changes').first().click();
}
await expect(changesHeader).toBeVisible({ timeout: 30000 });
if (options?.expectGit === false) {
return;
}
const changesScope = getChangesScope(page);
await expect(changesScope.getByTestId('changes-not-git')).toHaveCount(0, {
timeout: 30000,
});
await expect(changesScope.getByTestId('changes-branch')).not.toHaveText('Not a git repository', {
timeout: 30000,
});
}
async function waitForAgentTurnToSettle(page: Page, timeout = 90000) {
const stopButton = page.getByRole('button', { name: /stop agent|stop/i }).first();
if (!(await stopButton.isVisible().catch(() => false))) {
return;
}
await expect(stopButton).not.toBeVisible({ timeout });
}
async function createAgentAndWait(page: Page, message: string) {
await createAgent(page, message);
}
async function selectAttachWorktree(page: Page, branchName: string) {
const trigger = page.getByTestId('worktree-select-trigger').first();
await expect(trigger).toBeVisible({ timeout: 30000 });
await trigger.click();
const menu = page.getByTestId('combobox-desktop-container').first();
await expect(menu).toBeVisible({ timeout: 10000 });
const searchInput = page.getByRole('textbox', { name: /search worktrees/i }).first();
if (await searchInput.isVisible().catch(() => false)) {
await searchInput.fill(branchName);
}
const preferredOption = menu
.getByText(new RegExp(`^${escapeRegex(branchName)}$`, 'i'))
.first();
await expect(preferredOption).toBeVisible({ timeout: 10000 });
await preferredOption.click({ force: true });
await expect(menu).toHaveCount(0);
await expect(trigger).toContainText(branchName, { timeout: 30000 });
}
async function enableCreateWorktree(page: Page) {
const trigger = page.getByTestId('worktree-select-trigger').first();
await expect(trigger).toBeVisible({ timeout: 30000 });
const currentValue = ((await trigger.innerText().catch(() => '')) ?? '').trim();
if (/Create new worktree/i.test(currentValue)) {
await expect(page.getByTestId('worktree-base-branch-trigger')).toBeVisible({
timeout: 30000,
});
return;
}
await trigger.click();
const menu = page.getByTestId('combobox-desktop-container').first();
await expect(menu).toBeVisible({ timeout: 10000 });
const createOption = menu.getByText('Create new worktree', { exact: true }).first();
await expect(createOption).toBeVisible({ timeout: 10000 });
await createOption.click({ force: true });
await expect(menu).toHaveCount(0);
await expect(trigger).toContainText('Create new worktree', { timeout: 30000 });
await expect(page.getByTestId('worktree-base-branch-trigger')).toBeVisible({
timeout: 30000,
});
}
async function refreshUncommittedMode(page: Page) {
await selectChangesView(page, 'base');
await selectChangesView(page, 'working');
}
async function refreshChangesTab(page: Page) {
await ensureExplorerTabsVisible(page);
await page.getByTestId('explorer-tab-files').first().click();
await page.getByTestId('explorer-tab-changes').first().click();
}
function normalizeTmpPath(value: string) {
if (value.startsWith('/var/')) {
return `/private${value}`;
}
return value;
}
type GitWorktreeEntry = {
worktreePath: string;
branchRef: string | null;
};
function parseGitWorktreeList(raw: string): GitWorktreeEntry[] {
const blocks = raw
.split(/\n\s*\n/g)
.map((block) => block.trim())
.filter((block) => block.length > 0);
const entries: GitWorktreeEntry[] = [];
for (const block of blocks) {
const worktreeMatch = block.match(/^worktree (.+)$/m);
if (!worktreeMatch) {
continue;
}
const branchMatch = block.match(/^branch (.+)$/m);
entries.push({
worktreePath: worktreeMatch[1].trim(),
branchRef: branchMatch ? branchMatch[1].trim() : null,
});
}
return entries;
}
async function waitForCreatedWorktree(repoPath: string, timeoutMs = 30000) {
const deadline = Date.now() + timeoutMs;
const normalizedRepoPath = normalizeTmpPath(repoPath);
while (Date.now() < deadline) {
try {
const output = execSync('git worktree list --porcelain', {
cwd: repoPath,
encoding: 'utf8',
});
const entries = parseGitWorktreeList(output);
const candidate = entries.find((entry) => {
const normalizedWorktreePath = normalizeTmpPath(entry.worktreePath);
if (normalizedWorktreePath === normalizedRepoPath) {
return false;
}
if (!entry.branchRef) {
return false;
}
return !/\/main$/i.test(entry.branchRef);
});
if (candidate) {
const branchName = candidate.branchRef?.split('/').filter(Boolean).pop();
if (branchName) {
return {
worktreePath: candidate.worktreePath,
branchName,
};
}
}
} catch {
// Ignore transient git worktree read errors while polling.
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for a non-main worktree under ${repoPath}`);
}
test('checkout-first Changes panel ship loop', async ({ page }) => {
const repo = await createTempGitRepo('paseo-e2e-', { withRemote: true });
const nonGitDir = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-non-git-'));
try {
await gotoHome(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await enableCreateWorktree(page);
await createAgentAndWait(page, 'Respond with exactly: READY');
await waitForAgentTurnToSettle(page);
await openChangesPanel(page);
const branchLabelLocator = getChangesScope(page).getByTestId('changes-branch');
await expect
.poll(async () => (await branchLabelLocator.innerText()).trim(), { timeout: 30000 })
.not.toBe('Unknown');
const branchNameFromUi = (await branchLabelLocator.innerText()).trim();
expect(branchNameFromUi.length).toBeGreaterThan(0);
const { worktreePath: firstCwd, branchName: worktreeBranch } = await waitForCreatedWorktree(
repo.path
);
expect(worktreeBranch.length).toBeGreaterThan(0);
const [resolvedCwd, resolvedRepo] = await Promise.all([
realpath(firstCwd).catch(() => firstCwd),
realpath(repo.path).catch(() => repo.path),
]);
const normalizedRepo = normalizeTmpPath(resolvedRepo);
const normalizedCwd = normalizeTmpPath(resolvedCwd);
const expectedMarker = `${path.sep}worktrees${path.sep}`;
expect(normalizedCwd.includes(expectedMarker)).toBeTruthy();
await page.getByTestId('sidebar-new-agent').click();
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await selectAttachWorktree(page, worktreeBranch);
await createAgentAndWait(page, 'Respond with exactly: READY2');
await waitForAgentTurnToSettle(page);
await openChangesPanel(page);
const readmePath = path.join(firstCwd, 'README.md');
await appendFile(readmePath, '\nFirst change\n');
await refreshUncommittedMode(page);
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
timeout: 30000,
});
await getChangesScope(page).getByTestId('diff-file-0-toggle').first().click();
await expect(page.getByText('First change')).toBeVisible();
const primaryCta = getChangesScope(page).getByTestId('changes-primary-cta').first();
await expect(primaryCta).toBeVisible();
await expect(primaryCta).toContainText('Commit');
await primaryCta.click();
await expect
.poll(() => {
try {
return execSync('git status --porcelain', {
cwd: firstCwd,
encoding: 'utf8',
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
}).trim();
} catch {
return null;
}
}, { timeout: 30000 })
.toBe('');
await openChangesPanel(page);
await selectChangesView(page, 'working');
await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({
timeout: 30000,
});
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).not.toContainText('Commit');
await selectChangesView(page, 'base');
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
timeout: 30000,
});
// Push once from the menu so the branch has an origin/<branch> ref.
await openChangesPrimaryMenu(page);
await page.getByTestId('changes-menu-push').click();
await expect
.poll(() => {
try {
execSync(`git show-ref --verify --quiet refs/remotes/origin/${worktreeBranch}`, { cwd: firstCwd });
return true;
} catch {
return false;
}
}, { timeout: 30000 })
.toBe(true);
const notesPath = path.join(firstCwd, 'notes.txt');
await writeFile(notesPath, 'Second change\n');
await refreshUncommittedMode(page);
await refreshChangesTab(page);
await expect(getChangesScope(page).getByText('notes.txt', { exact: true })).toBeVisible({
timeout: 30000,
});
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toHaveCount(0);
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toContainText('Commit');
await selectChangesView(page, 'base');
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
timeout: 30000,
});
await getChangesScope(page).getByTestId('changes-primary-cta').click();
await expect
.poll(() => {
try {
return execSync('git status --porcelain', {
cwd: firstCwd,
encoding: 'utf8',
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
}).trim();
} catch {
return null;
}
}, { timeout: 30000 })
.toBe('');
await openChangesPanel(page);
await selectChangesView(page, 'working');
await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({ timeout: 30000 });
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).not.toContainText('Commit');
await selectChangesView(page, 'base');
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
timeout: 30000,
});
await expect(getChangesScope(page).getByText('notes.txt', { exact: true })).toBeVisible({
timeout: 30000,
});
// Push is now the primary action (origin/<branch> exists and we're ahead of it).
const pushPrimary = getChangesScope(page).getByTestId('changes-primary-cta').first();
await expect(pushPrimary).toContainText(/push/i, { timeout: 30000 });
await pushPrimary.click();
// Regression check: the primary CTA stays in place while pushing.
await expect(pushPrimary).toBeVisible();
await page.waitForTimeout(50);
await expect(pushPrimary).toBeVisible();
await expect
.poll(() => {
try {
const count = execSync(
`git rev-list --count origin/${worktreeBranch}..${worktreeBranch}`,
{ cwd: firstCwd, encoding: 'utf8' }
).trim();
return Number.parseInt(count, 10);
} catch {
return null;
}
}, { timeout: 30000 })
.toBe(0);
// Merge to base in the main worktree (worktree branches can't always check out base refs in-place).
// This avoids UI flakiness around ship actions while still validating the diff panel end-to-end.
execSync("git checkout main", { cwd: repo.path });
execSync(`git -c commit.gpgsign=false merge --no-edit ${worktreeBranch}`, { cwd: repo.path });
execSync("git push", { cwd: repo.path });
await selectChangesView(page, 'base');
await expect(getChangesScope(page).getByTestId('changes-diff-status')).toContainText(
'Committed',
{ timeout: 30000 }
);
await refreshChangesTab(page);
// Post-ship UI behavior is implementation-dependent (archive can be promoted into
// primary flow or hidden behind menu variants), so continue from a fresh draft.
await page.getByTestId('sidebar-new-agent').click();
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(?:\?|$)/, { timeout: 30000 });
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
} finally {
await rm(nonGitDir, { recursive: true, force: true });
await repo.cleanup();
}
});

View File

@@ -1,27 +0,0 @@
import { test, expect } from './fixtures';
import { createAgentInRepo } from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
test('create agent in a temp repo', async ({ page }) => {
const repo = await createTempGitRepo();
const prompt = "Respond with exactly: Hello";
try {
await createAgentInRepo(page, { directory: repo.path, prompt });
// Verify user message is shown in the stream
await expect(page.getByText(prompt, { exact: true })).toBeVisible();
// Verify we used the seeded fast model (do not fall back to other defaults).
const modelPicker = page.getByRole("button", { name: /select agent model/i }).first();
await expect(modelPicker).toBeVisible({ timeout: 30000 });
await expect(modelPicker).toContainText(/gpt-5\.1-codex-mini/i);
// Verify the assistant response is rendered.
await expect(page.getByText("Hello", { exact: true }).first()).toBeVisible({
timeout: 30000,
});
} finally {
await repo.cleanup();
}
});

View File

@@ -1,19 +0,0 @@
import { test, expect } from './fixtures';
import { gotoHome, openSettings } from './helpers/app';
test('daemon is connected in settings', async ({ page }) => {
const daemonPort = process.env.E2E_DAEMON_PORT;
const serverId = process.env.E2E_SERVER_ID;
if (!daemonPort) {
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
}
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
}
await gotoHome(page);
await openSettings(page);
await expect(page.getByText(`127.0.0.1:${daemonPort}`)).toBeVisible();
await expect(page.getByTestId(`daemon-card-${serverId}`).getByText('Online', { exact: true })).toBeVisible();
});

View File

@@ -1,64 +0,0 @@
import { test, expect } from './fixtures';
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
test('deleting an agent persists after reload', async ({ page }) => {
const repo = await createTempGitRepo();
const nonce = Math.random().toString(36).slice(2, 10);
const prompt = `respond-ready-${nonce}`;
try {
await gotoHome(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
// Create agent (via message input) so it shows up in the sidebar list.
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable();
await input.fill(prompt);
await input.press('Enter');
await expect(page).toHaveURL(/\/h\/[^/]+\/agent\/[^/?#]+(?:\?|$)/, {
timeout: 30000,
});
// Wait for the initial turn to complete so the agent can be archived (web uses a hover action).
const stopOrCancel = page.getByRole('button', { name: /Stop agent|Canceling agent/ });
await stopOrCancel.first().waitFor({ state: 'visible', timeout: 30000 }).catch(() => undefined);
await expect(stopOrCancel).toHaveCount(0, { timeout: 120000 });
const match =
page.url().match(/\/h\/([^/]+)\/agent\/([^/?#]+)/) ??
page.url().match(/\/agent\/([^/]+)\/([^/?#]+)/);
if (!match) {
throw new Error(`Expected /h/:serverId/agent/:agentId URL, got ${page.url()}`);
}
const serverId = decodeURIComponent(match[1]);
const agentId = decodeURIComponent(match[2]);
// Return home and delete via long-press in the agent list.
await gotoHome(page);
const rowTestId = `agent-row-${serverId}-${agentId}`;
const agentRow = page.getByTestId(rowTestId).first();
await expect(agentRow).toBeVisible({ timeout: 30000 });
// Web UX: hover shows a quick-archive icon. First click enters confirm state; second click archives.
await agentRow.hover();
const quickArchive = page.getByTestId(`agent-archive-${serverId}-${agentId}`).first();
await expect(quickArchive).toBeVisible({ timeout: 10000 });
await quickArchive.click({ force: true });
const confirmArchive = page
.getByTestId(`agent-archive-confirm-${serverId}-${agentId}`)
.first();
await expect(confirmArchive).toBeVisible({ timeout: 10000 });
await confirmArchive.click({ force: true });
// Ensure deletion finished before reload (avoids races).
await expect(page.getByTestId(rowTestId)).toHaveCount(0, { timeout: 30000 });
// A full reload should not bring the agent back.
await page.reload();
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeVisible();
await expect(page.getByTestId(rowTestId)).toHaveCount(0, { timeout: 30000 });
} finally {
await repo.cleanup();
}
});

View File

@@ -1,342 +0,0 @@
import { test, expect } from './fixtures';
import {
createAgent,
createAgentInRepo,
ensureHostSelected,
gotoHome,
openSettings,
setWorkingDirectory,
} from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
import type { Page } from '@playwright/test';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import {
buildHostWorkspaceAgentRoute,
buildHostWorkspaceRoute,
} from '@/utils/host-routes';
import {
ensureWorkspaceAgentPaneVisible,
waitForWorkspaceTabsVisible,
} from './helpers/workspace-tabs';
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
async function addFakeMicrophone(page: Page) {
const fixturePath = path.resolve(__dirname, 'fixtures', 'recording.wav');
const base64Audio = (await readFile(fixturePath)).toString('base64');
const mimeType = 'audio/wav';
return page.addInitScript(({ base64Audio, mimeType }) => {
const mic = {
active: 0,
getUserMediaCalls: 0,
stopCalls: 0,
lastRecorder: null as null | { state: string },
};
(window as any).__mic = mic;
(window as any).isSecureContext = true;
const nav = navigator as any;
if (!nav.mediaDevices) {
nav.mediaDevices = {};
}
nav.mediaDevices.getUserMedia = async () => {
mic.getUserMediaCalls += 1;
mic.active += 1;
const track = {
stop: () => {
mic.stopCalls += 1;
mic.active = Math.max(0, mic.active - 1);
},
};
return {
getTracks: () => [track],
};
};
const AudioContextCtor =
(window as any).AudioContext ?? (window as any).webkitAudioContext;
if (AudioContextCtor?.prototype?.createMediaStreamSource) {
const nativeCreateMediaStreamSource =
AudioContextCtor.prototype.createMediaStreamSource;
AudioContextCtor.prototype.createMediaStreamSource =
function patchedCreateMediaStreamSource(stream: unknown) {
const isFakeStream =
!!stream &&
typeof (stream as { getTracks?: unknown }).getTracks === 'function' &&
typeof (stream as { id?: unknown }).id === 'undefined';
if (isFakeStream) {
throw new Error('Force recorder fallback for fake microphone stream');
}
return nativeCreateMediaStreamSource.call(this, stream);
};
}
const blobFromBase64 = (base64: string, mimeType: string): Blob => {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return new Blob([bytes], { type: mimeType });
};
class FakeMediaRecorder extends EventTarget {
public static isTypeSupported() {
return true;
}
public state: 'inactive' | 'recording' = 'inactive';
public mimeType: string;
public ondataavailable: ((event: { data: Blob }) => void) | null = null;
public onerror: ((event: unknown) => void) | null = null;
constructor(_stream: unknown, options?: MediaRecorderOptions) {
super();
this.mimeType = options?.mimeType ?? 'audio/webm';
mic.lastRecorder = this;
}
public start() {
this.state = 'recording';
}
public stop() {
if (this.state !== 'recording') {
throw new Error('Not recording');
}
this.state = 'inactive';
try {
this.ondataavailable?.({
data: blobFromBase64(base64Audio, mimeType),
});
} catch (err) {
this.onerror?.(err);
}
this.dispatchEvent(new Event('stop'));
}
}
(window as any).MediaRecorder = FakeMediaRecorder;
}, { base64Audio, mimeType });
}
async function expectComposerReady(page: Page) {
await expect(page.getByRole('textbox', { name: 'Message agent...' }).first()).toBeEditable();
}
async function expectDictationStarted(page: Page) {
await expect
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
.toBe(1);
}
async function expectDictationStopped(page: Page) {
await expect
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
.toBe(0);
}
test('dictation hotkey works on a workspace agent tab', async ({ page }) => {
await addFakeMicrophone(page);
const repo = await createTempGitRepo();
try {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set.');
}
await createAgentInRepo(page, {
directory: repo.path,
prompt: 'Respond with exactly: Hello',
});
await page.goto(buildHostWorkspaceRoute(serverId, repo.path));
await waitForWorkspaceTabsVisible(page);
await ensureWorkspaceAgentPaneVisible(page);
await expect(page).toHaveURL(/\/workspace\//);
await expectComposerReady(page);
await page.keyboard.press('Control+d');
await expectDictationStarted(page);
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
expect(calls).toBe(1);
await page.keyboard.press('Escape');
await expectDictationStopped(page);
} finally {
await repo.cleanup();
}
});
test('dictation hotkey works on a workspace draft tab', async ({ page }) => {
await addFakeMicrophone(page);
const repo = await createTempGitRepo();
try {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set.');
}
await createAgentInRepo(page, {
directory: repo.path,
prompt: 'Respond with exactly: Hello',
});
await page.goto(buildHostWorkspaceRoute(serverId, repo.path));
await waitForWorkspaceTabsVisible(page);
await ensureWorkspaceAgentPaneVisible(page);
await page.getByTestId('workspace-new-agent-tab').first().click();
await expectComposerReady(page);
await page.keyboard.press('Control+d');
await expectDictationStarted(page);
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
expect(calls).toBe(1);
await page.keyboard.press('Escape');
await expectDictationStopped(page);
} finally {
await repo.cleanup();
}
});
test('dictation hotkeys do not trigger on background screens', async ({ page }) => {
await addFakeMicrophone(page);
const repo = await createTempGitRepo();
try {
await gotoHome(page);
await ensureHostSelected(page);
await setWorkingDirectory(page, repo.path);
await createAgent(page, 'Respond with exactly: Hello');
await expect(page).toHaveURL(/\/workspace\//);
await expectComposerReady(page);
await openSettings(page);
await page.keyboard.press('Control+d');
await page.waitForTimeout(200);
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
const active = await page.evaluate(() => (window as any).__mic.active as number);
expect(calls).toBe(0);
expect(active).toBe(0);
} finally {
await repo.cleanup();
}
});
test('dictation transcribes fixture via real STT', async ({ page }) => {
await addFakeMicrophone(page);
const repo = await createTempGitRepo();
try {
await gotoHome(page);
await ensureHostSelected(page);
await setWorkingDirectory(page, repo.path);
await createAgent(page, 'Respond with exactly: Hello');
await expect(page).toHaveURL(/\/workspace\//);
await expectComposerReady(page);
await page.keyboard.press('Control+d');
await expectDictationStarted(page);
const initialCopyMessageCount = await page
.getByRole('button', { name: 'Copy message' })
.count();
await page.keyboard.press('Control+d');
await expectDictationStopped(page);
await expect
.poll(
async () => page.getByRole('button', { name: 'Copy message' }).count(),
{ timeout: 60_000 }
)
.toBeGreaterThan(initialCopyMessageCount);
} finally {
await repo.cleanup();
}
});
test('cancel stops mic even if recorder is already inactive', async ({ page }) => {
await addFakeMicrophone(page);
const repo = await createTempGitRepo();
try {
await gotoHome(page);
await ensureHostSelected(page);
await setWorkingDirectory(page, repo.path);
await createAgent(page, 'Respond with exactly: Hello');
await expect(page).toHaveURL(/\/workspace\//);
await expectComposerReady(page);
await page.keyboard.press('Control+d');
await expectDictationStarted(page);
await page.evaluate(() => {
const mic = (window as any).__mic as { lastRecorder: null | { state: string } };
if (mic.lastRecorder) {
mic.lastRecorder.state = 'inactive';
}
});
await page.keyboard.press('Escape');
await expectDictationStopped(page);
} finally {
await repo.cleanup();
}
});
test('dictation confirm+send does not dispatch after navigating away', async ({ page }) => {
await addFakeMicrophone(page);
const repo = await createTempGitRepo();
try {
await gotoHome(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, 'Respond with exactly: Hello');
await expect(page).toHaveURL(/\/workspace\//);
const match = page.url().match(/\/h\/([^/]+)\/workspace\/[^?]+(?:\?open=agent%3A|\\?open=agent:)([^/?#&]+)/);
if (!match) {
throw new Error(`Expected workspace agent URL, got ${page.url()}`);
}
const serverId = decodeURIComponent(match[1]!);
const agentId = decodeURIComponent(match[2]!);
await expectComposerReady(page);
const initialCopyMessageCount = await page.getByRole('button', { name: 'Copy message' }).count();
await page.keyboard.press('Control+d');
await expectDictationStarted(page);
await page.keyboard.press('Control+d');
const newAgentButton = page.getByTestId('sidebar-new-agent');
await expect(newAgentButton).toBeVisible();
await newAgentButton.click();
await expect(page).toHaveURL(/\/h\/[^/]+\/new-agent(\?|$)/);
await page.waitForTimeout(10_000);
await page.goto(buildHostWorkspaceAgentRoute(serverId, repo.path, agentId));
await expect(page).toHaveURL(
new RegExp(`/h/${escapeRegex(serverId)}/workspace/[^?]+\\?open=agent(?:%3A|:)${escapeRegex(agentId)}(?:$|&)`)
);
await expect(page.getByRole('button', { name: 'Copy message' })).toHaveCount(initialCopyMessageCount);
await expect(page.getByTestId('agent-chat-scroll').getByText(/this is a voice note\./i)).toHaveCount(0);
} finally {
await repo.cleanup();
}
});

View File

@@ -1,42 +0,0 @@
import { test, expect } from "./fixtures";
import { ensureHostSelected, gotoHome, setWorkingDirectory } from "./helpers/app";
import { createTempGitRepo } from "./helpers/workspace";
test("draft enables explorer after selecting a working directory", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-draft-explorer-");
try {
await gotoHome(page);
await ensureHostSelected(page);
const newAgentButton = page.getByTestId("sidebar-new-agent").first();
await expect(newAgentButton).toBeVisible({ timeout: 30000 });
await newAgentButton.click();
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/, { timeout: 30000 });
await setWorkingDirectory(page, repo.path);
const toggle = page
.getByRole("button", {
name: /open explorer|close explorer|toggle explorer/i,
})
.first();
await expect(toggle).toBeVisible({ timeout: 30000 });
await toggle.click();
await expect(
page.locator('[data-testid="explorer-header"]:visible').first()
).toBeVisible({ timeout: 30000 });
const terminalsTab = page
.locator('[data-testid="explorer-tab-terminals"]:visible')
.first();
await expect(terminalsTab).toBeVisible({ timeout: 30000 });
await terminalsTab.click();
await expect(
page.locator('[data-testid="terminal-surface"]:visible').first()
).toBeVisible({ timeout: 30000 });
} finally {
await repo.cleanup();
}
});

View File

@@ -1,4 +1,8 @@
import { test as base, expect, type Page } from '@playwright/test';
import {
buildCreateAgentPreferences,
buildSeededHost,
} from './helpers/daemon-registry';
// Extend base test to provide dynamic baseURL from global-setup
const test = base.extend({
@@ -58,31 +62,12 @@ test.beforeEach(async ({ page }) => {
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set - expected from Playwright globalSetup.');
}
const testDaemon = {
const testDaemon = buildSeededHost({
serverId,
label: 'localhost',
connections: [
{
id: `direct:127.0.0.1:${daemonPort}`,
type: 'direct',
endpoint: `127.0.0.1:${daemonPort}`,
},
],
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
createdAt: nowIso,
updatedAt: nowIso,
};
const createAgentPreferences = {
// Ensure create flow never uses a remembered host from the developer's real app.
serverId: testDaemon.serverId,
// Keep e2e fast/cheap by default.
provider: 'codex',
providerPreferences: {
claude: { model: 'haiku' },
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
},
};
endpoint: `127.0.0.1:${daemonPort}`,
nowIso,
});
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
await page.addInitScript(
({ daemon, preferences, seedNonce }) => {

View File

@@ -1,119 +0,0 @@
import path from 'node:path';
import { appendFile } from 'node:fs/promises';
import { test, expect, type Page } from './fixtures';
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
test.describe.configure({ timeout: 90000 });
function getChangesScope(page: Page) {
return page.locator('[data-testid="explorer-content-area"]:visible').first();
}
async function ensureExplorerTabsVisible(page: Page) {
const changesTab = page.getByTestId('explorer-tab-changes').first();
if (await changesTab.isVisible().catch(() => false)) {
return;
}
const toggle = page
.getByRole('button', { name: /open explorer|close explorer|toggle explorer/i })
.first();
await expect(toggle).toBeVisible({ timeout: 10000 });
for (let attempt = 0; attempt < 4; attempt += 1) {
if (await changesTab.isVisible().catch(() => false)) {
return;
}
await toggle.click();
await page.waitForTimeout(200);
}
await expect(changesTab).toBeVisible({ timeout: 30000 });
}
async function openChangesPanel(page: Page) {
await ensureExplorerTabsVisible(page);
const changesHeader = getChangesScope(page).getByTestId('changes-header');
if (!(await changesHeader.isVisible())) {
await page.getByTestId('explorer-tab-changes').first().click();
}
await expect(changesHeader).toBeVisible();
}
async function refreshUncommittedMode(page: Page) {
const scope = getChangesScope(page);
const toggle = scope.getByTestId('changes-diff-status').first();
await expect(toggle).toBeVisible({ timeout: 30000 });
const diffModeBackdrop = page.getByTestId('changes-diff-status-menu-backdrop');
if (await diffModeBackdrop.isVisible().catch(() => false)) {
await diffModeBackdrop.click({ force: true });
await expect(diffModeBackdrop).toHaveCount(0);
}
const currentLabel = (await toggle.innerText()).trim();
await toggle.click({ force: true });
await expect(page.getByTestId('changes-diff-status-menu')).toBeVisible({ timeout: 10000 });
const firstTarget = currentLabel === 'Uncommitted' ? 'changes-diff-mode-committed' : 'changes-diff-mode-uncommitted';
await page.getByTestId(firstTarget).click({ force: true });
await expect.poll(async () => (await toggle.innerText()).trim()).not.toBe(currentLabel);
const nextLabel = (await toggle.innerText()).trim();
await toggle.click({ force: true });
await expect(page.getByTestId('changes-diff-status-menu')).toBeVisible({ timeout: 10000 });
const secondTarget = nextLabel === 'Uncommitted' ? 'changes-diff-mode-committed' : 'changes-diff-mode-uncommitted';
await page.getByTestId(secondTarget).click({ force: true });
await expect.poll(async () => (await toggle.innerText()).trim()).not.toBe(nextLabel);
}
async function createAgentAndWait(page: Page, message: string) {
await createAgent(page, message);
}
test('keeps file header sticky while scrolling within a long diff', async ({ page }) => {
const repo = await createTempGitRepo('paseo-e2e-sticky-');
try {
await gotoHome(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgentAndWait(page, 'Respond with exactly: READY');
await openChangesPanel(page);
const readmePath = path.join(repo.path, 'README.md');
const lines = Array.from({ length: 400 }, (_, idx) => `Sticky header line ${idx}\n`).join('');
await appendFile(readmePath, `\n${lines}`);
await refreshUncommittedMode(page);
const scope = getChangesScope(page);
await expect(scope.getByText('README.md', { exact: true })).toBeVisible({ timeout: 30000 });
const fileToggle = scope.getByTestId('diff-file-0-toggle').first();
await fileToggle.click();
const markerLine = scope.getByText('Sticky header line 250').first();
await expect(markerLine).toBeVisible({ timeout: 30000 });
const scroll = scope.getByTestId('git-diff-scroll').first();
await expect(scroll).toBeVisible();
await expect.poll(async () => {
return await scroll.evaluate((el) => (el.scrollHeight ?? 0) > (el.clientHeight ?? 0));
}).toBe(true);
await scroll.hover();
for (let i = 0; i < 12; i++) {
await page.mouse.wheel(0, 700);
}
await expect.poll(async () => {
return await scroll.evaluate((el) => el.scrollTop ?? 0);
}).toBeGreaterThan(0);
await expect(scope.getByText('Sticky header line 390').first()).toBeVisible({ timeout: 30000 });
await expect(fileToggle).toBeVisible();
} finally {
await repo.cleanup();
}
});

View File

@@ -1,47 +0,0 @@
import { test, expect } from './fixtures'
import { createTempGitRepo } from './helpers/workspace'
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
test('global draft create uses input status controls and preserves optimistic flow', async ({ page }) => {
const repo = await createTempGitRepo('paseo-e2e-global-draft-')
const prompt = `global draft prompt ${Date.now()}`
try {
await gotoHome(page)
await ensureHostSelected(page)
await setWorkingDirectory(page, repo.path)
await expect(page.getByTestId('working-directory-select').first()).toBeVisible({ timeout: 30_000 })
const providerSelector = page.getByTestId('agent-provider-selector').first()
const modeSelector = page.getByTestId('agent-mode-selector').first()
const modelSelector = page.getByTestId('agent-model-selector').first()
const thinkingSelector = page.getByTestId('agent-thinking-selector').first()
await expect(providerSelector).toBeVisible({ timeout: 30_000 })
await expect(modeSelector).toBeVisible({ timeout: 30_000 })
await expect(modelSelector).toBeVisible({ timeout: 30_000 })
await expect(thinkingSelector).toBeVisible({ timeout: 30_000 })
const selectedMode = ((await modeSelector.innerText()) ?? '').trim()
const selectedModel = ((await modelSelector.innerText()) ?? '').trim()
const selectedThinking = ((await thinkingSelector.innerText()) ?? '').trim()
const composer = page.getByRole('textbox', { name: 'Message agent...' }).first()
await composer.fill(prompt)
await composer.press('Enter')
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({ timeout: 5_000 })
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 })
await expect(modelSelector).toContainText(new RegExp(escapeRegex(selectedModel), 'i'))
await expect(modeSelector).toContainText(new RegExp(escapeRegex(selectedMode), 'i'))
await expect(thinkingSelector).toContainText(new RegExp(escapeRegex(selectedThinking), 'i'))
} finally {
await repo.cleanup()
}
})

View File

@@ -1,219 +0,0 @@
import { test, expect } from './fixtures'
import type { Page } from '@playwright/test'
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'
import { createTempGitRepo } from './helpers/workspace'
import { getWorkspaceTabTestIds } from './helpers/workspace-tabs'
function parseWorkspaceContextFromUrl(rawUrl: string): { workspaceToken: string | null; agentId: string | null } {
const url = new URL(rawUrl)
const pathMatch = url.pathname.match(/\/workspace\/([^/?#]+)(?:\/agent\/([^/?#]+))?/) ?? null
const workspaceToken = pathMatch?.[1] ?? null
let agentId = pathMatch?.[2] ?? null
if (!agentId) {
const open = url.searchParams.get('open')
const openMatch = open?.match(/^agent:(.+)$/) ?? null
if (openMatch?.[1]) {
agentId = openMatch[1]
}
}
return { workspaceToken, agentId }
}
function getOpenAgentIdFromUrl(rawUrl: string): string | null {
const url = new URL(rawUrl)
const open = url.searchParams.get('open')
const openMatch = open?.match(/^agent:(.+)$/) ?? null
return openMatch?.[1] ?? null
}
async function preferSlowerThinkingOption(page: Page): Promise<void> {
await page.keyboard.press('Escape').catch(() => undefined)
const thinkingTrigger = page.getByTestId('agent-thinking-selector').first()
if (!(await thinkingTrigger.isVisible().catch(() => false))) {
return
}
await thinkingTrigger.click({ force: true })
const menu = page.getByTestId('agent-thinking-menu').first()
if (!(await menu.isVisible().catch(() => false))) {
return
}
const preferred = ['high', 'max', 'deep', 'long', 'medium']
for (const label of preferred) {
const option = menu.getByRole('button', { name: new RegExp(`^${label}$`, 'i') }).first()
if (await option.isVisible().catch(() => false)) {
await option.click({ force: true })
await expect(menu).not.toBeVisible({ timeout: 5_000 })
return
}
}
const options = menu.getByRole('button')
const count = await options.count()
if (count > 0) {
await options.last().click({ force: true })
}
await expect(menu).not.toBeVisible({ timeout: 5_000 })
}
async function isAnyStopVisible(page: Page): Promise<boolean> {
const candidates = page.getByRole('button', { name: /stop|cancel/i })
const count = await candidates.count()
for (let index = 0; index < count; index += 1) {
if (await candidates.nth(index).isVisible().catch(() => false)) {
return true
}
}
return false
}
async function isRunningControlVisible(page: Page): Promise<boolean> {
if (await isAnyStopVisible(page)) {
return true
}
const interruptSendButton = page.getByRole('button', { name: /interrupt agent|send and interrupt/i })
const count = await interruptSendButton.count()
for (let index = 0; index < count; index += 1) {
if (await interruptSendButton.nth(index).isVisible().catch(() => false)) {
return true
}
}
return false
}
async function hasVisibleText(page: Page, matcher: RegExp | string): Promise<boolean> {
const locator = page.getByText(matcher).first()
const matches = page.getByText(matcher)
const count = await matches.count()
for (let index = 0; index < count; index += 1) {
if (await matches.nth(index).isVisible().catch(() => false)) {
return true
}
}
return await locator.isVisible().catch(() => false)
}
test('global draft create in existing workspace redirects to that workspace with created agent tab and settles to idle', async ({
page,
}) => {
test.setTimeout(360_000)
const serverId = process.env.E2E_SERVER_ID
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set.')
}
const repo = await createTempGitRepo('paseo-e2e-global-existing-workspace-')
const id = `${Date.now()}`
const seedPrompt = `hello seed-${id}. please reply exactly: ACK_SEED_${id}`
const secondPrompt = `hello second-${id}. please reply exactly: ACK_SECOND_${id}`
const lifecyclePrompt = `hello lifecycle-${id}. wait 8 seconds, then reply exactly: ACK_LIFECYCLE_${id}`
const seedToken = `ACK_SEED_${id}`
const secondToken = `ACK_SECOND_${id}`
expect(seedPrompt).not.toBe(secondPrompt)
expect(seedToken).not.toBe(secondToken)
try {
await gotoHome(page)
await ensureHostSelected(page)
await page.goto(`/h/${serverId}/new-agent`)
await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({
timeout: 30_000,
})
// 1) Seed workspace with an existing agent.
await setWorkingDirectory(page, repo.path)
const seedComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
await seedComposer.fill(seedPrompt)
await seedComposer.press('Enter')
await expect(page.getByText(seedPrompt, { exact: true }).first()).toBeVisible({ timeout: 30_000 })
await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 })
const seededContext = parseWorkspaceContextFromUrl(page.url())
if (!seededContext.workspaceToken || !seededContext.agentId) {
throw new Error(`Expected seeded workspace token and agent id in URL, got: ${page.url()}`)
}
const seededAgentId = seededContext.agentId
expect(getOpenAgentIdFromUrl(page.url())).toBe(seededAgentId)
await expect(page.getByText(new RegExp(seedToken)).first()).toBeVisible({ timeout: 180_000 })
// 2) Use global New Agent entry and 3) select same workspace.
await page.getByText('New agent', { exact: true }).first().click()
await expect(page).toHaveURL(new RegExp(`/h/${serverId}/new-agent`), { timeout: 30_000 })
await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({
timeout: 30_000,
})
await setWorkingDirectory(page, repo.path)
await preferSlowerThinkingOption(page)
await page.keyboard.press('Escape').catch(() => undefined)
// 4) Create second agent from global draft.
const composer = page.getByRole('textbox', { name: 'Message agent...' }).first()
await composer.fill(secondPrompt)
await composer.press('Enter')
await page.waitForTimeout(500)
if (page.url().includes('/new-agent')) {
const sendButton = page.getByRole('button', { name: /send message/i }).first()
await expect(sendButton).toBeVisible({ timeout: 10_000 })
await expect(sendButton).toBeEnabled({ timeout: 10_000 })
await sendButton.click({ force: true })
}
// 5) Assert redirect workspace context + created agent tab is active/open.
await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 })
const createdContext = parseWorkspaceContextFromUrl(page.url())
expect(createdContext.workspaceToken).toBe(seededContext.workspaceToken)
if (!createdContext.agentId) {
throw new Error(`Expected created agent id in URL, got: ${page.url()}`)
}
const createdAgentId = createdContext.agentId
expect(createdAgentId).toBeTruthy()
expect(createdAgentId).not.toBe(seededAgentId)
expect(getOpenAgentIdFromUrl(page.url())).toBe(createdAgentId)
expect(getOpenAgentIdFromUrl(page.url())).not.toBe(seededAgentId)
const createdTabTestId = `workspace-tab-agent_${createdAgentId}`
await expect
.poll(async () => {
const finalTabIds = await getWorkspaceTabTestIds(page)
return finalTabIds.includes(createdTabTestId)
})
.toBe(true)
expect(getOpenAgentIdFromUrl(page.url())).toBe(createdAgentId)
// 6) Response assertions are scoped to created-agent active context.
await expect
.poll(async () => hasVisibleText(page, new RegExp(secondToken)), { timeout: 240_000 })
.toBe(true)
await expect(page.getByText(seedToken, { exact: true }).first()).not.toBeVisible({ timeout: 30_000 })
// 7) Lifecycle assertions on created agent only: running first, then idle.
const lifecycleComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
await lifecycleComposer.fill(lifecyclePrompt)
await lifecycleComposer.press('Enter')
await expect
.poll(async () => hasVisibleText(page, lifecyclePrompt), { timeout: 30_000 })
.toBe(true)
let sawRunning = false
for (let attempt = 0; attempt < 1200; attempt += 1) {
if (await isRunningControlVisible(page)) {
sawRunning = true
break
}
await page.waitForTimeout(50)
}
expect(sawRunning).toBe(true)
await expect
.poll(async () => (await isRunningControlVisible(page)) === false, { timeout: 240_000 })
.toBe(true)
const idleComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
await expect(idleComposer).toBeVisible({ timeout: 30_000 })
await expect(idleComposer).toBeEditable({ timeout: 30_000 })
} finally {
await repo.cleanup()
}
})

View File

@@ -199,6 +199,19 @@ function stripAnsi(input: string): string {
return input.replace(/\u001b\[[0-9;]*m/g, '');
}
function ensureRelayBuildArtifact(repoRoot: string): void {
const relayDistEntry = path.join(repoRoot, 'packages/relay/dist/e2ee.js');
if (existsSync(relayDistEntry)) {
return;
}
console.log('[e2e] Building @getpaseo/relay for daemon startup');
execSync('npm run build --workspace=@getpaseo/relay', {
cwd: repoRoot,
stdio: 'inherit',
});
}
function decodeOfferFromFragmentUrl(url: string): OfferPayload {
const marker = '#offer=';
const idx = url.indexOf(marker);
@@ -217,6 +230,7 @@ function decodeOfferFromFragmentUrl(url: string): OfferPayload {
export default async function globalSetup() {
const repoRoot = path.resolve(__dirname, '../../..');
ensureRelayBuildArtifact(repoRoot);
const envTestPath = path.join(repoRoot, '.env.test');
if (existsSync(envTestPath)) {
dotenv.config({ path: envTestPath });

View File

@@ -66,17 +66,17 @@ function buildReplyBlock(label: string, lineCount = 14): string {
}).join("\n");
}
function buildProtocolMessage(label: string): string {
function buildProtocolMessage(label: string, lineCount = 14): string {
return [
"For every message in this chat, reply with exactly the text after the final line `REPLY:`.",
"Do not add extra words, bullets, markdown fences, or tool calls.",
"REPLY:",
buildReplyBlock(label),
buildReplyBlock(label, lineCount),
].join("\n");
}
function buildReplyMessage(label: string): string {
return ["REPLY:", buildReplyBlock(label)].join("\n");
function buildReplyMessage(label: string, lineCount = 14): string {
return ["REPLY:", buildReplyBlock(label, lineCount)].join("\n");
}
export function createReplyTurn(label: string): {
@@ -124,9 +124,11 @@ export async function seedBottomAnchorAgent(input: {
cwd: string;
title?: string;
turnCount?: number;
lineCount?: number;
}): Promise<SeededAgent> {
const title = input.title ?? `bottom-anchor-${Date.now()}`;
const turnCount = Math.max(3, input.turnCount ?? 5);
const lineCount = Math.max(14, input.lineCount ?? 14);
const created = await input.client.createAgent({
provider: "codex",
model: "gpt-5.1-codex-mini",
@@ -134,7 +136,7 @@ export async function seedBottomAnchorAgent(input: {
modeId: "full-access",
cwd: input.cwd,
title,
initialPrompt: buildProtocolMessage(`${title}-turn-00`),
initialPrompt: buildProtocolMessage(`${title}-turn-00`, lineCount),
});
const initialFinish = await input.client.waitForFinish(created.id, 120000);
if (initialFinish.status !== "idle") {
@@ -143,11 +145,11 @@ export async function seedBottomAnchorAgent(input: {
);
}
let expectedTailText = buildReplyBlock(`${title}-turn-00`);
let expectedTailText = buildReplyBlock(`${title}-turn-00`, lineCount);
for (let index = 1; index < turnCount; index += 1) {
const label = `${title}-turn-${index.toString().padStart(2, "0")}`;
expectedTailText = buildReplyBlock(label);
await input.client.sendAgentMessage(created.id, buildReplyMessage(label));
expectedTailText = buildReplyBlock(label, lineCount);
await input.client.sendAgentMessage(created.id, buildReplyMessage(label, lineCount));
const finish = await input.client.waitForFinish(created.id, 120000);
if (finish.status !== "idle") {
throw new Error(
@@ -165,16 +167,29 @@ export async function seedBottomAnchorAgent(input: {
};
}
function getVisibleChatScroll(page: Page) {
return page.locator('[data-testid="agent-chat-scroll"]:visible').first();
}
export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
return page.getByTestId("agent-chat-scroll").evaluate((root: Element) => {
const rootElement = root as HTMLElement;
const candidates = [rootElement, ...Array.from(rootElement.querySelectorAll("*"))];
return getVisibleChatScroll(page).evaluate((root: Element) => {
const candidates = [root, ...Array.from(root.querySelectorAll("*"))]
.filter((element): element is HTMLElement => element instanceof HTMLElement)
.filter((element) => {
const tagName = element.tagName.toLowerCase();
const isEditable =
tagName === "textarea" ||
tagName === "input" ||
element.getAttribute("contenteditable") === "true";
return !isEditable && element.scrollHeight - element.clientHeight > 1;
});
const scrollElement =
candidates.find(
(element) =>
element instanceof HTMLElement &&
element.scrollHeight - element.clientHeight > 1
) ?? rootElement;
candidates.sort(
(left, right) =>
right.scrollHeight -
right.clientHeight -
(left.scrollHeight - left.clientHeight)
)[0] ?? (root as HTMLElement);
const offsetY = Math.max(0, scrollElement.scrollTop);
const contentHeight = Math.max(0, scrollElement.scrollHeight);
@@ -194,29 +209,33 @@ export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
}
export async function scrollUpFromBottom(page: Page, pixels: number): Promise<void> {
await page.getByTestId("agent-chat-scroll").evaluate(
(root: Element, amount: number) => {
const rootElement = root as HTMLElement;
const candidates = [rootElement, ...Array.from(rootElement.querySelectorAll("*"))];
const scrollElement =
candidates.find(
(element) =>
element instanceof HTMLElement &&
element.scrollHeight - element.clientHeight > 1
) ?? rootElement;
const bottomOffset = Math.max(
0,
scrollElement.scrollHeight - scrollElement.clientHeight
const scrollViewport = getVisibleChatScroll(page);
await expect(scrollViewport).toHaveCount(1, { timeout: 30000 });
let remaining = Math.max(0, pixels);
while (remaining > 0) {
const delta = Math.min(240, remaining);
await scrollViewport.evaluate((element: Element, step: number) => {
const scrollContainer = element as HTMLElement;
scrollContainer.dispatchEvent(
new WheelEvent("wheel", {
deltaY: -step,
bubbles: true,
cancelable: true,
})
);
scrollElement.scrollTop = Math.max(0, bottomOffset - amount);
},
pixels
);
scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - step);
scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true }));
}, delta);
remaining -= delta;
if ((await readScrollMetrics(page)).distanceFromBottom > NEAR_BOTTOM_THRESHOLD_PX) {
return;
}
}
}
export async function waitForAgentReady(page: Page, expectedTailText?: string): Promise<void> {
await expect(page.getByTestId("agent-chat-scroll")).toBeVisible({ timeout: 60000 });
await expect(getVisibleChatScroll(page)).toBeVisible({ timeout: 60000 });
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
timeout: 60000,
});
@@ -263,9 +282,7 @@ export async function waitForContentGrowth(
}
export async function getChatContainerKey(page: Page): Promise<string | null> {
return page
.getByTestId("agent-chat-scroll")
.evaluate((element) => {
return getVisibleChatScroll(page).evaluate((element) => {
const nativeId = (element as HTMLElement).id;
const prefix = "agent-chat-scroll-";
return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null;

View File

@@ -1,4 +1,8 @@
import { expect, type Page } from '@playwright/test';
import {
buildCreateAgentPreferences,
buildSeededHost,
} from './daemon-registry';
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -33,8 +37,8 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
if (entry?.serverId !== expectedServerId) return true;
const connections = entry?.connections;
if (!Array.isArray(connections)) return true;
if (connections.some((c: any) => c?.type === 'direct' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) return true;
return !connections.some((c: any) => c?.type === 'direct' && c?.endpoint === expectedEndpoint);
if (connections.some((c: any) => c?.type === 'directTcp' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) return true;
return !connections.some((c: any) => c?.type === 'directTcp' && c?.endpoint === expectedEndpoint);
} catch {
return true;
}
@@ -45,42 +49,20 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
}
const nowIso = new Date().toISOString();
const daemon = buildSeededHost({
serverId: expectedServerId,
endpoint: expectedEndpoint,
nowIso,
});
const preferences = buildCreateAgentPreferences(expectedServerId);
await page.evaluate(
({ expectedEndpoint, nowIso, expectedServerId }) => {
({ daemon, preferences }) => {
localStorage.setItem('@paseo:e2e', '1');
localStorage.setItem(
'@paseo:daemon-registry',
JSON.stringify([
{
serverId: expectedServerId,
label: 'localhost',
connections: [
{
id: `direct:${expectedEndpoint}`,
type: 'direct',
endpoint: expectedEndpoint,
},
],
preferredConnectionId: `direct:${expectedEndpoint}`,
createdAt: nowIso,
updatedAt: nowIso,
},
])
);
localStorage.setItem(
'@paseo:create-agent-preferences',
JSON.stringify({
serverId: expectedServerId,
provider: 'codex',
providerPreferences: {
claude: { model: 'haiku' },
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
},
})
);
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
localStorage.removeItem('@paseo:settings');
},
{ expectedEndpoint, nowIso, expectedServerId }
{ daemon, preferences }
);
await page.reload();
@@ -128,13 +110,13 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
const connections: unknown = daemon?.connections;
if (
!Array.isArray(connections) ||
!connections.some((c: any) => c?.type === 'direct' && c?.endpoint === expectedEndpoint)
!connections.some((c: any) => c?.type === 'directTcp' && c?.endpoint === expectedEndpoint)
) {
throw new Error(
`E2E expected seeded daemon connections to include direct ${expectedEndpoint} (got ${JSON.stringify(connections)}).`
`E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`
);
}
if (Array.isArray(connections) && connections.some((c: any) => c?.type === 'direct' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) {
if (Array.isArray(connections) && connections.some((c: any) => c?.type === 'directTcp' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) {
throw new Error(`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`);
}
@@ -154,14 +136,36 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
}
}
export const gotoHome = async (page: Page) => {
export const gotoAppShell = async (page: Page) => {
await page.goto('/');
await ensureE2EStorageSeeded(page);
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
};
export const gotoHome = async (page: Page) => {
await gotoAppShell(page);
const composer = page.getByRole('textbox', { name: 'Message agent...' });
if (!(await composer.first().isVisible().catch(() => false))) {
const addProjectCta = page.getByText('Add a project', { exact: true }).first();
const addProjectSidebar = page.getByText('Add project', { exact: true }).first();
const newAgentButton = page.getByText('New agent', { exact: true }).first();
await newAgentButton.click();
await expect
.poll(
async () =>
(await addProjectCta.isVisible().catch(() => false)) ||
(await addProjectSidebar.isVisible().catch(() => false)) ||
(await newAgentButton.isVisible().catch(() => false)),
{ timeout: 10000 }
)
.toBe(true);
if (await addProjectCta.isVisible().catch(() => false)) {
await addProjectCta.click();
} else if (await addProjectSidebar.isVisible().catch(() => false)) {
await addProjectSidebar.click();
} else {
await newAgentButton.click();
}
}
await expect(composer.first()).toBeVisible({ timeout: 30000 });
};

View File

@@ -0,0 +1,39 @@
export const TEST_HOST_LABEL = "localhost";
export const TEST_PROVIDER_PREFERENCES = {
claude: { model: "haiku" },
codex: { model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
} as const;
export function buildDirectTcpConnection(endpoint: string) {
return {
id: `direct:${endpoint}`,
type: "directTcp" as const,
endpoint,
};
}
export function buildSeededHost(input: {
serverId: string;
endpoint: string;
label?: string;
nowIso: string;
}) {
const connection = buildDirectTcpConnection(input.endpoint);
return {
serverId: input.serverId,
label: input.label ?? TEST_HOST_LABEL,
connections: [connection],
preferredConnectionId: connection.id,
createdAt: input.nowIso,
updatedAt: input.nowIso,
};
}
export function buildCreateAgentPreferences(serverId: string) {
return {
serverId,
provider: "codex" as const,
providerPreferences: TEST_PROVIDER_PREFERENCES,
};
}

View File

@@ -1,44 +0,0 @@
import { test, expect } from './fixtures';
test('no hosts shows welcome; direct connection adds host and lands on agent create', async ({ page }) => {
const daemonPort = process.env.E2E_DAEMON_PORT;
const serverId = process.env.E2E_SERVER_ID;
if (!daemonPort) {
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
}
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
}
await page.addInitScript(() => {
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
localStorage.removeItem('@paseo:create-agent-preferences');
localStorage.removeItem('@paseo:settings');
});
await page.goto('/');
await expect(page.getByText('Welcome to Paseo', { exact: true })).toBeVisible();
await page.getByText('Direct connection', { exact: true }).click();
await page.getByPlaceholder('host:6767').fill(`127.0.0.1:${daemonPort}`);
await page.getByText('Connect', { exact: true }).click();
// First-time connection prompts for an optional label.
const nameModal = page.getByTestId('name-host-modal');
const showedNameModal = await nameModal
.waitFor({ state: 'visible', timeout: 5000 })
.then(() => true)
.catch(() => false);
if (showedNameModal) {
await nameModal.getByTestId('name-host-skip').click();
}
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
await expect(page.getByText(serverId, { exact: true })).toBeVisible();
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable({
timeout: 15000,
});
});

View File

@@ -1,119 +0,0 @@
import { test, expect } from './fixtures';
test('host removal removes the host from UI and persists after reload', async ({ page }) => {
const daemonPort = process.env.E2E_DAEMON_PORT;
const seededServerId = process.env.E2E_SERVER_ID;
if (!daemonPort) {
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
}
if (!seededServerId) {
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
}
const extraPort = Number(daemonPort) + 1;
const extraEndpoint = `127.0.0.1:${extraPort}`;
const nowIso = new Date().toISOString();
const extraDaemon = {
serverId: 'srv_e2e_extra_daemon',
label: 'extra',
connections: [
{ id: `direct:${extraEndpoint}`, type: 'direct', endpoint: extraEndpoint },
],
preferredConnectionId: `direct:${extraEndpoint}`,
createdAt: nowIso,
updatedAt: nowIso,
};
const seededTestDaemon = {
serverId: seededServerId,
label: 'localhost',
connections: [
{ id: `direct:127.0.0.1:${daemonPort}`, type: 'direct', endpoint: `127.0.0.1:${daemonPort}` },
],
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
createdAt: nowIso,
updatedAt: nowIso,
};
const seedOnceKey = `@paseo:e2e-host-removal-seeded:${Math.random().toString(36).slice(2)}`;
// Add a second host once (fixtures seed the primary host on every navigation).
await page.addInitScript(
({ daemon, seededTestDaemon, seedOnceKey }) => {
if (localStorage.getItem(seedOnceKey)) {
return;
}
const raw = localStorage.getItem('@paseo:daemon-registry');
let parsed: any[] = [];
if (raw) {
try {
parsed = JSON.parse(raw);
} catch {
parsed = [];
}
}
const list = Array.isArray(parsed) ? parsed : [];
localStorage.setItem(seedOnceKey, '1');
const next = [...list];
const hasSeeded = next.some((entry: any) => entry && entry.serverId === seededTestDaemon.serverId);
if (!hasSeeded) {
next.push(seededTestDaemon);
}
const alreadyPresent = next.some((entry: any) => entry && entry.serverId === daemon.serverId);
if (!alreadyPresent) {
next.push(daemon);
}
localStorage.setItem('@paseo:daemon-registry', JSON.stringify(next));
},
{ daemon: extraDaemon, seededTestDaemon, seedOnceKey }
);
await page.goto('/settings');
await expect(page.getByText('extra', { exact: true }).first()).toBeVisible();
await expect(page.getByText(extraEndpoint, { exact: true }).first()).toBeVisible();
const hostSettingsButton = page.getByTestId(`daemon-card-settings-${extraDaemon.serverId}`).first();
await expect(hostSettingsButton).toBeVisible({ timeout: 10000 });
await hostSettingsButton.click();
const hostDetailModal = page.getByTestId('host-detail-modal');
await expect(hostDetailModal).toBeVisible({ timeout: 10000 });
await hostDetailModal.getByText('Advanced', { exact: true }).click();
await page.getByText('Remove host', { exact: true }).last().click();
await expect(page.getByTestId('remove-host-confirm-modal')).toBeVisible();
await page.getByTestId('remove-host-confirm').click();
await expect(page.getByTestId(`daemon-card-${extraDaemon.serverId}`)).toHaveCount(0, {
timeout: 30000,
});
await expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0);
await page.waitForFunction(
(serverId) => {
const raw = localStorage.getItem('@paseo:daemon-registry');
if (!raw) return false;
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) && !parsed.some((entry: any) => entry && entry.serverId === serverId);
} catch {
return false;
}
},
extraDaemon.serverId,
{ timeout: 10000 }
);
// Prevent the fixture from overwriting storage on reload; verify persistence.
await page.evaluate(() => {
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
});
await page.reload();
await expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0);
});

View File

@@ -1,124 +0,0 @@
import { test, expect } from './fixtures';
import { ensureHostSelected, gotoHome } from './helpers/app';
test('new agent auto-selects the previous host', async ({ page }) => {
await gotoHome(page);
await ensureHostSelected(page);
await gotoHome(page);
// The selected host should be restored after a full reload without manual selection.
await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible();
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable({ timeout: 30000 });
});
test('new agent respects serverId in the URL', async ({ page }) => {
const daemonPort = process.env.E2E_DAEMON_PORT;
const serverId = process.env.E2E_SERVER_ID;
if (!daemonPort) {
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
}
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
}
// Ensure this test's storage is deterministic even under parallel load.
const nowIso = new Date().toISOString();
const testDaemon = {
serverId,
label: 'localhost',
connections: [
{
id: `direct:127.0.0.1:${daemonPort}`,
type: 'direct',
endpoint: `127.0.0.1:${daemonPort}`,
},
],
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
createdAt: nowIso,
updatedAt: nowIso,
};
const createAgentPreferences = {
serverId: testDaemon.serverId,
provider: 'codex',
providerPreferences: {
claude: { model: 'haiku' },
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
},
};
await page.goto('/settings');
await page.evaluate(
({ daemon, preferences }) => {
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
localStorage.removeItem('@paseo:settings');
},
{ daemon: testDaemon, preferences: createAgentPreferences }
);
await page.reload();
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible({ timeout: 20000 });
await page.goto(`/?serverId=${encodeURIComponent(serverId)}`);
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
const newAgentButton = page.getByTestId('sidebar-new-agent').first();
if (await newAgentButton.isVisible().catch(() => false)) {
await newAgentButton.click();
} else {
await page.getByText('New agent', { exact: true }).first().click();
}
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable({ timeout: 30000 });
});
test('new agent auto-selects first online host when no preference is stored', async ({ page }) => {
const daemonPort = process.env.E2E_DAEMON_PORT;
const serverId = process.env.E2E_SERVER_ID;
if (!daemonPort) {
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
}
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
}
const nowIso = new Date().toISOString();
const testDaemon = {
serverId,
label: 'localhost',
connections: [
{
id: `direct:127.0.0.1:${daemonPort}`,
type: 'direct',
endpoint: `127.0.0.1:${daemonPort}`,
},
],
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
createdAt: nowIso,
updatedAt: nowIso,
};
await gotoHome(page);
await page.evaluate(
({ daemon }) => {
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
localStorage.removeItem('@paseo:create-agent-preferences');
localStorage.removeItem('@paseo:settings');
},
{ daemon: testDaemon }
);
await page.reload();
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
// Host should be auto-selected (no manual selection required).
await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible();
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable({ timeout: 30000 });
});

View File

@@ -1,17 +0,0 @@
import { test, expect } from "./fixtures";
import { gotoHome } from "./helpers/app";
test("question mark opens keyboard shortcuts dialog", async ({ page }) => {
await gotoHome(page);
await page.getByTestId("menu-button").first().focus();
await page.keyboard.press("Shift+/");
const dialog = page.getByTestId("keyboard-shortcuts-dialog");
const content = page.getByTestId("keyboard-shortcuts-dialog-content");
await expect(dialog).toBeVisible({ timeout: 10000 });
await expect(content).toBeVisible({ timeout: 10000 });
await expect(content).toContainText("Show keyboard shortcuts");
await expect(content).toContainText("Toggle left sidebar");
});

View File

@@ -1,85 +0,0 @@
import { test, expect } from './fixtures';
test('manual host add accepts host:port only and persists a direct connection', async ({ page }) => {
const daemonPort = process.env.E2E_DAEMON_PORT;
const serverId = process.env.E2E_SERVER_ID;
if (!daemonPort) {
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
}
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
}
// Override the default fixture seeding for this navigation (must run before app boot).
await page.addInitScript(() => {
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
localStorage.removeItem('@paseo:settings');
});
await page.goto('/settings');
await expect
.poll(
async () => {
if (await page.getByText('Welcome to Paseo', { exact: true }).isVisible().catch(() => false)) {
return 'welcome';
}
if (await page.getByText('+ Add connection', { exact: true }).isVisible().catch(() => false)) {
return 'settings';
}
return '';
},
{ timeout: 15000 }
)
.not.toBe('');
const isWelcome = await page.getByText('Welcome to Paseo', { exact: true }).isVisible().catch(() => false);
if (isWelcome) {
await page.getByText('Direct connection', { exact: true }).first().click();
} else {
await page.getByText('+ Add connection', { exact: true }).click();
await page.getByText('Direct connection', { exact: true }).click();
}
const input = page.getByPlaceholder('host:6767');
await expect(input).toBeVisible();
await input.fill(`127.0.0.1:${daemonPort}`);
await page.getByText('Connect', { exact: true }).click();
const nameModal = page.getByTestId('name-host-modal');
if (await nameModal.isVisible().catch(() => false)) {
await nameModal.getByTestId('name-host-skip').click();
}
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible({ timeout: 30000 });
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
await expect(settingsButton).toBeVisible({ timeout: 10000 });
await settingsButton.click();
await expect(page.locator(`[data-testid="daemon-card-${serverId}"]:visible`).first()).toBeVisible({
timeout: 15000,
});
await page.waitForFunction(
({ port, serverId }) => {
const raw = localStorage.getItem('@paseo:daemon-registry');
if (!raw) return false;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length !== 1) return false;
const entry = parsed[0];
return (
entry?.serverId === serverId &&
Array.isArray(entry?.connections) &&
entry.connections.some(
(conn: any) => conn?.type === 'direct' && conn?.endpoint === `127.0.0.1:${port}`
)
);
} catch {
return false;
}
},
{ port: daemonPort, serverId },
{ timeout: 10000 }
);
});

View File

@@ -1,91 +0,0 @@
import { test, expect } from './fixtures';
import { Buffer } from 'node:buffer';
import { gotoHome, openSettings } from './helpers/app';
function encodeBase64Url(input: string): string {
return Buffer.from(input, 'utf8')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
test('pairing flow accepts #offer=ConnectionOfferV2 and stores relay-only host', async ({ page }) => {
const relayPort = process.env.E2E_RELAY_PORT;
const serverId = process.env.E2E_SERVER_ID;
const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
if (!relayPort || !serverId || !daemonPublicKeyB64) {
throw new Error(
'E2E_RELAY_PORT, E2E_SERVER_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).'
);
}
// Override the default fixture seeding for this test.
await gotoHome(page);
await openSettings(page);
await page.evaluate(() => {
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
localStorage.removeItem('@paseo:settings');
});
await page.goto('/');
const relayEndpoint = `127.0.0.1:${relayPort}`;
const offer = {
v: 2 as const,
serverId,
daemonPublicKeyB64,
relay: { endpoint: relayEndpoint },
};
const offerUrl = `https://app.paseo.sh/#offer=${encodeBase64Url(JSON.stringify(offer))}`;
const welcomeTitle = page.getByText('Welcome to Paseo', { exact: true });
if (await welcomeTitle.isVisible().catch(() => false)) {
await page.getByTestId('welcome-paste-pairing-link').click();
} else {
await page.getByText('+ Add connection', { exact: true }).click();
await page.getByText('Paste pairing link', { exact: true }).click();
}
const input = page.getByPlaceholder('https://app.paseo.sh/#offer=...');
await expect(input).toBeVisible();
await input.fill(offerUrl);
await page.getByTestId('pair-link-submit').click();
const nameHostModal = page.getByTestId('name-host-modal');
if (await nameHostModal.isVisible().catch(() => false)) {
await nameHostModal.getByTestId('name-host-skip').click();
}
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible({ timeout: 30000 });
await page.waitForFunction(
({ expected }) => {
const raw = localStorage.getItem('@paseo:daemon-registry');
if (!raw) return false;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length !== 1) return false;
const entry = parsed[0];
const relayId = `relay:${expected.relay.endpoint}`;
return (
entry?.serverId === expected.serverId &&
Array.isArray(entry?.connections) &&
entry.connections.length === 1 &&
entry.connections[0]?.id === relayId &&
entry.connections[0]?.type === 'relay' &&
entry.connections[0]?.relayEndpoint === expected.relay.endpoint &&
entry.connections[0]?.daemonPublicKeyB64 === expected.daemonPublicKeyB64
);
} catch {
return false;
}
},
{ expected: offer },
{ timeout: 10000 }
);
});

View File

@@ -1,53 +0,0 @@
import { test, expect } from "./fixtures";
import { ensureHostSelected, gotoHome, setWorkingDirectory } from "./helpers/app";
import { createTempGitRepo } from "./helpers/workspace";
test("pastes clipboard image into prompt attachments", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-paste-image-");
try {
await gotoHome(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
const input = page.getByRole("textbox", { name: "Message agent..." });
await expect(input).toBeEditable();
await input.focus();
const result = await page.evaluate(() => {
const active = document.activeElement;
if (!(active instanceof HTMLTextAreaElement)) {
return {
pasted: false,
elementTag: active ? active.tagName : null,
defaultPrevented: false,
};
}
const file = new File([new Uint8Array([0, 1, 2, 3])], "paste.png", {
type: "image/png",
});
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
const event = new ClipboardEvent("paste", {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true,
});
active.dispatchEvent(event);
return {
pasted: true,
elementTag: active.tagName,
defaultPrevented: event.defaultPrevented,
};
});
expect(result.pasted).toBe(true);
expect(result.defaultPrevented).toBe(true);
await expect(page.getByTestId("message-input-image-pill")).toHaveCount(1);
} finally {
await repo.cleanup();
}
});

View File

@@ -1,92 +0,0 @@
import { existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { test, expect } from './fixtures';
import {
createAgentWithConfig,
waitForPermissionPrompt,
allowPermission,
denyPermission,
waitForAgentFinishUI,
} from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
const FILE_CONTENT = 'Hello from permission test';
function buildWriteCommand(filePath: string): string {
return `bash -lc 'sleep 2; printf "${FILE_CONTENT}" > "${filePath}"'`;
}
test.describe('permission prompts', () => {
test('allow permission creates the file', async ({ page }) => {
const repo = await createTempGitRepo();
const uniqueFilename = `test-allow-${Date.now()}.txt`;
const filePath = path.join(repo.path, uniqueFilename);
const shellCommand = buildWriteCommand(filePath);
const prompt = [
`Use your shell tool to run exactly this command:`,
shellCommand,
`Do not write outside this exact path.`,
].join(' ');
try {
await createAgentWithConfig(page, {
directory: repo.path,
provider: 'claude',
mode: 'Always Ask',
prompt,
});
await waitForPermissionPrompt(page, 30000);
await allowPermission(page);
// Wait for file to be created
await expect
.poll(() => existsSync(filePath), {
message: `File ${filePath} should exist after allowing permission`,
timeout: 30000,
})
.toBe(true);
// After allowing, the file should be created successfully
// The tool call count might still be 1 if the UI updates quickly
const fileContent = await readFile(filePath, 'utf-8');
expect(fileContent.trim()).toBe(FILE_CONTENT);
} finally {
await repo.cleanup();
}
});
test('deny permission does not create the file', async ({ page }) => {
const repo = await createTempGitRepo();
const uniqueFilename = `test-deny-${Date.now()}.txt`;
const filePath = path.join(repo.path, uniqueFilename);
const shellCommand = buildWriteCommand(filePath);
const prompt = [
`Use your shell tool to run exactly this command:`,
shellCommand,
`Do not write outside this exact path.`,
].join(' ');
try {
await createAgentWithConfig(page, {
directory: repo.path,
provider: 'claude',
mode: 'Always Ask',
prompt,
});
await waitForPermissionPrompt(page, 30000);
await denyPermission(page);
await waitForAgentFinishUI(page, 30000);
await expect(page.getByTestId('permission-request-question')).toHaveCount(0);
expect(existsSync(filePath)).toBe(false);
} finally {
await repo.cleanup();
}
});
});

View File

@@ -1,25 +0,0 @@
import { test, expect } from './fixtures';
import { gotoHome, ensureHostSelected, setWorkingDirectory } from './helpers/app';
test('preserves prompt text when trying to create agent with non-existent directory', async ({ page }) => {
const nonExistentDir = '/non/existent/directory/that/does/not/exist';
const promptText = `Test prompt that should be preserved ${Date.now()}`;
await gotoHome(page);
await ensureHostSelected(page);
await setWorkingDirectory(page, nonExistentDir);
// Enter prompt text
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable();
await input.fill(promptText);
// Try to submit - this should fail with an error about the directory not existing
await input.press('Enter');
// Verify error message is displayed (error includes the path)
await expect(page.getByText(/Working directory does not exist/)).toBeVisible();
// Verify the prompt text is still preserved in the input
await expect(input).toHaveValue(promptText);
});

View File

@@ -1,44 +0,0 @@
import { test, expect } from './fixtures';
import { gotoHome, openSettings } from './helpers/app';
test('connects via relay when direct endpoints fail', async ({ page }) => {
const relayPort = process.env.E2E_RELAY_PORT;
const serverId = process.env.E2E_SERVER_ID;
const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
if (!relayPort || !serverId || !daemonPublicKeyB64) {
throw new Error(
'E2E_RELAY_PORT, E2E_SERVER_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).'
);
}
const nowIso = new Date().toISOString();
const relayEndpoint = `127.0.0.1:${relayPort}`;
const host = {
serverId,
label: 'relay-daemon',
connections: [
{ id: 'direct:127.0.0.1:9', type: 'direct', endpoint: '127.0.0.1:9' },
{ id: `relay:${relayEndpoint}`, type: 'relay', relayEndpoint, daemonPublicKeyB64 },
],
preferredConnectionId: 'direct:127.0.0.1:9',
createdAt: nowIso,
updatedAt: nowIso,
};
// Override the default fixture seeding for this test.
await gotoHome(page);
await openSettings(page);
await page.evaluate((daemon) => {
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
localStorage.removeItem('@paseo:settings');
}, host);
await page.reload();
// Should eventually connect through the relay connection.
const card = page.getByTestId(`daemon-card-${serverId}`);
await expect(card.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 });
await expect(card.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 });
});

View File

@@ -1,62 +0,0 @@
import { test, expect } from './fixtures';
import { gotoHome, openSettings } from './helpers/app';
test('relay connection stays stable across multiple tabs', async ({ page }) => {
const relayPort = process.env.E2E_RELAY_PORT;
const serverId = process.env.E2E_SERVER_ID;
const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
if (!relayPort || !serverId || !daemonPublicKeyB64) {
throw new Error(
'E2E_RELAY_PORT, E2E_SERVER_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).'
);
}
const nowIso = new Date().toISOString();
const relayEndpoint = `127.0.0.1:${relayPort}`;
const host = {
serverId,
label: 'relay-daemon',
connections: [
{ id: 'direct:127.0.0.1:9', type: 'direct', endpoint: '127.0.0.1:9' },
{ id: `relay:${relayEndpoint}`, type: 'relay', relayEndpoint, daemonPublicKeyB64 },
],
preferredConnectionId: 'direct:127.0.0.1:9',
createdAt: nowIso,
updatedAt: nowIso,
};
// Use relay by making the direct endpoint intentionally fail.
await gotoHome(page);
await openSettings(page);
await page.evaluate((daemon) => {
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
localStorage.removeItem('@paseo:settings');
}, host);
await page.reload();
const card = page.getByTestId(`daemon-card-${serverId}`);
await expect(card.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 });
await expect(card.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 });
// Open a second tab. It should be able to connect independently without forcing disconnect churn.
const page2 = await page.context().newPage();
await page2.route(/:(6767)\b/, (route) => route.abort());
await page2.routeWebSocket(/:(6767)\b/, async (ws) => {
await ws.close({ code: 1008, reason: 'Blocked connection to localhost:6767 during e2e.' });
});
await page2.goto('/');
const settingsButton2 = page2.locator('[data-testid="sidebar-settings"]:visible').first();
await expect(settingsButton2).toBeVisible({ timeout: 20000 });
await settingsButton2.click();
const card2 = page2.getByTestId(`daemon-card-${serverId}`);
await expect(card2.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 });
await expect(card2.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 });
// Stability window: keep both tabs open and ensure they remain online.
await page.waitForTimeout(30_000);
await expect(card.getByText('Online', { exact: true })).toBeVisible();
await expect(card2.getByText('Online', { exact: true })).toBeVisible();
});

View File

@@ -1,39 +0,0 @@
import { test, expect } from './fixtures';
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
test('sidebar New Agent opens a fresh create screen', async ({ page }) => {
const repoA = await createTempGitRepo();
try {
await gotoHome(page);
await ensureHostSelected(page);
await setWorkingDirectory(page, repoA.path);
await createAgent(page, 'Agent A: respond with exactly A');
await expect(page).toHaveURL(/\/agent\//);
// Click sidebar New Agent and assert it re-opens the host draft route while
// preserving working directory context from the selected agent.
await page.getByTestId('sidebar-new-agent').click();
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
const searchWorkingDir = await page.evaluate(() => {
try {
return new URL(window.location.href).searchParams.get('workingDir');
} catch {
return null;
}
});
const normalizedCandidates = new Set<string>([repoA.path]);
if (repoA.path.startsWith('/var/')) {
normalizedCandidates.add(`/private${repoA.path}`);
}
if (repoA.path.startsWith('/private/var/')) {
normalizedCandidates.add(repoA.path.replace(/^\/private/, ''));
}
expect(searchWorkingDir).not.toBeNull();
expect(normalizedCandidates.has(searchWorkingDir ?? '')).toBe(true);
} finally {
await repoA.cleanup();
}
});

View File

@@ -1,117 +0,0 @@
import { test, expect } from "./fixtures";
import { gotoHome } from "./helpers/app";
test("project filter dropdown never appears visibly at 0,0 on open", async ({ page }) => {
await gotoHome(page);
const trigger = page.getByText("Project", { exact: true }).first();
await expect(trigger).toBeVisible();
await page.evaluate(() => {
(window as any).__projectFilterFlashProbe = new Promise<{
targetFound: boolean;
visibleAtOrigin: boolean;
records: Array<{ left: number; top: number; opacity: number }>;
}>((resolve) => {
let target: HTMLElement | null = null;
const records: Array<{ left: number; top: number; opacity: number }> = [];
const capture = () => {
if (!target) return;
const style = getComputedStyle(target);
records.push({
left: Number.parseFloat(style.left || "0"),
top: Number.parseFloat(style.top || "0"),
opacity: Number.parseFloat(style.opacity || "1"),
});
};
const tryResolveTarget = (root: HTMLElement) => {
const stack = [root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))];
for (const element of stack) {
if (element.dataset?.testid === "combobox-desktop-container") {
target = element;
return true;
}
}
return false;
};
const finish = () => {
observer.disconnect();
if (!target) {
resolve({
targetFound: false,
visibleAtOrigin: false,
records: [],
});
return;
}
const visibleAtOrigin = records.some(
(entry) => entry.left <= 1 && entry.top <= 1 && entry.opacity > 0.01
);
resolve({
targetFound: true,
visibleAtOrigin,
records,
});
};
const sampleFrames = () => {
capture();
requestAnimationFrame(() => {
capture();
requestAnimationFrame(() => {
capture();
requestAnimationFrame(() => {
capture();
requestAnimationFrame(() => {
capture();
finish();
});
});
});
});
};
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const added of Array.from(mutation.addedNodes)) {
if (!(added instanceof HTMLElement)) continue;
if (tryResolveTarget(added)) {
sampleFrames();
return;
}
}
if (
mutation.type === "attributes" &&
mutation.target instanceof HTMLElement &&
!target &&
tryResolveTarget(mutation.target)
) {
sampleFrames();
return;
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["style"],
});
setTimeout(() => finish(), 2500);
});
});
await trigger.click();
const probe = await page.evaluate(() => (window as any).__projectFilterFlashProbe);
expect(probe.targetFound).toBe(true);
expect(probe.visibleAtOrigin).toBe(false);
});

View File

@@ -1,47 +0,0 @@
import { test, expect } from './fixtures';
import { gotoHome, openSettings } from './helpers/app';
test('sidebar toggle shows tooltip on the right', async ({ page }) => {
await gotoHome(page);
await openSettings(page);
const menuButton = page.getByRole('button', { name: /menu/i }).first();
await expect(menuButton).toBeVisible();
// Baseline: tooltip should appear on keyboard focus (a11y requirement).
await menuButton.focus();
const tooltip = page.getByTestId('menu-button-tooltip');
await expect(tooltip).toBeVisible();
await expect(tooltip).toContainText('Toggle sidebar');
await expect(tooltip).toContainText(/⌘B|Ctrl\+\./);
await page.waitForTimeout(250);
await expect(tooltip).toBeVisible();
// Tooltip should also appear on hover.
await menuButton.blur();
await expect(tooltip).toHaveCount(0);
await menuButton.hover();
await expect(tooltip).toBeVisible();
const triggerBox = await menuButton.boundingBox();
const tooltipBox = await tooltip.boundingBox();
expect(triggerBox).not.toBeNull();
expect(tooltipBox).not.toBeNull();
if (!triggerBox || !tooltipBox) return;
// side=right => tooltip starts to the right of the trigger.
expect(tooltipBox.x).toBeGreaterThan(triggerBox.x + triggerBox.width - 1);
// Keep it reasonably close (should be ~trigger.right + offset).
const expectedX = triggerBox.x + triggerBox.width + 8;
expect(Math.abs(tooltipBox.x - expectedX)).toBeLessThanOrEqual(12);
expect(tooltipBox.width).toBeGreaterThanOrEqual(60);
expect(tooltipBox.width).toBeLessThanOrEqual(500);
expect(tooltipBox.height).toBeGreaterThanOrEqual(20);
expect(tooltipBox.height).toBeLessThanOrEqual(80);
// align=center => centers should be roughly aligned (allow some clamping tolerance).
const triggerCenterY = triggerBox.y + triggerBox.height / 2;
const tooltipCenterY = tooltipBox.y + tooltipBox.height / 2;
expect(Math.abs(triggerCenterY - tooltipCenterY)).toBeLessThanOrEqual(24);
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,209 +0,0 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { test, expect, type Page } from "./fixtures";
import { setWorkingDirectory } from "./helpers/app";
import { createTempGitRepo } from "./helpers/workspace";
import {
openNewAgentComposer,
seedWorkspaceActivity,
switchWorkspaceViaSidebar,
} from "./helpers/workspace-ui";
function percentile(values: number[], p: number): number {
if (values.length === 0) {
return 0;
}
const sorted = [...values].sort((a, b) => a - b);
const rank = Math.ceil((p / 100) * sorted.length);
const index = Math.min(sorted.length - 1, Math.max(0, rank - 1));
return sorted[index] ?? 0;
}
function summarize(values: number[]) {
if (values.length === 0) {
return {
count: 0,
minMs: 0,
maxMs: 0,
avgMs: 0,
p95Ms: 0,
p99Ms: 0,
};
}
const sum = values.reduce((acc, value) => acc + value, 0);
return {
count: values.length,
minMs: Math.min(...values),
maxMs: Math.max(...values),
avgMs: Math.round((sum / values.length) * 100) / 100,
p95Ms: percentile(values, 95),
p99Ms: percentile(values, 99),
};
}
function buildStressCommand(doneMarker: string): string {
// Deterministic synthetic "TUI-like" redraw loop: alternate screen + cursor-home repaint.
const markerFile = ".paseo-terminal-benchmark-marker";
return [
"i=1",
"printf '\\033[?1049h\\033[2J'",
"while [ $i -le 240 ]; do",
"printf '\\033[H'",
"r=1",
"while [ $r -le 24 ]; do",
"printf 'bench frame:%03d row:%02d ########################################\\n' \"$i\" \"$r\"",
"r=$((r+1))",
"done",
"sleep 0.01",
"i=$((i+1))",
"done",
`printf '\\033[?1049l\\n${doneMarker}\\n'`,
`printf '${doneMarker}\\n' > '${markerFile}'`,
].join("; ");
}
async function markerFileContains(filePath: string, marker: string): Promise<boolean> {
try {
const text = await readFile(filePath, "utf8");
return text.includes(marker);
} catch {
return false;
}
}
async function toggleExplorerAndMeasureLatency(page: Page): Promise<number> {
const toggle = page.getByTestId("workspace-explorer-toggle").first();
await expect(toggle).toBeVisible({ timeout: 30_000 });
const currentlyExpanded = (await toggle.getAttribute("aria-expanded")) === "true";
const expected = currentlyExpanded ? "false" : "true";
const start = Date.now();
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", expected, { timeout: 15_000 });
return Date.now() - start;
}
test("workspace terminal responsiveness benchmark (report-only, single stress profile)", async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
const repo = await createTempGitRepo("paseo-e2e-terminal-benchmark-");
const markerFilePath = path.join(repo.path, ".paseo-terminal-benchmark-marker");
try {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
await openNewAgentComposer(page);
await setWorkingDirectory(page, repo.path);
await seedWorkspaceActivity(page, `terminal benchmark seed ${Date.now()}`);
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path });
await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({
timeout: 30_000,
});
const newTerminalButton = page.getByTestId("workspace-new-terminal-tab").first();
await expect(newTerminalButton).toBeVisible({ timeout: 30_000 });
await newTerminalButton.click();
const surface = page.locator('[data-testid="terminal-surface"]:visible').first();
await expect(surface).toBeVisible({ timeout: 60_000 });
await surface.click({ force: true });
await page.evaluate(() => {
const monitor = {
samples: [] as number[],
active: true,
rafId: 0,
lastTs: performance.now(),
};
const tick = (ts: number) => {
if (!monitor.active) {
return;
}
monitor.samples.push(ts - monitor.lastTs);
monitor.lastTs = ts;
monitor.rafId = requestAnimationFrame(tick);
};
monitor.rafId = requestAnimationFrame(tick);
(window as { __PASEO_E2E_RAF_MONITOR__?: { stop: () => { samples: number[] } } }).__PASEO_E2E_RAF_MONITOR__ =
{
stop: () => {
if (!monitor.active) {
return { samples: monitor.samples };
}
monitor.active = false;
cancelAnimationFrame(monitor.rafId);
return { samples: monitor.samples };
},
};
});
const doneMarker = `TERMINAL_BENCH_DONE_${Date.now()}`;
const postMarker = `TERMINAL_BENCH_POST_${Date.now()}`;
const stressCommand = buildStressCommand(doneMarker);
await page.keyboard.type(stressCommand, { delay: 0 });
await page.keyboard.press("Enter");
const interactionLatenciesMs: number[] = [];
for (let attempt = 0; attempt < 18; attempt += 1) {
const latency = await toggleExplorerAndMeasureLatency(page);
interactionLatenciesMs.push(latency);
}
const rafResult = await page.evaluate(() => {
const handle = (
window as { __PASEO_E2E_RAF_MONITOR__?: { stop: () => { samples: number[] } } }
).__PASEO_E2E_RAF_MONITOR__;
if (!handle || typeof handle.stop !== "function") {
return { samples: [] as number[] };
}
return handle.stop();
});
await surface.click({ force: true });
await page.keyboard.type(`echo ${postMarker} >> .paseo-terminal-benchmark-marker`, { delay: 0 });
await page.keyboard.press("Enter");
await expect.poll(async () => await markerFileContains(markerFilePath, postMarker), {
timeout: 120_000,
}).toBe(true);
const frameGapsMs = (rafResult.samples ?? []).filter(
(sample) => Number.isFinite(sample) && sample > 0
);
const report = {
mode: "report-only",
profile: "single-stress",
generatedAt: new Date().toISOString(),
workload: {
frames: 240,
rowsPerFrame: 24,
frameSleepMs: 10,
doneMarker,
postMarker,
doneMarkerObserved: await markerFileContains(markerFilePath, doneMarker),
},
frameGapMs: {
...summarize(frameGapsMs),
over100Ms: frameGapsMs.filter((gap) => gap > 100).length,
over250Ms: frameGapsMs.filter((gap) => gap > 250).length,
over500Ms: frameGapsMs.filter((gap) => gap > 500).length,
},
explorerToggleLatencyMs: summarize(interactionLatenciesMs),
};
await testInfo.attach("terminal-responsiveness-report", {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
} finally {
await repo.cleanup();
}
});

View File

@@ -1,158 +0,0 @@
import { expect, test } from "./fixtures";
import { gotoHome } from "./helpers/app";
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
test("working directory combobox stays visually stable while typing search", async ({ page }) => {
await gotoHome(page);
const workingDirectorySelect = page
.locator('[data-testid="working-directory-select"]:visible')
.first();
await expect(workingDirectorySelect).toBeVisible();
await workingDirectorySelect.click({ force: true });
const searchInput = page.getByRole("textbox", { name: /search directories/i }).first();
await expect(searchInput).toBeVisible();
await page.evaluate(() => {
const trigger = document.querySelector('[data-testid="working-directory-select"]');
const searchInput = document.querySelector('input[placeholder="Search directories..."]');
const container = document.querySelector('[data-testid="combobox-desktop-container"]');
if (!(trigger instanceof HTMLElement)) {
throw new Error("Missing working-directory-select trigger.");
}
if (!(searchInput instanceof HTMLInputElement)) {
throw new Error("Missing working directory search input.");
}
if (!(container instanceof HTMLElement)) {
throw new Error("Missing combobox desktop container.");
}
const state = {
samples: 0,
underTriggerSamples: 0,
emptyWhileSearchingSamples: 0,
minSearchDelta: Number.POSITIVE_INFINITY,
maxSearchDelta: Number.NEGATIVE_INFINITY,
minContainerDelta: Number.POSITIVE_INFINITY,
maxContainerDelta: Number.NEGATIVE_INFINITY,
logs: [] as Array<{
reason: string;
query: string;
searchDelta: number;
containerDelta: number;
hasEmpty: boolean;
containerTop: number;
containerBottom: number;
triggerTop: number;
searchBottom: number;
}>,
};
const sample = (reason: string) => {
if (!document.body.contains(trigger) || !document.body.contains(searchInput) || !document.body.contains(container)) {
return;
}
const query = searchInput.value.trim();
if (!query) {
return;
}
const triggerRect = trigger.getBoundingClientRect();
const searchRect = searchInput.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const hasEmpty = Boolean(container.querySelector('[data-testid="combobox-empty-text"]'));
const searchDelta = searchRect.bottom - triggerRect.top;
const containerDelta = containerRect.bottom - triggerRect.top;
state.samples += 1;
state.minSearchDelta = Math.min(state.minSearchDelta, searchDelta);
state.maxSearchDelta = Math.max(state.maxSearchDelta, searchDelta);
state.minContainerDelta = Math.min(state.minContainerDelta, containerDelta);
state.maxContainerDelta = Math.max(state.maxContainerDelta, containerDelta);
if (searchDelta > 2 || containerDelta > 2) {
state.underTriggerSamples += 1;
}
if (hasEmpty) {
state.emptyWhileSearchingSamples += 1;
}
if (state.logs.length < 80) {
state.logs.push({
reason,
query,
searchDelta,
containerDelta,
hasEmpty,
containerTop: containerRect.top,
containerBottom: containerRect.bottom,
triggerTop: triggerRect.top,
searchBottom: searchRect.bottom,
});
}
};
const mutationObserver = new MutationObserver(() => sample("mutation"));
mutationObserver.observe(container, {
childList: true,
subtree: true,
attributes: true,
characterData: true,
});
const resizeObserver = new ResizeObserver(() => sample("resize"));
resizeObserver.observe(container);
resizeObserver.observe(searchInput);
let rafId = 0;
const loop = () => {
sample("raf");
rafId = requestAnimationFrame(loop);
};
rafId = requestAnimationFrame(loop);
(window as any).__paseoComboboxObserver = {
stop: () => {
cancelAnimationFrame(rafId);
mutationObserver.disconnect();
resizeObserver.disconnect();
sample("stop");
return {
...state,
minSearchDelta: state.samples > 0 ? state.minSearchDelta : 0,
maxSearchDelta: state.samples > 0 ? state.maxSearchDelta : 0,
minContainerDelta: state.samples > 0 ? state.minContainerDelta : 0,
maxContainerDelta: state.samples > 0 ? state.maxContainerDelta : 0,
};
},
};
});
const queries = [
"/tmp/paseo-stability-a",
"/tmp/paseo-stability-ab",
"/tmp/paseo-stability-abc",
"/tmp/paseo-stability-longer-branch",
"/tmp/paseo-stability-z",
];
for (const query of queries) {
await searchInput.fill("");
await searchInput.type(query, { delay: 20 });
const customOption = page.getByText(new RegExp(`^${escapeRegex(query)}$`)).first();
await expect(customOption).toBeVisible();
await page.waitForTimeout(100);
}
const stats = await page.evaluate(() => (window as any).__paseoComboboxObserver.stop());
const debug = JSON.stringify(stats.logs.slice(-10));
expect(stats.samples, debug).toBeGreaterThan(20);
expect(stats.underTriggerSamples, debug).toBe(0);
expect(stats.emptyWhileSearchingSamples, debug).toBe(0);
expect(stats.maxSearchDelta - stats.minSearchDelta, debug).toBeLessThanOrEqual(3);
expect(stats.maxContainerDelta - stats.minContainerDelta, debug).toBeLessThanOrEqual(3);
expect(stats.maxContainerDelta, debug).toBeLessThanOrEqual(2);
});

View File

@@ -1,107 +0,0 @@
import { test, expect } from './fixtures'
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'
import { createTempGitRepo } from './helpers/workspace'
import {
ensureWorkspaceAgentPaneVisible,
getWorkspaceTabTestIds,
waitForWorkspaceTabsVisible,
} from './helpers/workspace-tabs'
import { switchWorkspaceViaSidebar } from './helpers/workspace-ui'
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
test('workspace draft tab uses input status controls with optimistic create and in-place transition', async ({
page,
}) => {
const serverId = process.env.E2E_SERVER_ID
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set.')
}
const repo = await createTempGitRepo('paseo-e2e-workspace-draft-')
const seedPrompt = `seed workspace ${Date.now()}`
const createPrompt = `workspace draft prompt ${Date.now()}`
try {
await gotoHome(page)
await ensureHostSelected(page)
// Force the setup onto the global draft surface. In slow runs the app can
// still be on a previously opened agent/workspace view where the placement
// form is not rendered.
await page.goto(`/h/${serverId}/new-agent`)
await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({
timeout: 30_000,
})
await setWorkingDirectory(page, repo.path)
const seedComposer = page.getByRole('textbox', { name: 'Message agent...' }).first()
await seedComposer.fill(seedPrompt)
await page.getByRole('button', { name: /send message/i }).first().click()
await expect(page.getByText(seedPrompt, { exact: true }).first()).toBeVisible({
timeout: 30_000,
})
await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 })
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path })
const workspaceRouteToken = page.url().match(/\/workspace\/([^/?#]+)/)?.[1] ?? null
expect(workspaceRouteToken).toBeTruthy()
await waitForWorkspaceTabsVisible(page)
await ensureWorkspaceAgentPaneVisible(page)
const beforeDraftIds = await getWorkspaceTabTestIds(page)
await page.getByTestId('workspace-new-agent-tab').first().click()
await ensureWorkspaceAgentPaneVisible(page)
const withDraftIds = await getWorkspaceTabTestIds(page)
const draftTabTestId = withDraftIds.find((id) => !beforeDraftIds.includes(id))
if (!draftTabTestId) {
throw new Error('Expected a draft workspace tab to be created.')
}
const draftId = draftTabTestId.replace('workspace-tab-', '')
const draftCloseButton = page.getByTestId(`workspace-draft-close-${draftId}`).first()
await expect(draftCloseButton).toBeVisible({ timeout: 30_000 })
await expect(page.getByTestId('working-directory-select').first()).not.toBeVisible()
await expect(page.getByTestId('worktree-select-trigger').first()).not.toBeVisible()
const providerSelector = page.getByTestId('agent-provider-selector').first()
const modeSelector = page.getByTestId('agent-mode-selector').first()
const modelSelector = page.getByTestId('agent-model-selector').first()
const thinkingSelector = page.getByTestId('agent-thinking-selector').first()
await expect(providerSelector).toBeVisible({ timeout: 30_000 })
await expect(modeSelector).toBeVisible({ timeout: 30_000 })
await expect(modelSelector).toBeVisible({ timeout: 30_000 })
await expect(thinkingSelector).toBeVisible({ timeout: 30_000 })
const selectedMode = ((await modeSelector.innerText()) ?? '').trim()
const selectedModel = ((await modelSelector.innerText()) ?? '').trim()
const selectedThinking = ((await thinkingSelector.innerText()) ?? '').trim()
const composer = page.getByRole('textbox', { name: 'Message agent...' }).first()
await composer.fill(createPrompt)
await composer.press('Enter')
await expect(page.getByText(createPrompt, { exact: true }).first()).toBeVisible({ timeout: 5_000 })
await expect(draftCloseButton).not.toBeVisible({ timeout: 30_000 })
const finalIds = await getWorkspaceTabTestIds(page)
expect(finalIds).toContain(draftTabTestId)
await expect(modelSelector).toContainText(new RegExp(escapeRegex(selectedModel), 'i'))
await expect(modeSelector).toContainText(new RegExp(escapeRegex(selectedMode), 'i'))
await expect(thinkingSelector).toContainText(new RegExp(escapeRegex(selectedThinking), 'i'))
const currentUrl = page.url()
if (!workspaceRouteToken) {
throw new Error('Expected workspace route token to be present.')
}
expect(currentUrl).toContain(`/workspace/${workspaceRouteToken}`)
} finally {
await repo.cleanup()
}
})

View File

@@ -1,125 +0,0 @@
import { test, expect } from "./fixtures";
import type { Page } from "@playwright/test";
import { createAgentInRepo } from "./helpers/app";
import { createTempGitRepo } from "./helpers/workspace";
import {
ensureWorkspaceAgentPaneVisible,
getWorkspaceTabTestIds,
sampleWorkspaceTabIds,
waitForWorkspaceTabsVisible,
} from "./helpers/workspace-tabs";
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
async function expectComposerFocused(page: Page) {
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
await expect(composer).toBeEditable({ timeout: 30_000 });
await expect
.poll(async () => {
return await composer.evaluate(
(element) => document.activeElement === element
);
})
.toBe(true);
}
test("workspace draft submit retargets tab in place without transient extra tabs", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const repo = await createTempGitRepo("paseo-e2e-draft-retarget-");
const seedPrompt = `seed prompt ${Date.now()}`;
const createPrompt = `retarget prompt ${Date.now()}`;
try {
await createAgentInRepo(page, { directory: repo.path, prompt: seedPrompt });
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path });
await waitForWorkspaceTabsVisible(page);
await ensureWorkspaceAgentPaneVisible(page);
const beforeDraftIds = await getWorkspaceTabTestIds(page);
await page.getByTestId("workspace-new-agent-tab").first().click();
await ensureWorkspaceAgentPaneVisible(page);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable();
const withDraftIds = await getWorkspaceTabTestIds(page);
expect(withDraftIds.length).toBe(beforeDraftIds.length + 1);
const draftTabTestId = withDraftIds.find((id) => !beforeDraftIds.includes(id));
expect(draftTabTestId).toBeTruthy();
const draftId = draftTabTestId!.replace("workspace-tab-", "");
const draftCloseButton = page.getByTestId(`workspace-draft-close-${draftId}`).first();
await expect(draftCloseButton).toBeVisible({ timeout: 30_000 });
const samplingPromise = sampleWorkspaceTabIds(page, { durationMs: 3_000, intervalMs: 40 });
const input = page.getByRole("textbox", { name: "Message agent..." });
await input.fill(createPrompt);
await input.press("Enter");
await expect(page.getByText(createPrompt, { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const snapshots = await samplingPromise;
const maxObservedCount = snapshots.reduce((max, ids) => Math.max(max, ids.length), 0);
expect(maxObservedCount).toBe(withDraftIds.length);
const finalIds = await getWorkspaceTabTestIds(page);
expect(finalIds.length).toBe(withDraftIds.length);
expect(finalIds).toContain(draftTabTestId!);
await expect(draftCloseButton).not.toBeVisible({ timeout: 30_000 });
} finally {
await repo.cleanup();
}
});
test("workspace agent tab switch focuses composer on desktop web", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const repo = await createTempGitRepo("paseo-e2e-tab-focus-");
const firstPrompt = `first tab prompt ${Date.now()}`;
const secondPrompt = `second tab prompt ${Date.now()}`;
try {
await createAgentInRepo(page, { directory: repo.path, prompt: firstPrompt });
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path });
await waitForWorkspaceTabsVisible(page);
await ensureWorkspaceAgentPaneVisible(page);
const beforeSecondAgentIds = await getWorkspaceTabTestIds(page);
await page.getByTestId("workspace-new-agent-tab").first().click();
await expectComposerFocused(page);
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
await composer.fill(secondPrompt);
await composer.press("Enter");
await expect(page.getByText(secondPrompt, { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const withSecondAgentIds = await getWorkspaceTabTestIds(page);
const secondAgentTabTestId = withSecondAgentIds.find(
(id) => !beforeSecondAgentIds.includes(id)
);
if (!secondAgentTabTestId) {
throw new Error("Expected second agent tab to be created.");
}
const firstAgentTabTestId = beforeSecondAgentIds[0];
if (!firstAgentTabTestId) {
throw new Error("Expected first agent tab to exist.");
}
await page.getByTestId(firstAgentTabTestId).first().click();
await expectComposerFocused(page);
await page.getByTestId(secondAgentTabTestId).first().click();
await expectComposerFocused(page);
} finally {
await repo.cleanup();
}
});

View File

@@ -1,165 +0,0 @@
import { test, expect, type Page } from "./fixtures";
import { createAgentInRepo } from "./helpers/app";
import { createTempGitRepo } from "./helpers/workspace";
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
async function openWorkspaceWithAgent(page: Page, workspacePath: string): Promise<void> {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
await createAgentInRepo(page, {
directory: workspacePath,
prompt: `workspace header restore ${Date.now()}`,
});
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: workspacePath });
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible({
timeout: 30000,
});
await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({
timeout: 30000,
});
}
test("workspace new-tab buttons stay on-screen during horizontal scroll", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-workspace-new-tab-");
try {
await openWorkspaceWithAgent(page, repo.path);
const agentButton = page.getByTestId("workspace-new-agent-tab").first();
const terminalButton = page.getByTestId("workspace-new-terminal-tab").first();
const tabsScroll = page.getByTestId("workspace-tabs-scroll").first();
await expect(agentButton).toBeVisible({ timeout: 30000 });
await expect(terminalButton).toBeVisible({ timeout: 30000 });
await expect(tabsScroll).toBeVisible({ timeout: 30000 });
// Create enough terminal tabs to ensure the tabs row has overflow to scroll.
const workspaceTabs = page.locator(
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])'
);
const initialTabCount = await workspaceTabs.count();
const targetTabCount = initialTabCount + 8;
for (let attempt = initialTabCount; attempt < targetTabCount; attempt += 1) {
await expect(terminalButton).toBeEnabled({ timeout: 30000 });
await terminalButton.click();
await expect
.poll(async () => await workspaceTabs.count(), { timeout: 30000 })
.toBeGreaterThanOrEqual(attempt + 1);
}
const agentBoundsBefore = await agentButton.boundingBox();
const terminalBoundsBefore = await terminalButton.boundingBox();
const viewport = page.viewportSize();
expect(agentBoundsBefore).not.toBeNull();
expect(terminalBoundsBefore).not.toBeNull();
expect(viewport).not.toBeNull();
if (!agentBoundsBefore || !terminalBoundsBefore || !viewport) {
return;
}
expect(agentBoundsBefore.x).toBeGreaterThanOrEqual(0);
expect(agentBoundsBefore.y).toBeGreaterThanOrEqual(0);
expect(agentBoundsBefore.x + agentBoundsBefore.width).toBeLessThanOrEqual(viewport.width);
expect(agentBoundsBefore.y + agentBoundsBefore.height).toBeLessThanOrEqual(viewport.height);
expect(terminalBoundsBefore.x).toBeGreaterThanOrEqual(0);
expect(terminalBoundsBefore.y).toBeGreaterThanOrEqual(0);
expect(terminalBoundsBefore.x + terminalBoundsBefore.width).toBeLessThanOrEqual(viewport.width);
expect(terminalBoundsBefore.y + terminalBoundsBefore.height).toBeLessThanOrEqual(viewport.height);
// Scroll tabs horizontally; the new-tab buttons should remain fixed on the right edge.
await tabsScroll.evaluate((el) => {
(el as HTMLElement).scrollLeft = (el as HTMLElement).scrollWidth;
});
await page.waitForTimeout(200);
const agentBoundsAfter = await agentButton.boundingBox();
const terminalBoundsAfter = await terminalButton.boundingBox();
expect(agentBoundsAfter).not.toBeNull();
expect(terminalBoundsAfter).not.toBeNull();
if (!agentBoundsAfter || !terminalBoundsAfter) {
return;
}
expect(agentBoundsAfter.x).toBeGreaterThanOrEqual(0);
expect(agentBoundsAfter.y).toBeGreaterThanOrEqual(0);
expect(agentBoundsAfter.x + agentBoundsAfter.width).toBeLessThanOrEqual(viewport.width);
expect(agentBoundsAfter.y + agentBoundsAfter.height).toBeLessThanOrEqual(viewport.height);
expect(terminalBoundsAfter.x).toBeGreaterThanOrEqual(0);
expect(terminalBoundsAfter.y).toBeGreaterThanOrEqual(0);
expect(terminalBoundsAfter.x + terminalBoundsAfter.width).toBeLessThanOrEqual(viewport.width);
expect(terminalBoundsAfter.y + terminalBoundsAfter.height).toBeLessThanOrEqual(viewport.height);
} finally {
await repo.cleanup();
}
});
test("workspace new-tab buttons sit immediately after tabs before overflow", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-workspace-new-tab-adjacent-");
try {
await openWorkspaceWithAgent(page, repo.path);
const agentButton = page.getByTestId("workspace-new-agent-tab").first();
const workspaceTabs = page.locator(
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])'
);
await expect(agentButton).toBeVisible({ timeout: 30000 });
await expect(workspaceTabs).toHaveCount(1, { timeout: 30000 });
const lastTabBounds = await workspaceTabs.last().boundingBox();
const agentBounds = await agentButton.boundingBox();
expect(lastTabBounds).not.toBeNull();
expect(agentBounds).not.toBeNull();
if (!lastTabBounds || !agentBounds) {
return;
}
const horizontalGap = agentBounds.x - (lastTabBounds.x + lastTabBounds.width);
expect(horizontalGap).toBeGreaterThanOrEqual(0);
expect(horizontalGap).toBeLessThanOrEqual(24);
} finally {
await repo.cleanup();
}
});
test("workspace explorer toggle opens and closes explorer", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-workspace-explorer-toggle-");
try {
await openWorkspaceWithAgent(page, repo.path);
const toggle = page.getByTestId("workspace-explorer-toggle").first();
const explorerHeader = page.locator('[data-testid="explorer-header"]:visible').first();
await expect(toggle).toBeVisible({ timeout: 30000 });
const initiallyExpanded = (await toggle.getAttribute("aria-expanded")) === "true";
if (initiallyExpanded) {
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(explorerHeader).not.toBeVisible({ timeout: 10000 });
}
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(explorerHeader).toBeVisible({ timeout: 10000 });
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(explorerHeader).not.toBeVisible({ timeout: 10000 });
} finally {
await repo.cleanup();
}
});

View File

@@ -1,58 +0,0 @@
import { execSync } from 'node:child_process';
import { test } from './fixtures';
import { setWorkingDirectory } from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
import {
expectWorkspaceHeader,
openNewAgentComposer,
seedWorkspaceActivity,
switchWorkspaceViaSidebar,
workspaceLabelFromPath,
} from './helpers/workspace-ui';
test('sidebar workspace switch keeps visible content in sync with selected workspace', async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set.');
}
const repoA = await createTempGitRepo('paseo-e2e-sync-a-');
const repoB = await createTempGitRepo('paseo-e2e-sync-b-');
const tokenA = `SYNC_A_${Date.now()}`;
const tokenB = `SYNC_B_${Date.now()}`;
try {
execSync('git checkout -b sync-a-branch', { cwd: repoA.path, stdio: 'ignore' });
execSync('git checkout -b sync-b-branch', { cwd: repoB.path, stdio: 'ignore' });
await openNewAgentComposer(page);
await setWorkingDirectory(page, repoA.path);
await seedWorkspaceActivity(page, tokenA);
await openNewAgentComposer(page);
await setWorkingDirectory(page, repoB.path);
await seedWorkspaceActivity(page, tokenB);
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoA.path });
await expectWorkspaceHeader(page, {
title: 'sync-a-branch',
subtitle: workspaceLabelFromPath(repoA.path),
});
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoB.path });
await expectWorkspaceHeader(page, {
title: 'sync-b-branch',
subtitle: workspaceLabelFromPath(repoB.path),
});
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoA.path });
await expectWorkspaceHeader(page, {
title: 'sync-a-branch',
subtitle: workspaceLabelFromPath(repoA.path),
});
} finally {
await repoA.cleanup();
await repoB.cleanup();
}
});

View File

@@ -1,6 +1,4 @@
const { getDefaultConfig } = require("expo/metro-config");
const exclusionList =
require("@expo/metro/metro-config/defaults/exclusionList").default;
const { resolve } = require("metro-resolver");
const fs = require("fs");
const path = require("path");
@@ -18,8 +16,10 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
const config = getDefaultConfig(projectRoot);
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
const escapedAppSrcRoot = appSrcRoot
.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&")
.replace(/\//g, "[\\\\/]");
.split(path.sep)
.map((segment) => segment.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&"))
.join("[\\\\/]");
const pathSeparatorPattern = "[\\\\/]";
config.resolver.extraNodeModules = {
...(config.resolver.extraNodeModules ?? {}),
@@ -28,9 +28,9 @@ config.resolver.extraNodeModules = {
"react/jsx-runtime": path.join(appNodeModulesRoot, "react/jsx-runtime"),
"react/jsx-dev-runtime": path.join(appNodeModulesRoot, "react/jsx-dev-runtime"),
};
config.resolver.blockList = exclusionList([
new RegExp(`^${escapedAppSrcRoot}[\\\\/].*\\.(test|spec)\\.(ts|tsx)$`),
]);
config.resolver.blockList = new RegExp(
`(^${escapedAppSrcRoot}${pathSeparatorPattern}.*\\.(test|spec)\\.(ts|tsx)$|${pathSeparatorPattern}__tests__${pathSeparatorPattern}.*)$`,
);
function isLocalModuleImport(moduleName) {
return (

View File

@@ -1,17 +1,16 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.26",
"version": "0.1.28",
"private": true,
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "npm run android:development",
"android:clear-autolinking-cache": "node -e \"require('node:fs').rmSync('android/build/generated/autolinking', { recursive: true, force: true })\"",
"android:development": "npm run android:clear-autolinking-cache && APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
"android:production": "npm run android:clear-autolinking-cache && APP_VARIANT=production expo prebuild --platform android --non-interactive && APP_VARIANT=production expo run:android --variant=release",
"android:development": "APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
"android:production": "APP_VARIANT=production expo prebuild --platform android --non-interactive && APP_VARIANT=production expo run:android --variant=release",
"android:release": "npm run android:production",
"android:clean": "expo prebuild --platform android --clean --non-interactive",
"android:clear": "node -e \"require('node:fs').rmSync('android', { recursive: true, force: true })\"",
"ios": "expo run:ios",
"ios:release": "expo run:ios --configuration Release",
"web": "expo start --web",
@@ -24,16 +23,16 @@
"build": "npm run build:web",
"build:web": "expo export --platform web",
"build:web:tauri": "PASEO_WEB_PLATFORM=tauri expo export --platform web",
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app"
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main"
},
"dependencies": {
"@boudra/expo-two-way-audio": "^0.1.3",
"@getpaseo/expo-two-way-audio": "0.1.28",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/server": "0.1.26",
"@getpaseo/server": "0.1.28",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",

View File

@@ -50,6 +50,7 @@ import { getIsTauri } from "@/constants/layout";
import { CommandCenter } from "@/components/command-center";
import { ProjectPickerModal } from "@/components/project-picker-modal";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { listenToDesktopNotificationClicks } from "@/desktop/notifications/desktop-notifications";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { queryClient } from "@/query/query-client";
import {
@@ -60,6 +61,7 @@ import {
import { buildNotificationRoute } from "@/utils/notification-routing";
import {
buildHostRootRoute,
parseServerIdFromPathname,
parseHostAgentRouteFromPathname,
parseWorkspaceOpenIntent,
} from "@/utils/host-routes";
@@ -70,18 +72,6 @@ polyfillCrypto();
attachConsole();
const HostRuntimeBootstrapContext = createContext(false);
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
function logLeftSidebarOpenGesture(
event: string,
details: Record<string, unknown>
): void {
if (!IS_DEV) {
return;
}
console.log(`[LeftSidebarOpenGesture] ${event}`, details);
}
function PushNotificationRouter() {
const router = useRouter();
const lastHandledIdRef = useRef<string | null>(null);
@@ -89,20 +79,39 @@ function PushNotificationRouter() {
useEffect(() => {
if (Platform.OS === "web") {
if (getTauri()) {
void ensureOsNotificationPermission().then((granted) => {
console.log(
"[OSNotifications][Tauri] Startup permission preflight result:",
granted ? "granted" : "not-granted"
);
});
void ensureOsNotificationPermission();
let disposed = false;
let unlisten: (() => void) | null = null;
void listenToDesktopNotificationClicks((payload) => {
router.push(buildNotificationRoute(payload.data) as any);
})
.then((cleanup) => {
if (disposed) {
cleanup();
return;
}
unlisten = cleanup;
})
.catch((error) => {
console.error(
"[OSNotifications][Desktop] Failed to register notification click listener",
error
);
});
return () => {
disposed = true;
unlisten?.();
};
}
const target = globalThis as unknown as EventTarget;
const openFromWebClick = (event: Event) => {
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
const route = buildNotificationRoute(customEvent.detail?.data);
event.preventDefault();
router.push(route as any);
router.push(buildNotificationRoute(customEvent.detail?.data) as any);
};
target.addEventListener(
@@ -319,10 +328,6 @@ function AppContainer({
})
.onStart(() => {
isGesturing.value = true;
runOnJS(logLeftSidebarOpenGesture)("start", {
mobileView,
openGestureEnabled,
});
})
.onUpdate((event) => {
// Start from closed position (-windowWidth) and move towards 0
@@ -339,13 +344,6 @@ function AppContainer({
isGesturing.value = false;
// Open if dragged more than 1/3 of sidebar or fast swipe
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
runOnJS(logLeftSidebarOpenGesture)("end", {
translationX: event.translationX,
velocityX: event.velocityX,
shouldOpen,
mobileView,
openGestureEnabled,
});
if (shouldOpen) {
animateToOpen();
runOnJS(openAgentList)();
@@ -422,6 +420,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
return (
<VoiceProvider>
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
<HostSessionManager />
{children}
</VoiceProvider>
);
@@ -471,7 +470,8 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
const pathname = usePathname();
const params = useGlobalSearchParams<{ open?: string | string[] }>();
useFaviconStatus();
const shouldShowAppChrome = pathname !== "/" && pathname !== "";
const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
const shouldShowAppChrome = activeServerId !== null;
// Parse selectedAgentKey directly from pathname
// useLocalSearchParams doesn't update when navigating between same-pattern routes
@@ -562,7 +562,6 @@ export default function RootLayout() {
<QueryProvider>
<HostRuntimeBootstrapProvider>
<PushNotificationRouter />
<HostSessionManager />
<ProvidersWrapper>
<SidebarAnimationProvider>
<HorizontalScrollProvider>

View File

@@ -10,7 +10,7 @@ import {
shouldRedirectToWelcome,
shouldWaitOnStartupRace,
WELCOME_ROUTE,
} from './index-startup'
} from '@/app-support/index-startup'
const STARTUP_TIMEOUT_MS = 30_000
function useAnyHostOnline(serverIds: string[]): string | null {

View File

@@ -0,0 +1,14 @@
import { afterEach, describe, expect, it } from "vitest";
import { __setAttachmentStoreForTests, getAttachmentStore } from "./store";
describe("attachment store", () => {
afterEach(() => {
__setAttachmentStoreForTests(null);
});
it("creates the default web attachment store without runtime module resolution errors", async () => {
const store = await getAttachmentStore();
expect(store.storageType).toBe("web-indexeddb");
});
});

View File

@@ -7,20 +7,20 @@ let attachmentStorePromise: Promise<AttachmentStore> | null = null;
async function createAttachmentStore(): Promise<AttachmentStore> {
if (Platform.OS === "web") {
if (isTauriEnvironment()) {
const { createDesktopAttachmentStore } = require(
"@/desktop/attachments/desktop-attachment-store"
const { createDesktopAttachmentStore } = await import(
"../desktop/attachments/desktop-attachment-store"
);
return createDesktopAttachmentStore();
}
const { createIndexedDbAttachmentStore } = require(
"@/attachments/web/indexeddb-attachment-store"
const { createIndexedDbAttachmentStore } = await import(
"./web/indexeddb-attachment-store"
);
return createIndexedDbAttachmentStore();
}
const { createNativeFileAttachmentStore } = require(
"@/attachments/native/native-file-attachment-store"
const { createNativeFileAttachmentStore } = await import(
"./native/native-file-attachment-store"
);
return createNativeFileAttachmentStore();
}

View File

@@ -31,6 +31,7 @@ import { useAgentAutocomplete } from '@/hooks/use-agent-autocomplete'
import { useHostRuntimeSession } from '@/runtime/host-runtime'
import {
deleteAttachments,
persistAttachmentFromBlob,
persistAttachmentFromFileUri,
} from '@/attachments/service'
import { resolveStatusControlMode } from '@/components/agent-input-area.status-controls'
@@ -390,16 +391,24 @@ export function AgentInputArea({
async function handlePickImage() {
const result = await pickImages()
if (!result?.assets?.length) {
if (!result?.length) {
return
}
const newImages = await Promise.all(
result.assets.map(async (asset) => {
result.map(async (pickedImage) => {
if (pickedImage.source.kind === 'blob') {
return await persistAttachmentFromBlob({
blob: pickedImage.source.blob,
mimeType: pickedImage.mimeType || 'image/jpeg',
fileName: pickedImage.fileName ?? null,
})
}
return await persistAttachmentFromFileUri({
uri: asset.uri,
mimeType: asset.mimeType || 'image/jpeg',
fileName: asset.fileName ?? null,
uri: pickedImage.source.uri,
mimeType: pickedImage.mimeType || 'image/jpeg',
fileName: pickedImage.fileName ?? null,
})
})
)

View File

@@ -69,6 +69,7 @@ import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { buildHostWorkspaceFileRoute } from "@/utils/host-routes";
import { normalizeInlinePathTarget } from "@/utils/inline-path";
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem) =>
@@ -86,6 +87,7 @@ export interface AgentStreamViewProps {
pendingPermissions: Map<string, PendingPermission>;
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady?: boolean;
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
}
export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(function AgentStreamView({
@@ -96,6 +98,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
pendingPermissions,
routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true,
onOpenWorkspaceFile,
}, ref) {
const viewportRef = useRef<StreamViewportHandle | null>(null);
const { theme } = useUnistyles();
@@ -153,12 +156,17 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
return;
}
const normalized = normalizeInlinePath(target.path, agent.cwd);
const normalized = normalizeInlinePathTarget(target.path, agent.cwd);
if (!normalized) {
return;
}
if (normalized.file) {
if (onOpenWorkspaceFile) {
onOpenWorkspaceFile({ filePath: normalized.file });
return;
}
const route = buildHostWorkspaceFileRoute(
resolvedServerId,
workspaceId,
@@ -188,6 +196,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
resolvedServerId,
router,
setExplorerTabForCheckout,
onOpenWorkspaceFile,
workspaceId,
]
);
@@ -306,6 +315,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
message={item.text}
timestamp={item.timestamp.getTime()}
onInlinePathPress={handleInlinePathPress}
workspaceRoot={workspaceRoot}
/>
);
@@ -648,102 +658,6 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
);
});
function normalizeInlinePath(
rawPath: string,
cwd?: string
): { directory: string; file?: string } | null {
if (!rawPath) {
return null;
}
const normalizedInput = normalizePathInput(rawPath);
if (!normalizedInput) {
return null;
}
let normalized = normalizedInput;
const cwdRelative = resolvePathAgainstCwd(normalized, cwd);
if (cwdRelative) {
normalized = cwdRelative;
}
if (normalized.startsWith("./")) {
normalized = normalized.slice(2) || ".";
}
if (!normalized.length) {
normalized = ".";
}
if (normalized === ".") {
return { directory: "." };
}
if (normalized.endsWith("/")) {
const dir = normalized.replace(/\/+$/, "");
return { directory: dir.length > 0 ? dir : "." };
}
const lastSlash = normalized.lastIndexOf("/");
const directory = lastSlash >= 0 ? normalized.slice(0, lastSlash) : ".";
return {
directory: directory.length > 0 ? directory : ".",
file: normalized,
};
}
function normalizePathInput(value: string | undefined): string | null {
if (!value) {
return null;
}
const trimmed = value
.trim()
.replace(/^['"`]/, "")
.replace(/['"`]$/, "");
if (!trimmed) {
return null;
}
return trimmed.replace(/\\/g, "/").replace(/\/{2,}/g, "/");
}
function resolvePathAgainstCwd(pathValue: string, cwd?: string): string | null {
const normalizedCwd = normalizePathInput(cwd);
if (
!normalizedCwd ||
!isAbsolutePath(pathValue) ||
!isAbsolutePath(normalizedCwd)
) {
return null;
}
const normalizedCwdBase = normalizedCwd.replace(/\/+$/, "") || "/";
const comparePath = normalizePathForCompare(pathValue);
const compareCwd = normalizePathForCompare(normalizedCwdBase);
const prefix = normalizedCwdBase === "/" ? "/" : `${normalizedCwdBase}/`;
const comparePrefix = normalizePathForCompare(prefix);
if (comparePath === compareCwd) {
return ".";
}
if (comparePath.startsWith(comparePrefix)) {
return pathValue.slice(prefix.length) || ".";
}
return null;
}
function normalizePathForCompare(value: string): string {
return /^[A-Za-z]:/.test(value) ? value.toLowerCase() : value;
}
function isAbsolutePath(value: string): boolean {
return value.startsWith("/") || /^[A-Za-z]:\//.test(value);
}
function WorkingIndicator() {
const dotOne = useSharedValue(0);
const dotTwo = useSharedValue(0);

View File

@@ -6,7 +6,6 @@ import {
Modal,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useEffect } from "react";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
@@ -154,16 +153,6 @@ const styles = StyleSheet.create((theme) => ({
}));
export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
useEffect(() => {
if (!artifact) return;
console.log(
"[ArtifactDrawer] Showing artifact:",
artifact.id,
artifact.type,
artifact.title
);
}, [artifact]);
if (!artifact) {
return null;
}

View File

@@ -69,7 +69,6 @@ export function DictationControls({
<VolumeMeter
volume={volume}
isMuted={false}
isDetecting
isSpeaking={false}
orientation="horizontal"
/>
@@ -177,7 +176,6 @@ export function DictationOverlay({
<VolumeMeter
volume={volume}
isMuted={false}
isDetecting
isSpeaking={false}
orientation="horizontal"
color={theme.colors.palette.white}

View File

@@ -23,13 +23,7 @@ import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
const MIN_CHAT_WIDTH = 400;
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
function logExplorerSidebar(event: string, details: Record<string, unknown>): void {
if (!IS_DEV) {
return;
}
console.log(`[ExplorerSidebar] ${event}`, details);
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {
}
interface ExplorerSidebarProps {

View File

@@ -838,7 +838,7 @@ function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
backgroundColor: theme.colors.surfaceSidebar,
},
desktopSplit: {
flex: 1,
@@ -879,7 +879,7 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
backgroundColor: theme.colors.surfaceSidebar,
},
paneHeaderLeft: {
flex: 1,

View File

@@ -1,62 +0,0 @@
import { describe, expect, it } from "vitest";
import { shouldShowMergeFromBaseAction } from "./git-action-visibility";
describe("git-action-visibility", () => {
describe("shouldShowMergeFromBaseAction", () => {
it("shows on non-base branches", () => {
expect(
shouldShowMergeFromBaseAction({
isOnBaseBranch: false,
hasRemote: false,
aheadOfOrigin: 0,
behindOfOrigin: 0,
})
).toBe(true);
});
it("hides on base branch when no remote exists", () => {
expect(
shouldShowMergeFromBaseAction({
isOnBaseBranch: true,
hasRemote: false,
aheadOfOrigin: 0,
behindOfOrigin: 0,
})
).toBe(false);
});
it("hides on base branch when local is in sync with origin", () => {
expect(
shouldShowMergeFromBaseAction({
isOnBaseBranch: true,
hasRemote: true,
aheadOfOrigin: 0,
behindOfOrigin: 0,
})
).toBe(false);
});
it("shows on base branch when ahead of origin", () => {
expect(
shouldShowMergeFromBaseAction({
isOnBaseBranch: true,
hasRemote: true,
aheadOfOrigin: 1,
behindOfOrigin: 0,
})
).toBe(true);
});
it("shows on base branch when behind origin", () => {
expect(
shouldShowMergeFromBaseAction({
isOnBaseBranch: true,
hasRemote: true,
aheadOfOrigin: 0,
behindOfOrigin: 2,
})
).toBe(true);
});
});
});

View File

@@ -1,14 +0,0 @@
export function shouldShowMergeFromBaseAction(input: {
isOnBaseBranch: boolean;
hasRemote: boolean;
aheadOfOrigin: number;
behindOfOrigin: number;
}): boolean {
if (!input.isOnBaseBranch) {
return true;
}
if (!input.hasRemote) {
return false;
}
return input.aheadOfOrigin > 0 || input.behindOfOrigin > 0;
}

View File

@@ -0,0 +1,149 @@
import { describe, expect, it } from "vitest";
import { buildGitActions, type BuildGitActionsInput } from "./git-actions-policy";
function createInput(
overrides: Partial<BuildGitActionsInput> = {}
): BuildGitActionsInput {
return {
isGit: true,
githubFeaturesEnabled: true,
hasPullRequest: false,
pullRequestUrl: null,
hasRemote: false,
isPaseoOwnedWorktree: false,
isOnBaseBranch: true,
hasUncommittedChanges: false,
baseRefAvailable: true,
baseRefLabel: "main",
aheadCount: 0,
aheadOfOrigin: 0,
behindOfOrigin: 0,
shouldPromoteArchive: false,
shipDefault: "merge",
runtime: {
commit: {
disabled: false,
status: "idle",
handler: () => undefined,
},
push: {
disabled: false,
status: "idle",
handler: () => undefined,
},
pr: {
disabled: false,
status: "idle",
handler: () => undefined,
},
"merge-branch": {
disabled: false,
status: "idle",
handler: () => undefined,
},
"merge-from-base": {
disabled: false,
status: "idle",
handler: () => undefined,
},
"archive-worktree": {
disabled: false,
status: "idle",
handler: () => undefined,
},
},
...overrides,
};
}
describe("git-actions-policy", () => {
it("keeps the secondary menu order stable while the primary action changes", () => {
const noPrActions = buildGitActions(createInput());
const withPrActions = buildGitActions(
createInput({
hasRemote: true,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/123",
aheadCount: 3,
aheadOfOrigin: 2,
shipDefault: "pr",
})
);
expect(noPrActions.primary).toBeNull();
expect(withPrActions.primary?.id).toBe("push");
expect(noPrActions.secondary.map((action) => action.id)).toEqual([
"merge-branch",
"pr",
"merge-from-base",
"push",
]);
expect(withPrActions.secondary.map((action) => action.id)).toEqual([
"merge-branch",
"pr",
"merge-from-base",
"push",
]);
});
it("disables hidden-before actions with explanations instead", () => {
const actions = buildGitActions(createInput());
const actionById = new Map(actions.secondary.map((action) => [action.id, action]));
expect(actionById.get("push")).toMatchObject({
disabled: true,
description: "No remote configured",
});
expect(actionById.get("pr")).toMatchObject({
label: "Create PR",
disabled: true,
description: "Branch has no commits ahead of main",
});
expect(actionById.get("merge-branch")).toMatchObject({
disabled: true,
description: "No commits to merge into main",
});
expect(actionById.get("merge-from-base")).toMatchObject({
disabled: true,
description: "No remote configured",
});
expect(actionById.has("archive-worktree")).toBe(false);
});
it("keeps the current primary action visible in the menu", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
})
);
expect(actions.primary?.id).toBe("pr");
expect(actions.secondary.some((action) => action.id === "pr" && action.label === "View PR")).toBe(true);
});
it("disables sync on the base branch when already up to date", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
})
);
const syncAction = actions.secondary.find((action) => action.id === "merge-from-base");
expect(syncAction).toMatchObject({
label: "Sync",
disabled: true,
description: "Already up to date",
});
});
it("only shows archive worktree for paseo worktrees", () => {
const hidden = buildGitActions(createInput());
const shown = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));
expect(hidden.secondary.some((action) => action.id === "archive-worktree")).toBe(false);
expect(shown.secondary.some((action) => action.id === "archive-worktree")).toBe(true);
});
});

View File

@@ -0,0 +1,256 @@
import type { ReactElement } from "react";
import type { ActionStatus } from "@/components/ui/dropdown-menu";
export type GitActionId =
| "commit"
| "push"
| "pr"
| "merge-branch"
| "merge-from-base"
| "archive-worktree";
export interface GitAction {
id: GitActionId;
label: string;
pendingLabel: string;
successLabel: string;
disabled: boolean;
status: ActionStatus;
description?: string;
icon?: ReactElement;
handler: () => void;
}
export interface GitActions {
primary: GitAction | null;
secondary: GitAction[];
menu: GitAction[];
}
interface GitActionRuntimeState {
disabled: boolean;
status: ActionStatus;
icon?: ReactElement;
handler: () => void;
}
export interface BuildGitActionsInput {
isGit: boolean;
githubFeaturesEnabled: boolean;
hasPullRequest: boolean;
pullRequestUrl: string | null;
hasRemote: boolean;
isPaseoOwnedWorktree: boolean;
isOnBaseBranch: boolean;
hasUncommittedChanges: boolean;
baseRefAvailable: boolean;
baseRefLabel: string;
aheadCount: number;
aheadOfOrigin: number;
behindOfOrigin: number;
shouldPromoteArchive: boolean;
shipDefault: "merge" | "pr";
runtime: Record<GitActionId, GitActionRuntimeState>;
}
const SECONDARY_ACTION_IDS: GitActionId[] = [
"merge-branch",
"pr",
"merge-from-base",
"push",
];
export function buildGitActions(input: BuildGitActionsInput): GitActions {
if (!input.isGit) {
return { primary: null, secondary: [], menu: [] };
}
const allActions = new Map<GitActionId, GitAction>();
allActions.set("commit", {
id: "commit",
label: "Commit",
pendingLabel: "Committing...",
successLabel: "Committed",
disabled: input.runtime.commit.disabled,
status: input.runtime.commit.status,
icon: input.runtime.commit.icon,
handler: input.runtime.commit.handler,
});
allActions.set("push", {
id: "push",
label: "Push",
pendingLabel: "Pushing...",
successLabel: "Pushed",
disabled: input.runtime.push.disabled || !input.hasRemote,
status: input.runtime.push.status,
description: input.hasRemote ? undefined : "No remote configured",
icon: input.runtime.push.icon,
handler: input.runtime.push.handler,
});
allActions.set("pr", buildPrAction(input));
allActions.set("merge-branch", {
id: "merge-branch",
label: `Merge into ${input.baseRefLabel}`,
pendingLabel: "Merging...",
successLabel: "Merged",
disabled:
input.runtime["merge-branch"].disabled ||
!input.baseRefAvailable ||
input.hasUncommittedChanges ||
input.aheadCount === 0,
status: input.runtime["merge-branch"].status,
description: getMergeBranchDescription(input),
icon: input.runtime["merge-branch"].icon,
handler: input.runtime["merge-branch"].handler,
});
allActions.set("merge-from-base", {
id: "merge-from-base",
label: input.isOnBaseBranch ? "Sync" : `Update from ${input.baseRefLabel}`,
pendingLabel: "Updating...",
successLabel: "Updated",
disabled: input.runtime["merge-from-base"].disabled || !canMergeFromBase(input),
status: input.runtime["merge-from-base"].status,
description: getMergeFromBaseDescription(input),
icon: input.runtime["merge-from-base"].icon,
handler: input.runtime["merge-from-base"].handler,
});
allActions.set("archive-worktree", {
id: "archive-worktree",
label: "Archive worktree",
pendingLabel: "Archiving...",
successLabel: "Archived",
disabled: input.runtime["archive-worktree"].disabled || !input.isPaseoOwnedWorktree,
status: input.runtime["archive-worktree"].status,
description: input.isPaseoOwnedWorktree ? undefined : "Only available for Paseo worktrees",
icon: input.runtime["archive-worktree"].icon,
handler: input.runtime["archive-worktree"].handler,
});
const primaryActionId = getPrimaryActionId(input);
const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null;
const secondary = SECONDARY_ACTION_IDS.map((id) => allActions.get(id)!);
if (input.isPaseoOwnedWorktree) {
secondary.push(allActions.get("archive-worktree")!);
}
return { primary, secondary, menu: [] };
}
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (input.shouldPromoteArchive && input.isPaseoOwnedWorktree) {
return "archive-worktree";
}
if (input.hasUncommittedChanges) {
return "commit";
}
if (input.aheadOfOrigin > 0 && input.hasRemote) {
return "push";
}
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
return "pr";
}
if (
input.isOnBaseBranch &&
input.hasRemote &&
(input.aheadOfOrigin > 0 || input.behindOfOrigin > 0)
) {
return "merge-from-base";
}
if (input.aheadCount > 0) {
return input.shipDefault === "merge" ? "merge-branch" : "pr";
}
return null;
}
function buildPrAction(input: BuildGitActionsInput): GitAction {
if (input.hasPullRequest && input.pullRequestUrl) {
return {
id: "pr",
label: "View PR",
pendingLabel: "View PR",
successLabel: "View PR",
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled,
status: input.runtime.pr.status,
description: input.githubFeaturesEnabled ? undefined : "GitHub features unavailable",
icon: input.runtime.pr.icon,
handler: input.runtime.pr.handler,
};
}
return {
id: "pr",
label: "Create PR",
pendingLabel: "Creating PR...",
successLabel: "PR Created",
disabled:
input.runtime.pr.disabled ||
!input.githubFeaturesEnabled ||
input.aheadCount === 0,
status: input.runtime.pr.status,
description: getCreatePrDescription(input),
icon: input.runtime.pr.icon,
handler: input.runtime.pr.handler,
};
}
function canMergeFromBase(input: BuildGitActionsInput): boolean {
if (!input.baseRefAvailable || input.hasUncommittedChanges) {
return false;
}
if (!input.isOnBaseBranch) {
return true;
}
if (!input.hasRemote) {
return false;
}
return input.aheadOfOrigin > 0 || input.behindOfOrigin > 0;
}
function getCreatePrDescription(input: BuildGitActionsInput): string | undefined {
if (!input.githubFeaturesEnabled) {
return "GitHub features unavailable";
}
if (input.aheadCount === 0) {
return `Branch has no commits ahead of ${input.baseRefLabel}`;
}
return undefined;
}
function getMergeBranchDescription(input: BuildGitActionsInput): string | undefined {
if (!input.baseRefAvailable) {
return "Base ref unavailable";
}
if (input.hasUncommittedChanges) {
return "Requires clean working tree";
}
if (input.aheadCount === 0) {
return `No commits to merge into ${input.baseRefLabel}`;
}
return undefined;
}
function getMergeFromBaseDescription(input: BuildGitActionsInput): string | undefined {
if (!input.baseRefAvailable) {
return "Base ref unavailable";
}
if (input.hasUncommittedChanges) {
return "Requires clean working tree";
}
if (!input.isOnBaseBranch) {
return undefined;
}
if (!input.hasRemote) {
return "No remote configured";
}
if (input.aheadOfOrigin === 0 && input.behindOfOrigin === 0) {
return "Already up to date";
}
return undefined;
}

View File

@@ -9,7 +9,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { GitAction, GitActions } from "@/hooks/use-git-actions";
import type { GitAction, GitActions } from "@/components/git-actions-policy";
interface GitActionsSplitButtonProps {
gitActions: GitActions;
@@ -79,7 +79,11 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
status={action.status}
pendingLabel={action.pendingLabel}
successLabel={action.successLabel}
closeOnSelect={action.status === "idle" && action.id === "view-pr"}
closeOnSelect={
action.status === "idle" &&
action.id === "pr" &&
action.label === "View PR"
}
description={action.description}
onSelect={action.handler}
>

View File

@@ -47,19 +47,19 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { GitHubIcon } from "@/components/icons/github-icon";
import {
buildGitActions,
type GitActions,
} from "@/components/git-actions-policy";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "@/components/web-desktop-scrollbar";
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
import { openExternalUrl } from "@/utils/open-external-url";
import { shouldShowMergeFromBaseAction } from "./git-action-visibility";
import { type GitActionId, type GitAction, type GitActions } from "@/hooks/use-git-actions";
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
// Re-export types from shared hook
export type { GitActionId, GitAction, GitActions } from "@/hooks/use-git-actions";
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
function openURLInNewTab(url: string): void {
void openExternalUrl(url);
@@ -790,19 +790,13 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const commitDisabled = actionsDisabled || commitStatus === "pending";
const prDisabled = actionsDisabled || prCreateStatus === "pending";
const mergeDisabled =
actionsDisabled || mergeStatus === "pending" || hasUncommittedChanges || !baseRef;
actionsDisabled || mergeStatus === "pending";
const mergeFromBaseDisabled =
actionsDisabled ||
mergeFromBaseStatus === "pending" ||
hasUncommittedChanges ||
!baseRef ||
(isOnBaseBranch && !hasRemote);
actionsDisabled || mergeFromBaseStatus === "pending";
const pushDisabled =
actionsDisabled || pushStatus === "pending" || !(gitStatus?.hasRemote ?? false);
actionsDisabled || pushStatus === "pending";
const archiveDisabled =
actionsDisabled ||
archiveStatus === "pending" ||
!gitStatus?.isPaseoOwnedWorktree;
actionsDisabled || archiveStatus === "pending";
let bodyContent: ReactElement;
@@ -892,184 +886,67 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
// ==========================================================================
const gitActions: GitActions = useMemo(() => {
if (!isGit) {
return { primary: null, secondary: [], menu: [] };
}
// Build all possible actions
const allActions = new Map<GitActionId, GitAction>();
// Commit - always available
allActions.set("commit", {
id: "commit",
label: "Commit",
pendingLabel: "Committing...",
successLabel: "Committed",
disabled: commitDisabled,
status: commitStatus,
icon: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
handler: handleCommit,
return buildGitActions({
isGit,
githubFeaturesEnabled,
hasPullRequest,
pullRequestUrl: prStatus?.url ?? null,
hasRemote,
isPaseoOwnedWorktree,
isOnBaseBranch,
hasUncommittedChanges,
baseRefAvailable: Boolean(baseRef),
baseRefLabel,
aheadCount,
aheadOfOrigin,
behindOfOrigin,
shouldPromoteArchive,
shipDefault,
runtime: {
commit: {
disabled: commitDisabled,
status: commitStatus,
icon: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
handler: handleCommit,
},
push: {
disabled: pushDisabled,
status: pushStatus,
icon: <Upload size={16} color={theme.colors.foregroundMuted} />,
handler: handlePush,
},
pr: {
disabled: prDisabled,
status: hasPullRequest ? "idle" : prCreateStatus,
icon: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
handler: () => {
if (prStatus?.url) {
openURLInNewTab(prStatus.url);
return;
}
handleCreatePr();
},
},
"merge-branch": {
disabled: mergeDisabled,
status: mergeStatus,
icon: <GitMerge size={16} color={theme.colors.foregroundMuted} />,
handler: handleMergeBranch,
},
"merge-from-base": {
disabled: mergeFromBaseDisabled,
status: mergeFromBaseStatus,
icon: <RefreshCcw size={16} color={theme.colors.foregroundMuted} />,
handler: handleMergeFromBase,
},
"archive-worktree": {
disabled: archiveDisabled,
status: archiveStatus,
icon: <Archive size={16} color={theme.colors.foregroundMuted} />,
handler: handleArchiveWorktree,
},
},
});
// Push - when has remote
if (hasRemote) {
allActions.set("push", {
id: "push",
label: "Push",
pendingLabel: "Pushing...",
successLabel: "Pushed",
disabled: pushDisabled,
status: pushStatus,
description: !hasRemote ? "No remote configured" : undefined,
icon: <Upload size={16} color={theme.colors.foregroundMuted} />,
handler: handlePush,
});
}
// View PR - when PR exists
if (githubFeaturesEnabled && hasPullRequest && prStatus?.url) {
const prUrl = prStatus.url;
allActions.set("view-pr", {
id: "view-pr",
label: "View PR",
pendingLabel: "View PR",
successLabel: "View PR",
disabled: false,
status: "idle",
icon: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
handler: () => openURLInNewTab(prUrl),
});
}
// Create PR - when ahead of base and no PR
if (githubFeaturesEnabled && aheadCount > 0 && !hasPullRequest) {
allActions.set("create-pr", {
id: "create-pr",
label: "Create PR",
pendingLabel: "Creating PR...",
successLabel: "PR Created",
disabled: prDisabled,
status: prCreateStatus,
icon: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
handler: handleCreatePr,
});
}
// Merge branch - when ahead of base
if (aheadCount > 0) {
allActions.set("merge-branch", {
id: "merge-branch",
label: `Merge into ${baseRefLabel}`,
pendingLabel: "Merging...",
successLabel: "Merged",
disabled: mergeDisabled,
status: mergeStatus,
description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
icon: <GitMerge size={16} color={theme.colors.foregroundMuted} />,
handler: handleMergeBranch,
});
}
// Update/sync from base
if (
shouldShowMergeFromBaseAction({
isOnBaseBranch,
hasRemote,
aheadOfOrigin,
behindOfOrigin,
})
) {
allActions.set("merge-from-base", {
id: "merge-from-base",
label: isOnBaseBranch ? "Sync" : `Update from ${baseRefLabel}`,
pendingLabel: "Updating...",
successLabel: "Updated",
disabled: mergeFromBaseDisabled,
status: mergeFromBaseStatus,
description:
hasUncommittedChanges
? "Requires clean working tree"
: isOnBaseBranch && !hasRemote
? "No remote configured"
: undefined,
icon: <RefreshCcw size={16} color={theme.colors.foregroundMuted} />,
handler: handleMergeFromBase,
});
}
// Archive worktree - only for Paseo worktrees
if (isPaseoOwnedWorktree) {
allActions.set("archive-worktree", {
id: "archive-worktree",
label: "Archive worktree",
pendingLabel: "Archiving...",
successLabel: "Archived",
disabled: archiveDisabled,
status: archiveStatus,
icon: <Archive size={16} color={theme.colors.foregroundMuted} />,
handler: handleArchiveWorktree,
});
}
// Select primary action (priority rules)
let primaryActionId: GitActionId | null = null;
// Rule 0: Post-ship in worktree -> Archive
if (shouldPromoteArchive && allActions.has("archive-worktree")) {
primaryActionId = "archive-worktree";
}
// Rule 1: Uncommitted changes → Commit
else if (hasUncommittedChanges) {
primaryActionId = "commit";
}
// Rule 2: Ahead of origin → Push
else if (aheadOfOrigin > 0 && allActions.has("push")) {
primaryActionId = "push";
}
// Rule 3: Has PR → View PR
else if (hasPullRequest) {
primaryActionId = "view-pr";
}
// Rule 4: On base branch -> surface sync explicitly
else if (isOnBaseBranch && allActions.has("merge-from-base")) {
primaryActionId = "merge-from-base";
}
// Rule 5: Ahead of base → Ship action based on preference
else if (aheadCount > 0) {
const preferred: GitActionId = shipDefault === "merge" ? "merge-branch" : "create-pr";
const fallback: GitActionId = shipDefault === "merge" ? "create-pr" : "merge-branch";
const preferredAction = allActions.get(preferred);
const fallbackAction = allActions.get(fallback);
if (preferredAction && !preferredAction.disabled) {
primaryActionId = preferred;
} else if (fallbackAction && !fallbackAction.disabled) {
primaryActionId = fallback;
} else if (preferredAction) {
primaryActionId = preferred;
}
}
const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null;
// Secondary actions: ship-related + merge from base + push (excluding primary)
const secondaryIds: GitActionId[] = [
"merge-branch",
"create-pr",
"view-pr",
"merge-from-base",
"push",
"archive-worktree",
];
const secondary = secondaryIds
.filter(id => id !== primaryActionId && allActions.has(id))
.map(id => allActions.get(id)!);
// Menu actions: none for now (all actionable items are in primary/secondary)
const menu: GitAction[] = [];
return { primary, secondary, menu };
}, [
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled,
hasUncommittedChanges, aheadOfOrigin, behindOfOrigin, shipDefault, baseRefLabel, shouldPromoteArchive,

View File

@@ -3,11 +3,13 @@ import Svg, { Rect, Line } from "react-native-svg";
interface SourceControlPanelIconProps {
size?: number;
color?: string;
strokeWidth?: number;
}
export function SourceControlPanelIcon({
size = 16,
color = "currentColor",
strokeWidth = 2,
}: SourceControlPanelIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
@@ -18,15 +20,15 @@ export function SourceControlPanelIcon({
height={18}
rx={2}
stroke={color}
strokeWidth={2}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Plus */}
<Line x1={9} y1={9.5} x2={15} y2={9.5} stroke={color} strokeWidth={2} strokeLinecap="round" />
<Line x1={12} y1={6.5} x2={12} y2={12.5} stroke={color} strokeWidth={2} strokeLinecap="round" />
<Line x1={9} y1={9.5} x2={15} y2={9.5} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" />
<Line x1={12} y1={6.5} x2={12} y2={12.5} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" />
{/* Minus */}
<Line x1={9} y1={16} x2={15} y2={16} stroke={color} strokeWidth={2} strokeLinecap="round" />
<Line x1={9} y1={16} x2={15} y2={16} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" />
</Svg>
);
}

View File

@@ -15,6 +15,7 @@ import { router, usePathname } from 'expo-router'
import { usePanelStore } from '@/stores/panel-store'
import { SidebarWorkspaceList } from './sidebar-workspace-list'
import { SidebarAgentListSkeleton } from './sidebar-agent-list-skeleton'
import { useSidebarShortcutModel } from '@/hooks/use-sidebar-shortcut-model'
import { useSidebarWorkspacesList } from '@/hooks/use-sidebar-workspaces-list'
import { useSidebarAnimation } from '@/contexts/sidebar-animation-context'
import { useTauriDragHandlers, useTrafficLightPadding } from '@/utils/tauri-window'
@@ -31,14 +32,6 @@ import {
import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store'
const DESKTOP_SIDEBAR_WIDTH = 320
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__)
function logLeftSidebarCloseGesture(event: string, details: Record<string, unknown>): void {
if (!IS_DEV) {
return
}
console.log(`[LeftSidebarCloseGesture] ${event}`, details)
}
interface LeftSidebarProps {
selectedAgentId?: string
@@ -124,6 +117,8 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
serverId: activeServerId,
enabled: isOpen,
})
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
useSidebarShortcutModel(projects)
const {
translateX,
backdropOpacity,
@@ -253,7 +248,6 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
})
.onStart(() => {
isGesturing.value = true
runOnJS(logLeftSidebarCloseGesture)('start', { isOpen, isMobile })
})
.onUpdate((event) => {
if (!isMobile) return
@@ -271,11 +265,6 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
isGesturing.value = false
if (!isMobile) return
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500
runOnJS(logLeftSidebarCloseGesture)('end', {
translationX: event.translationX,
velocityX: event.velocityX,
shouldClose,
})
if (shouldClose) {
animateToClose()
runOnJS(handleClose)()
@@ -351,8 +340,10 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
isOpen={isOpen}
serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
@@ -475,8 +466,10 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
isOpen={isOpen}
serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}

View File

@@ -101,7 +101,6 @@ export interface MessageInputRef {
const MIN_INPUT_HEIGHT = 30
const MAX_INPUT_HEIGHT = 160
const IS_WEB = Platform.OS === 'web'
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__)
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
TextInputKeyPressEventData & {
@@ -125,13 +124,10 @@ type TextAreaHandle = {
}
function logWebStickyBottom(
event: string,
details: Record<string, unknown>
_event: string,
_details: Record<string, unknown>
): void {
if (!IS_DEV || !IS_WEB) {
return
}
console.log('[WebStickyBottom]', event, details)
// Intentionally disabled: this path is too noisy during voice debugging.
}
function getDebugNow(): number | null {
@@ -407,6 +403,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const showRealtimeOverlay = isRealtimeVoiceForCurrentAgent
const showOverlay = showDictationOverlay || showRealtimeOverlay
useEffect(() => {
if (isDictating || isDictationProcessing) {
return
@@ -1070,10 +1067,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
/>
) : showRealtimeOverlay && voice ? (
<RealtimeVoiceOverlay
volume={voice.volume}
isMuted={voice.isMuted}
isDetecting={voice.isDetecting}
isSpeaking={voice.isSpeaking}
isSwitching={voice.isVoiceSwitching}
onToggleMute={voice.toggleMute}
onStop={() => {

View File

@@ -18,6 +18,9 @@ import {
useCallback,
createContext,
useContext,
isValidElement,
Children,
cloneElement,
} from "react";
import type { ReactNode, ComponentType } from "react";
import Markdown, { MarkdownIt } from "react-native-markdown-display";
@@ -68,7 +71,11 @@ import {
buildToolCallDisplayModel,
} from "@/utils/tool-call-display";
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path";
import {
parseAssistantFileLink,
parseInlinePathToken,
type InlinePathTarget,
} from "@/utils/inline-path";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { openExternalUrl } from "@/utils/open-external-url";
import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation";
@@ -422,6 +429,7 @@ interface AssistantMessageProps {
message: string;
timestamp: number;
onInlinePathPress?: (target: InlinePathTarget) => void;
workspaceRoot?: string;
disableOuterSpacing?: boolean;
}
@@ -433,20 +441,6 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
containerSpacing: {
marginBottom: theme.spacing[4],
},
// Used in custom markdownRules for inline code styling
markdownCodeInline: {
backgroundColor: theme.colors.surface2,
color: theme.colors.foreground,
paddingHorizontal: theme.spacing[2],
paddingVertical: 2,
borderRadius: theme.borderRadius.sm,
fontFamily: Fonts.mono,
fontSize: 13,
},
markdownCodeInlineLink: {
color: theme.colors.primary,
textDecorationLine: "underline",
},
// Used in custom markdownRules for path chip styling
pathChip: {
backgroundColor: theme.colors.surface2,
@@ -463,6 +457,44 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
},
}));
function MarkdownLink({
href,
style,
onPress,
children,
}: {
href: string;
style: any;
onPress: (url: string) => void;
children: ReactNode;
}) {
const [hovered, setHovered] = useState(false);
if (Platform.OS !== "web") {
return (
<Text
accessibilityRole="link"
onPress={() => onPress(href)}
style={style}
>
{children}
</Text>
);
}
return (
<Pressable
accessibilityRole="link"
onPress={() => onPress(href)}
onHoverIn={() => setHovered(true)}
onHoverOut={() => setHovered(false)}
>
<Text style={[style, hovered && { textDecorationLine: "underline" }]}>
{children}
</Text>
</Pressable>
);
}
function getInlineCodeAutoLinkUrl(
markdownParser: ReturnType<typeof MarkdownIt>,
content: string
@@ -487,6 +519,19 @@ function getInlineCodeAutoLinkUrl(
return match.url;
}
function nodeHasParentType(parent: unknown, type: string): boolean {
if (Array.isArray(parent)) {
return parent.some((entry) => entry?.type === type);
}
return (
typeof parent === "object" &&
parent !== null &&
"type" in parent &&
(parent as { type?: string }).type === type
);
}
const turnCopyButtonStylesheet = StyleSheet.create((theme) => ({
container: {
alignSelf: "flex-start",
@@ -700,6 +745,7 @@ export const AssistantMessage = memo(function AssistantMessage({
message,
timestamp,
onInlinePathPress,
workspaceRoot,
disableOuterSpacing,
}: AssistantMessageProps) {
const { theme } = useUnistyles();
@@ -709,16 +755,35 @@ export const AssistantMessage = memo(function AssistantMessage({
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownParser = useMemo(
() => MarkdownIt({ typographer: true, linkify: true }),
() => {
const parser = MarkdownIt({ typographer: true, linkify: true });
const defaultValidateLink = parser.validateLink.bind(parser);
parser.validateLink = (url: string) => {
if (url.trim().toLowerCase().startsWith("file://")) {
return true;
}
return defaultValidateLink(url);
};
return parser;
},
[]
);
const handleLinkPress = useCallback((url: string) => {
const fileTarget = onInlinePathPress
? parseAssistantFileLink(url, { workspaceRoot })
: null;
if (fileTarget) {
onInlinePathPress?.(fileTarget);
return false;
}
void openExternalUrl(url);
// react-native-markdown-display opens the link itself when this returns true.
// We already handled it above, so return false to avoid duplicate opens.
return false;
}, []);
}, [onInlinePathPress, workspaceRoot]);
const markdownRules = useMemo(() => {
return {
@@ -775,12 +840,16 @@ export const AssistantMessage = memo(function AssistantMessage({
code_inline: (
node: any,
_children: ReactNode[],
_parent: any,
_styles: any,
parent: any,
styles: any,
inheritedStyles: any = {}
) => {
const content = node.content ?? "";
const parsed = onInlinePathPress
const isLinkedInlineCode =
nodeHasParentType(parent, "link") ||
(!Array.isArray(parent) &&
typeof parent?.attributes?.href === "string");
const parsed = onInlinePathPress && !isLinkedInlineCode
? parseInlinePathToken(content)
: null;
@@ -803,30 +872,21 @@ export const AssistantMessage = memo(function AssistantMessage({
const inlineCodeLinkUrl = getInlineCodeAutoLinkUrl(markdownParser, content);
if (inlineCodeLinkUrl) {
return (
<Text
<MarkdownLink
key={node.key}
accessibilityRole="link"
onPress={() => {
handleLinkPress(inlineCodeLinkUrl);
}}
style={[
inheritedStyles,
assistantMessageStylesheet.markdownCodeInline,
assistantMessageStylesheet.markdownCodeInlineLink,
]}
href={inlineCodeLinkUrl}
style={[inheritedStyles, styles.code_inline, styles.link]}
onPress={handleLinkPress}
>
{content}
</Text>
</MarkdownLink>
);
}
return (
<Text
key={node.key}
style={[
inheritedStyles,
assistantMessageStylesheet.markdownCodeInline,
]}
style={[inheritedStyles, styles.code_inline]}
>
{content}
</Text>
@@ -877,6 +937,25 @@ export const AssistantMessage = memo(function AssistantMessage({
</View>
);
},
link: (
node: any,
children: ReactNode[],
_parent: any,
styles: any,
) => (
<MarkdownLink
key={node.key}
href={node.attributes?.href ?? ""}
style={styles.link}
onPress={handleLinkPress}
>
{Children.map(children, (child) =>
isValidElement(child)
? cloneElement(child, { style: [(child.props as any).style, { color: styles.link.color }] } as any)
: child
)}
</MarkdownLink>
),
};
}, [handleLinkPress, markdownParser, onInlinePathPress]);
@@ -1849,9 +1928,11 @@ export const ToolCall = memo(function ToolCall({
const hasDetails =
Boolean(error) ||
(effectiveDetail
? effectiveDetail.type !== "unknown" ||
effectiveDetail.input !== null ||
effectiveDetail.output !== null
? effectiveDetail.type === "plain_text"
? Boolean(effectiveDetail.text)
: effectiveDetail.type !== "unknown" ||
effectiveDetail.input !== null ||
effectiveDetail.output !== null
: false);
const handleToggle = useCallback(() => {

View File

@@ -2,13 +2,11 @@ import { ActivityIndicator, Pressable, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, MicOff, Square } from "lucide-react-native";
import { FOOTER_HEIGHT } from "@/constants/layout";
import { useVoiceTelemetry } from "@/contexts/voice-context";
import { VolumeMeter } from "./volume-meter";
interface RealtimeVoiceOverlayProps {
volume: number;
isMuted: boolean;
isDetecting: boolean;
isSpeaking: boolean;
isSwitching: boolean;
onToggleMute: () => void;
onStop: () => void;
@@ -18,23 +16,19 @@ const OVERLAY_BUTTON_SIZE = 44;
const OVERLAY_VERTICAL_PADDING = (FOOTER_HEIGHT - OVERLAY_BUTTON_SIZE) / 2;
export function RealtimeVoiceOverlay({
volume,
isMuted,
isDetecting,
isSpeaking,
isSwitching,
onToggleMute,
onStop,
}: RealtimeVoiceOverlayProps) {
const { theme } = useUnistyles();
const { volume, isSpeaking } = useVoiceTelemetry();
return (
<View style={styles.container}>
<View style={styles.meterContainer}>
<VolumeMeter
volume={volume}
isMuted={isMuted}
isDetecting={isDetecting}
isSpeaking={isSpeaking}
orientation="horizontal"
/>

View File

@@ -59,7 +59,6 @@ import {
import { SyncedLoader } from '@/components/synced-loader'
import { useToast } from '@/contexts/toast-context'
import { useCheckoutGitActionsStore } from '@/stores/checkout-git-actions-store'
import { buildSidebarShortcutModel } from '@/utils/sidebar-shortcuts'
import { hasVisibleOrderChanged, mergeWithRemainder } from '@/utils/sidebar-reorder'
import { decideLongPressMove } from '@/utils/sidebar-gesture-arbitration'
import { confirmDialog } from '@/utils/confirm-dialog'
@@ -79,10 +78,17 @@ function toProjectIconDataUri(icon: { mimeType: string; data: string } | null):
return `data:${icon.mimeType};base64,${icon.data}`
}
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) =>
workspace.workspaceKey
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey
interface SidebarWorkspaceListProps {
isOpen?: boolean
projects: SidebarProjectEntry[]
serverId: string | null
collapsedProjectKeys: ReadonlySet<string>
onToggleProjectCollapsed: (projectKey: string) => void
shortcutIndexByWorkspaceKey: Map<string, number>
isRefreshing?: boolean
onRefresh?: () => void
onWorkspacePress?: () => void
@@ -122,6 +128,7 @@ interface WorkspaceRowInnerProps {
archiveStatus?: 'idle' | 'pending' | 'success'
archivePendingLabel?: string
onArchive?: () => void
onCopyBranchName?: () => void
onCopyPath?: () => void
}
@@ -250,7 +257,6 @@ function NewWorktreeButton({
function useLongPressDragInteraction(input: {
drag: () => void
menuController: ReturnType<typeof useContextMenu> | null
debugId: string
}) {
const didLongPressRef = useRef(false)
const dragArmedRef = useRef(false)
@@ -288,15 +294,11 @@ function useLongPressDragInteraction(input: {
input.menuController.setOpen(true)
menuOpenedRef.current = true
didLongPressRef.current = true
console.log('[sidebar-dnd-debug] context menu opened', { id: input.debugId })
}, [input.debugId, input.menuController])
}, [input.menuController])
const handleLongPress = useCallback(() => {
// Manual timers own long-press behavior on mobile.
console.log('[sidebar-dnd-debug] native onLongPress ignored (manual state machine active)', {
id: input.debugId,
})
}, [input.debugId])
}, [])
useEffect(() => {
return () => {
@@ -325,16 +327,11 @@ function useLongPressDragInteraction(input: {
const dy = current.y - start.y
const distance = Math.sqrt(dx * dx + dy * dy)
if (distance > DRAG_ARM_STATIONARY_SLOP_PX) {
console.log('[sidebar-dnd-debug] drag arm cancelled (movement)', {
id: input.debugId,
distance,
})
return
}
dragArmedRef.current = true
dragActivatedRef.current = true
didLongPressRef.current = true
console.log('[sidebar-dnd-debug] drag armed', { id: input.debugId })
void Haptics.selectionAsync().catch(() => {})
input.drag()
}, DRAG_ARM_DELAY_MS)
@@ -356,17 +353,12 @@ function useLongPressDragInteraction(input: {
const dy = current.y - start.y
const distance = Math.sqrt(dx * dx + dy * dy)
if (distance > CONTEXT_MENU_STATIONARY_SLOP_PX) {
console.log('[sidebar-dnd-debug] context menu cancelled (movement)', {
id: input.debugId,
distance,
})
return
}
console.log('[sidebar-dnd-debug] long-press armed', { id: input.debugId })
void Haptics.selectionAsync().catch(() => {})
openContextMenuAtStartPoint()
}, CONTEXT_MENU_DELAY_MS)
}, [clearTimers, input.debugId, input.menuController, openContextMenuAtStartPoint])
}, [clearTimers, input.menuController, openContextMenuAtStartPoint])
const handleDragIntent = useCallback(
(details: { dx: number; dy: number; distance: number }) => {
@@ -376,10 +368,9 @@ function useLongPressDragInteraction(input: {
didStartDragRef.current = true
didLongPressRef.current = true
clearTimers()
console.log('[sidebar-dnd-debug] drag movement detected', { id: input.debugId, ...details })
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {})
},
[clearTimers, input.debugId]
[clearTimers]
)
const handleScrollIntent = useCallback(
@@ -387,18 +378,16 @@ function useLongPressDragInteraction(input: {
scrollIntentRef.current = true
didLongPressRef.current = true
clearTimers()
console.log('[sidebar-dnd-debug] scroll intent detected', { id: input.debugId, ...details })
},
[clearTimers, input.debugId]
[clearTimers]
)
const handleSwipeIntent = useCallback(
(details: { dx: number; dy: number; distance: number }) => {
didLongPressRef.current = true
clearTimers()
console.log('[sidebar-dnd-debug] swipe intent detected', { id: input.debugId, ...details })
},
[clearTimers, input.debugId]
[clearTimers]
)
const handlePressIn = useCallback((event: GestureResponderEvent) => {
@@ -416,13 +405,8 @@ function useLongPressDragInteraction(input: {
x: event.nativeEvent.pageX,
y: event.nativeEvent.pageY,
}
console.log('[sidebar-dnd-debug] press-in', {
id: input.debugId,
x: event.nativeEvent.pageX,
y: event.nativeEvent.pageY,
})
armTimers()
}, [armTimers, input.debugId])
}, [armTimers])
const handleTouchMove = useCallback(
(event: any) => {
@@ -469,20 +453,11 @@ function useLongPressDragInteraction(input: {
const handlePressOut = useCallback(() => {
clearTimers()
console.log('[sidebar-dnd-debug] press-out no context-menu', {
id: input.debugId,
didLongPress: didLongPressRef.current,
didStartDrag: didStartDragRef.current,
dragActivated: dragActivatedRef.current,
scrollIntent: scrollIntentRef.current,
dragArmed: dragArmedRef.current,
menuOpened: menuOpenedRef.current,
})
dragArmedRef.current = false
dragActivatedRef.current = false
touchStartRef.current = null
touchCurrentRef.current = null
}, [clearTimers, input.debugId])
}, [clearTimers])
return {
didLongPressRef,
@@ -511,7 +486,6 @@ function ProjectHeaderRow({
const interaction = useLongPressDragInteraction({
drag,
menuController,
debugId: `project:${project.projectKey}`,
})
const handlePress = useCallback(() => {
@@ -610,6 +584,7 @@ function WorkspaceRowInner({
archiveStatus = 'idle',
archivePendingLabel,
onArchive,
onCopyBranchName,
onCopyPath,
}: WorkspaceRowInnerProps) {
const { theme } = useUnistyles()
@@ -618,7 +593,6 @@ function WorkspaceRowInner({
const interaction = useLongPressDragInteraction({
drag,
menuController,
debugId: `workspace:${workspace.workspaceKey}`,
})
const handlePress = useCallback(() => {
@@ -694,6 +668,15 @@ function WorkspaceRowInner({
Copy path
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-branch-name-${workspace.workspaceKey}`}
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
onSelect={onCopyBranchName}
>
Copy branch name
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
@@ -731,6 +714,7 @@ function WorkspaceRowWithMenu({
drag,
isDragging,
dragHandleProps,
canCopyBranchName,
}: {
workspace: SidebarWorkspaceEntry
selected: boolean
@@ -740,6 +724,7 @@ function WorkspaceRowWithMenu({
drag: () => void
isDragging: boolean
dragHandleProps?: DraggableListDragHandleProps
canCopyBranchName: boolean
}) {
const toast = useToast()
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree)
@@ -825,6 +810,11 @@ function WorkspaceRowWithMenu({
toast.copied('Path copied')
}, [toast, workspace.workspaceId])
const handleCopyBranchName = useCallback(() => {
void Clipboard.setStringAsync(workspace.name)
toast.copied('Branch name copied')
}, [toast, workspace.name])
return (
<WorkspaceRowInner
workspace={workspace}
@@ -841,6 +831,7 @@ function WorkspaceRowWithMenu({
archiveStatus={isWorktree ? archiveStatus : isArchivingWorkspace ? 'pending' : 'idle'}
archivePendingLabel={isWorktree ? 'Archiving...' : 'Hiding...'}
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath}
/>
)
@@ -1006,6 +997,7 @@ function WorkspaceRow({
drag,
isDragging,
dragHandleProps,
canCopyBranchName,
}: {
workspace: SidebarWorkspaceEntry
selected: boolean
@@ -1015,6 +1007,7 @@ function WorkspaceRow({
drag: () => void
isDragging: boolean
dragHandleProps?: DraggableListDragHandleProps
canCopyBranchName: boolean
}) {
return (
<WorkspaceRowWithMenu
@@ -1026,6 +1019,7 @@ function WorkspaceRow({
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
canCopyBranchName={canCopyBranchName}
/>
)
}
@@ -1093,6 +1087,7 @@ function ProjectBlock({
selected={isSelected}
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canCopyBranchName={project.projectKind === 'git'}
onPress={() => {
if (!serverId) {
return
@@ -1131,6 +1126,13 @@ function ProjectBlock({
[renderWorkspaceRow]
)
const handleWorkspaceDragEnd = useCallback(
(workspaces: SidebarWorkspaceEntry[]) => {
onWorkspaceReorder(project.projectKey, workspaces)
},
[onWorkspaceReorder, project.projectKey]
)
return (
<View style={styles.projectBlock}>
{flattenedWorkspace ? (
@@ -1176,9 +1178,9 @@ function ProjectBlock({
<DraggableList
testID={`sidebar-workspace-list-${project.projectKey}`}
data={project.workspaces}
keyExtractor={(workspace) => workspace.workspaceKey}
keyExtractor={workspaceKeyExtractor}
renderItem={renderWorkspace}
onDragEnd={(workspaces) => onWorkspaceReorder(project.projectKey, workspaces)}
onDragEnd={handleWorkspaceDragEnd}
scrollEnabled={false}
useDragHandle
nestable={useNestable}
@@ -1193,9 +1195,11 @@ function ProjectBlock({
}
export function SidebarWorkspaceList({
isOpen = true,
projects,
serverId,
collapsedProjectKeys,
onToggleProjectCollapsed,
shortcutIndexByWorkspaceKey,
isRefreshing = false,
onRefresh,
onWorkspacePress,
@@ -1205,16 +1209,9 @@ export function SidebarWorkspaceList({
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
const isNative = Platform.OS !== 'web'
const pathname = usePathname()
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set())
const isTauri = getIsTauri()
const altDown = useKeyboardShortcutsStore((state) => state.altDown)
const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown)
const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore(
(state) => state.setSidebarShortcutWorkspaceTargets
)
const setVisibleWorkspaceTargets = useKeyboardShortcutsStore(
(state) => state.setVisibleWorkspaceTargets
)
const showShortcutBadges = altDown || (isTauri && cmdOrCtrlDown)
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder)
@@ -1236,21 +1233,8 @@ export function SidebarWorkspaceList({
}
}, [pathname])
useEffect(() => {
setCollapsedProjectKeys((prev) => {
const validProjectKeys = new Set(projects.map((project) => project.projectKey))
const next = new Set<string>()
for (const key of prev) {
if (validProjectKeys.has(key)) {
next.add(key)
}
}
return next
})
}, [projects])
const projectIconRequests = useMemo(() => {
if (!isOpen || !serverId) {
if (!serverId) {
return []
}
const unique = new Map<string, { serverId: string; cwd: string }>()
@@ -1262,7 +1246,7 @@ export function SidebarWorkspaceList({
unique.set(`${serverId}:${cwd}`, { serverId, cwd })
}
return Array.from(unique.values())
}, [isOpen, projects, serverId])
}, [projects, serverId])
const projectIconQueries = useQueries({
queries: projectIconRequests.map((request) => ({
@@ -1277,7 +1261,6 @@ export function SidebarWorkspaceList({
},
select: toProjectIconDataUri,
enabled: Boolean(
isOpen &&
getHostRuntimeStore().getClient(request.serverId) &&
isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)) &&
request.cwd
@@ -1316,44 +1299,6 @@ export function SidebarWorkspaceList({
return byProject
}, [projectIconQueries, projectIconRequests, projects, serverId])
const shortcutModel = useMemo(
() =>
buildSidebarShortcutModel({
projects,
collapsedProjectKeys,
}),
[collapsedProjectKeys, projects]
)
useEffect(() => {
setSidebarShortcutWorkspaceTargets(shortcutModel.shortcutTargets)
setVisibleWorkspaceTargets(shortcutModel.visibleTargets)
}, [
setSidebarShortcutWorkspaceTargets,
setVisibleWorkspaceTargets,
shortcutModel.shortcutTargets,
shortcutModel.visibleTargets,
])
useEffect(() => {
return () => {
setSidebarShortcutWorkspaceTargets([])
setVisibleWorkspaceTargets([])
}
}, [setSidebarShortcutWorkspaceTargets, setVisibleWorkspaceTargets])
const toggleProjectCollapsed = useCallback((projectKey: string) => {
setCollapsedProjectKeys((prev) => {
const next = new Set(prev)
if (next.has(projectKey)) {
next.delete(projectKey)
} else {
next.add(projectKey)
}
return next
})
}, [])
const handleProjectDragEnd = useCallback(
(reorderedProjects: SidebarProjectEntry[]) => {
if (!serverId) {
@@ -1437,9 +1382,9 @@ export function SidebarWorkspaceList({
serverId={serverId}
activeWorkspaceSelection={activeWorkspaceSelection}
showShortcutBadges={showShortcutBadges}
shortcutIndexByWorkspaceKey={shortcutModel.shortcutIndexByWorkspaceKey}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
parentGestureRef={parentGestureRef}
onToggleCollapsed={() => toggleProjectCollapsed(item.projectKey)}
onToggleCollapsed={() => onToggleProjectCollapsed(item.projectKey)}
onWorkspacePress={onWorkspacePress}
onWorkspaceReorder={handleWorkspaceReorder}
onCreateWorktree={handleCreateWorktree}
@@ -1456,12 +1401,12 @@ export function SidebarWorkspaceList({
handleCreateWorktree,
handleWorkspaceReorder,
onWorkspacePress,
onToggleProjectCollapsed,
parentGestureRef,
projectIconByProjectKey,
serverId,
shortcutModel.shortcutIndexByWorkspaceKey,
shortcutIndexByWorkspaceKey,
showShortcutBadges,
toggleProjectCollapsed,
isNative,
]
)
@@ -1474,7 +1419,7 @@ export function SidebarWorkspaceList({
<DraggableList
testID="sidebar-project-list"
data={projects}
keyExtractor={(project) => project.projectKey}
keyExtractor={projectKeyExtractor}
renderItem={renderProject}
onDragEnd={handleProjectDragEnd}
scrollEnabled={false}
@@ -1495,9 +1440,7 @@ export function SidebarWorkspaceList({
style={styles.list}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
onScrollBeginDrag={() =>
console.log('[sidebar-dnd-debug] outer scroll begin')
}
testID="sidebar-project-workspace-list-scroll"
>
{content}
@@ -1507,9 +1450,7 @@ export function SidebarWorkspaceList({
style={styles.list}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
onScrollBeginDrag={() =>
console.log('[sidebar-dnd-debug] outer scroll begin')
}
testID="sidebar-project-workspace-list-scroll"
>
{content}

View File

@@ -24,7 +24,6 @@ const USER_SCROLL_DELTA_EPSILON = 1
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1
const WEB_STREAM_SCROLLBAR_STYLE_ID = 'web-stream-viewport-scrollbar-style'
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__)
const WEB_STREAM_SCROLLBAR_STYLE = `
#agent-chat-scroll-web-dom-scroll,
#agent-chat-scroll-web-dom-virtualized {
@@ -40,11 +39,8 @@ const WEB_STREAM_SCROLLBAR_STYLE = `
}
`
function logWebStickyBottom(event: string, details: Record<string, unknown>): void {
if (!IS_DEV) {
return
}
console.log('[WebStickyBottom]', event, details)
function logWebStickyBottom(_event: string, _details: Record<string, unknown>): void {
// Intentionally disabled: this path is too noisy during voice debugging.
}
function getDebugNow(): number | null {

View File

@@ -109,6 +109,7 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
ref={bottomSheetRef}
snapPoints={snapPoints}
index={0}
stackBehavior="replace"
enableDynamicSizing={false}
onChange={handleSheetChange}
backdropComponent={renderBackdrop}

View File

@@ -64,6 +64,7 @@ export interface ComboboxProps {
title?: string
open?: boolean
onOpenChange?: (open: boolean) => void
enableDismissOnClose?: boolean
desktopPlacement?: 'top-start' | 'bottom-start'
/**
* Prevents an initial frame at 0,0 by hiding desktop content until floating
@@ -224,6 +225,7 @@ export function Combobox({
title = 'Select',
open,
onOpenChange,
enableDismissOnClose,
desktopPlacement = 'top-start',
desktopPreventInitialFlash = true,
anchorRef,
@@ -235,6 +237,7 @@ export function Combobox({
!isMobile && Platform.OS === 'web' && effectiveOptionsPosition === 'above-search'
const { height: windowHeight } = useWindowDimensions()
const bottomSheetRef = useRef<BottomSheetModal>(null)
const hasPresentedBottomSheetRef = useRef(false)
const snapPoints = useMemo(() => ['60%', '90%'], [])
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(
null
@@ -379,11 +382,20 @@ export function Combobox({
useEffect(() => {
if (!isMobile) return
if (isOpen) {
bottomSheetRef.current?.present()
if (enableDismissOnClose === false && hasPresentedBottomSheetRef.current) {
bottomSheetRef.current?.snapToIndex(0)
} else {
hasPresentedBottomSheetRef.current = true
bottomSheetRef.current?.present()
}
} else {
bottomSheetRef.current?.dismiss()
if (enableDismissOnClose === false) {
bottomSheetRef.current?.close()
} else {
bottomSheetRef.current?.dismiss()
}
}
}, [isOpen, isMobile])
}, [enableDismissOnClose, isOpen, isMobile])
const handleSheetChange = useCallback(
(index: number) => {
@@ -622,6 +634,7 @@ export function Combobox({
onChange={handleSheetChange}
backdropComponent={renderBackdrop}
enablePanDownToClose
enableDismissOnClose={enableDismissOnClose}
backgroundComponent={ComboboxSheetBackground}
handleIndicatorStyle={styles.bottomSheetHandle}
keyboardBehavior="extend"

View File

@@ -9,6 +9,8 @@ import {
useState,
type PropsWithChildren,
type ReactElement,
type Ref,
type MutableRefObject,
} from "react";
import {
ActivityIndicator,
@@ -182,6 +184,16 @@ function coerceEventPoint(event: unknown): { pageX: number; pageY: number } | nu
return null;
}
function assignRef<T>(ref: Ref<T> | undefined, value: T): void {
if (typeof ref === "function") {
ref(value);
return;
}
if (ref && typeof ref === "object") {
(ref as MutableRefObject<T>).current = value;
}
}
export function ContextMenu({
open,
defaultOpen,
@@ -231,6 +243,7 @@ export function ContextMenuTrigger({
enabledOnMobile = false,
enabledOnWeb = true,
longPressDelayMs,
triggerRef,
...props
}: PropsWithChildren<
Omit<PressableProps, "style"> & {
@@ -239,6 +252,7 @@ export function ContextMenuTrigger({
enabledOnMobile?: boolean;
enabledOnWeb?: boolean;
longPressDelayMs?: number;
triggerRef?: Ref<View | null>;
}
>): ReactElement {
const ctx = useContextMenuContext("ContextMenuTrigger");
@@ -268,10 +282,18 @@ export function ContextMenuTrigger({
[ctx, disabled, shouldEnableOnThisPlatform]
);
const handleRef = useCallback(
(node: View | null) => {
assignRef(ctx.triggerRef, node);
assignRef(triggerRef, node);
},
[ctx.triggerRef, triggerRef]
);
return (
<Pressable
{...props}
ref={ctx.triggerRef}
ref={handleRef}
collapsable={false}
disabled={disabled}
delayLongPress={longPressDelayMs}

View File

@@ -170,10 +170,6 @@ export function useDropdownFloating({
}, []);
const update = useCallback(async () => {
console.log("[useDropdownFloating] update called", {
hasReferenceEl: !!referenceEl,
hasFloatingEl: !!floatingElRef.current,
});
if (!referenceEl || !floatingElRef.current) {
return;
@@ -185,12 +181,6 @@ export function useDropdownFloating({
measureElement(floatingElRef.current),
]);
console.log("[useDropdownFloating] measured", {
fromRect,
contentRect,
displayArea,
});
const result = computeGeometry({
fromRect,
contentSize: { width: contentRect.width, height: contentRect.height },
@@ -201,8 +191,6 @@ export function useDropdownFloating({
padding,
});
console.log("[useDropdownFloating] geometry result", result);
setGeometry(result);
} catch (e) {
console.warn("[useDropdownFloating] measure failed:", e);
@@ -210,7 +198,6 @@ export function useDropdownFloating({
}, [referenceEl, displayArea, placement, alignment, offset, padding]);
const floatingRef = useCallback((el: View | null) => {
console.log("[useDropdownFloating] floatingRef called", { hasEl: !!el });
floatingElRef.current = el;
}, []);
@@ -218,17 +205,10 @@ export function useDropdownFloating({
const [floatingReady, setFloatingReady] = useState(false);
const handleFloatingLayout = useCallback(() => {
console.log("[useDropdownFloating] handleFloatingLayout called");
setFloatingReady(true);
}, []);
useEffect(() => {
console.log("[useDropdownFloating] useEffect", {
open,
floatingReady,
hasReferenceEl: !!referenceEl,
hasFloatingEl: !!floatingElRef.current,
});
if (!open) {
setGeometry(null);
@@ -237,7 +217,6 @@ export function useDropdownFloating({
}
if (floatingReady && referenceEl && floatingElRef.current) {
console.log("[useDropdownFloating] calling update from useEffect");
update();
}
}, [open, floatingReady, referenceEl, update]);

View File

@@ -23,6 +23,7 @@ type SegmentedControlProps<T extends string> = {
value: T;
onValueChange: (value: T) => void;
size?: SegmentedControlSize;
hideLabels?: boolean;
style?: StyleProp<ViewStyle>;
testID?: string;
};
@@ -32,6 +33,7 @@ export function SegmentedControl<T extends string>({
value,
onValueChange,
size = "md",
hideLabels = false,
style,
testID,
}: SegmentedControlProps<T>) {
@@ -72,12 +74,14 @@ export function SegmentedControl<T extends string>({
{option.icon({ color: iconColor, size: iconSize })}
</View>
) : null}
<Text
style={[styles.label, labelSizeStyle, isSelected && styles.labelSelected]}
numberOfLines={1}
>
{option.label}
</Text>
{hideLabels ? null : (
<Text
style={[styles.label, labelSizeStyle, isSelected && styles.labelSelected]}
numberOfLines={1}
>
{option.label}
</Text>
)}
</Pressable>
);
})}

View File

@@ -1,5 +1,8 @@
import {
Children,
cloneElement,
createContext,
isValidElement,
useCallback,
useContext,
useEffect,
@@ -8,6 +11,7 @@ import {
useState,
type PropsWithChildren,
type ReactElement,
type Ref,
} from "react";
import {
Dimensions,
@@ -53,6 +57,26 @@ function useTooltipContext(componentName: string): TooltipContextValue {
return ctx;
}
function composeEventHandlers<E>(
original?: (event: E) => void,
injected?: (event: E) => void
): (event: E) => void {
return (event: E) => {
original?.(event);
injected?.(event);
};
}
function assignRef<T>(ref: Ref<T> | undefined, value: T): void {
if (typeof ref === "function") {
ref(value);
return;
}
if (ref && typeof ref === "object") {
(ref as { current: T }).current = value;
}
}
function useControllableOpenState({
open,
defaultOpen,
@@ -224,8 +248,15 @@ export function TooltipTrigger({
onFocus,
onBlur,
onPress,
asChild = false,
triggerRefProp = "ref",
...props
}: PropsWithChildren<PressableProps>): ReactElement {
}: PropsWithChildren<
PressableProps & {
asChild?: boolean;
triggerRefProp?: string;
}
>): ReactElement {
const ctx = useTooltipContext("TooltipTrigger");
const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -276,37 +307,86 @@ export function TooltipTrigger({
[onHoverOut, close]
);
const handleFocus = useCallback(
(e: any) => {
onFocus?.(e);
if (!ctx.enabled || disabled) return;
clearOpenTimer();
ctx.setOpen(true);
},
[clearOpenTimer, ctx, disabled, onFocus]
);
const handleBlur = useCallback(
(e: any) => {
onBlur?.(e);
close();
},
[close, onBlur]
);
const handlePress = useCallback(
(e: any) => {
onPress?.(e);
close();
},
[close, onPress]
);
const triggerProps = {
...props,
disabled,
onHoverIn: handleHoverIn,
onHoverOut: handleHoverOut,
onFocus: handleFocus,
onBlur: handleBlur,
onPress: handlePress,
...(Platform.OS === "web"
? ({
// RN Web's hover handling can vary across environments; pointer events are the most reliable.
onPointerEnter: handleHoverIn,
onPointerLeave: handleHoverOut,
onMouseEnter: handleHoverIn,
onMouseLeave: handleHoverOut,
} as any)
: null),
};
if (asChild) {
const child = Children.only(children);
if (!isValidElement(child)) {
throw new Error("TooltipTrigger with asChild expects a single React element child");
}
const childProps = child.props as Record<string, any>;
const mergedProps = {
...childProps,
...triggerProps,
onHoverIn: composeEventHandlers(childProps.onHoverIn, handleHoverIn),
onHoverOut: composeEventHandlers(childProps.onHoverOut, handleHoverOut),
onFocus: composeEventHandlers(childProps.onFocus, handleFocus),
onBlur: composeEventHandlers(childProps.onBlur, handleBlur),
onPress: composeEventHandlers(childProps.onPress, handlePress),
onPointerEnter: composeEventHandlers(childProps.onPointerEnter, handleHoverIn),
onPointerLeave: composeEventHandlers(childProps.onPointerLeave, handleHoverOut),
onMouseEnter: composeEventHandlers(childProps.onMouseEnter, handleHoverIn),
onMouseLeave: composeEventHandlers(childProps.onMouseLeave, handleHoverOut),
} as Record<string, any>;
const existingRefProp = childProps[triggerRefProp] as Ref<View | null> | undefined;
mergedProps[triggerRefProp] = (node: View | null) => {
assignRef(existingRefProp, node);
assignRef(ctx.triggerRef, node);
};
return cloneElement(child, mergedProps);
}
return (
<Pressable
{...props}
{...triggerProps}
ref={ctx.triggerRef}
collapsable={false}
disabled={disabled}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
onFocus={(e) => {
onFocus?.(e);
if (!ctx.enabled || disabled) return;
clearOpenTimer();
ctx.setOpen(true);
}}
onBlur={(e) => {
onBlur?.(e);
close();
}}
onPress={(e) => {
onPress?.(e);
close();
}}
{...(Platform.OS === "web"
? ({
// RN Web's hover handling can vary across environments; pointer events are the most reliable.
onPointerEnter: handleHoverIn,
onPointerLeave: handleHoverOut,
onMouseEnter: handleHoverIn,
onMouseLeave: handleHoverOut,
} as any)
: null)}
>
{children}
</Pressable>

View File

@@ -132,8 +132,6 @@ function createDriverHarness(input?: {
context.measurementState.offsetY = 720;
});
const modeChanges: BottomAnchorMode[] = [];
const warnings: Array<{ agentId: string; reason: string }> = [];
const logs: Array<{ event: string; details: Record<string, unknown> }> = [];
const driver = __private__.createBottomAnchorControllerDriver({
getAgentId: () => context.agentId,
getIsAuthoritativeHistoryReady: () => context.authoritativeReady,
@@ -145,10 +143,6 @@ function createDriverHarness(input?: {
onModeChange: (mode) => {
modeChanges.push(mode);
},
log: (event, details) => {
logs.push({ event, details });
},
warn: (details) => warnings.push(details),
scheduleFrame: (params) => scheduler.schedule(params),
cancelFrame: (handle) => scheduler.cancel(handle),
});
@@ -159,8 +153,6 @@ function createDriverHarness(input?: {
scheduler,
scrollToBottom,
modeChanges,
logs,
warnings,
};
}
@@ -367,7 +359,6 @@ describe("bottom anchor controller driver", () => {
harness.scheduler.flushAll();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.warnings).toEqual([]);
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
});

View File

@@ -110,8 +110,6 @@ type CreateBottomAnchorControllerDriverInput = {
isNearBottom: () => boolean;
scrollToBottom: (animated: boolean) => void;
onModeChange: (mode: BottomAnchorMode) => void;
log: (event: BottomAnchorEvent, details: Record<string, unknown>) => void;
warn: (details: { agentId: string; reason: BottomAnchorRequestReason }) => void;
scheduleFrame: (params: {
kind: "attempt" | "verification";
callback: () => void;
@@ -123,17 +121,6 @@ type CreateBottomAnchorControllerDriverInput = {
const MAX_VERIFICATION_RETRIES = 3;
const WEB_PARTIAL_VIRTUALIZED_CONFIRMATION_DELAY_FRAMES = 1;
const USER_SCROLL_AWAY_DELTA_PX = 24;
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
function logBottomAnchorEvent(
event: BottomAnchorEvent,
details: Record<string, unknown>
): void {
if (!IS_DEV) {
return;
}
console.debug("[BottomAnchor]", event, details);
}
function scheduleAnimationFrameWithDelay(input: {
callback: () => void;
@@ -323,10 +310,6 @@ function createBottomAnchorControllerDriver(
return;
}
blockedReason = nextBlockedReason;
input.log(
"blocked_reason_changed",
getLogContext({ nextBlockedReason })
);
};
const setModeInternal = (nextMode: BottomAnchorMode) => {
@@ -367,13 +350,6 @@ function createBottomAnchorControllerDriver(
setBlockedReason(null);
return;
}
input.log(
"request_cancelled",
getLogContext({
cancelledRequestReason: currentRequest.reason,
cancelReason: reason,
})
);
pendingRequest = null;
cancelPendingAttempt();
setBlockedReason(null);
@@ -398,19 +374,6 @@ function createBottomAnchorControllerDriver(
if (verificationHandle) {
input.cancelFrame(verificationHandle);
}
input.log(
"verification_scheduled",
getLogContext({
retries: attemptContext.retries,
startedContentHeight: attemptContext.startedContentHeight ?? null,
startedOffsetY: attemptContext.startedOffsetY ?? null,
startedViewportHeight: attemptContext.startedViewportHeight ?? null,
scheduledMeasurementState:
getDetailedMeasurementState(scheduledMeasurementState),
verificationDelayFrames:
delayFramesOverride ?? input.getTransportBehavior().verificationDelayFrames,
})
);
verificationHandle = input.scheduleFrame({
kind: "verification",
delayFrames:
@@ -427,15 +390,6 @@ function createBottomAnchorControllerDriver(
});
if (verificationBlockedReason) {
input.log(
"attempt_verified",
getLogContext({
verificationPhase: "blocked",
verificationBlockedReason,
retries: attemptContext.retries,
measurementState: getDetailedMeasurementState(measurementState),
})
);
pendingVerification = attemptContext;
setBlockedReason(verificationBlockedReason);
return;
@@ -451,26 +405,6 @@ function createBottomAnchorControllerDriver(
input.getTransportBehavior().verificationRetryMode,
});
input.log(
"attempt_verified",
getLogContext({
verifiedNearBottom,
retries: attemptContext.retries,
retryDisposition,
contentHeightDeltaSinceAttempt:
measurementState.contentHeight -
(attemptContext.startedContentHeight ?? measurementState.contentHeight),
offsetDeltaSinceAttempt:
measurementState.offsetY -
(attemptContext.startedOffsetY ?? measurementState.offsetY),
viewportHeightDeltaSinceAttempt:
measurementState.viewportHeight -
(attemptContext.startedViewportHeight ??
measurementState.viewportHeight),
measurementState: getDetailedMeasurementState(measurementState),
})
);
if (verifiedNearBottom) {
if (
isRequestAttempt &&
@@ -494,7 +428,6 @@ function createBottomAnchorControllerDriver(
pendingVerification = null;
markStickyMeasurementVerified();
if (isRequestAttempt) {
input.log("request_fulfilled", getLogContext());
pendingRequest = null;
}
setBlockedReason(null);
@@ -520,21 +453,7 @@ function createBottomAnchorControllerDriver(
return;
}
input.log(
"attempt_failed",
getLogContext({
retries: attemptContext.retries,
retryDisposition,
measurementState: getDetailedMeasurementState(measurementState),
})
);
pendingVerification = null;
if (isRequestAttempt && currentRequest) {
input.warn({
agentId: input.getAgentId(),
reason: currentRequest.reason,
});
}
setBlockedReason(
isRequestAttempt ? "waiting_for_post_layout_verification" : null
);
@@ -552,14 +471,6 @@ function createBottomAnchorControllerDriver(
startedViewportHeight: measurementState.viewportHeight,
};
pendingVerification = attemptContext;
input.log(
"attempt_started",
getLogContext({
animated,
retries: attemptContext.retries,
measurementState: getDetailedMeasurementState(measurementState),
})
);
input.scrollToBottom(animated);
scheduleVerification(attemptContext);
setBlockedReason(deriveDriverBlockedReason(input.getMeasurementState()));
@@ -576,18 +487,6 @@ function createBottomAnchorControllerDriver(
| "manual_reevaluate"
| "retry_scroll"
) => {
input.log(
"evaluate_called",
getLogContext({
evaluateReason: reason,
animated,
hasAttemptHandle: attemptHandle !== null,
hasVerificationHandle: verificationHandle !== null,
pendingVerificationRequestId: pendingVerification?.requestId ?? null,
pendingVerificationRetries: pendingVerification?.retries ?? null,
measurementState: getDetailedMeasurementState(input.getMeasurementState()),
})
);
if (attemptHandle) {
return;
}
@@ -610,17 +509,6 @@ function createBottomAnchorControllerDriver(
!shouldAttemptForPendingRequest &&
!shouldAttemptForStickyVerification
) {
input.log(
"attempt_started",
getLogContext({
attemptPhase: "skipped",
evaluateReason: reason,
nextBlockedReason,
shouldAttemptForPendingRequest,
shouldAttemptForStickyVerification,
measurementState: getDetailedMeasurementState(measurementState),
})
);
return;
}
@@ -630,17 +518,6 @@ function createBottomAnchorControllerDriver(
};
const createRequest = (request: BottomAnchorRouteRequest | BottomAnchorLocalRequest) => {
const existing = pendingRequest;
if (existing) {
input.log(
"request_cancelled",
getLogContext({
cancelledRequestReason: existing.reason,
cancelReason: "replaced_by_new_request",
})
);
}
cancelPendingAttempt();
const nextRequest: BottomAnchorRequest = {
id: requestSequence + 1,
@@ -659,10 +536,6 @@ function createBottomAnchorControllerDriver(
? "sticky-bottom"
: __private__.deriveModeForLocalRequest({ reason: request.reason })
);
input.log(
"request_created",
getLogContext({ requestReason: request.reason })
);
evaluate(request.reason === "jump-to-bottom", "request_created");
};
@@ -707,7 +580,6 @@ function createBottomAnchorControllerDriver(
}
cancelPendingRequest("user_scrolled_away");
setModeInternal("detached");
input.log("detached_by_user", getLogContext());
},
handleViewportMetricsChange(params) {
if (
@@ -905,10 +777,6 @@ export function useBottomAnchorController(input: {
isNearBottom: () => isNearBottomRef.current(),
scrollToBottom: (animated) => scrollToBottomRef.current(animated),
onModeChange: (nextMode) => setMode(nextMode),
log: (event, details) => logBottomAnchorEvent(event, details),
warn: (details) => {
console.warn("[BottomAnchor] request could not be fulfilled", details);
},
scheduleFrame: ({ callback, delayFrames }) =>
scheduleAnimationFrameWithDelay({ callback, delayFrames }),
cancelFrame: (handle) => cancelScheduledAnimationFrame(handle),

View File

@@ -2,21 +2,18 @@ import { ActivityIndicator, Alert, Pressable, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, MicOff, Square } from "lucide-react-native";
import { VolumeMeter } from "@/components/volume-meter";
import { useVoice } from "@/contexts/voice-context";
import { useVoice, useVoiceTelemetry } from "@/contexts/voice-context";
export function VoiceCompactIndicator() {
const { theme } = useUnistyles();
const { volume, isSpeaking } = useVoiceTelemetry();
const {
isVoiceMode,
isVoiceSwitching,
volume,
isMuted,
isDetecting,
isSpeaking,
toggleMute,
stopVoice,
} = useVoice();
if (!isVoiceMode) {
return null;
}
@@ -27,7 +24,6 @@ export function VoiceCompactIndicator() {
<VolumeMeter
volume={volume}
isMuted={isMuted}
isDetecting={isDetecting}
isSpeaking={isSpeaking}
orientation="horizontal"
variant="compact"

View File

@@ -2,22 +2,19 @@ import { View, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { MicOff, Square } from "lucide-react-native";
import { VolumeMeter } from "./volume-meter";
import { useVoice } from "@/contexts/voice-context";
import { useVoice, useVoiceTelemetry } from "@/contexts/voice-context";
import { useHosts } from "@/runtime/host-runtime";
export function VoicePanel() {
const { theme } = useUnistyles();
const daemons = useHosts();
const { volume, isSpeaking } = useVoiceTelemetry();
const {
volume,
isMuted,
isDetecting,
isSpeaking,
stopVoice,
toggleMute,
activeServerId,
} = useVoice();
const hostLabel = activeServerId
? daemons.find((daemon) => daemon.serverId === activeServerId)?.label ?? null
: null;
@@ -30,7 +27,6 @@ export function VoicePanel() {
<VolumeMeter
volume={volume}
isMuted={isMuted}
isDetecting={isDetecting}
isSpeaking={isSpeaking}
orientation="horizontal"
variant="compact"

View File

@@ -3,10 +3,9 @@ import { View } from "react-native";
import ReanimatedAnimated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
withRepeat,
withSequence,
withTiming,
Easing,
} from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -14,7 +13,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
interface VolumeMeterProps {
volume: number;
isMuted?: boolean;
isDetecting?: boolean;
isSpeaking?: boolean;
orientation?: "vertical" | "horizontal";
variant?: "default" | "compact";
@@ -41,9 +39,7 @@ export function VolumeMeter({
orientation === "horizontal" ? (isCompact ? 8 : 12) : isCompact ? 14 : 20;
// Create shared values for 3 dots unconditionally
const line1Height = useSharedValue(MIN_HEIGHT);
const line2Height = useSharedValue(MIN_HEIGHT);
const line3Height = useSharedValue(MIN_HEIGHT);
const animatedVolume = useSharedValue(0);
const line1Pulse = useSharedValue(1);
const line2Pulse = useSharedValue(1);
const line3Pulse = useSharedValue(1);
@@ -90,54 +86,19 @@ export function VolumeMeter({
);
}, [isMuted]);
// Update heights based on volume with different responsiveness for all dots
// Drive a single animated volume value and derive the individual bar heights
// on the UI thread instead of scheduling three independent springs per sample.
useEffect(() => {
if (isMuted) {
// When muted, keep all lines at minimum height without animation
line1Height.value = MIN_HEIGHT;
line2Height.value = MIN_HEIGHT;
line3Height.value = MIN_HEIGHT;
animatedVolume.value = 0;
return;
}
if (volume > 0.001) {
// Active volume - animate heights based on volume
const target1 = MIN_HEIGHT + (MAX_HEIGHT * volume * 1.2);
const target2 = MIN_HEIGHT + (MAX_HEIGHT * volume * 1.05);
const target3 = MIN_HEIGHT + (MAX_HEIGHT * volume * 0.9);
line1Height.value = withSpring(target1, {
damping: 10,
stiffness: 200,
});
line2Height.value = withSpring(target2, {
damping: 12.5,
stiffness: 175,
});
line3Height.value = withSpring(target3, {
damping: 15,
stiffness: 150,
});
} else {
// No volume - return to minimum
line1Height.value = withSpring(MIN_HEIGHT, {
damping: 20,
stiffness: 150,
});
line2Height.value = withSpring(MIN_HEIGHT, {
damping: 20,
stiffness: 150,
});
line3Height.value = withSpring(MIN_HEIGHT, {
damping: 20,
stiffness: 150,
});
}
}, [volume, isMuted]);
animatedVolume.value = withTiming(volume, {
duration: volume > animatedVolume.value ? 70 : 140,
easing: Easing.out(Easing.cubic),
});
}, [animatedVolume, isMuted, volume]);
const lineColor = color ?? theme.colors.foreground;
const containerHeight =
@@ -147,9 +108,12 @@ export function VolumeMeter({
const line1Style = useAnimatedStyle(() => {
const isActive = isSpeaking;
const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5;
const volumeBoost = isMuted || !isActive ? 0 : volume * 0.3;
const currentVolume = isMuted ? 0 : animatedVolume.value;
const currentHeight = MIN_HEIGHT + (MAX_HEIGHT * currentVolume * 1.2);
const volumeBoost = isMuted || !isActive ? 0 : currentVolume * 0.3;
return {
height: line1Height.value * (isMuted || volume > 0.001 ? 1 : line1Pulse.value),
height:
currentHeight * (isMuted || currentVolume > 0.001 ? 1 : line1Pulse.value),
opacity: baseOpacity + volumeBoost,
};
});
@@ -157,9 +121,12 @@ export function VolumeMeter({
const line2Style = useAnimatedStyle(() => {
const isActive = isSpeaking;
const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5;
const volumeBoost = isMuted || !isActive ? 0 : volume * 0.3;
const currentVolume = isMuted ? 0 : animatedVolume.value;
const currentHeight = MIN_HEIGHT + (MAX_HEIGHT * currentVolume * 1.05);
const volumeBoost = isMuted || !isActive ? 0 : currentVolume * 0.3;
return {
height: line2Height.value * (isMuted || volume > 0.001 ? 1 : line2Pulse.value),
height:
currentHeight * (isMuted || currentVolume > 0.001 ? 1 : line2Pulse.value),
opacity: baseOpacity + volumeBoost,
};
});
@@ -167,9 +134,12 @@ export function VolumeMeter({
const line3Style = useAnimatedStyle(() => {
const isActive = isSpeaking;
const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5;
const volumeBoost = isMuted || !isActive ? 0 : volume * 0.3;
const currentVolume = isMuted ? 0 : animatedVolume.value;
const currentHeight = MIN_HEIGHT + (MAX_HEIGHT * currentVolume * 0.9);
const volumeBoost = isMuted || !isActive ? 0 : currentVolume * 0.3;
return {
height: line3Height.value * (isMuted || volume > 0.001 ? 1 : line3Pulse.value),
height:
currentHeight * (isMuted || currentVolume > 0.001 ? 1 : line3Pulse.value),
opacity: baseOpacity + volumeBoost,
};
});

View File

@@ -1,8 +1,9 @@
import { useCallback, useState } from "react";
import { Image, Pressable, Text, View, Platform, ScrollView } from "react-native";
import { Pressable, Text, View, Platform, ScrollView } from "react-native";
import { useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { HostProfile } from "@/types/host-connection";
import { useHostMutations } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
@@ -12,12 +13,23 @@ import { NameHostModal } from "./name-host-modal";
import { resolveAppVersion } from "@/utils/app-version";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
import { buildHostRootRoute } from "@/utils/host-routes";
import { PaseoLogo } from "@/components/icons/paseo-logo";
type WelcomeAction = {
key: "scan-qr" | "direct-connection" | "paste-pairing-link";
label: string;
testID: string;
primary: boolean;
icon: typeof QrCode;
onPress: () => void;
};
const styles = StyleSheet.create((theme) => ({
container: {
flexGrow: 1,
backgroundColor: theme.colors.surface0,
padding: theme.spacing[6],
paddingBottom: 0,
alignItems: "center",
},
content: {
@@ -26,11 +38,6 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center",
alignItems: "center",
},
logo: {
width: 96,
height: 96,
marginBottom: theme.spacing[6],
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xl,
@@ -61,8 +68,8 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.border,
},
actionButtonPrimary: {
backgroundColor: theme.colors.palette.blue[500],
borderColor: theme.colors.palette.blue[500],
backgroundColor: theme.colors.accent,
borderColor: theme.colors.accent,
},
actionText: {
color: theme.colors.foreground,
@@ -70,7 +77,7 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.medium,
},
actionTextPrimary: {
color: theme.colors.palette.white,
color: theme.colors.accentForeground,
},
versionLabel: {
color: theme.colors.foregroundMuted,
@@ -86,6 +93,7 @@ export interface WelcomeScreenProps {
export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const router = useRouter();
const { renameHost } = useHostMutations();
const appVersion = resolveAppVersion();
@@ -111,51 +119,87 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
[router]
);
const actions: WelcomeAction[] = Platform.OS === "web"
? [
{
key: "direct-connection",
label: "Direct connection",
testID: "welcome-direct-connection",
primary: true,
icon: Link2,
onPress: () => setIsDirectOpen(true),
},
{
key: "paste-pairing-link",
label: "Paste pairing link",
testID: "welcome-paste-pairing-link",
primary: false,
icon: ClipboardPaste,
onPress: () => setIsPasteLinkOpen(true),
},
]
: [
{
key: "scan-qr",
label: "Scan QR code",
testID: "welcome-scan-qr",
primary: true,
icon: QrCode,
onPress: () => router.push("/pair-scan?source=onboarding"),
},
{
key: "direct-connection",
label: "Direct connection",
testID: "welcome-direct-connection",
primary: false,
icon: Link2,
onPress: () => setIsDirectOpen(true),
},
{
key: "paste-pairing-link",
label: "Paste pairing link",
testID: "welcome-paste-pairing-link",
primary: false,
icon: ClipboardPaste,
onPress: () => setIsPasteLinkOpen(true),
},
];
return (
<ScrollView
style={{ flex: 1, backgroundColor: theme.colors.surface0 }}
contentContainerStyle={styles.container}
contentContainerStyle={[
styles.container,
{ paddingBottom: theme.spacing[6] + insets.bottom },
]}
showsVerticalScrollIndicator={false}
testID="welcome-screen"
>
<View style={styles.content}>
<Image
source={require("../../assets/images/icon.png")}
style={styles.logo}
resizeMode="contain"
/>
<PaseoLogo size={96} color={theme.colors.foreground} />
<Text style={styles.title}>Welcome to Paseo</Text>
<Text style={styles.subtitle}>Add a host to start.</Text>
<Text style={styles.subtitle}>Connect to your host to start</Text>
<View style={styles.actions}>
<Pressable
style={[styles.actionButton, styles.actionButtonPrimary]}
onPress={() => setIsDirectOpen(true)}
testID="welcome-direct-connection"
>
<Link2 size={18} color={theme.colors.palette.white} />
<Text style={[styles.actionText, styles.actionTextPrimary]}>Direct connection</Text>
</Pressable>
<Pressable
style={styles.actionButton}
onPress={() => setIsPasteLinkOpen(true)}
testID="welcome-paste-pairing-link"
>
<ClipboardPaste size={18} color={theme.colors.foreground} />
<Text style={styles.actionText}>Paste pairing link</Text>
</Pressable>
{Platform.OS !== "web" ? (
{actions.map((action) => {
const Icon = action.icon;
return (
<Pressable
style={styles.actionButton}
onPress={() => router.push("/pair-scan?source=onboarding")}
testID="welcome-scan-qr"
key={action.key}
style={[styles.actionButton, action.primary ? styles.actionButtonPrimary : null]}
onPress={action.onPress}
testID={action.testID}
>
<QrCode size={18} color={theme.colors.foreground} />
<Text style={styles.actionText}>Scan QR code</Text>
<Icon
size={18}
color={action.primary ? theme.colors.accentForeground : theme.colors.foreground}
/>
<Text style={[styles.actionText, action.primary ? styles.actionTextPrimary : null]}>
{action.label}
</Text>
</Pressable>
) : null}
);
})}
</View>
</View>
<Text style={styles.versionLabel}>{appVersionText}</Text>

View File

@@ -12,18 +12,6 @@ import { usePanelStore } from "@/stores/panel-store";
const ANIMATION_DURATION = 220;
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
function logExplorerAnimation(
event: string,
details: Record<string, unknown>
): void {
if (!IS_DEV) {
return;
}
console.log(`[ExplorerAnimation] ${event}`, details);
}
interface ExplorerSidebarAnimationContextValue {
translateX: SharedValue<number>;
backdropOpacity: SharedValue<number>;
@@ -66,23 +54,9 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
if (isGesturing.value) {
logExplorerAnimation("sync-skipped-during-gesture", {
previousIsOpen,
nextIsOpen: isOpen,
mobileView,
desktopFileExplorerOpen,
});
return;
}
logExplorerAnimation("sync-state-change", {
previousIsOpen,
nextIsOpen: isOpen,
mobileView,
desktopFileExplorerOpen,
windowWidth,
});
if (isOpen) {
translateX.value = withTiming(0, {
duration: ANIMATION_DURATION,

View File

@@ -1,7 +1,7 @@
import { useRef, ReactNode, useCallback, useEffect, useMemo } from "react";
import { Buffer } from "buffer";
import { AppState, Platform } from "react-native";
import { useQueryClient } from "@tanstack/react-query";
import { useAudioPlayer } from "@/hooks/use-audio-player";
import { useClientActivity } from "@/hooks/use-client-activity";
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
@@ -29,8 +29,13 @@ import type { DaemonClient } from "@server/client/daemon-client";
import { File } from "expo-file-system";
import {
getHostRuntimeStore,
useHostRuntimeSession,
useHostRuntimeIsConnected,
} from "@/runtime/host-runtime";
import {
useVoiceAudioEngineOptional,
useVoiceRuntimeOptional,
} from "@/contexts/voice-context";
import type { AudioPlaybackSource } from "@/voice/audio-engine-types";
import {
useSessionStore,
type Agent,
@@ -41,6 +46,7 @@ import {
import { useDraftStore } from "@/stores/draft-store";
import type { AgentDirectoryEntry } from "@/types/agent-directory";
import { sendOsNotification } from "@/utils/os-notifications";
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
import {
getInitKey,
getInitDeferred,
@@ -72,6 +78,55 @@ export type {
const HISTORY_STALE_AFTER_MS = 60_000;
const AUTHORITATIVE_REVALIDATION_DEBOUNCE_MS = 300;
type AudioOutputPayload = Extract<
SessionOutboundMessage,
{ type: "audio_output" }
>["payload"];
interface BufferedAudioChunk {
chunkIndex: number;
audio: string;
format: string;
id: string;
}
function decodeBase64Chunk(base64: string): Uint8Array {
return Buffer.from(base64, "base64");
}
function buildAudioPlaybackSource(
chunks: BufferedAudioChunk[]
): AudioPlaybackSource {
const decodedChunks = chunks.map((chunk) => decodeBase64Chunk(chunk.audio));
const totalSize = decodedChunks.reduce((sum, chunk) => sum + chunk.length, 0);
const output = new Uint8Array(totalSize);
let offset = 0;
for (const chunk of decodedChunks) {
output.set(chunk, offset);
offset += chunk.length;
}
const format = chunks[0]?.format ?? "pcm";
const mimeType =
format === "pcm"
? "audio/pcm;rate=24000;bits=16"
: format === "mp3"
? "audio/mpeg"
: `audio/${format}`;
const bytes = output.slice();
return {
size: bytes.byteLength,
type: mimeType,
async arrayBuffer() {
return bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength
);
},
};
}
const findLatestAssistantMessageText = (items: StreamItem[]): string | null => {
for (let i = items.length - 1; i >= 0; i -= 1) {
const item = items[i];
@@ -205,8 +260,10 @@ function SessionProviderInternal({
serverId,
client,
}: SessionProviderClientProps) {
const voiceRuntime = useVoiceRuntimeOptional();
const voiceAudioEngine = useVoiceAudioEngineOptional();
const queryClient = useQueryClient();
const { isConnected } = useHostRuntimeSession(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
// Zustand store actions
const initializeSession = useSessionStore((state) => state.initializeSession);
@@ -275,16 +332,6 @@ function SessionProviderInternal({
(state) => state.sessions[serverId]?.agents
);
// State for voice detection flags (will be set by RealtimeContext)
const isDetectingRef = useRef(false);
const isSpeakingRef = useRef(false);
const audioPlayer = useAudioPlayer({
isDetecting: () => isDetectingRef.current,
isSpeaking: () => isSpeakingRef.current,
});
const activeAudioGroupsRef = useRef<Set<string>>(new Set());
const previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>(
new Map()
);
@@ -307,6 +354,10 @@ function SessionProviderInternal({
const revalidationInFlightRef = useRef<Promise<void> | null>(null);
const revalidationQueuedRef = useRef(false);
const wasConnectedRef = useRef(isConnected);
const audioOutputBuffersRef = useRef<Map<string, BufferedAudioChunk[]>>(
new Map()
);
const activeAudioGroupsRef = useRef<Set<string>>(new Set());
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState) => {
@@ -452,11 +503,6 @@ function SessionProviderInternal({
const queue = session?.queuedMessages.get(agent.id);
if (queue && queue.length > 0) {
const [next, ...rest] = queue;
console.log(
"[Session] Flushing queued message for agent:",
agent.id,
next.text
);
if (sendAgentMessageRef.current) {
void sendAgentMessageRef.current(agent.id, next.text, next.images);
}
@@ -570,18 +616,10 @@ function SessionProviderInternal({
if (params.reason === "error") {
return;
}
const isActive = appState ? appState === "active" : true;
const isAwayFromAgent = !isActive || focusedAgentId !== params.agentId;
const isActivelyVisible = getIsAppActivelyVisible(appState);
const isAwayFromAgent =
!isActivelyVisible || focusedAgentId !== params.agentId;
if (!isAwayFromAgent) {
console.log(
"[OSNotifications] Skipping attention notification: user already focused on agent",
{
agentId: params.agentId,
reason: params.reason,
appState,
focusedAgentId,
}
);
return;
}
@@ -624,24 +662,61 @@ function SessionProviderInternal({
[serverId]
);
// Buffer for streaming audio chunks
interface AudioChunk {
chunkIndex: number;
audio: string; // base64
format: string;
id: string;
}
const audioChunkBuffersRef = useRef<Map<string, AudioChunk[]>>(new Map());
// Initialize session in store
useEffect(() => {
initializeSession(serverId, client, audioPlayer);
}, [serverId, client, audioPlayer, initializeSession]);
initializeSession(serverId, client);
}, [serverId, client, initializeSession]);
useEffect(() => {
updateSessionClient(serverId, client);
}, [serverId, client, updateSessionClient]);
useEffect(() => {
if (!voiceRuntime) {
return;
}
return voiceRuntime.registerSession({
serverId,
setVoiceMode: async (enabled, agentId) => {
if (!client) {
throw new Error("Daemon unavailable");
}
await client.setVoiceMode(enabled, agentId);
},
sendVoiceAudioChunk: async (audioData, mimeType) => {
if (!client) {
throw new Error("Daemon unavailable");
}
await client.sendVoiceAudioChunk(audioData, mimeType);
},
audioPlayed: async (chunkId) => {
if (!client) {
throw new Error("Daemon unavailable");
}
await client.audioPlayed(chunkId);
},
abortRequest: async () => {
if (!client) {
throw new Error("Daemon unavailable");
}
await client.abortRequest();
},
setAssistantAudioPlaying: (isPlaying) => {
setIsPlayingAudio(serverId, isPlaying);
},
});
}, [
client,
serverId,
setIsPlayingAudio,
voiceRuntime,
]);
useEffect(() => {
voiceRuntime?.updateSessionConnection(serverId, isConnected);
}, [isConnected, serverId, voiceRuntime]);
// If the client drops mid-initialization, clear pending flags
useEffect(() => {
if (!isConnected) {
@@ -969,6 +1044,14 @@ function SessionProviderInternal({
const { agentId, event, timestamp, seq, epoch } = message.payload;
const parsedTimestamp = new Date(timestamp);
const streamEvent = event as AgentStreamEventPayload;
if (
event.type === "turn_started" ||
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled"
) {
voiceRuntime?.onTurnEvent(serverId, agentId, event.type);
}
// Attention notification stays in React (not extractable to pure reducer)
if (event.type === "attention_required") {
@@ -1118,13 +1201,6 @@ function SessionProviderInternal({
if (message.type !== "agent_permission_request") return;
const { agentId, request } = message.payload;
console.log(
"[Session] Permission request:",
request.id,
"for agent:",
agentId
);
setPendingPermissions(serverId, (prev) => {
const next = new Map(prev);
const key = derivePendingPermissionKey(agentId, request);
@@ -1140,13 +1216,6 @@ function SessionProviderInternal({
if (message.type !== "agent_permission_resolved") return;
const { requestId, agentId } = message.payload;
console.log(
"[Session] Permission resolved:",
requestId,
"for agent:",
agentId
);
setPendingPermissions(serverId, (prev) => {
const next = new Map(prev);
const derivedKey = `${agentId}:${requestId}`;
@@ -1168,92 +1237,75 @@ function SessionProviderInternal({
const unsubAudioOutput = client.on("audio_output", async (message) => {
if (message.type !== "audio_output") return;
const data = message.payload;
const playbackGroupId = data.groupId ?? data.id;
const isFinalChunk = data.isLastChunk ?? true;
const chunkIndex = data.chunkIndex ?? 0;
if (!voiceAudioEngine) {
return;
}
const payload: AudioOutputPayload = message.payload;
if (payload.isVoiceMode && voiceRuntime) {
voiceRuntime.handleAudioOutput(serverId, payload);
return;
}
const playbackGroupId = payload.groupId ?? payload.id;
const chunkIndex = payload.chunkIndex ?? 0;
const isFinalChunk = payload.isLastChunk ?? true;
if (!audioOutputBuffersRef.current.has(playbackGroupId)) {
audioOutputBuffersRef.current.set(playbackGroupId, []);
}
const bufferedChunks = audioOutputBuffersRef.current.get(playbackGroupId)!;
bufferedChunks.push({
chunkIndex,
audio: payload.audio,
format: payload.format,
id: payload.id,
});
activeAudioGroupsRef.current.add(playbackGroupId);
setIsPlayingAudio(serverId, true);
if (!audioChunkBuffersRef.current.has(playbackGroupId)) {
audioChunkBuffersRef.current.set(playbackGroupId, []);
}
const buffer = audioChunkBuffersRef.current.get(playbackGroupId)!;
buffer.push({
chunkIndex,
audio: data.audio,
format: data.format,
id: data.id,
});
if (!isFinalChunk) {
return;
}
buffer.sort((a, b) => a.chunkIndex - b.chunkIndex);
let playbackFailed = false;
const chunkIds = buffer.map((chunk) => chunk.id);
const confirmAudioPlayed = (ids: string[]) => {
if (!client) {
console.warn("[Session] audio_played skipped: daemon unavailable");
return;
}
ids.forEach((chunkId) => {
void client.audioPlayed(chunkId).catch((error) => {
console.warn("[Session] Failed to confirm audio playback:", error);
});
});
bufferedChunks.sort((left, right) => left.chunkIndex - right.chunkIndex);
const chunkIds = bufferedChunks.map((chunk) => chunk.id);
const shouldPlay =
!payload.isVoiceMode ||
(voiceRuntime?.shouldPlayVoiceAudio(serverId) ?? false);
const audioBlob = buildAudioPlaybackSource(bufferedChunks);
const confirmAudioPlayed = async () => {
await Promise.all(
chunkIds.map((chunkId) =>
client.audioPlayed(chunkId).catch((error) => {
console.warn("[Session] Failed to confirm audio playback:", error);
})
)
);
};
let startedVoicePlayback = false;
try {
const mimeType =
data.format === "mp3" ? "audio/mpeg" : `audio/${data.format}`;
const decodedChunks: Uint8Array[] = [];
let totalSize = 0;
for (const chunk of buffer) {
const binaryString = atob(chunk.audio);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
if (shouldPlay) {
if (payload.isVoiceMode) {
startedVoicePlayback = true;
voiceRuntime?.onAssistantAudioStarted(serverId);
}
decodedChunks.push(bytes);
totalSize += bytes.length;
await voiceAudioEngine.play(audioBlob);
}
const concatenatedBytes = new Uint8Array(totalSize);
let offset = 0;
for (const chunk of decodedChunks) {
concatenatedBytes.set(chunk, offset);
offset += chunk.length;
}
const audioBlob = {
type: mimeType,
size: totalSize,
arrayBuffer: async () => {
return concatenatedBytes.buffer;
},
} as Blob;
await audioPlayer.play(audioBlob);
confirmAudioPlayed(chunkIds);
} catch (error: any) {
playbackFailed = true;
await confirmAudioPlayed();
} catch (error) {
console.error("[Session] Audio playback error:", error);
confirmAudioPlayed(chunkIds);
await confirmAudioPlayed();
} finally {
audioChunkBuffersRef.current.delete(playbackGroupId);
audioOutputBuffersRef.current.delete(playbackGroupId);
activeAudioGroupsRef.current.delete(playbackGroupId);
setIsPlayingAudio(serverId, activeAudioGroupsRef.current.size > 0);
if (activeAudioGroupsRef.current.size === 0) {
setIsPlayingAudio(serverId, false);
if (startedVoicePlayback) {
voiceRuntime?.onAssistantAudioFinished(serverId);
}
}
});
@@ -1261,7 +1313,6 @@ function SessionProviderInternal({
const unsubActivity = client.on("activity_log", (message) => {
if (message.type !== "activity_log") return;
const data = message.payload;
if (data.type === "system" && data.content.includes("Transcribing")) {
return;
}
@@ -1381,13 +1432,20 @@ function SessionProviderInternal({
if (message.type !== "transcription_result") return;
const transcriptText = message.payload.text.trim();
voiceRuntime?.onTranscriptionResult(serverId, transcriptText);
if (!transcriptText) {
} else {
audioPlayer.stop();
setIsPlayingAudio(serverId, false);
setCurrentAssistantMessage(serverId, "");
return;
}
setCurrentAssistantMessage(serverId, "");
});
const unsubVoiceInputState = client.on("voice_input_state", (message) => {
if (message.type !== "voice_input_state") return;
voiceRuntime?.onServerSpeechStateChanged(
serverId,
message.payload.isSpeaking
);
});
const unsubAgentDeleted = client.on("agent_deleted", (message) => {
@@ -1395,7 +1453,6 @@ function SessionProviderInternal({
return;
}
const { agentId } = message.payload;
console.log("[Session] Agent deleted:", agentId);
deletePendingAgentUpdate(serverId, agentId);
clearArchiveAgentPending({ queryClient, serverId, agentId });
@@ -1471,7 +1528,6 @@ function SessionProviderInternal({
return;
}
const { agentId, archivedAt } = message.payload;
console.log("[Session] Agent archived:", agentId);
clearArchiveAgentPending({ queryClient, serverId, agentId });
setAgents(serverId, (prev) => {
@@ -1500,12 +1556,12 @@ function SessionProviderInternal({
unsubActivity();
unsubChunk();
unsubTranscription();
unsubVoiceInputState();
unsubAgentDeleted();
unsubAgentArchived();
};
}, [
client,
audioPlayer,
queryClient,
serverId,
setIsPlayingAudio,
@@ -1528,6 +1584,8 @@ function SessionProviderInternal({
requestCanonicalCatchUp,
applyAgentUpdatePayload,
applyTimelineResponse,
voiceRuntime,
voiceAudioEngine,
]);
const sendAgentMessage = useCallback(
@@ -1654,11 +1712,6 @@ function SessionProviderInternal({
worktreeName?: string;
requestId?: string;
}) => {
console.log(
"[Session] createAgent called with images:",
images?.length ?? 0,
images
);
if (!client) {
console.warn("[Session] createAgent skipped: daemon unavailable");
return;
@@ -1667,14 +1720,6 @@ function SessionProviderInternal({
let imagesData: Array<{ data: string; mimeType: string }> | undefined;
try {
imagesData = await encodeImages(images);
console.log(
"[Session] encodeImages result:",
imagesData?.length ?? 0,
imagesData?.map((img) => ({
dataLength: img.data?.length ?? 0,
mimeType: img.mimeType,
}))
);
} catch (error) {
console.error(
"[Session] Failed to prepare images for agent creation:",
@@ -1749,20 +1794,12 @@ function SessionProviderInternal({
[client]
);
const setVoiceDetectionFlags = useCallback(
(isDetecting: boolean, isSpeaking: boolean) => {
isDetectingRef.current = isDetecting;
isSpeakingRef.current = isSpeaking;
},
[]
);
// Cleanup on unmount
useEffect(() => {
return () => {
clearSession(serverId);
};
}, [serverId, clearSession]);
}, [clearSession, serverId]);
return children;
}

View File

@@ -1,41 +1,105 @@
import { createContext, useContext, useState, ReactNode, useCallback, useEffect, useRef } from "react";
import { useSpeechmaticsAudio } from "@/hooks/use-speechmatics-audio";
import type { SessionState } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities";
import {
createContext,
useContext,
useEffect,
useRef,
useSyncExternalStore,
type ReactNode,
} from "react";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import { REALTIME_VOICE_VAD_CONFIG } from "@/voice/realtime-voice-config";
import { useSessionStore } from "@/stores/session-store";
import { createAudioEngine } from "@/voice/audio-engine";
import type { AudioEngine } from "@/voice/audio-engine-types";
import {
createVoiceRuntime,
type VoiceRuntime,
type VoiceRuntimeSnapshot,
type VoiceRuntimeTelemetrySnapshot,
} from "@/voice/voice-runtime";
const KEEP_AWAKE_TAG = "paseo:voice";
interface VoiceContextValue {
isVoiceMode: boolean;
isVoiceSwitching: boolean;
volume: number;
isMuted: boolean;
isDetecting: boolean;
isSpeaking: boolean;
segmentDuration: number;
interface VoiceContextValue extends VoiceRuntimeSnapshot {
startVoice: (serverId: string, agentId: string) => Promise<void>;
stopVoice: () => Promise<void>;
isVoiceModeForAgent: (serverId: string, agentId: string) => boolean;
toggleMute: () => void;
activeServerId: string | null;
activeAgentId: string | null;
}
const VoiceContext = createContext<VoiceContextValue | null>(null);
const EMPTY_SNAPSHOT: VoiceRuntimeSnapshot = {
phase: "disabled",
isVoiceMode: false,
isVoiceSwitching: false,
isMuted: false,
activeServerId: null,
activeAgentId: null,
};
const EMPTY_TELEMETRY: VoiceRuntimeTelemetrySnapshot = {
volume: 0,
isSpeaking: false,
segmentDuration: 0,
};
const VoiceRuntimeContext = createContext<VoiceRuntime | null>(null);
const VoiceAudioEngineContext = createContext<AudioEngine | null>(null);
const noopSubscribe = () => () => {};
const getEmptySnapshot = () => EMPTY_SNAPSHOT;
const getEmptyTelemetry = () => EMPTY_TELEMETRY;
export function useVoice() {
const context = useContext(VoiceContext);
if (!context) {
const value = useVoiceOptional();
if (!value) {
throw new Error("useVoice must be used within VoiceProvider");
}
return context;
return value;
}
export function useVoiceOptional(): VoiceContextValue | null {
return useContext(VoiceContext);
const runtime = useContext(VoiceRuntimeContext);
const snapshot = useSyncExternalStore(
runtime ? runtime.subscribe : noopSubscribe,
runtime ? runtime.getSnapshot : getEmptySnapshot,
runtime ? runtime.getSnapshot : getEmptySnapshot
);
if (!runtime) {
return null;
}
return {
...snapshot,
startVoice: runtime.startVoice,
stopVoice: runtime.stopVoice,
isVoiceModeForAgent: runtime.isVoiceModeForAgent,
toggleMute: runtime.toggleMute,
};
}
export function useVoiceTelemetry() {
const telemetry = useVoiceTelemetryOptional();
if (!telemetry) {
throw new Error("useVoiceTelemetry must be used within VoiceProvider");
}
return telemetry;
}
export function useVoiceTelemetryOptional(): VoiceRuntimeTelemetrySnapshot | null {
const runtime = useContext(VoiceRuntimeContext);
const snapshot = useSyncExternalStore(
runtime ? runtime.subscribeTelemetry : noopSubscribe,
runtime ? runtime.getTelemetrySnapshot : getEmptyTelemetry,
runtime ? runtime.getTelemetrySnapshot : getEmptyTelemetry
);
return runtime ? snapshot : null;
}
export function useVoiceRuntimeOptional(): VoiceRuntime | null {
return useContext(VoiceRuntimeContext);
}
export function useVoiceAudioEngineOptional(): AudioEngine | null {
return useContext(VoiceAudioEngineContext);
}
interface VoiceProviderProps {
@@ -43,405 +107,55 @@ interface VoiceProviderProps {
}
export function VoiceProvider({ children }: VoiceProviderProps) {
const getSession = useSessionStore((state) => state.getSession);
const [activeServerId, setActiveServerId] = useState<string | null>(null);
const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
const { client: runtimeClient, isConnected: activeRuntimeConnected } = useHostRuntimeSession(
activeServerId ?? ""
);
const activeSession = useSessionStore(
useCallback(
(state: ReturnType<typeof useSessionStore.getState>) => {
if (!activeServerId) {
return null;
}
return state.sessions[activeServerId] ?? null;
const engineRef = useRef<AudioEngine | null>(null);
const runtimeRef = useRef<VoiceRuntime | null>(null);
if (!engineRef.current) {
let runtime: VoiceRuntime | null = null;
const engine = createAudioEngine({
onCaptureData: (pcm) => {
runtime?.handleCapturePcm(pcm);
},
[activeServerId]
)
);
const realtimeSessionRef = useRef<SessionState | null>(null);
const [isVoiceMode, setIsVoiceMode] = useState(false);
const [isVoiceSwitching, setIsVoiceSwitching] = useState(false);
const bargeInPlaybackStopRef = useRef<number | null>(null);
const wasVoiceSocketConnectedRef = useRef(false);
const lastVoiceModeSyncedClientRef = useRef<SessionState["client"] | null>(null);
const voiceTransportReadyRef = useRef(false);
const voiceResyncInFlightRef = useRef(false);
const silenceGraceStartMsRef = useRef<number | null>(null);
const speechInterruptTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const speechInterruptStartMsRef = useRef<number | null>(null);
const speechStartInterruptSentRef = useRef(false);
const isVoiceModeRef = useRef(false);
const vadStateRef = useRef<{ isDetecting: boolean; isSpeaking: boolean }>({
isDetecting: false,
isSpeaking: false,
});
onVolumeLevel: (level) => {
runtime?.handleCaptureVolume(level);
},
onError: (error) => {
console.error("[VoiceEngine] Capture error:", error);
},
});
const clearSpeechStartInterruptTimer = useCallback((reason: string) => {
const timer = speechInterruptTimerRef.current;
if (timer) {
clearTimeout(timer);
speechInterruptTimerRef.current = null;
const startedAt = speechInterruptStartMsRef.current;
speechInterruptStartMsRef.current = null;
if (startedAt !== null) {
console.log("[Voice] Cleared speech-start interrupt timer", {
reason,
elapsedMs: Date.now() - startedAt,
});
} else {
console.log("[Voice] Cleared speech-start interrupt timer", { reason });
}
return;
}
speechInterruptStartMsRef.current = null;
}, []);
runtime = createVoiceRuntime({
engine,
getServerInfo: (serverId) =>
useSessionStore.getState().getSession(serverId)?.serverInfo ?? null,
activateKeepAwake: async (tag) => {
await activateKeepAwakeAsync(tag);
},
deactivateKeepAwake: async (tag) => {
await deactivateKeepAwake(tag);
},
});
const interruptActiveVoiceTurn = useCallback((source: string) => {
const session = realtimeSessionRef.current;
const sessionAudioPlayer = session?.audioPlayer ?? null;
const sessionClient = session?.client ?? null;
const sessionIsPlayingAudio = session?.isPlayingAudio ?? false;
engineRef.current = engine;
runtimeRef.current = runtime;
}
if (sessionIsPlayingAudio && sessionAudioPlayer) {
if (bargeInPlaybackStopRef.current === null) {
bargeInPlaybackStopRef.current = Date.now();
}
sessionAudioPlayer.stop();
}
try {
if (sessionClient) {
void sessionClient.abortRequest().catch((error) => {
console.error("[Voice] Failed to send abort_request:", error);
});
}
console.log("[Voice] Sent abort_request before streaming audio", { source });
} catch (error) {
console.error("[Voice] Failed to send abort_request:", error);
}
}, []);
const realtimeAudio = useSpeechmaticsAudio({
onSpeechStart: () => {
console.log("[Voice] Segment started (speech confirmed)");
},
onSpeechEnd: () => {
const silenceMs =
silenceGraceStartMsRef.current === null
? null
: Date.now() - silenceGraceStartMsRef.current;
if (silenceMs === null) {
console.log("[Voice] Segment finalized");
} else {
console.log("[Voice] Segment finalized", { silenceMs });
}
silenceGraceStartMsRef.current = null;
clearSpeechStartInterruptTimer("speech ended");
speechStartInterruptSentRef.current = false;
},
onAudioSegment: ({ audioData, isLast }) => {
if (!voiceTransportReadyRef.current) {
console.log("[Voice] Skipping audio segment: voice transport not ready");
return;
}
console.log(
"[Voice] Sending audio segment, length:",
audioData.length,
"isLast:",
isLast
);
// Send audio segment to server (realtime always goes to orchestrator)
const session = realtimeSessionRef.current;
try {
if (session?.client) {
void session.client
.sendVoiceAudioChunk(
audioData,
"audio/pcm;rate=16000;bits=16",
isLast
)
.catch((error) => {
console.error("[Voice] Failed to send audio segment:", error);
});
}
} catch (error) {
console.error("[Voice] Failed to send audio segment:", error);
}
},
onError: (error) => {
console.error("[Voice] Audio error:", error);
const session = realtimeSessionRef.current;
if (session?.client) {
// Send error through websocket instead of directly manipulating messages
console.error("[Voice] Cannot handle error - setMessages not available from SessionState");
}
},
volumeThreshold: REALTIME_VOICE_VAD_CONFIG.volumeThreshold,
confirmedDropGracePeriod: REALTIME_VOICE_VAD_CONFIG.confirmedDropGracePeriodMs,
silenceDuration: REALTIME_VOICE_VAD_CONFIG.silenceDurationMs,
speechConfirmationDuration: REALTIME_VOICE_VAD_CONFIG.speechConfirmationMs,
detectionGracePeriod: REALTIME_VOICE_VAD_CONFIG.detectionGracePeriodMs,
});
useEffect(() => {
realtimeSessionRef.current = activeSession;
}, [activeSession]);
useEffect(() => {
isVoiceModeRef.current = isVoiceMode;
}, [isVoiceMode]);
useEffect(() => {
if (!isVoiceMode) {
clearSpeechStartInterruptTimer("voice mode disabled");
speechStartInterruptSentRef.current = false;
return;
}
if (realtimeAudio.isSpeaking) {
if (
speechStartInterruptSentRef.current ||
speechInterruptTimerRef.current !== null
) {
return;
}
speechInterruptStartMsRef.current = Date.now();
speechInterruptTimerRef.current = setTimeout(() => {
speechInterruptTimerRef.current = null;
speechInterruptStartMsRef.current = null;
if (
!isVoiceModeRef.current ||
!vadStateRef.current.isSpeaking ||
speechStartInterruptSentRef.current
) {
return;
}
speechStartInterruptSentRef.current = true;
console.log("[Voice] Speech persisted beyond grace; interrupting turn", {
graceMs: REALTIME_VOICE_VAD_CONFIG.interruptGracePeriodMs,
});
interruptActiveVoiceTurn("speech_start_grace_elapsed");
}, REALTIME_VOICE_VAD_CONFIG.interruptGracePeriodMs);
return;
}
clearSpeechStartInterruptTimer("speech stopped before grace");
}, [
clearSpeechStartInterruptTimer,
interruptActiveVoiceTurn,
isVoiceMode,
realtimeAudio.isSpeaking,
]);
const engine = engineRef.current!;
const runtime = runtimeRef.current!;
useEffect(() => {
return () => {
clearSpeechStartInterruptTimer("voice provider unmounted");
};
}, [clearSpeechStartInterruptTimer]);
useEffect(() => {
const next = {
isDetecting: realtimeAudio.isDetecting,
isSpeaking: realtimeAudio.isSpeaking,
};
const prev = vadStateRef.current;
if (!isVoiceMode) {
silenceGraceStartMsRef.current = null;
vadStateRef.current = next;
return;
}
// Confirmed speech dropped below threshold: grace window starts.
if (prev.isSpeaking && !next.isSpeaking && next.isDetecting) {
silenceGraceStartMsRef.current = Date.now();
console.log("[Voice] Grace started (speech dropped below threshold)");
}
// User resumed speaking before grace timeout elapsed.
if (!prev.isSpeaking && next.isSpeaking && silenceGraceStartMsRef.current !== null) {
const resumedAfterMs = Date.now() - silenceGraceStartMsRef.current;
console.log("[Voice] Speech resumed during grace", { resumedAfterMs });
silenceGraceStartMsRef.current = null;
}
// Fully idle (neither detecting nor speaking).
if (!next.isDetecting && !next.isSpeaking) {
silenceGraceStartMsRef.current = null;
speechStartInterruptSentRef.current = false;
}
vadStateRef.current = next;
}, [isVoiceMode, realtimeAudio.isDetecting, realtimeAudio.isSpeaking]);
useEffect(() => {
const connected = activeRuntimeConnected;
const client = runtimeClient;
if (!connected) {
voiceTransportReadyRef.current = false;
}
if (!isVoiceMode || !activeServerId || !activeAgentId || !client) {
wasVoiceSocketConnectedRef.current = connected;
if (!isVoiceMode) {
lastVoiceModeSyncedClientRef.current = null;
}
voiceTransportReadyRef.current = false;
return;
}
const connectionRecovered = connected && !wasVoiceSocketConnectedRef.current;
const clientChanged = lastVoiceModeSyncedClientRef.current !== client;
if (connected && (connectionRecovered || clientChanged)) {
if (!voiceResyncInFlightRef.current) {
voiceResyncInFlightRef.current = true;
voiceTransportReadyRef.current = false;
setIsVoiceSwitching(true);
void client.setVoiceMode(true, activeAgentId).then(
() => {
console.log("[Voice] Re-synced voice mode after reconnect");
lastVoiceModeSyncedClientRef.current = client;
voiceTransportReadyRef.current = true;
},
(error) => {
console.error("[Voice] Failed to re-sync voice mode:", error);
}
).finally(() => {
voiceResyncInFlightRef.current = false;
setIsVoiceSwitching(false);
});
}
}
wasVoiceSocketConnectedRef.current = connected;
}, [activeAgentId, activeRuntimeConnected, activeServerId, isVoiceMode, runtimeClient]);
const isPlayingAudio = activeSession?.isPlayingAudio ?? false;
useEffect(() => {
if (!isPlayingAudio && bargeInPlaybackStopRef.current !== null) {
const latencyMs = Date.now() - bargeInPlaybackStopRef.current;
console.log("[Telemetry] barge_in.playback_stop_latency", {
latencyMs,
startedAt: new Date(bargeInPlaybackStopRef.current).toISOString(),
completedAt: new Date().toISOString(),
void runtime.destroy().catch((error) => {
console.error("[VoiceProvider] Failed to destroy voice runtime", error);
});
bargeInPlaybackStopRef.current = null;
}
}, [isPlayingAudio]);
const startVoice = useCallback(
async (serverId: string, agentId: string) => {
const session = getSession(serverId) ?? null;
if (!session) {
throw new Error(`Host ${serverId} is not connected`);
}
const unavailableMessage = resolveVoiceUnavailableMessage({
serverInfo: session.serverInfo,
mode: "voice",
});
if (unavailableMessage) {
throw new Error(unavailableMessage);
}
setIsVoiceSwitching(true);
voiceTransportReadyRef.current = false;
try {
const previousSession = realtimeSessionRef.current;
if (
isVoiceMode &&
previousSession?.client &&
(activeServerId !== serverId || activeAgentId !== agentId)
) {
await previousSession.client.setVoiceMode(false);
}
realtimeSessionRef.current = session;
setActiveServerId(serverId);
setActiveAgentId(agentId);
await activateKeepAwakeAsync(KEEP_AWAKE_TAG).catch((error) => {
console.warn("[Voice] Failed to activate keep-awake:", error);
});
if (session?.client) {
await session.client.setVoiceMode(true, agentId);
} else {
console.warn("[Voice] setVoiceMode skipped: daemon unavailable");
}
await session.audioPlayer?.warmup?.();
await realtimeAudio.start();
voiceTransportReadyRef.current = true;
setIsVoiceMode(true);
lastVoiceModeSyncedClientRef.current = session.client;
console.log("[Voice] Mode enabled");
} catch (error: any) {
console.error("[Voice] Failed to start:", error);
await realtimeAudio.stop().catch(() => undefined);
setActiveServerId((current) => (current === serverId ? null : current));
setActiveAgentId((current) => (current === agentId ? null : current));
await deactivateKeepAwake(KEEP_AWAKE_TAG).catch(() => undefined);
throw error;
} finally {
setIsVoiceSwitching(false);
}
},
[activeAgentId, activeServerId, getSession, isVoiceMode, realtimeAudio]
);
const stopVoice = useCallback(async () => {
setIsVoiceSwitching(true);
voiceTransportReadyRef.current = false;
try {
const session = realtimeSessionRef.current;
session?.audioPlayer?.stop();
if (session?.client) {
await session.client.setVoiceMode(false);
lastVoiceModeSyncedClientRef.current = session.client;
} else {
console.warn("[Voice] setVoiceMode skipped: daemon unavailable");
}
await realtimeAudio.stop();
setIsVoiceMode(false);
setActiveServerId(null);
setActiveAgentId(null);
await deactivateKeepAwake(KEEP_AWAKE_TAG).catch(() => undefined);
console.log("[Voice] Mode disabled");
} catch (error: any) {
console.error("[Voice] Failed to stop:", error);
await deactivateKeepAwake(KEEP_AWAKE_TAG).catch(() => undefined);
throw error;
} finally {
setIsVoiceSwitching(false);
}
}, [realtimeAudio]);
const isVoiceModeForAgent = useCallback(
(serverId: string, agentId: string) =>
isVoiceMode && activeServerId === serverId && activeAgentId === agentId,
[activeAgentId, activeServerId, isVoiceMode]
);
const value: VoiceContextValue = {
isVoiceMode,
isVoiceSwitching,
volume: realtimeAudio.volume,
isMuted: realtimeAudio.isMuted,
isDetecting: realtimeAudio.isDetecting,
isSpeaking: realtimeAudio.isSpeaking,
segmentDuration: realtimeAudio.segmentDuration,
startVoice,
stopVoice,
isVoiceModeForAgent,
toggleMute: realtimeAudio.toggleMute,
activeServerId,
activeAgentId,
};
};
}, [runtime]);
return (
<VoiceContext.Provider value={value}>
{children}
</VoiceContext.Provider>
<VoiceAudioEngineContext.Provider value={engine}>
<VoiceRuntimeContext.Provider value={runtime}>
{children}
</VoiceRuntimeContext.Provider>
</VoiceAudioEngineContext.Provider>
);
}

View File

@@ -554,7 +554,7 @@ function PairingOfferDialogContent(input: {
void QRCode.toDataURL(pairingOffer.url, {
errorCorrectionLevel: 'M',
margin: 1,
width: 320,
width: 480,
})
.then((dataUrl) => {
if (cancelled) {
@@ -606,17 +606,19 @@ function PairingOfferDialogContent(input: {
</Text>
<View style={styles.qrCard}>
{qrDataUrl ? (
<Image source={{ uri: qrDataUrl }} style={styles.qrImage} />
<Image source={{ uri: qrDataUrl }} style={styles.qrImage} resizeMode="contain" />
) : qrError ? (
<Text style={styles.hintText}>QR unavailable: {qrError}</Text>
) : (
<ActivityIndicator size="small" />
)}
</View>
<Text style={styles.linkLabel}>Pairing link</Text>
<Text style={styles.linkText} selectable>
{pairingOffer.url}
</Text>
<View style={styles.linkSection}>
<Text style={styles.linkLabel}>Pairing link</Text>
<Text style={styles.linkText} selectable>
{pairingOffer.url}
</Text>
</View>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={onCopyLink}>
Copy link
@@ -717,18 +719,21 @@ const styles = StyleSheet.create((theme) => ({
qrCard: {
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'center',
minHeight: 220,
minWidth: 220,
padding: theme.spacing[4],
width: '100%',
aspectRatio: 1,
alignSelf: 'stretch',
padding: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
},
qrImage: {
width: 220,
height: 220,
width: '100%',
height: '100%',
},
linkSection: {
gap: theme.spacing[2],
},
linkLabel: {
color: theme.colors.foreground,

View File

@@ -0,0 +1,58 @@
import { invokeDesktopCommand } from "@/desktop/tauri/invoke-desktop-command";
import { getTauri } from "@/utils/tauri";
export const DESKTOP_NOTIFICATION_CLICK_EVENT = "desktop-notification-click";
export interface DesktopNotificationInput {
title: string;
body?: string;
data?: Record<string, unknown>;
}
export interface DesktopNotificationClickPayload {
data?: Record<string, unknown>;
}
export type DesktopNotificationClickHandler = (
payload: DesktopNotificationClickPayload
) => void;
export type DesktopNotificationClickUnlisten = () => void;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export async function sendDesktopNotification(
input: DesktopNotificationInput
): Promise<boolean> {
try {
await invokeDesktopCommand("send_desktop_notification", { input });
return true;
} catch (error) {
console.warn("[OSNotifications][Desktop] Failed to send desktop notification", error);
return false;
}
}
export async function listenToDesktopNotificationClicks(
handler: DesktopNotificationClickHandler
): Promise<DesktopNotificationClickUnlisten> {
const listen = getTauri()?.event?.listen;
if (typeof listen !== "function") {
throw new Error("Tauri event API is unavailable.");
}
const unlisten = await listen(DESKTOP_NOTIFICATION_CLICK_EVENT, (event: unknown) => {
const payload = isRecord(event) && isRecord(event.payload) ? event.payload : null;
if (!payload) {
return;
}
handler({
data: isRecord(payload.data) ? payload.data : undefined,
});
});
return typeof unlisten === "function" ? unlisten : () => {};
}

View File

@@ -0,0 +1,107 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const tauriState = vi.hoisted(() => ({
api: null as any,
}));
vi.mock("@/utils/tauri", () => ({
getTauri: () => tauriState.api,
}));
import {
normalizePickedImageAssets,
openImagePathsWithTauriDialog,
} from "./image-attachment-picker";
describe("image-attachment-picker", () => {
beforeEach(() => {
tauriState.api = null;
});
it("normalizes a picked File into a blob source", async () => {
const file = new File(["hello"], "picked.png", { type: "image/png" });
const result = await normalizePickedImageAssets([
{
uri: "blob:test",
mimeType: "image/png",
fileName: null,
file,
},
]);
expect(result).toHaveLength(1);
expect(result[0]?.source.kind).toBe("blob");
expect(result[0]?.fileName).toBe("picked.png");
expect(result[0]?.mimeType).toBe("image/png");
});
it("keeps filesystem picker results as file uris", async () => {
const result = await normalizePickedImageAssets([
{
uri: "file:///tmp/picked.png",
mimeType: "image/png",
fileName: "picked.png",
},
]);
expect(result).toEqual([
{
source: { kind: "file_uri", uri: "file:///tmp/picked.png" },
mimeType: "image/png",
fileName: "picked.png",
},
]);
});
it("converts data urls into blob sources when no file path exists", async () => {
const result = await normalizePickedImageAssets([
{
uri: "data:image/png;base64,AAEC",
mimeType: "image/png",
fileName: "inline.png",
},
]);
expect(result).toHaveLength(1);
expect(result[0]?.source.kind).toBe("blob");
expect(result[0]?.fileName).toBe("inline.png");
expect(result[0]?.mimeType).toBe("image/png");
});
it("uses the tauri dialog api when available", async () => {
const open = vi.fn().mockResolvedValue(["/tmp/one.png", "/tmp/two.jpg"]);
tauriState.api = {
dialog: { open },
};
const result = await openImagePathsWithTauriDialog();
expect(open).toHaveBeenCalledWith(
expect.objectContaining({
multiple: true,
directory: false,
title: "Attach images",
})
);
expect(result).toEqual(["/tmp/one.png", "/tmp/two.jpg"]);
});
it("falls back to core invoke for the tauri dialog plugin", async () => {
const invoke = vi.fn().mockResolvedValue("/tmp/one.png");
tauriState.api = {
core: { invoke },
};
const result = await openImagePathsWithTauriDialog();
expect(invoke).toHaveBeenCalledWith("plugin:dialog|open", {
options: expect.objectContaining({
multiple: true,
directory: false,
title: "Attach images",
}),
});
expect(result).toEqual(["/tmp/one.png"]);
});
});

View File

@@ -0,0 +1,110 @@
import { getTauri } from "@/utils/tauri";
export type PickedImageSource =
| { kind: "file_uri"; uri: string }
| { kind: "blob"; blob: Blob };
export interface PickedImageAttachmentInput {
source: PickedImageSource;
mimeType?: string | null;
fileName?: string | null;
}
export interface ExpoImagePickerAssetLike {
uri: string;
mimeType?: string | null;
fileName?: string | null;
file?: File | null;
}
const IMAGE_FILE_EXTENSIONS = [
"png",
"jpg",
"jpeg",
"gif",
"webp",
"avif",
"heic",
"heif",
"tiff",
"bmp",
"svg",
];
function isAbsoluteWindowsPath(value: string): boolean {
return /^[a-zA-Z]:[\\/]/.test(value);
}
function shouldTreatAsFileUri(uri: string): boolean {
return uri.startsWith("file://") || uri.startsWith("/") || isAbsoluteWindowsPath(uri);
}
async function blobFromUri(uri: string): Promise<Blob> {
const response = await fetch(uri);
if (!response.ok) {
throw new Error(`Failed to read picked image from '${uri}'.`);
}
return await response.blob();
}
export async function normalizePickedImageAssets(
assets: readonly ExpoImagePickerAssetLike[]
): Promise<PickedImageAttachmentInput[]> {
return await Promise.all(
assets.map(async (asset) => {
if (asset.file instanceof Blob) {
return {
source: { kind: "blob", blob: asset.file },
mimeType: asset.mimeType ?? asset.file.type ?? null,
fileName: asset.fileName ?? asset.file.name ?? null,
};
}
if (shouldTreatAsFileUri(asset.uri)) {
return {
source: { kind: "file_uri", uri: asset.uri },
mimeType: asset.mimeType ?? null,
fileName: asset.fileName ?? null,
};
}
return {
source: { kind: "blob", blob: await blobFromUri(asset.uri) },
mimeType: asset.mimeType ?? null,
fileName: asset.fileName ?? null,
};
})
);
}
function normalizeTauriDialogSelection(selection: string | string[] | null): string[] {
if (!selection) {
return [];
}
return Array.isArray(selection) ? selection : [selection];
}
export async function openImagePathsWithTauriDialog(): Promise<string[]> {
const tauri = getTauri();
const options = {
directory: false,
multiple: true,
filters: [{ name: "Images", extensions: IMAGE_FILE_EXTENSIONS }],
title: "Attach images",
};
const dialogOpen = tauri?.dialog?.open;
if (typeof dialogOpen === "function") {
return normalizeTauriDialogSelection(await dialogOpen(options));
}
const invoke = tauri?.core?.invoke;
if (typeof invoke !== "function") {
throw new Error("Tauri dialog API is not available.");
}
const result = await invoke("plugin:dialog|open", { options });
return normalizeTauriDialogSelection(
Array.isArray(result) || typeof result === "string" || result === null ? result : null
);
}

View File

@@ -5,6 +5,7 @@ import {
shouldClearAgentAttention,
type AgentAttentionClearTrigger,
} from "@/utils/agent-attention";
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
type AttentionReason = "finished" | "error" | "permission" | null | undefined;
@@ -23,20 +24,6 @@ interface AgentAttentionClearController {
clearOnAgentBlur: () => void;
}
function getIsAppVisible(): boolean {
const isAppStateActive = AppState.currentState === "active";
if (Platform.OS !== "web") {
return isAppStateActive;
}
const documentVisible =
typeof document === "undefined" || document.visibilityState === "visible";
const windowFocused =
typeof document === "undefined" ||
typeof document.hasFocus !== "function" ||
document.hasFocus();
return isAppStateActive && documentVisible && windowFocused;
}
export function useAgentAttentionClear({
agentId,
client,
@@ -45,12 +32,14 @@ export function useAgentAttentionClear({
attentionReason,
isScreenFocused,
}: UseAgentAttentionClearParams): AgentAttentionClearController {
const [isAppVisible, setIsAppVisible] = useState<boolean>(() => getIsAppVisible());
const [isAppVisible, setIsAppVisible] = useState<boolean>(() =>
getIsAppActivelyVisible()
);
const deferredFocusEntryClearRef = useRef(false);
const prevRequiresAttentionRef = useRef(Boolean(requiresAttention));
const prevActivelyViewedRef = useRef(isScreenFocused && getIsAppVisible());
const prevActivelyViewedRef = useRef(isScreenFocused && getIsAppActivelyVisible());
const prevScreenFocusedRef = useRef(false);
const prevAppVisibleRef = useRef(getIsAppVisible());
const prevAppVisibleRef = useRef(getIsAppActivelyVisible());
const clearAttention = useCallback(
(trigger: AgentAttentionClearTrigger) => {
@@ -78,7 +67,7 @@ export function useAgentAttentionClear({
useEffect(() => {
const updateVisibility = () => {
setIsAppVisible(getIsAppVisible());
setIsAppVisible(getIsAppActivelyVisible());
};
const appStateSubscription = AppState.addEventListener(

View File

@@ -1 +0,0 @@
export * from "./use-audio-player.native";

View File

@@ -1,366 +0,0 @@
import { useState, useRef } from "react";
import {
initialize,
playPCMData,
stopPlayback,
pausePlayback,
resumePlayback,
} from "@boudra/expo-two-way-audio";
interface QueuedAudio {
audioData: Blob;
resolve: (duration: number) => void;
reject: (error: Error) => void;
}
/**
* Resample PCM16 audio between sample rates.
* Speechmatics expects 16kHz.
*/
function resamplePcm16(pcm: Uint8Array, fromRate: number, toRate: number): Uint8Array {
if (fromRate === toRate) {
return pcm;
}
const inputSamples = Math.floor(pcm.length / 2);
const outputSamples = Math.floor((inputSamples * toRate) / fromRate);
const out = new Uint8Array(outputSamples * 2);
const ratio = fromRate / toRate;
const readInt16 = (sampleIndex: number): number => {
const i = sampleIndex * 2;
if (i + 1 >= pcm.length) {
return 0;
}
const lo = pcm[i]!;
const hi = pcm[i + 1]!;
let value = (hi << 8) | lo;
if (value & 0x8000) {
value = value - 0x10000;
}
return value;
};
const writeInt16 = (sampleIndex: number, value: number): void => {
const clamped = Math.max(-32768, Math.min(32767, Math.round(value)));
const i = sampleIndex * 2;
out[i] = clamped & 0xff;
out[i + 1] = (clamped >> 8) & 0xff;
};
for (let i = 0; i < outputSamples; i++) {
const srcPos = i * ratio;
const i0 = Math.floor(srcPos);
const frac = srcPos - i0;
const s0 = readInt16(i0);
const s1 = readInt16(Math.min(inputSamples - 1, i0 + 1));
writeInt16(i, s0 + (s1 - s0) * frac);
}
return out;
}
function parsePcmSampleRate(mimeType: string): number | null {
const match = /rate=(\d+)/i.exec(mimeType);
if (!match) {
return null;
}
const rate = Number(match[1]);
return Number.isFinite(rate) && rate > 0 ? rate : null;
}
export interface AudioPlayerOptions {
isDetecting?: () => boolean;
isSpeaking?: () => boolean;
}
/**
* Hook for audio playback using Speechmatics two-way audio with echo cancellation
*/
export function useAudioPlayer(options?: AudioPlayerOptions) {
const [isPlaying, setIsPlaying] = useState(false);
const [audioInitialized, setAudioInitialized] = useState(false);
const queueRef = useRef<QueuedAudio[]>([]);
const suppressedQueueRef = useRef<QueuedAudio[]>([]);
const isProcessingQueueRef = useRef(false);
const activePlaybackRef = useRef<{
resolve: (duration: number) => void;
reject: (error: Error) => void;
} | null>(null);
const playbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const checkIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
async function play(audioData: Blob): Promise<number> {
return new Promise((resolve, reject) => {
// Check if we should suppress playback due to voice detection/speaking
const shouldSuppress =
(options?.isDetecting && options.isDetecting()) ||
(options?.isSpeaking && options.isSpeaking());
if (shouldSuppress) {
console.log("[AudioPlayer] Suppressing playback - voice detection/speaking active");
// Add to suppressed queue instead
suppressedQueueRef.current.push({ audioData, resolve, reject });
// Start checking for when flags clear
startCheckingForClearFlags();
return;
}
// Add to queue with its promise handlers
queueRef.current.push({ audioData, resolve, reject });
// Start processing queue if not already processing
if (!isProcessingQueueRef.current) {
processQueue();
}
});
}
function startCheckingForClearFlags(): void {
// Already checking
if (checkIntervalRef.current !== null) {
return;
}
console.log("[AudioPlayer] Starting to check for clear flags");
checkIntervalRef.current = setInterval(() => {
const isStillBlocked =
(options?.isDetecting && options.isDetecting()) ||
(options?.isSpeaking && options.isSpeaking());
if (!isStillBlocked && suppressedQueueRef.current.length > 0) {
console.log("[AudioPlayer] Flags cleared - moving suppressed queue to main queue");
// Move all suppressed items to main queue
const suppressedItems = [...suppressedQueueRef.current];
suppressedQueueRef.current = [];
// Add to front of main queue (they were waiting)
queueRef.current = [...suppressedItems, ...queueRef.current];
// Stop checking
if (checkIntervalRef.current !== null) {
clearInterval(checkIntervalRef.current);
checkIntervalRef.current = null;
}
// Start processing if not already
if (!isProcessingQueueRef.current) {
processQueue();
}
} else if (!isStillBlocked && suppressedQueueRef.current.length === 0) {
// No more suppressed items and flags are clear - stop checking
if (checkIntervalRef.current !== null) {
clearInterval(checkIntervalRef.current);
checkIntervalRef.current = null;
}
}
}, 100); // Check every 100ms
}
async function processQueue(): Promise<void> {
if (isProcessingQueueRef.current || queueRef.current.length === 0) {
return;
}
isProcessingQueueRef.current = true;
while (queueRef.current.length > 0) {
// Before processing each item, check if flags became active
const shouldSuppress =
(options?.isDetecting && options.isDetecting()) ||
(options?.isSpeaking && options.isSpeaking());
if (shouldSuppress) {
console.log("[AudioPlayer] Flags became active during processing - moving remaining queue to suppressed");
// Move remaining queue to suppressed
suppressedQueueRef.current = [...queueRef.current, ...suppressedQueueRef.current];
queueRef.current = [];
startCheckingForClearFlags();
break;
}
const item = queueRef.current.shift()!;
try {
const duration = await playAudio(item.audioData);
item.resolve(duration);
} catch (error) {
item.reject(error as Error);
}
}
isProcessingQueueRef.current = false;
}
async function processNextInQueue(): Promise<void> {
if (queueRef.current.length > 0) {
const item = queueRef.current.shift()!;
try {
const duration = await playAudio(item.audioData);
item.resolve(duration);
} catch (error) {
item.reject(error as Error);
}
} else {
isProcessingQueueRef.current = false;
}
}
async function playAudio(audioData: Blob): Promise<number> {
return new Promise(async (resolve, reject) => {
activePlaybackRef.current = { resolve, reject };
try {
console.log(
`[AudioPlayer] Playing audio (${audioData.size} bytes, type: ${audioData.type})`
);
// Initialize audio if not already initialized
if (!audioInitialized) {
console.log("[AudioPlayer] Initializing audio...");
await initialize();
setAudioInitialized(true);
console.log(
"[AudioPlayer] ✅ Initialized (Speechmatics two-way audio)"
);
}
// Workaround: Resume playback before playing new audio to ensure the audio engine is ready
// This fixes the issue where playback doesn't work after calling stopPlayback()
console.log("[AudioPlayer] Resuming playback engine...");
resumePlayback();
// Get PCM data from blob (server sends PCM16)
const arrayBuffer = await audioData.arrayBuffer();
const pcm = new Uint8Array(arrayBuffer);
const inputRate = parsePcmSampleRate(audioData.type || "") ?? 24000;
const pcm16k = resamplePcm16(pcm, inputRate, 16000);
// Calculate total duration
const samples = pcm16k.length / 2; // 16-bit = 2 bytes per sample
const durationSec = samples / 16000; // 16kHz sample rate
const audioSizeKb = (pcm16k.length / 1024).toFixed(2);
console.log(
"[AudioPlayer] 🔊 Playing audio:",
audioSizeKb,
"KB, duration:",
durationSec.toFixed(2),
"s"
);
setIsPlaying(true);
// Play entire PCM data at once through Speechmatics
playPCMData(pcm16k);
// Clear any existing timeout
if (playbackTimeoutRef.current) {
clearTimeout(playbackTimeoutRef.current);
}
// Wait for playback to finish (estimate based on duration)
playbackTimeoutRef.current = setTimeout(() => {
console.log("[AudioPlayer] ✅ Playback finished");
setIsPlaying(false);
playbackTimeoutRef.current = null;
activePlaybackRef.current = null;
resolve(durationSec);
}, durationSec * 1000);
} catch (error) {
console.error("[AudioPlayer] Error playing audio:", error);
// Clear timeout on error
if (playbackTimeoutRef.current) {
clearTimeout(playbackTimeoutRef.current);
playbackTimeoutRef.current = null;
}
setIsPlaying(false);
activePlaybackRef.current = null;
reject(error);
}
});
}
function stop(): void {
if (isPlaying) {
console.log("[AudioPlayer] 🛑 Stopping playback (interrupted)");
// Stop native playback
stopPlayback();
// Clear playback timeout
if (playbackTimeoutRef.current) {
clearTimeout(playbackTimeoutRef.current);
playbackTimeoutRef.current = null;
}
}
setIsPlaying(false);
// Reject the currently playing promise, if any.
if (activePlaybackRef.current) {
activePlaybackRef.current.reject(new Error("Playback stopped"));
activePlaybackRef.current = null;
}
// Reject all pending promises in the main queue
while (queueRef.current.length > 0) {
const item = queueRef.current.shift()!;
item.reject(new Error("Playback stopped"));
}
// Reject all pending promises in the suppressed queue
while (suppressedQueueRef.current.length > 0) {
const item = suppressedQueueRef.current.shift()!;
item.reject(new Error("Playback stopped"));
}
// Clear check interval
if (checkIntervalRef.current !== null) {
clearInterval(checkIntervalRef.current);
checkIntervalRef.current = null;
}
isProcessingQueueRef.current = false;
}
function clearQueue(): void {
// Reject all pending promises in the main queue
while (queueRef.current.length > 0) {
const item = queueRef.current.shift()!;
item.reject(new Error("Queue cleared"));
}
// Reject all pending promises in the suppressed queue
while (suppressedQueueRef.current.length > 0) {
const item = suppressedQueueRef.current.shift()!;
item.reject(new Error("Queue cleared"));
}
// Clear check interval
if (checkIntervalRef.current !== null) {
clearInterval(checkIntervalRef.current);
checkIntervalRef.current = null;
}
}
return {
play,
stop,
isPlaying: () => isPlaying,
clearQueue,
warmup: async () => {
if (!audioInitialized) {
await initialize();
setAudioInitialized(true);
}
// Ensure playback engine isn't suspended after a previous stop.
resumePlayback();
},
};
}

View File

@@ -1,275 +0,0 @@
import { useMemo, useRef, useState } from "react";
export interface AudioPlayerOptions {
isDetecting?: () => boolean;
isSpeaking?: () => boolean;
}
/**
* Web audio player for server-provided audio chunks.
*
* Supports:
* - `audio/pcm` (assumed PCM16 LE, mono, default 24kHz unless `rate=` is present)
* - formats supported by `AudioContext.decodeAudioData` (e.g. mp3)
*/
export function useAudioPlayer(options?: AudioPlayerOptions) {
const [isPlayingState, setIsPlayingState] = useState(false);
type QueuedAudio = {
audioData: Blob;
resolve: (duration: number) => void;
reject: (error: Error) => void;
};
const audioContextRef = useRef<AudioContext | null>(null);
const queueRef = useRef<QueuedAudio[]>([]);
const suppressedQueueRef = useRef<QueuedAudio[]>([]);
const isProcessingQueueRef = useRef(false);
const checkIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const activePlaybackRef = useRef<{
source: AudioBufferSourceNode;
resolve: (duration: number) => void;
reject: (error: Error) => void;
settled: boolean;
} | null>(null);
const decodeAudioData = async (context: AudioContext, buffer: ArrayBuffer): Promise<AudioBuffer> => {
const maybePromise = context.decodeAudioData(buffer);
if (maybePromise && typeof (maybePromise as Promise<AudioBuffer>).then === "function") {
return maybePromise as Promise<AudioBuffer>;
}
return await new Promise<AudioBuffer>((resolve, reject) => {
context.decodeAudioData(buffer, resolve, reject);
});
};
const parsePcmSampleRate = (mimeType: string): number | null => {
const match = /rate=(\\d+)/i.exec(mimeType);
if (!match) {
return null;
}
const rate = Number(match[1]);
return Number.isFinite(rate) && rate > 0 ? rate : null;
};
const pcm16LeToAudioBuffer = (context: AudioContext, bytes: Uint8Array, sampleRate: number): AudioBuffer => {
const sampleCount = Math.floor(bytes.length / 2);
const audioBuffer = context.createBuffer(1, sampleCount, sampleRate);
const channel = audioBuffer.getChannelData(0);
for (let i = 0; i < sampleCount; i++) {
const lo = bytes[i * 2]!;
const hi = bytes[i * 2 + 1]!;
let value = (hi << 8) | lo;
if (value & 0x8000) {
value = value - 0x10000;
}
channel[i] = value / 0x8000;
}
return audioBuffer;
};
const ensureContext = async (): Promise<AudioContext> => {
if (audioContextRef.current) {
if (audioContextRef.current.state === "suspended") {
try {
await audioContextRef.current.resume();
} catch {
// Best effort. If this fails due to autoplay policies, a later user gesture
// (or explicit warmup call) can unlock it.
}
}
return audioContextRef.current;
}
const context = new AudioContext();
if (context.state === "suspended") {
try {
await context.resume();
} catch {
// See note above.
}
}
audioContextRef.current = context;
return context;
};
const shouldSuppressPlayback = (): boolean => {
return Boolean(options?.isDetecting?.()) || Boolean(options?.isSpeaking?.());
};
const startCheckingForClearFlags = (): void => {
if (checkIntervalRef.current !== null) {
return;
}
checkIntervalRef.current = setInterval(() => {
const isStillBlocked = shouldSuppressPlayback();
if (!isStillBlocked && suppressedQueueRef.current.length > 0) {
const suppressedItems = [...suppressedQueueRef.current];
suppressedQueueRef.current = [];
queueRef.current = [...suppressedItems, ...queueRef.current];
if (checkIntervalRef.current !== null) {
clearInterval(checkIntervalRef.current);
checkIntervalRef.current = null;
}
if (!isProcessingQueueRef.current) {
void processQueue();
}
} else if (!isStillBlocked && suppressedQueueRef.current.length === 0) {
if (checkIntervalRef.current !== null) {
clearInterval(checkIntervalRef.current);
checkIntervalRef.current = null;
}
}
}, 100);
};
const playAudio = async (audioData: Blob): Promise<number> => {
const context = await ensureContext();
const arrayBuffer = await audioData.arrayBuffer();
let audioBuffer: AudioBuffer;
const type = (audioData.type || "").toLowerCase();
if (type.startsWith("audio/pcm")) {
const sampleRate = parsePcmSampleRate(type) ?? 24000;
audioBuffer = pcm16LeToAudioBuffer(context, new Uint8Array(arrayBuffer), sampleRate);
} else {
audioBuffer = await decodeAudioData(context, arrayBuffer);
}
const durationSec = audioBuffer.duration;
const source = context.createBufferSource();
source.buffer = audioBuffer;
source.connect(context.destination);
return await new Promise<number>((resolve, reject) => {
activePlaybackRef.current = { source, resolve, reject, settled: false };
setIsPlayingState(true);
const settleOnce = (fn: () => void) => {
const active = activePlaybackRef.current;
if (!active || active.source !== source || active.settled) {
return;
}
active.settled = true;
activePlaybackRef.current = null;
setIsPlayingState(false);
fn();
};
source.onended = () => {
settleOnce(() => resolve(durationSec));
};
try {
source.start();
} catch (e) {
settleOnce(() => reject(e instanceof Error ? e : new Error(String(e))));
}
});
};
const processQueue = async (): Promise<void> => {
if (isProcessingQueueRef.current || queueRef.current.length === 0) {
return;
}
isProcessingQueueRef.current = true;
while (queueRef.current.length > 0) {
if (shouldSuppressPlayback()) {
suppressedQueueRef.current = [...queueRef.current, ...suppressedQueueRef.current];
queueRef.current = [];
startCheckingForClearFlags();
break;
}
const item = queueRef.current.shift()!;
try {
const duration = await playAudio(item.audioData);
item.resolve(duration);
} catch (error) {
item.reject(error as Error);
}
}
isProcessingQueueRef.current = false;
};
const play = async (audioData: Blob): Promise<number> => {
return await new Promise<number>((resolve, reject) => {
if (shouldSuppressPlayback()) {
suppressedQueueRef.current.push({ audioData, resolve, reject });
startCheckingForClearFlags();
return;
}
queueRef.current.push({ audioData, resolve, reject });
if (!isProcessingQueueRef.current) {
void processQueue();
}
});
};
const stop = (): void => {
// Stop currently playing audio (and reject its promise)
if (activePlaybackRef.current) {
const active = activePlaybackRef.current;
activePlaybackRef.current = null;
try {
active.source.onended = null;
active.source.stop();
} catch {
// ignore
}
if (!active.settled) {
active.settled = true;
active.reject(new Error("Playback stopped"));
}
}
setIsPlayingState(false);
while (queueRef.current.length > 0) {
queueRef.current.shift()!.reject(new Error("Playback stopped"));
}
while (suppressedQueueRef.current.length > 0) {
suppressedQueueRef.current.shift()!.reject(new Error("Playback stopped"));
}
if (checkIntervalRef.current !== null) {
clearInterval(checkIntervalRef.current);
checkIntervalRef.current = null;
}
isProcessingQueueRef.current = false;
};
const clearQueue = (): void => {
while (queueRef.current.length > 0) {
queueRef.current.shift()!.reject(new Error("Queue cleared"));
}
while (suppressedQueueRef.current.length > 0) {
suppressedQueueRef.current.shift()!.reject(new Error("Queue cleared"));
}
if (checkIntervalRef.current !== null) {
clearInterval(checkIntervalRef.current);
checkIntervalRef.current = null;
}
};
return useMemo(
() => ({
play,
stop,
isPlaying: () => isPlayingState,
clearQueue,
warmup: async () => {
await ensureContext();
},
}),
[isPlayingState]
);
}

View File

@@ -18,41 +18,27 @@ export interface AudioCaptureConfig {
*/
async function getActualRecordingUri(createdAt: Date): Promise<string | null> {
try {
console.log('[AudioRecorder] Searching for recording file created at:', createdAt.toISOString());
const audioDir = new Directory(Paths.cache, 'Audio');
console.log('[AudioRecorder] Audio cache directory URI:', audioDir.uri);
console.log('[AudioRecorder] Directory exists:', audioDir.exists);
if (!audioDir.exists) {
console.log('[AudioRecorder] Audio cache directory does not exist');
return null;
}
const files = audioDir.list();
console.log('[AudioRecorder] Found files in Audio cache:', files.length);
if (!files.length) {
console.log('[AudioRecorder] No files found in Audio cache directory');
return null;
}
const validFiles = files
.map(file => {
const info = file.info();
console.log('[AudioRecorder] File info:', {
uri: info.uri,
size: info.size,
creationTime: info.creationTime ? new Date(info.creationTime).toISOString() : null,
});
return info;
})
.filter(f => f.size && f.size > 0);
console.log('[AudioRecorder] Valid files (size > 0):', validFiles.length);
if (validFiles.length === 0) {
console.log('[AudioRecorder] No valid files found (all are zero-byte)');
return null;
}
@@ -62,10 +48,6 @@ async function getActualRecordingUri(createdAt: Date): Promise<string | null> {
for (const file of validFiles) {
if (!file.creationTime || !file.uri) continue;
const diff = Math.abs(file.creationTime - createdAt.getTime());
console.log('[AudioRecorder] Time diff for file:', {
uri: file.uri,
diffMs: diff,
});
if (diff < minDiff) {
closest = file;
minDiff = diff;
@@ -74,15 +56,9 @@ async function getActualRecordingUri(createdAt: Date): Promise<string | null> {
if (closest) {
const resultUri = closest.uri?.slice(0, -1) ?? null;
console.log('[AudioRecorder] Found closest file:', {
uri: resultUri,
size: closest.size,
timeDiffMs: minDiff,
});
return resultUri;
}
console.log('[AudioRecorder] No closest file found');
return null;
} catch (e) {
console.error('[AudioRecorder] Error finding actual recording file:', e);
@@ -208,7 +184,6 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
attemptGuardRef.current.assertCurrent(attemptId);
// Request microphone permissions
console.log('[AudioRecorder] Requesting recording permissions...');
const permissionResponse = await requestRecordingPermissionsAsync();
attemptGuardRef.current.assertCurrent(attemptId);
@@ -217,38 +192,26 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
}
// Configure audio mode for recording
console.log('[AudioRecorder] Configuring audio mode...');
await setAudioModeAsync({
playsInSilentMode: true,
allowsRecording: true,
});
attemptGuardRef.current.assertCurrent(attemptId);
console.log('[AudioRecorder] Starting recording with options:', {
sampleRate: recordingOptions.sampleRate,
numberOfChannels: recordingOptions.numberOfChannels,
bitRate: recordingOptions.bitRate,
});
const startTime = new Date();
setRecordingStartTime(startTime);
attemptGuardRef.current.assertCurrent(attemptId);
// Prepare the recorder before recording (required step)
console.log('[AudioRecorder] Preparing recorder...');
await recorder.prepareToRecordAsync();
attemptGuardRef.current.assertCurrent(attemptId);
console.log('[AudioRecorder] Starting recording...');
await recorder.record();
attemptGuardRef.current.assertCurrent(attemptId);
console.log('[AudioRecorder] Recording started at:', startTime.toISOString());
console.log('[AudioRecorder] Recorder isRecording:', recorder.isRecording);
} catch (error: any) {
setRecordingStartTime(null);
if (error instanceof AttemptCancelledError) {
console.log('[AudioRecorder] Recording start cancelled.');
return;
}
if (error?.message !== 'Recording cancelled') {
@@ -278,15 +241,12 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
// Get URI from recorder
let uri = recorder.uri;
console.log('[AudioRecorder] Initial URI from recorder:', uri);
// Workaround for Expo SDK 54 Android bug - find actual recording file
if (recordingStartTime && (!uri || uri === '')) {
console.log('[AudioRecorder] Using workaround to find actual recording file...');
const actualUri = await getActualRecordingUri(recordingStartTime);
if (actualUri) {
uri = actualUri;
console.log('[AudioRecorder] Found actual recording URI:', uri);
}
}
@@ -299,7 +259,6 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
// Get file info
const file = new File(uri);
const exists = file.exists;
console.log('[AudioRecorder] File exists:', exists);
if (!exists) {
setRecordingStartTime(null);
@@ -309,11 +268,6 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
// Convert URI to Blob
const audioBlob = await uriToBlob(uri);
console.log('[AudioRecorder] Recording converted to blob:', {
size: audioBlob.size,
type: audioBlob.type,
});
// Clean up the temporary file
file.delete();

View File

@@ -163,16 +163,6 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
throw new Error("Microphone capture is not supported in this environment");
}
console.log("[AudioRecorder][Web] Microphone preflight", {
secureContext,
currentOrigin,
isTauri,
hasMediaDevices:
typeof navigator !== "undefined" &&
!!navigator.mediaDevices &&
typeof navigator.mediaDevices.getUserMedia === "function",
});
if (!secureContext && !isTauri) {
throw new Error(
`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`

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