Compare commits

..
Author SHA1 Message Date
thevortex8 6d4b94acda fix duplicate prompt replay admission 2026-03-21 20:25:06 -04:00
thevortex8 954046f671 fix codex stalled runs on idle thread status 2026-03-21 19:28:45 -04:00
thevortex8 ff89c85255 fix daemon stale-progress semantics and hydrate replay 2026-03-21 18:41:10 -04:00
thevortex8 1d8a138d0d fix: preserve queued message ids on replay 2026-03-21 16:49:42 -04:00
thevortex8 a5c6acb73b fix: make codex send replays idempotent 2026-03-21 16:06:07 -04:00
Mohamed Boudra 00a2eec5f6 Fix Linux AppImage sandbox startup 2026-03-22 01:16:38 +07:00
Mohamed Boudra 859d234543 Add no-sandbox switch for Linux alongside chrome-sandbox removal
Both are needed: afterPack removes chrome-sandbox to prevent SUID fatal,
appendSwitch handles Ubuntu 24.04+ AppArmor user namespace restrictions.
2026-03-22 01:12:01 +07:00
Mohamed Boudra 207bdd1fae desktop: remove chrome-sandbox from linux builds 2026-03-22 01:10:05 +07:00
Mohamed Boudra d102a8fd64 fix Linux AppImage sandbox 2026-03-22 00:47:40 +07:00
Mohamed Boudra fcbdb09796 Add resizable left sidebar on desktop
Mirror the right sidebar's resize pattern: Gesture.Pan() drag handle
on the right edge, animated width, persisted to panel store.
2026-03-21 23:44:40 +07:00
Mohamed Boudra 59f3f4737a Split diff horizontal scroll into platform-specific components
Use Metro .web.tsx resolution to separate native gesture arbitration
(RNGH ScrollView, waitFor, closeGestureRef) from web (plain RN ScrollView),
fixing text selection drag on desktop web.
2026-03-21 23:32:38 +07:00
Mohamed Boudra c80c3e0256 Extract line number gutter width calculation and improve text selection 2026-03-21 23:15:52 +07:00
Mohamed Boudra ad1cb4c200 Add keyboard shortcut rebinding with string-based format and sectioned settings UI
- Parse/format shortcut strings ("Cmd+Shift+O") bidirectionally via shortcut-string.ts
- Convert all 60+ bindings from structured KeyCombo objects to declarative strings
- Add keyboard shortcut overrides storage (AsyncStorage + React Query)
- Wire overrides into the keyboard shortcut resolution pipeline
- Add sectioned settings layout with sidebar on desktop, single scroll on mobile
- Add keyboard shortcuts settings section with rebind/reset UI and key capture
- Create useShortcutKeys hook so tooltip shortcut displays update on rebind
- Replace hardcoded shortcut displays in agent-input-area, left-sidebar, command center
2026-03-21 23:14:59 +07:00
Mohamed Boudra 68ab7b81a1 Remove cleanup-assets job from desktop release workflow
Assets should be overwritten by publish jobs, not deleted upfront.
The cleanup job was wiping macOS/Linux assets on platform-specific retries.
2026-03-21 22:34:56 +07:00
Mohamed Boudra f1cf4ebe60 Fix Windows desktop build: split workspace deps from metro-patched export
The NODE_OPTIONS --require patch for metro config was being applied
during build:workspace-deps, causing expo-module-build (a bash script)
to be loaded through Node's JS loader on Windows.
2026-03-21 22:31:59 +07:00
Mohamed Boudra 0a452fad04 Sort Claude model catalog from most to least powerful
Reorder: Opus 4.6 → Sonnet 4.6 → Sonnet 4.5 → Haiku 4.5
2026-03-21 22:26:30 +07:00
Mohamed Boudra eb618638d2 Use useMutation for worktree creation with loading spinner on mobile
Move worktree creation into a useMutation in ProjectHeaderRow so each
row owns its own pending state. Show ActivityIndicator on the + button
while the request is in flight, and always show the + button on mobile
breakpoints where hover isn't available.
2026-03-21 22:26:17 +07:00
Mohamed Boudra 7b8a2fbcef Fix deleted diff syntax highlighting 2026-03-21 22:25:53 +07:00
Mohamed Boudra 928169a4f2 Add highlight package to build:daemon pipeline and add build:highlight script 2026-03-21 21:01:14 +07:00
Mohamed Boudra 0af666e3ac Dismiss keyboard before opening mobile tab switcher sheet 2026-03-21 20:47:42 +07:00
Mohamed Boudra d41808efe1 fix(app): clear composer immediately on submit 2026-03-21 20:47:33 +07:00
Mohamed Boudra aec28c8b67 Fix mobile diff mode trigger clipping 2026-03-21 20:47:02 +07:00
Mohamed Boudra 3b5daa1451 Fix agent preferences sheet restoring tab switcher on close
Use stackBehavior="replace" on the preferences BottomSheetModal so it
dismisses the tab switcher combobox instead of minimizing it, preventing
the provider from auto-restoring the tab switcher when preferences closes.
2026-03-21 20:46:44 +07:00
Mohamed Boudra f6579dbeb4 Switch to double quotes and reformat codebase with Biome 2026-03-21 20:22:15 +07:00
Mohamed Boudra ab40786882 Remove freezeOnBlur from workspace and agent screens 2026-03-21 20:21:57 +07:00
Mohamed Boudra 5f065c2685 Extract isomorphic @getpaseo/highlight package with syntax-highlighted file preview
- Create packages/highlight with shared Lezer-based syntax highlighting (types, parsers, highlighter, color maps)
- Add syntax highlighting and line numbers to file-pane.tsx with memoized CodeLine component
- Import shared highlight colors in git-diff-pane.tsx instead of local definitions
- Delete duplicated syntax-highlighter.ts from both app and server
- Fix TSX dialect to "ts jsx" (correct per Lezer docs)
2026-03-21 19:56:49 +07:00
Mohamed Boudra 6b50ace2bd Refactor agent input draft persistence 2026-03-21 19:56:23 +07:00
Mohamed Boudra c46c03158e Simplify Claude agent session flow 2026-03-21 19:47:56 +07:00
Mohamed Boudra 7395fc6fd1 fix: interrupt scaffold drain and desktop CLI bin layout 2026-03-21 18:37:51 +07:00
Mohamed Boudra c35d649f6d refactor(app): rename agents route to sessions with improved list layout 2026-03-21 16:20:17 +07:00
834 changed files with 43151 additions and 44249 deletions
+5 -37
View File
@@ -35,41 +35,7 @@ env:
DESKTOP_PACKAGE_PATH: 'packages/desktop'
jobs:
cleanup-assets:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Resolve release tag
shell: bash
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
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"
- name: Delete all existing release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
assets=$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json assets --jq '.assets[].name' 2>/dev/null || true)
if [[ -z "$assets" ]]; then
echo "No existing assets to clean up"
exit 0
fi
for asset in $assets; do
echo "Deleting $asset"
gh release delete-asset "$RELEASE_TAG" "$asset" --repo "${{ github.repository }}" --yes || true
done
publish-macos:
needs: cleanup-assets
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v'))) }}
strategy:
fail-fast: false
@@ -183,7 +149,6 @@ jobs:
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
publish-linux:
needs: cleanup-assets
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v'))) }}
permissions:
contents: write
@@ -284,7 +249,6 @@ jobs:
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
publish-windows:
needs: cleanup-assets
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v'))) }}
permissions:
contents: write
@@ -348,12 +312,16 @@ jobs:
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
- name: Build workspace dependencies
run: npm run build:workspace-deps --workspace=@getpaseo/app
- name: Build web app for desktop
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
npx expo export --platform web
working-directory: packages/app
- name: Detect existing GitHub release state
if: env.IS_SMOKE_TAG != 'true'
-21
View File
@@ -1,21 +0,0 @@
# Dependencies
node_modules
# Build outputs
dist
.next
.expo
build
*.tsbuildinfo
# Coverage
coverage
# Lock files
*.lock
package-lock.json
# Generated
android
ios
.turbo
-7
View File
@@ -1,7 +0,0 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "es5",
"tabWidth": 2,
"printWidth": 100
}
+32
View File
@@ -0,0 +1,32 @@
{
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["**", "!*.lock"]
},
"formatter": {
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"trailingCommas": "all",
"semicolons": "always"
}
},
"css": {
"parser": {
"cssModules": true,
"tailwindDirectives": true
}
},
"linter": {
"enabled": false
}
}
+662 -71
View File
@@ -11,6 +11,7 @@
"license": "AGPL-3.0-or-later",
"workspaces": [
"packages/expo-two-way-audio",
"packages/highlight",
"packages/server",
"packages/app",
"packages/relay",
@@ -19,13 +20,14 @@
"packages/cli"
],
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.11"
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@modelcontextprotocol/sdk": "^1.27.1"
},
"devDependencies": {
"@biomejs/biome": "^2.4.8",
"get-port-cli": "^3.0.0",
"knip": "^5.82.1",
"patch-package": "^8.0.1",
"prettier": "^3.5.3",
"react": "19.1.4",
"react-dom": "19.1.4",
"typescript": "^5.9.3"
@@ -2557,11 +2559,10 @@
}
},
"node_modules/@biomejs/biome": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz",
"integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==",
"version": "2.4.8",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.8.tgz",
"integrity": "sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT OR Apache-2.0",
"bin": {
"biome": "bin/biome"
@@ -2574,20 +2575,20 @@
"url": "https://opencollective.com/biome"
},
"optionalDependencies": {
"@biomejs/cli-darwin-arm64": "1.9.4",
"@biomejs/cli-darwin-x64": "1.9.4",
"@biomejs/cli-linux-arm64": "1.9.4",
"@biomejs/cli-linux-arm64-musl": "1.9.4",
"@biomejs/cli-linux-x64": "1.9.4",
"@biomejs/cli-linux-x64-musl": "1.9.4",
"@biomejs/cli-win32-arm64": "1.9.4",
"@biomejs/cli-win32-x64": "1.9.4"
"@biomejs/cli-darwin-arm64": "2.4.8",
"@biomejs/cli-darwin-x64": "2.4.8",
"@biomejs/cli-linux-arm64": "2.4.8",
"@biomejs/cli-linux-arm64-musl": "2.4.8",
"@biomejs/cli-linux-x64": "2.4.8",
"@biomejs/cli-linux-x64-musl": "2.4.8",
"@biomejs/cli-win32-arm64": "2.4.8",
"@biomejs/cli-win32-x64": "2.4.8"
}
},
"node_modules/@biomejs/cli-darwin-arm64": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz",
"integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==",
"version": "2.4.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.8.tgz",
"integrity": "sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ==",
"cpu": [
"arm64"
],
@@ -2602,9 +2603,9 @@
}
},
"node_modules/@biomejs/cli-darwin-x64": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz",
"integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==",
"version": "2.4.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.8.tgz",
"integrity": "sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ==",
"cpu": [
"x64"
],
@@ -2619,9 +2620,9 @@
}
},
"node_modules/@biomejs/cli-linux-arm64": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz",
"integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==",
"version": "2.4.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.8.tgz",
"integrity": "sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig==",
"cpu": [
"arm64"
],
@@ -2636,9 +2637,9 @@
}
},
"node_modules/@biomejs/cli-linux-arm64-musl": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz",
"integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==",
"version": "2.4.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.8.tgz",
"integrity": "sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA==",
"cpu": [
"arm64"
],
@@ -2653,9 +2654,9 @@
}
},
"node_modules/@biomejs/cli-linux-x64": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz",
"integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==",
"version": "2.4.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.8.tgz",
"integrity": "sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw==",
"cpu": [
"x64"
],
@@ -2670,9 +2671,9 @@
}
},
"node_modules/@biomejs/cli-linux-x64-musl": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz",
"integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==",
"version": "2.4.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.8.tgz",
"integrity": "sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw==",
"cpu": [
"x64"
],
@@ -2687,9 +2688,9 @@
}
},
"node_modules/@biomejs/cli-win32-arm64": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz",
"integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==",
"version": "2.4.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.8.tgz",
"integrity": "sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ==",
"cpu": [
"arm64"
],
@@ -2704,9 +2705,9 @@
}
},
"node_modules/@biomejs/cli-win32-x64": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz",
"integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==",
"version": "2.4.8",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.8.tgz",
"integrity": "sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg==",
"cpu": [
"x64"
],
@@ -6596,6 +6597,10 @@
"resolved": "packages/expo-two-way-audio",
"link": true
},
"node_modules/@getpaseo/highlight": {
"resolved": "packages/highlight",
"link": true
},
"node_modules/@getpaseo/relay": {
"resolved": "packages/relay",
"link": true
@@ -6708,6 +6713,18 @@
"@hapi/hoek": "^9.0.0"
}
},
"node_modules/@hono/node-server": {
"version": "1.19.11",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
},
"peerDependencies": {
"hono": "^4"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -7901,6 +7918,365 @@
"node": ">=10"
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.27.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz",
"integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==",
"license": "MIT",
"dependencies": {
"@hono/node-server": "^1.19.9",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"content-type": "^1.0.5",
"cors": "^2.8.5",
"cross-spawn": "^7.0.5",
"eventsource": "^3.0.2",
"eventsource-parser": "^3.0.0",
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"hono": "^4.11.4",
"jose": "^6.1.3",
"json-schema-typed": "^8.0.2",
"pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.25 || ^4.0",
"zod-to-json-schema": "^3.25.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@cfworker/json-schema": "^4.1.1",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"@cfworker/json-schema": {
"optional": true
},
"zod": {
"optional": false
}
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/express-rate-limit": {
"version": "8.3.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz",
"integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==",
"license": "MIT",
"dependencies": {
"ip-address": "10.1.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
@@ -21828,6 +22204,15 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/hono": {
"version": "4.12.8",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz",
"integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
}
},
"node_modules/hosted-git-info": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
@@ -22250,7 +22635,6 @@
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -22682,6 +23066,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -23639,6 +24029,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/jose": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/joycon": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
@@ -23908,6 +24307,12 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/json-schema-typed": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause"
},
"node_modules/json-stable-stringify": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
@@ -29935,6 +30340,32 @@
"integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==",
"license": "MIT"
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/router/node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -34276,24 +34707,10 @@
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.32",
"@getpaseo/highlight": "*",
"@getpaseo/server": "0.1.32",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
"@lezer/css": "^1.3.0",
"@lezer/go": "^1.0.1",
"@lezer/highlight": "^1.2.3",
"@lezer/html": "^1.3.13",
"@lezer/java": "^1.1.3",
"@lezer/javascript": "^1.5.4",
"@lezer/json": "^1.0.3",
"@lezer/markdown": "^1.6.2",
"@lezer/php": "^1.0.5",
"@lezer/python": "^1.1.18",
"@lezer/rust": "^1.0.2",
"@lezer/xml": "^1.0.6",
"@lezer/yaml": "^1.0.4",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native/normalize-colors": "^0.81.5",
@@ -34332,7 +34749,6 @@
"expo-system-ui": "~6.0.7",
"expo-updates": "~29.0.12",
"expo-web-browser": "~15.0.8",
"lezer-elixir": "^1.1.2",
"lucide-react-native": "^0.546.0",
"mnemonic-id": "^3.2.7",
"react": "19.1.4",
@@ -34490,6 +34906,170 @@
"react-native": "*"
}
},
"packages/expo-two-way-audio/node_modules/@biomejs/biome": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz",
"integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==",
"dev": true,
"hasInstallScript": true,
"license": "MIT OR Apache-2.0",
"bin": {
"biome": "bin/biome"
},
"engines": {
"node": ">=14.21.3"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/biome"
},
"optionalDependencies": {
"@biomejs/cli-darwin-arm64": "1.9.4",
"@biomejs/cli-darwin-x64": "1.9.4",
"@biomejs/cli-linux-arm64": "1.9.4",
"@biomejs/cli-linux-arm64-musl": "1.9.4",
"@biomejs/cli-linux-x64": "1.9.4",
"@biomejs/cli-linux-x64-musl": "1.9.4",
"@biomejs/cli-win32-arm64": "1.9.4",
"@biomejs/cli-win32-x64": "1.9.4"
}
},
"packages/expo-two-way-audio/node_modules/@biomejs/cli-darwin-arm64": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz",
"integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=14.21.3"
}
},
"packages/expo-two-way-audio/node_modules/@biomejs/cli-darwin-x64": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz",
"integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=14.21.3"
}
},
"packages/expo-two-way-audio/node_modules/@biomejs/cli-linux-arm64": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz",
"integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"packages/expo-two-way-audio/node_modules/@biomejs/cli-linux-arm64-musl": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz",
"integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"packages/expo-two-way-audio/node_modules/@biomejs/cli-linux-x64": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz",
"integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"packages/expo-two-way-audio/node_modules/@biomejs/cli-linux-x64-musl": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz",
"integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"packages/expo-two-way-audio/node_modules/@biomejs/cli-win32-arm64": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz",
"integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.21.3"
}
},
"packages/expo-two-way-audio/node_modules/@biomejs/cli-win32-x64": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz",
"integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.21.3"
}
},
"packages/expo-two-way-audio/node_modules/@types/react": {
"version": "18.3.28",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
@@ -34511,6 +35091,32 @@
"invariant": "^2.2.4"
}
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.32",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
"@lezer/css": "^1.3.0",
"@lezer/go": "^1.0.1",
"@lezer/highlight": "^1.2.3",
"@lezer/html": "^1.3.13",
"@lezer/java": "^1.1.3",
"@lezer/javascript": "^1.5.4",
"@lezer/json": "^1.0.3",
"@lezer/markdown": "^1.6.2",
"@lezer/php": "^1.0.5",
"@lezer/python": "^1.1.18",
"@lezer/rust": "^1.0.2",
"@lezer/xml": "^1.0.6",
"@lezer/yaml": "^1.0.4",
"lezer-elixir": "^1.1.2"
},
"devDependencies": {
"typescript": "^5.9.2",
"vitest": "^3.2.4"
}
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.32",
@@ -34534,22 +35140,8 @@
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "*",
"@getpaseo/relay": "0.1.32",
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
"@lezer/css": "^1.3.0",
"@lezer/go": "^1.0.1",
"@lezer/highlight": "^1.2.3",
"@lezer/html": "^1.3.13",
"@lezer/java": "^1.1.3",
"@lezer/javascript": "^1.5.4",
"@lezer/json": "^1.0.3",
"@lezer/markdown": "^1.6.2",
"@lezer/php": "^1.0.5",
"@lezer/python": "^1.1.18",
"@lezer/rust": "^1.0.2",
"@lezer/xml": "^1.0.6",
"@lezer/yaml": "^1.0.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
"@sctg/sentencepiece-js": "^1.1.0",
@@ -34560,7 +35152,6 @@
"express": "^4.18.2",
"express-basic-auth": "^1.2.1",
"fast-uri": "^3.1.0",
"lezer-elixir": "^1.1.2",
"mnemonic-id": "^3.2.7",
"node-pty": "1.2.0-beta.11",
"onnxruntime-node": "^1.23.0",
+8 -5
View File
@@ -4,6 +4,7 @@
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
"packages/highlight",
"packages/server",
"packages/app",
"packages/relay",
@@ -18,12 +19,13 @@
"dev:website": "npm run dev --workspace=@getpaseo/website",
"postinstall": "node scripts/postinstall-patches.mjs",
"build": "npm run build --workspaces --if-present",
"build:daemon": "npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
"build:daemon": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
"typecheck": "npm run typecheck --workspaces --if-present",
"typecheck:daemon": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
"test": "npm run test --workspaces --if-present",
"format": "prettier --write .",
"format:check": "prettier --check .",
"format": "biome format --write .",
"format:check": "biome format .",
"start": "npm run start --workspace=@getpaseo/server",
"android": "npm run android --workspace=@getpaseo/app",
"android:development": "npm run android:development --workspace=@getpaseo/app",
@@ -56,7 +58,7 @@
"release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push"
},
"devDependencies": {
"prettier": "^3.5.3",
"@biomejs/biome": "^2.4.8",
"get-port-cli": "^3.0.0",
"knip": "^5.82.1",
"patch-package": "^8.0.1",
@@ -81,6 +83,7 @@
"react-dom": "19.1.4"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.11"
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@modelcontextprotocol/sdk": "^1.27.1"
}
}
+2 -5
View File
@@ -65,8 +65,7 @@ export default {
ios: {
supportsTablet: true,
infoPlist: {
NSMicrophoneUsageDescription:
"This app needs access to the microphone for voice commands.",
NSMicrophoneUsageDescription: "This app needs access to the microphone for voice commands.",
ITSAppUsesNonExemptEncryption: false,
},
bundleIdentifier: variant.packageId,
@@ -92,9 +91,7 @@ export default {
"android.permission.CAMERA",
],
package: variant.packageId,
...(variant.googleServicesFile
? { googleServicesFile: variant.googleServicesFile }
: {}),
...(variant.googleServicesFile ? { googleServicesFile: variant.googleServicesFile } : {}),
},
web: {
output: "single",
+23 -26
View File
@@ -1,15 +1,12 @@
import { test as base, expect, type Page } from '@playwright/test';
import {
buildCreateAgentPreferences,
buildSeededHost,
} from './helpers/daemon-registry';
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({
baseURL: async ({}, use) => {
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) {
throw new Error('E2E_METRO_PORT not set - globalSetup must run first');
throw new Error("E2E_METRO_PORT not set - globalSetup must run first");
}
await use(`http://localhost:${metroPort}`);
},
@@ -22,19 +19,19 @@ test.beforeEach(async ({ page }) => {
const metroPort = process.env.E2E_METRO_PORT;
if (!daemonPort) {
throw new Error(
'E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). ' +
'Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.'
"E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). " +
"Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.",
);
}
if (daemonPort === '6767') {
if (daemonPort === "6767") {
throw new Error(
'E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. ' +
'Fix Playwright globalSetup to start an isolated test daemon and export its port.'
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. " +
"Fix Playwright globalSetup to start an isolated test daemon and export its port.",
);
}
if (!metroPort) {
throw new Error(
'E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.'
"E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.",
);
}
@@ -42,17 +39,17 @@ test.beforeEach(async ({ page }) => {
// This blocks both HTTP and WS attempts to :6767 (before any navigation).
await page.route(/:(6767)\b/, (route) => route.abort());
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
await ws.close({ code: 1008, reason: 'Blocked connection to localhost:6767 during e2e.' });
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
});
const entries: string[] = [];
consoleEntries.set(page, entries);
page.on('console', (message) => {
page.on("console", (message) => {
entries.push(`[console:${message.type()}] ${message.text()}`);
});
page.on('pageerror', (error) => {
page.on("pageerror", (error) => {
entries.push(`[pageerror] ${error.message}`);
});
@@ -60,7 +57,7 @@ test.beforeEach(async ({ page }) => {
const seedNonce = Math.random().toString(36).slice(2);
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set - expected from Playwright globalSetup.');
throw new Error("E2E_SERVER_ID is not set - expected from Playwright globalSetup.");
}
const testDaemon = buildSeededHost({
serverId,
@@ -74,7 +71,7 @@ test.beforeEach(async ({ page }) => {
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
// override storage and reload; they can opt out of seeding for the *next* navigation by
// setting this flag before the reload.
const disableOnceKey = '@paseo:e2e-disable-default-seed-once';
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
const disableValue = localStorage.getItem(disableOnceKey);
if (disableValue) {
localStorage.removeItem(disableOnceKey);
@@ -83,15 +80,15 @@ test.beforeEach(async ({ page }) => {
}
}
localStorage.setItem('@paseo:e2e', '1');
localStorage.setItem('@paseo:e2e-seed-nonce', seedNonce);
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
// Hard-reset anything that could point to a developer's real daemon.
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
localStorage.removeItem('@paseo:settings');
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.removeItem("@paseo:settings");
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce }
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
);
});
@@ -105,9 +102,9 @@ test.afterEach(async ({ page }, testInfo) => {
return;
}
await testInfo.attach('browser-console', {
body: entries.join('\n'),
contentType: 'text/plain',
await testInfo.attach("browser-console", {
body: entries.join("\n"),
contentType: "text/plain",
});
});
+128 -105
View File
@@ -1,11 +1,11 @@
import { spawn, type ChildProcess, execSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import net from 'node:net';
import { Buffer } from 'node:buffer';
import dotenv from 'dotenv';
import { spawn, type ChildProcess, execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import net from "node:net";
import { Buffer } from "node:buffer";
import dotenv from "dotenv";
type WaitForServerOptions = {
host?: string;
@@ -18,11 +18,11 @@ type WaitForServerOptions = {
async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.once("error", reject);
server.listen(0, () => {
const address = server.address();
if (!address || typeof address === 'string') {
server.close(() => reject(new Error('Failed to acquire port')));
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to acquire port")));
return;
}
server.close(() => resolve(address.port));
@@ -40,18 +40,18 @@ function createLineBuffer(maxLines = 120): { add: (line: string) => void; dump:
}
},
dump() {
return lines.join('\n');
return lines.join("\n");
},
};
}
function formatRecentOutput(getRecentOutput?: () => string): string {
if (!getRecentOutput) {
return '';
return "";
}
const output = getRecentOutput().trim();
if (!output) {
return '';
return "";
}
return `\nRecent output:\n${output}`;
}
@@ -61,21 +61,15 @@ function sleep(ms: number): Promise<void> {
}
async function waitForServer(port: number, options: WaitForServerOptions): Promise<void> {
const {
host = '127.0.0.1',
timeoutMs = 15000,
label,
childProcess,
getRecentOutput,
} = options;
const { host = "127.0.0.1", timeoutMs = 15000, label, childProcess, getRecentOutput } = options;
const start = Date.now();
let lastConnectionError: unknown = null;
while (Date.now() - start < timeoutMs) {
if (childProcess && childProcess.exitCode !== null) {
const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : '';
const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : "";
throw new Error(
`${label} exited before listening on ${host}:${port} (exit code ${childProcess.exitCode}${signal}).${formatRecentOutput(getRecentOutput)}`
`${label} exited before listening on ${host}:${port} (exit code ${childProcess.exitCode}${signal}).${formatRecentOutput(getRecentOutput)}`,
);
}
@@ -89,7 +83,7 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi
socket.destroy();
reject(new Error(`Connection timed out to ${host}:${port}`));
});
socket.on('error', reject);
socket.on("error", reject);
});
return;
} catch (error) {
@@ -99,9 +93,11 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi
}
const reason =
lastConnectionError instanceof Error ? ` Last connection error: ${lastConnectionError.message}` : '';
lastConnectionError instanceof Error
? ` Last connection error: ${lastConnectionError.message}`
: "";
throw new Error(
`${label} did not start on ${host}:${port} within ${timeoutMs}ms.${reason}${formatRecentOutput(getRecentOutput)}`
`${label} did not start on ${host}:${port} within ${timeoutMs}ms.${reason}${formatRecentOutput(getRecentOutput)}`,
);
}
@@ -126,15 +122,15 @@ async function stopProcess(child: ChildProcess | null): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
child.kill('SIGTERM');
child.kill("SIGTERM");
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill('SIGKILL');
child.kill("SIGKILL");
}
resolve();
}, 5000);
child.once('exit', () => {
child.once("exit", () => {
clearTimeout(timeout);
resolve();
});
@@ -144,7 +140,7 @@ async function stopProcess(child: ChildProcess | null): Promise<void> {
function summarizeOpenAiErrorBody(body: string): string {
const trimmed = body.trim();
if (!trimmed) {
return 'empty response body';
return "empty response body";
}
if (trimmed.length <= 240) {
return trimmed;
@@ -159,8 +155,8 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
}
try {
const response = await fetch('https://api.openai.com/v1/models?limit=1', {
method: 'GET',
const response = await fetch("https://api.openai.com/v1/models?limit=1", {
method: "GET",
headers: {
Authorization: `Bearer ${key}`,
},
@@ -170,14 +166,14 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
}
const body = await response.text();
console.warn(
`[e2e] OPENAI_API_KEY probe failed (${response.status}): ${summarizeOpenAiErrorBody(body)}`
`[e2e] OPENAI_API_KEY probe failed (${response.status}): ${summarizeOpenAiErrorBody(body)}`,
);
return false;
} catch (error) {
console.warn(
`[e2e] OPENAI_API_KEY probe request failed: ${
error instanceof Error ? error.message : String(error)
}`
}`,
);
return false;
}
@@ -196,42 +192,42 @@ type OfferPayload = {
};
function stripAnsi(input: string): string {
return input.replace(/\u001b\[[0-9;]*m/g, '');
return input.replace(/\u001b\[[0-9;]*m/g, "");
}
function ensureRelayBuildArtifact(repoRoot: string): void {
const relayDistEntry = path.join(repoRoot, 'packages/relay/dist/e2ee.js');
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', {
console.log("[e2e] Building @getpaseo/relay for daemon startup");
execSync("npm run build --workspace=@getpaseo/relay", {
cwd: repoRoot,
stdio: 'inherit',
stdio: "inherit",
});
}
function decodeOfferFromFragmentUrl(url: string): OfferPayload {
const marker = '#offer=';
const marker = "#offer=";
const idx = url.indexOf(marker);
if (idx === -1) {
throw new Error(`missing ${marker} fragment: ${url}`);
}
const encoded = url.slice(idx + marker.length);
const json = Buffer.from(encoded, 'base64url').toString('utf8');
const json = Buffer.from(encoded, "base64url").toString("utf8");
const offer = JSON.parse(json) as Partial<OfferPayload>;
if (offer.v !== 2) throw new Error('offer.v missing/invalid');
if (!offer.serverId) throw new Error('offer.serverId missing');
if (!offer.daemonPublicKeyB64) throw new Error('offer.daemonPublicKeyB64 missing');
if (!offer.relay?.endpoint) throw new Error('offer.relay.endpoint missing');
if (offer.v !== 2) throw new Error("offer.v missing/invalid");
if (!offer.serverId) throw new Error("offer.serverId missing");
if (!offer.daemonPublicKeyB64) throw new Error("offer.daemonPublicKeyB64 missing");
if (!offer.relay?.endpoint) throw new Error("offer.relay.endpoint missing");
return offer as OfferPayload;
}
export default async function globalSetup() {
const repoRoot = path.resolve(__dirname, '../../..');
const repoRoot = path.resolve(__dirname, "../../..");
ensureRelayBuildArtifact(repoRoot);
const envTestPath = path.join(repoRoot, '.env.test');
const envTestPath = path.join(repoRoot, ".env.test");
if (existsSync(envTestPath)) {
dotenv.config({ path: envTestPath });
}
@@ -239,13 +235,17 @@ export default async function globalSetup() {
const port = await getAvailablePort();
let relayPort = 0;
const metroPort = await getAvailablePort();
paseoHome = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-home-'));
paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"));
let relayLineBuffer = createLineBuffer();
const metroLineBuffer = createLineBuffer();
const daemonLineBuffer = createLineBuffer();
const cleanup = async () => {
await Promise.all([stopProcess(daemonProcess), stopProcess(metroProcess), stopProcess(relayProcess)]);
await Promise.all([
stopProcess(daemonProcess),
stopProcess(metroProcess),
stopProcess(relayProcess),
]);
daemonProcess = null;
metroProcess = null;
relayProcess = null;
@@ -256,24 +256,30 @@ export default async function globalSetup() {
};
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
const defaultLocalModelsDir = path.join(process.env.HOME ?? '', '.paseo', 'models', 'local-speech');
const hasDefaultLocalModelsDir = defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
const dictationProvider = openAiUsable ? 'openai' : 'local';
const defaultLocalModelsDir = path.join(
process.env.HOME ?? "",
".paseo",
"models",
"local-speech",
);
const hasDefaultLocalModelsDir =
defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
const dictationProvider = openAiUsable ? "openai" : "local";
if (dictationProvider === 'local' && !hasDefaultLocalModelsDir) {
if (dictationProvider === "local" && !hasDefaultLocalModelsDir) {
throw new Error(
'OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. ' +
'Either provide a valid OPENAI_API_KEY or install local speech models before running app e2e tests.'
"OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. " +
"Either provide a valid OPENAI_API_KEY or install local speech models before running app e2e tests.",
);
}
const localModelsDir = dictationProvider === 'local' ? defaultLocalModelsDir : null;
const localModelsDir = dictationProvider === "local" ? defaultLocalModelsDir : null;
console.log(
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? '' : ' (OpenAI probe failed)'}`
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`,
);
try {
const relayDir = path.resolve(__dirname, '..', '..', 'relay');
const relayDir = path.resolve(__dirname, "..", "..", "relay");
const maxRelayStartupAttempts = 5;
let relayStarted = false;
let lastRelayStartupError: unknown = null;
@@ -285,18 +291,21 @@ export default async function globalSetup() {
let relayReadyForSelectedPort = false;
relayProcess = spawn(
'npx',
['wrangler', 'dev', '--local', '--ip', '127.0.0.1', '--port', String(relayPort)],
"npx",
["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)],
{
cwd: relayDir,
env: { ...process.env },
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ["ignore", "pipe", "pipe"],
detached: false,
}
},
);
relayProcess.stdout?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter((line) => line.trim());
relayProcess.stdout?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
relayLineBuffer.add(`[stdout] ${line}`);
const failure = parseRelayStartupFailure(line);
@@ -311,8 +320,11 @@ export default async function globalSetup() {
console.log(`[relay] ${line}`);
}
});
relayProcess.stderr?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter((line) => line.trim());
relayProcess.stderr?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
relayLineBuffer.add(`[stderr] ${line}`);
const failure = parseRelayStartupFailure(line);
@@ -330,7 +342,7 @@ export default async function globalSetup() {
try {
await waitForServer(relayPort, {
label: 'Relay dev server',
label: "Relay dev server",
timeoutMs: 30000,
childProcess: relayProcess,
getRecentOutput: relayLineBuffer.dump,
@@ -353,15 +365,15 @@ export default async function globalSetup() {
if (!relayReadyForSelectedPort) {
throw new Error(
`Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput(
relayLineBuffer.dump
)}`
relayLineBuffer.dump,
)}`,
);
}
if (relayProcess.exitCode !== null || relayProcess.signalCode !== null) {
throw new Error(
`Relay process exited before startup completed (exit code ${relayProcess.exitCode}, signal ${relayProcess.signalCode}).${formatRecentOutput(
relayLineBuffer.dump
)}`
relayLineBuffer.dump,
)}`,
);
}
@@ -380,40 +392,46 @@ export default async function globalSetup() {
? lastRelayStartupError.message
: String(lastRelayStartupError);
throw new Error(
`Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`
`Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`,
);
}
// Start Metro bundler on dynamic port
const appDir = path.resolve(__dirname, '..');
metroProcess = spawn('npx', ['expo', 'start', '--web', '--port', String(metroPort)], {
const appDir = path.resolve(__dirname, "..");
metroProcess = spawn("npx", ["expo", "start", "--web", "--port", String(metroPort)], {
cwd: appDir,
env: {
...process.env,
BROWSER: 'none', // Don't auto-open browser
BROWSER: "none", // Don't auto-open browser
},
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
metroProcess.stdout?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter((line) => line.trim());
metroProcess.stdout?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
metroLineBuffer.add(`[stdout] ${line}`);
console.log(`[metro] ${line}`);
}
});
metroProcess.stderr?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter((line) => line.trim());
metroProcess.stderr?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
metroLineBuffer.add(`[stderr] ${line}`);
console.error(`[metro] ${line}`);
}
});
const serverDir = path.resolve(__dirname, '../../..', 'packages/server');
const tsxBin = execSync('which tsx').toString().trim();
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
const tsxBin = execSync("which tsx").toString().trim();
let offerPayload: OfferPayload | null = null;
let offerResolve: (() => void) | null = null;
@@ -421,33 +439,33 @@ export default async function globalSetup() {
offerResolve = resolve;
});
daemonProcess = spawn(tsxBin, ['src/server/index.ts'], {
daemonProcess = spawn(tsxBin, ["src/server/index.ts"], {
cwd: serverDir,
env: {
...process.env,
PASEO_HOME: paseoHome,
PASEO_SERVER_ID: 'srv_e2e_test_daemon',
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
// Use OpenAI speech providers in e2e to avoid local model bootstrapping delays.
PASEO_DICTATION_ENABLED: '1',
PASEO_VOICE_MODE_ENABLED: '1',
PASEO_DICTATION_ENABLED: "1",
PASEO_VOICE_MODE_ENABLED: "1",
PASEO_DICTATION_STT_PROVIDER: dictationProvider,
PASEO_VOICE_STT_PROVIDER: 'openai',
PASEO_VOICE_TTS_PROVIDER: 'openai',
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
NODE_ENV: 'development',
NODE_ENV: "development",
},
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
let stdoutBuffer = '';
daemonProcess.stdout?.on('data', (data: Buffer) => {
stdoutBuffer += data.toString('utf8');
const lines = stdoutBuffer.split('\n');
stdoutBuffer = lines.pop() ?? '';
let stdoutBuffer = "";
daemonProcess.stdout?.on("data", (data: Buffer) => {
stdoutBuffer += data.toString("utf8");
const lines = stdoutBuffer.split("\n");
stdoutBuffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
@@ -456,13 +474,13 @@ export default async function globalSetup() {
const clean = stripAnsi(trimmed);
try {
const obj = JSON.parse(clean) as { msg?: string; url?: string };
if (obj.msg === 'pairing_offer' && typeof obj.url === 'string') {
if (obj.msg === "pairing_offer" && typeof obj.url === "string") {
offerPayload = decodeOfferFromFragmentUrl(obj.url);
offerResolve?.();
}
} catch {
const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
if (match && clean.includes('pairing_offer')) {
if (match && clean.includes("pairing_offer")) {
try {
offerPayload = decodeOfferFromFragmentUrl(match[0]);
offerResolve?.();
@@ -476,8 +494,11 @@ export default async function globalSetup() {
}
});
daemonProcess.stderr?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter((line) => line.trim());
daemonProcess.stderr?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
daemonLineBuffer.add(`[stderr] ${line}`);
console.error(`[daemon] ${line}`);
@@ -487,12 +508,12 @@ export default async function globalSetup() {
// Wait for both daemon and Metro to be ready
await Promise.all([
waitForServer(port, {
label: 'Paseo daemon',
label: "Paseo daemon",
childProcess: daemonProcess,
getRecentOutput: daemonLineBuffer.dump,
}),
waitForServer(metroPort, {
label: 'Metro web server',
label: "Metro web server",
timeoutMs: 120000, // Metro can take longer to start
childProcess: metroProcess,
getRecentOutput: metroLineBuffer.dump,
@@ -503,11 +524,11 @@ export default async function globalSetup() {
await Promise.race([
offerPromise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timed out waiting for pairing_offer log')), 15000)
setTimeout(() => reject(new Error("Timed out waiting for pairing_offer log")), 15000),
),
]);
if (!offerPayload) {
throw new Error('pairing_offer was not parsed from daemon logs');
throw new Error("pairing_offer was not parsed from daemon logs");
}
const offer = offerPayload as OfferPayload;
@@ -516,11 +537,13 @@ export default async function globalSetup() {
process.env.E2E_SERVER_ID = offer.serverId;
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
process.env.E2E_METRO_PORT = String(metroPort);
console.log(`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`);
console.log(
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`,
);
return async () => {
await cleanup();
console.log('[e2e] Test daemon stopped');
console.log("[e2e] Test daemon stopped");
};
} catch (error) {
await cleanup();
+19 -25
View File
@@ -34,10 +34,7 @@ export type DaemonClientInstance = {
initialPrompt: string;
}): Promise<{ id: string }>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
waitForFinish(
agentId: string,
timeout?: number
): Promise<{ status: string }>;
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
};
function getDaemonWsUrl(): string {
@@ -86,14 +83,16 @@ export function createReplyTurn(label: string): {
};
}
async function loadDaemonClientConstructor(): Promise<new (config: {
url: string;
clientId: string;
clientType: "cli";
}) => DaemonClientInstance> {
async function loadDaemonClientConstructor(): Promise<
new (config: {
url: string;
clientId: string;
clientType: "cli";
}) => DaemonClientInstance
> {
const repoRoot = path.resolve(process.cwd(), "../..");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js")
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: {
@@ -138,7 +137,7 @@ export async function seedBottomAnchorAgent(input: {
const initialFinish = await input.client.waitForFinish(created.id, 120000);
if (initialFinish.status !== "idle") {
throw new Error(
`Expected seeded agent ${created.id} to become idle after initial prompt, got ${initialFinish.status}.`
`Expected seeded agent ${created.id} to become idle after initial prompt, got ${initialFinish.status}.`,
);
}
@@ -150,7 +149,7 @@ export async function seedBottomAnchorAgent(input: {
const finish = await input.client.waitForFinish(created.id, 120000);
if (finish.status !== "idle") {
throw new Error(
`Expected seeded agent ${created.id} to become idle after turn ${index}, got ${finish.status}.`
`Expected seeded agent ${created.id} to become idle after turn ${index}, got ${finish.status}.`,
);
}
}
@@ -183,18 +182,13 @@ export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
const scrollElement =
candidates.sort(
(left, right) =>
right.scrollHeight -
right.clientHeight -
(left.scrollHeight - left.clientHeight)
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);
const viewportHeight = Math.max(0, scrollElement.clientHeight);
const distanceFromBottom = Math.max(
0,
contentHeight - (offsetY + viewportHeight)
);
const distanceFromBottom = Math.max(0, contentHeight - (offsetY + viewportHeight));
return {
offsetY,
@@ -218,7 +212,7 @@ export async function scrollUpFromBottom(page: Page, pixels: number): Promise<vo
deltaY: -step,
bubbles: true,
cancelable: true,
})
}),
);
scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - step);
scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true }));
@@ -267,7 +261,7 @@ export async function expectDetachedFromBottom(page: Page): Promise<void> {
export async function waitForContentGrowth(
page: Page,
previousContentHeight: number
previousContentHeight: number,
): Promise<ScrollMetrics> {
await expect
.poll(async () => {
@@ -280,8 +274,8 @@ export async function waitForContentGrowth(
export async function getChatContainerKey(page: Page): Promise<string | null> {
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;
});
const nativeId = (element as HTMLElement).id;
const prefix = "agent-chat-scroll-";
return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null;
});
}
+185 -148
View File
@@ -1,20 +1,19 @@
import { expect, type Page } from '@playwright/test';
import {
buildCreateAgentPreferences,
buildSeededHost,
} from './daemon-registry';
import { expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function getE2EDaemonPort(): string {
const port = process.env.E2E_DAEMON_PORT;
if (!port) {
throw new Error('E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).');
throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
}
if (port === '6767') {
throw new Error('E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.');
if (port === "6767") {
throw new Error(
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.",
);
}
return port;
}
@@ -24,25 +23,38 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
const expectedEndpoint = `127.0.0.1:${port}`;
const expectedServerId = process.env.E2E_SERVER_ID;
if (!expectedServerId) {
throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).');
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
const needsReset = await page.evaluate(({ expectedEndpoint, expectedServerId }) => {
const raw = localStorage.getItem('@paseo:daemon-registry');
if (!raw) return true;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
const entry = parsed[0] as any;
if (entry?.serverId !== expectedServerId) return true;
const connections = entry?.connections;
if (!Array.isArray(connections)) return true;
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;
}
}, { expectedEndpoint, expectedServerId });
const needsReset = await page.evaluate(
({ expectedEndpoint, expectedServerId }) => {
const raw = localStorage.getItem("@paseo:daemon-registry");
if (!raw) return true;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
const entry = parsed[0] as any;
if (entry?.serverId !== expectedServerId) return true;
const connections = entry?.connections;
if (!Array.isArray(connections)) return true;
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;
}
},
{ expectedEndpoint, expectedServerId },
);
if (!needsReset) {
return;
@@ -57,12 +69,12 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
const preferences = buildCreateAgentPreferences(expectedServerId);
await page.evaluate(
({ daemon, preferences }) => {
localStorage.setItem('@paseo:e2e', '1');
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
localStorage.removeItem('@paseo:settings');
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
localStorage.removeItem("@paseo:settings");
},
{ daemon, preferences }
{ daemon, preferences },
);
await page.reload();
@@ -73,81 +85,98 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
const expectedEndpoint = `127.0.0.1:${port}`;
const expectedServerId = process.env.E2E_SERVER_ID;
if (!expectedServerId) {
throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).');
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
const snapshot = await page.evaluate(() => {
const registryRaw = localStorage.getItem('@paseo:daemon-registry');
const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences');
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
return { registryRaw, prefsRaw };
});
if (!snapshot.registryRaw) {
throw new Error('E2E expected @paseo:daemon-registry to be set before app load.');
throw new Error("E2E expected @paseo:daemon-registry to be set before app load.");
}
let registry: any;
try {
registry = JSON.parse(snapshot.registryRaw);
} catch {
throw new Error('E2E expected @paseo:daemon-registry to be valid JSON.');
throw new Error("E2E expected @paseo:daemon-registry to be valid JSON.");
}
if (!Array.isArray(registry) || registry.length !== 1) {
throw new Error(
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : 'non-array'}).`
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : "non-array"}).`,
);
}
const daemon = registry[0];
if (typeof daemon?.serverId !== 'string' || daemon.serverId.length === 0) {
throw new Error(`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`);
if (typeof daemon?.serverId !== "string" || daemon.serverId.length === 0) {
throw new Error(
`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`,
);
}
if (daemon.serverId !== expectedServerId) {
throw new Error(`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${daemon.serverId}).`);
throw new Error(
`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${daemon.serverId}).`,
);
}
const connections: unknown = daemon?.connections;
if (
!Array.isArray(connections) ||
!connections.some((c: any) => c?.type === 'directTcp' && c?.endpoint === expectedEndpoint)
!connections.some((c: any) => c?.type === "directTcp" && c?.endpoint === expectedEndpoint)
) {
throw new Error(
`E2E expected seeded daemon connections to include directTcp ${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 === 'directTcp' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) {
throw new Error(`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`);
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)}).`,
);
}
if (!snapshot.prefsRaw) {
throw new Error('E2E expected @paseo:create-agent-preferences to be set before app load.');
throw new Error("E2E expected @paseo:create-agent-preferences to be set before app load.");
}
try {
const prefs = JSON.parse(snapshot.prefsRaw) as any;
if (prefs?.serverId !== daemon.serverId) {
throw new Error(
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${daemon.serverId}) (got ${String(prefs?.serverId)}).`
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${daemon.serverId}) (got ${String(prefs?.serverId)}).`,
);
}
} catch (error) {
if (error instanceof Error) throw error;
throw new Error('E2E expected @paseo:create-agent-preferences to be valid JSON.');
throw new Error("E2E expected @paseo:create-agent-preferences to be valid JSON.");
}
}
export const gotoAppShell = async (page: Page) => {
await page.goto('/');
await page.goto("/");
await ensureE2EStorageSeeded(page);
};
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();
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 expect
.poll(
@@ -155,7 +184,7 @@ export const gotoHome = async (page: Page) => {
(await addProjectCta.isVisible().catch(() => false)) ||
(await addProjectSidebar.isVisible().catch(() => false)) ||
(await newAgentButton.isVisible().catch(() => false)),
{ timeout: 10000 }
{ timeout: 10000 },
)
.toBe(true);
@@ -173,7 +202,7 @@ export const gotoHome = async (page: Page) => {
export const openSettings = async (page: Page) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).');
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
// Navigate through the real app control so route changes stay aligned with UI behavior.
@@ -189,23 +218,19 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
.first();
await expect(workingDirectorySelect).toBeVisible({ timeout: 30000 });
const legacyInput = page.getByRole('textbox', { name: '/path/to/project' }).first();
const directorySearchInput = page.getByRole('textbox', { name: /search directories/i }).first();
const worktreePicker = page.getByTestId('worktree-attach-picker');
const worktreeSheetTitle = page.getByText('Select worktree', { exact: true }).first();
const legacyInput = page.getByRole("textbox", { name: "/path/to/project" }).first();
const directorySearchInput = page.getByRole("textbox", { name: /search directories/i }).first();
const worktreePicker = page.getByTestId("worktree-attach-picker");
const worktreeSheetTitle = page.getByText("Select worktree", { exact: true }).first();
const closeBottomSheet = async () => {
const bottomSheetBackdrop = page
.getByRole('button', { name: 'Bottom sheet backdrop' })
.first();
const bottomSheetHandle = page
.getByRole('slider', { name: 'Bottom sheet handle' })
.first();
const bottomSheetBackdrop = page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
const bottomSheetHandle = page.getByRole("slider", { name: "Bottom sheet handle" }).first();
for (let attempt = 0; attempt < 3; attempt += 1) {
if (!(await bottomSheetBackdrop.isVisible())) {
return;
}
await bottomSheetBackdrop.click({ force: true });
await page.keyboard.press('Escape').catch(() => undefined);
await page.keyboard.press("Escape").catch(() => undefined);
await page.waitForTimeout(200);
}
if (await bottomSheetBackdrop.isVisible()) {
@@ -225,7 +250,7 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
if (!(await worktreeSheetTitle.isVisible()) && !(await worktreePicker.isVisible())) {
return;
}
const attachToggle = page.getByTestId('worktree-attach-toggle');
const attachToggle = page.getByTestId("worktree-attach-toggle");
if (await attachToggle.isVisible()) {
await attachToggle.click({ force: true });
await page.waitForTimeout(200);
@@ -246,26 +271,23 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
await closeBottomSheet();
await workingDirectorySelect.click({ force: true });
}
await expect
.poll(async () => pickerInputVisible(), { timeout: 10000 })
.toBe(true);
await expect.poll(async () => pickerInputVisible(), { timeout: 10000 }).toBe(true);
}
const trimmedDirectory = directory.replace(/\/+$/, '');
const activeInput =
(await directorySearchInput.isVisible().catch(() => false))
? directorySearchInput
: legacyInput;
const trimmedDirectory = directory.replace(/\/+$/, "");
const activeInput = (await directorySearchInput.isVisible().catch(() => false))
? directorySearchInput
: legacyInput;
await activeInput.fill(trimmedDirectory);
if (activeInput === directorySearchInput) {
// Combobox custom rows can be either plain path labels or prefixed labels.
const plainOption = page
.getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, 'i'))
.getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, "i"))
.first();
const prefixedUseOption = page
.getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, 'i'))
.getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, "i"))
.first();
if (await plainOption.isVisible().catch(() => false)) {
@@ -274,33 +296,38 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
await prefixedUseOption.click({ force: true });
} else {
// Fallback: accept highlighted option (directory suggestion).
await activeInput.press('Enter');
await activeInput.press("Enter");
}
} else {
// Legacy path picker fallback.
await activeInput.press('Enter');
await activeInput.press("Enter");
}
// Wait for picker to close.
await expect(activeInput).not.toBeVisible({ timeout: 10000 });
const directoryCandidates = new Set<string>([trimmedDirectory]);
if (trimmedDirectory.startsWith('/var/')) {
if (trimmedDirectory.startsWith("/var/")) {
directoryCandidates.add(`/private${trimmedDirectory}`);
}
if (trimmedDirectory.startsWith('/private/var/')) {
directoryCandidates.add(trimmedDirectory.replace(/^\/private/, ''));
if (trimmedDirectory.startsWith("/private/var/")) {
directoryCandidates.add(trimmedDirectory.replace(/^\/private/, ""));
}
const basename = trimmedDirectory.split('/').filter(Boolean).pop() ?? trimmedDirectory;
const basename = trimmedDirectory.split("/").filter(Boolean).pop() ?? trimmedDirectory;
await expect.poll(async () => {
const text = await workingDirectorySelect.innerText().catch(() => '');
if (text.includes(basename)) return true;
for (const candidate of directoryCandidates) {
if (text.includes(candidate)) return true;
}
return false;
}, { timeout: 30000 }).toBe(true);
await expect
.poll(
async () => {
const text = await workingDirectorySelect.innerText().catch(() => "");
if (text.includes(basename)) return true;
for (const candidate of directoryCandidates) {
if (text.includes(candidate)) return true;
}
return false;
},
{ timeout: 30000 },
)
.toBe(true);
};
export const ensureHostSelected = async (page: Page) => {
@@ -318,19 +345,21 @@ export const ensureHostSelected = async (page: Page) => {
}
const fix = await page.evaluate(() => {
const registryRaw = localStorage.getItem('@paseo:daemon-registry');
const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences');
if (!registryRaw || !prefsRaw) return { ok: false, reason: 'missing storage' } as const;
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
if (!registryRaw || !prefsRaw) return { ok: false, reason: "missing storage" } as const;
const registry = JSON.parse(registryRaw) as any[];
const prefs = JSON.parse(prefsRaw) as any;
if (!Array.isArray(registry) || registry.length !== 1) return { ok: false, reason: 'registry shape' } as const;
if (!Array.isArray(registry) || registry.length !== 1)
return { ok: false, reason: "registry shape" } as const;
const serverId = registry[0]?.serverId;
if (typeof serverId !== 'string' || serverId.length === 0) return { ok: false, reason: 'missing serverId' } as const;
if (typeof serverId !== "string" || serverId.length === 0)
return { ok: false, reason: "missing serverId" } as const;
prefs.serverId = serverId;
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(prefs));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(prefs));
// Prevent the fixture's init-script from overwriting the corrected prefs on reload.
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce") ?? "1";
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
return { ok: true } as const;
});
@@ -342,20 +371,22 @@ export const ensureHostSelected = async (page: Page) => {
await assertE2EUsesSeededTestDaemon(page);
}
const input = page.getByRole('textbox', { name: 'Message agent...' });
const input = page.getByRole("textbox", { name: "Message agent..." });
await expect(input).toBeVisible();
if (await input.isEditable()) {
return;
}
const selectHostLabel = page.getByText('Select host', { exact: true });
const selectHostLabel = page.getByText("Select host", { exact: true });
if (await selectHostLabel.isVisible()) {
await selectHostLabel.click();
// E2E safety: we enforce a single seeded daemon, so the option should be unambiguous.
const localhostOption = page.getByText('localhost', { exact: true }).first();
const daemonIdOption = page.getByText(process.env.E2E_SERVER_ID ?? 'srv_e2e_test_daemon', { exact: true }).first();
const localhostOption = page.getByText("localhost", { exact: true }).first();
const daemonIdOption = page
.getByText(process.env.E2E_SERVER_ID ?? "srv_e2e_test_daemon", { exact: true })
.first();
if (await localhostOption.isVisible()) {
await localhostOption.click();
@@ -369,11 +400,11 @@ export const ensureHostSelected = async (page: Page) => {
};
export const createAgent = async (page: Page, message: string) => {
const input = page.getByRole('textbox', { name: 'Message agent...' });
const input = page.getByRole("textbox", { name: "Message agent..." });
await expect(input).toBeEditable();
await preferFastThinkingOption(page);
await input.fill(message);
await input.press('Enter');
await input.press("Enter");
// The composer may remain on the draft screen briefly while the initial run starts,
// so assert the user-visible result instead of forcing one route shape here.
@@ -385,21 +416,23 @@ export const createAgent = async (page: Page, message: string) => {
async function preferFastThinkingOption(page: Page): Promise<void> {
const providerTrigger = page
.locator('[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible')
.locator(
'[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible',
)
.first();
if (await providerTrigger.isVisible().catch(() => false)) {
const providerText = ((await providerTrigger.innerText().catch(() => '')) ?? '').trim();
const providerText = ((await providerTrigger.innerText().catch(() => "")) ?? "").trim();
if (!/codex/i.test(providerText)) {
return;
}
}
const thinkingTrigger = page.getByTestId('agent-thinking-selector').first();
const thinkingTrigger = page.getByTestId("agent-thinking-selector").first();
if (!(await thinkingTrigger.isVisible().catch(() => false))) {
return;
}
const currentThinkingLabel = ((await thinkingTrigger.innerText().catch(() => '')) ?? '')
const currentThinkingLabel = ((await thinkingTrigger.innerText().catch(() => "")) ?? "")
.trim()
.toLowerCase();
if (/\b(low|minimal|off)\b/.test(currentThinkingLabel)) {
@@ -407,16 +440,16 @@ async function preferFastThinkingOption(page: Page): Promise<void> {
}
await thinkingTrigger.click();
const menu = page.getByTestId('agent-thinking-menu').first();
const menu = page.getByTestId("agent-thinking-menu").first();
if (!(await menu.isVisible().catch(() => false))) {
return;
}
const preferredLabels = ['low', 'minimal', 'off', 'medium'];
const preferredLabels = ["low", "minimal", "off", "medium"];
let selected = false;
for (const label of preferredLabels) {
const option = menu
.getByRole('button', { name: new RegExp(`^${escapeRegex(label)}$`, 'i') })
.getByRole("button", { name: new RegExp(`^${escapeRegex(label)}$`, "i") })
.first();
if (await option.isVisible().catch(() => false)) {
await option.click({ force: true });
@@ -426,11 +459,11 @@ async function preferFastThinkingOption(page: Page): Promise<void> {
}
if (!selected) {
const options = menu.getByRole('button');
const options = menu.getByRole("button");
const count = await options.count();
for (let index = 0; index < count; index += 1) {
const option = options.nth(index);
const label = ((await option.innerText().catch(() => '')) ?? '').trim();
const label = ((await option.innerText().catch(() => "")) ?? "").trim();
if (!label) {
continue;
}
@@ -444,7 +477,7 @@ async function preferFastThinkingOption(page: Page): Promise<void> {
}
if (!selected) {
await page.keyboard.press('Escape').catch(() => undefined);
await page.keyboard.press("Escape").catch(() => undefined);
return;
}
@@ -462,15 +495,17 @@ export interface AgentConfig {
export const selectProvider = async (page: Page, provider: string) => {
const normalizedProvider = provider.trim();
if (!normalizedProvider) {
throw new Error('Provider must be a non-empty string.');
throw new Error("Provider must be a non-empty string.");
}
const providerTrigger = page
.locator('[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible')
.locator(
'[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible',
)
.first();
if (
await providerTrigger
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i'))
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, "i"))
.first()
.isVisible()
.catch(() => false)
@@ -481,20 +516,18 @@ export const selectProvider = async (page: Page, provider: string) => {
if (await providerTrigger.isVisible().catch(() => false)) {
await providerTrigger.click();
} else {
const providerLabel = page.getByText('PROVIDER', { exact: true }).first();
const providerLabel = page.getByText("PROVIDER", { exact: true }).first();
await expect(providerLabel).toBeVisible();
await providerLabel.click();
}
const dialog = page.getByRole('dialog').last();
const searchInput = dialog.getByRole('textbox', { name: /search provider/i }).first();
const dialog = page.getByRole("dialog").last();
const searchInput = dialog.getByRole("textbox", { name: /search provider/i }).first();
if (await searchInput.isVisible().catch(() => false)) {
await searchInput.fill(normalizedProvider);
}
const option = dialog
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i'))
.first();
const option = dialog.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, "i")).first();
await expect(option).toBeVisible();
await option.click();
};
@@ -502,15 +535,17 @@ export const selectProvider = async (page: Page, provider: string) => {
export const selectModel = async (page: Page, model: string) => {
const normalizedModel = model.trim();
if (!normalizedModel) {
throw new Error('Model must be a non-empty string.');
throw new Error("Model must be a non-empty string.");
}
const modelTrigger = page
.locator('[data-testid="agent-model-selector"]:visible, [data-testid="draft-model-select"]:visible')
.locator(
'[data-testid="agent-model-selector"]:visible, [data-testid="draft-model-select"]:visible',
)
.first();
if (
await modelTrigger
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i'))
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, "i"))
.first()
.isVisible()
.catch(() => false)
@@ -521,21 +556,21 @@ export const selectModel = async (page: Page, model: string) => {
if (await modelTrigger.isVisible().catch(() => false)) {
await modelTrigger.click();
} else {
const modelLabel = page.getByText('MODEL', { exact: true }).first();
const modelLabel = page.getByText("MODEL", { exact: true }).first();
await expect(modelLabel).toBeVisible();
await modelLabel.click();
}
// Wait for the model dropdown to open
const searchInput = page.getByRole('textbox', { name: /search model/i });
const searchInput = page.getByRole("textbox", { name: /search model/i });
await expect(searchInput).toBeVisible({ timeout: 10000 });
// Type to search/filter models
await searchInput.fill(normalizedModel);
const dialog = page.getByRole('dialog');
const dialog = page.getByRole("dialog");
const exactOption = dialog
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i'))
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, "i"))
.first();
const exactVisible = await exactOption.isVisible().catch(() => false);
if (exactVisible) {
@@ -543,39 +578,39 @@ export const selectModel = async (page: Page, model: string) => {
} else {
// Modern labels include version suffixes (for example "Haiku 4.5"), so
// select the first filtered result using keyboard confirm.
await searchInput.press('Enter');
await searchInput.press("Enter");
}
// Wait for dropdown to close
if (await searchInput.isVisible().catch(() => false)) {
await page.keyboard.press('Escape').catch(() => undefined);
await page.keyboard.press("Escape").catch(() => undefined);
}
await expect(searchInput).not.toBeVisible({ timeout: 5000 });
};
export const selectMode = async (page: Page, mode: string) => {
const modeTrigger = page
.locator('[data-testid="agent-mode-selector"]:visible, [data-testid="draft-mode-select"]:visible')
.locator(
'[data-testid="agent-mode-selector"]:visible, [data-testid="draft-mode-select"]:visible',
)
.first();
if (await modeTrigger.isVisible().catch(() => false)) {
await modeTrigger.click();
} else {
const modeLabel = page.getByText('MODE', { exact: true }).first();
const modeLabel = page.getByText("MODE", { exact: true }).first();
await expect(modeLabel).toBeVisible();
await modeLabel.click();
}
// Wait for the mode dropdown to open
const searchInput = page.getByRole('textbox', { name: /search mode/i });
const searchInput = page.getByRole("textbox", { name: /search mode/i });
await expect(searchInput).toBeVisible({ timeout: 10000 });
// Type to filter modes
await searchInput.fill(mode);
const dialog = page.getByRole('dialog');
const option = dialog
.getByText(new RegExp(`^${escapeRegex(mode)}$`, 'i'))
.first();
const dialog = page.getByRole("dialog");
const option = dialog.getByText(new RegExp(`^${escapeRegex(mode)}$`, "i")).first();
await expect(option).toBeVisible();
await option.click({ force: true });
@@ -605,7 +640,7 @@ export const createAgentWithConfig = async (page: Page, config: AgentConfig) =>
export const createAgentInRepo = async (
page: Page,
config: Pick<AgentConfig, 'directory' | 'prompt'>
config: Pick<AgentConfig, "directory" | "prompt">,
) => {
await gotoHome(page);
await ensureHostSelected(page);
@@ -614,25 +649,25 @@ export const createAgentInRepo = async (
};
export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => {
const promptText = page.getByTestId('permission-request-question').first();
const promptText = page.getByTestId("permission-request-question").first();
await expect(promptText).toBeVisible({ timeout });
};
export const allowPermission = async (page: Page) => {
const acceptButton = page.getByTestId('permission-request-accept').first();
const acceptButton = page.getByTestId("permission-request-accept").first();
await expect(acceptButton).toBeVisible({ timeout: 5000 });
await acceptButton.click();
};
export const denyPermission = async (page: Page) => {
const denyButton = page.getByTestId('permission-request-deny').first();
const denyButton = page.getByTestId("permission-request-deny").first();
await expect(denyButton).toBeVisible({ timeout: 5000 });
await denyButton.click();
};
export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
// Wait for the stop button to disappear
const stopButton = page.getByRole('button', { name: /stop|cancel/i });
const stopButton = page.getByRole("button", { name: /stop|cancel/i });
// First, let's debug what's happening - wait a bit to see the state
await page.waitForTimeout(2000);
@@ -649,9 +684,11 @@ export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
const toolCallResult = page.getByText(/permission.*denied|denied|blocked/i);
// Wait for the tool call result to appear
await expect(toolCallResult).toBeVisible({ timeout: 10000 }).catch(() => {
// If no specific message, just wait for the button to disappear
});
await expect(toolCallResult)
.toBeVisible({ timeout: 10000 })
.catch(() => {
// If no specific message, just wait for the button to disappear
});
// Now wait for the stop button to disappear
await expect(stopButton).not.toBeVisible({ timeout });
+1 -1
View File
@@ -38,7 +38,7 @@ export async function ensureWorkspaceAgentPaneVisible(page: Page): Promise<void>
export async function sampleWorkspaceTabIds(
page: Page,
options: { durationMs?: number; intervalMs?: number } = {}
options: { durationMs?: number; intervalMs?: number } = {},
): Promise<string[][]> {
const durationMs = options.durationMs ?? 2_500;
const intervalMs = options.intervalMs ?? 50;
+17 -17
View File
@@ -1,38 +1,38 @@
import { expect, type Page } from '@playwright/test';
import { buildHostWorkspaceRoute } from '@/utils/host-routes';
import { gotoHome } from './app';
import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { gotoHome } from "./app";
export async function openNewAgentComposer(page: Page): Promise<void> {
await gotoHome(page);
}
export function workspaceLabelFromPath(value: string): string {
const normalized = value.replace(/\\/g, '/').replace(/\/+$/, '');
const parts = normalized.split('/').filter(Boolean);
const normalized = value.replace(/\\/g, "/").replace(/\/+$/, "");
const parts = normalized.split("/").filter(Boolean);
return parts[parts.length - 1] ?? normalized;
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function candidateWorkspaceIds(inputPath: string): string[] {
const trimmed = inputPath.replace(/\/+$/, '');
const trimmed = inputPath.replace(/\/+$/, "");
const candidates = new Set<string>([trimmed]);
if (trimmed.startsWith('/var/')) {
if (trimmed.startsWith("/var/")) {
candidates.add(`/private${trimmed}`);
}
if (trimmed.startsWith('/private/var/')) {
candidates.add(trimmed.replace(/^\/private/, ''));
if (trimmed.startsWith("/private/var/")) {
candidates.add(trimmed.replace(/^\/private/, ""));
}
return Array.from(candidates);
}
function workspaceRowLocator(page: Page, serverId: string, workspacePath: string) {
const ids = candidateWorkspaceIds(workspacePath).map(
(id) => `[data-testid="sidebar-workspace-row-${serverId}:${id}"]`
(id) => `[data-testid="sidebar-workspace-row-${serverId}:${id}"]`,
);
return page.locator(ids.join(',')).first();
return page.locator(ids.join(",")).first();
}
export async function switchWorkspaceViaSidebar(input: {
@@ -52,10 +52,10 @@ export async function switchWorkspaceViaSidebar(input: {
export async function expectWorkspaceHeader(
page: Page,
input: { title: string; subtitle: string }
input: { title: string; subtitle: string },
): Promise<void> {
const titleLocator = page.getByTestId('workspace-header-title');
const subtitleLocator = page.getByTestId('workspace-header-subtitle');
const titleLocator = page.getByTestId("workspace-header-title");
const subtitleLocator = page.getByTestId("workspace-header-subtitle");
await expect(titleLocator.first()).toHaveText(input.title, {
timeout: 30_000,
@@ -66,9 +66,9 @@ export async function expectWorkspaceHeader(
}
export async function seedWorkspaceActivity(page: Page, marker: string): Promise<void> {
const input = page.getByRole('textbox', { name: 'Message agent...' });
const input = page.getByRole("textbox", { name: "Message agent..." });
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill(marker);
await input.press('Enter');
await input.press("Enter");
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
}
+18 -18
View File
@@ -1,7 +1,7 @@
import { execSync } from 'node:child_process';
import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { execSync } from "node:child_process";
import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
type TempRepo = {
path: string;
@@ -9,29 +9,29 @@ type TempRepo = {
};
export const createTempGitRepo = async (
prefix = 'paseo-e2e-',
options?: { withRemote?: boolean }
prefix = "paseo-e2e-",
options?: { withRemote?: boolean },
): Promise<TempRepo> => {
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
const tempRoot = process.platform === 'win32' ? tmpdir() : '/tmp';
const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp";
const repoPath = await mkdtemp(path.join(tempRoot, prefix));
const withRemote = options?.withRemote ?? false;
execSync('git init -b main', { cwd: repoPath, stdio: 'ignore' });
execSync('git config user.email "e2e@paseo.test"', { cwd: repoPath, stdio: 'ignore' });
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: 'ignore' });
execSync('git config commit.gpgsign false', { cwd: repoPath, stdio: 'ignore' });
await writeFile(path.join(repoPath, 'README.md'), '# Temp Repo\n');
execSync('git add README.md', { cwd: repoPath, stdio: 'ignore' });
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: 'ignore' });
execSync("git init -b main", { cwd: repoPath, stdio: "ignore" });
execSync('git config user.email "e2e@paseo.test"', { cwd: repoPath, stdio: "ignore" });
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" });
execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" });
await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n");
execSync("git add README.md", { cwd: repoPath, stdio: "ignore" });
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
if (withRemote) {
// Deterministic local remote to avoid relying on external auth/network in e2e.
const remoteDir = path.join(repoPath, 'remote.git');
const remoteDir = path.join(repoPath, "remote.git");
await mkdir(remoteDir, { recursive: true });
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: 'ignore' });
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: 'ignore' });
execSync('git push -u origin main', { cwd: repoPath, stdio: 'ignore' });
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
execSync("git push -u origin main", { cwd: repoPath, stdio: "ignore" });
}
return {
+3 -3
View File
@@ -1,10 +1,10 @@
// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require('eslint/config');
const expoConfig = require('eslint-config-expo/flat');
const { defineConfig } = require("eslint/config");
const expoConfig = require("eslint-config-expo/flat");
module.exports = defineConfig([
expoConfig,
{
ignores: ['dist/*'],
ignores: ["dist/*"],
},
]);
+3 -18
View File
@@ -6,7 +6,7 @@
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"build:workspace-deps": "npm run build --prefix ../expo-two-way-audio",
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --prefix ../expo-two-way-audio",
"eas-build-post-install": "npm run build:workspace-deps",
"android": "npm run android:development",
"android:development": "APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
@@ -22,7 +22,7 @@
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"build": "npm run build:web",
"build:web": "expo export --platform web",
"build:web": "npm run build:workspace-deps && expo export --platform web",
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main"
},
"dependencies": {
@@ -32,24 +32,10 @@
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.32",
"@getpaseo/highlight": "*",
"@getpaseo/server": "0.1.32",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
"@lezer/css": "^1.3.0",
"@lezer/go": "^1.0.1",
"@lezer/highlight": "^1.2.3",
"@lezer/html": "^1.3.13",
"@lezer/java": "^1.1.3",
"@lezer/javascript": "^1.5.4",
"@lezer/json": "^1.0.3",
"@lezer/markdown": "^1.6.2",
"@lezer/php": "^1.0.5",
"@lezer/python": "^1.1.18",
"@lezer/rust": "^1.0.2",
"@lezer/xml": "^1.0.6",
"@lezer/yaml": "^1.0.4",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native/normalize-colors": "^0.81.5",
@@ -88,7 +74,6 @@
"expo-system-ui": "~6.0.7",
"expo-updates": "~29.0.12",
"expo-web-browser": "~15.0.8",
"lezer-elixir": "^1.1.2",
"lucide-react-native": "^0.546.0",
"mnemonic-id": "^3.2.7",
"react": "19.1.4",
+11 -10
View File
@@ -1,12 +1,13 @@
import { defineConfig, devices } from '@playwright/test';
import { defineConfig, devices } from "@playwright/test";
// E2E_METRO_PORT is set dynamically by global-setup.ts after finding a free port
// This allows multiple test runs in parallel across different worktrees
const baseURL = process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? '8081'}`;
const baseURL =
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
export default defineConfig({
testDir: './e2e',
globalSetup: './e2e/global-setup.ts',
testDir: "./e2e",
globalSetup: "./e2e/global-setup.ts",
timeout: 60_000,
expect: {
timeout: 10_000,
@@ -16,17 +17,17 @@ export default defineConfig({
fullyParallel: false,
workers: 1,
retries: process.env.CI ? 1 : 0,
reporter: [['list']],
reporter: [["list"]],
use: {
baseURL,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{
name: 'Desktop Chrome',
use: { ...devices['Desktop Chrome'] },
name: "Desktop Chrome",
use: { ...devices["Desktop Chrome"] },
},
],
// Note: Metro is started by global-setup.ts on a dynamic port to allow parallel test runs
+1 -2
View File
@@ -1,8 +1,7 @@
import { defineConfig, devices } from "@playwright/test";
const baseURL =
process.env.E2E_BASE_URL ??
`http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
export default defineConfig({
testDir: "./e2e",
+11 -11
View File
@@ -1,11 +1,11 @@
import { defineConfig, devices } from '@playwright/test'
import { defineConfig, devices } from "@playwright/test";
const baseURL =
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? '8081'}`
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
export default defineConfig({
testDir: './e2e',
globalSetup: './e2e/global-setup.ts',
testDir: "./e2e",
globalSetup: "./e2e/global-setup.ts",
timeout: 60_000,
expect: {
timeout: 10_000,
@@ -13,17 +13,17 @@ export default defineConfig({
fullyParallel: false,
workers: 1,
retries: 0,
reporter: [['list']],
reporter: [["list"]],
use: {
baseURL,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{
name: 'Desktop Safari',
use: { ...devices['Desktop Safari'] },
name: "Desktop Safari",
use: { ...devices["Desktop Safari"] },
},
],
})
});
+2 -2
View File
@@ -91,7 +91,7 @@ const moveDirectories = async (userInput) => {
userInput === "y"
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
: ""
}`
}`,
);
} catch (error) {
console.error(`❌ Error during script execution: ${error.message}`);
@@ -108,5 +108,5 @@ rl.question(
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
rl.close();
}
}
},
);
+19 -19
View File
@@ -1,36 +1,36 @@
export const WELCOME_ROUTE = '/welcome'
export const WELCOME_ROUTE = "/welcome";
export function shouldWaitOnStartupRace(input: {
onlineServerId: string | null
hasTimedOut: boolean
isDesktopStartupRace: boolean
daemonCount: number
pathname: string
onlineServerId: string | null;
hasTimedOut: boolean;
isDesktopStartupRace: boolean;
daemonCount: number;
pathname: string;
}): boolean {
if (input.onlineServerId) {
return false
return false;
}
if (input.pathname === WELCOME_ROUTE) {
return false
return false;
}
if (input.hasTimedOut) {
return false
return false;
}
return input.isDesktopStartupRace || input.daemonCount > 0
return input.isDesktopStartupRace || input.daemonCount > 0;
}
export function shouldRedirectToWelcome(input: {
onlineServerId: string | null
hasTimedOut: boolean
pathname: string
isDesktopStartupRace: boolean
daemonCount: number
onlineServerId: string | null;
hasTimedOut: boolean;
pathname: string;
isDesktopStartupRace: boolean;
daemonCount: number;
}): boolean {
if (input.onlineServerId || !input.hasTimedOut) {
return false
return false;
}
if (input.pathname !== '/' && input.pathname !== '') {
return false
if (input.pathname !== "/" && input.pathname !== "") {
return false;
}
return input.isDesktopStartupRace || input.daemonCount > 0
return input.isDesktopStartupRace || input.daemonCount > 0;
}
+21 -48
View File
@@ -99,10 +99,10 @@ function PushNotificationRouter() {
"data" in payload &&
typeof (payload as { data?: unknown }).data === "object" &&
(payload as { data?: unknown }).data !== null
? ((payload as { data: Record<string, unknown> }).data)
? (payload as { data: Record<string, unknown> }).data
: undefined;
router.push(buildNotificationRoute(data) as any);
}
},
);
void Promise.resolve(unlistenResult).then((unlisten) => {
@@ -124,18 +124,12 @@ function PushNotificationRouter() {
router.push(buildNotificationRoute(customEvent.detail?.data) as any);
};
target.addEventListener(
WEB_NOTIFICATION_CLICK_EVENT,
openFromWebClick as EventListener
);
target.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
return () => {
cancelled = true;
removeDesktopNotificationListener?.();
target.removeEventListener(
WEB_NOTIFICATION_CLICK_EVENT,
openFromWebClick as EventListener
);
target.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
};
}
@@ -163,8 +157,7 @@ function PushNotificationRouter() {
router.push(buildNotificationRoute(data) as any);
};
const subscription =
Notifications.addNotificationResponseReceivedListener(openFromResponse);
const subscription = Notifications.addNotificationResponseReceivedListener(openFromResponse);
void Notifications.getLastNotificationResponseAsync().then((response) => {
if (response) {
@@ -188,11 +181,7 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
}
return (
<SessionProvider
key={daemon.serverId}
serverId={daemon.serverId}
client={client}
>
<SessionProvider key={daemon.serverId} serverId={daemon.serverId} client={client}>
{null}
</SessionProvider>
);
@@ -276,8 +265,7 @@ function AppContainer({
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
useKeyboardShortcuts({
@@ -290,16 +278,14 @@ function AppContainer({
const containerStyle = useMemo(
() => ({ flex: 1 as const, backgroundColor: theme.colors.surface0 }),
[theme.colors.surface0]
[theme.colors.surface0],
);
const content = (
<View style={containerStyle}>
<View style={rowStyle}>
{!isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<View style={flexStyle}>
{children}
</View>
<View style={flexStyle}>{children}</View>
</View>
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast />
@@ -314,11 +300,7 @@ function AppContainer({
return content;
}
return (
<MobileGestureWrapper chromeEnabled={chromeEnabled}>
{content}
</MobileGestureWrapper>
);
return <MobileGestureWrapper chromeEnabled={chromeEnabled}>{content}</MobileGestureWrapper>;
}
function MobileGestureWrapper({
@@ -331,14 +313,8 @@ function MobileGestureWrapper({
const mobileView = usePanelStore((state) => state.mobileView);
const openAgentList = usePanelStore((state) => state.openAgentList);
const horizontalScroll = useHorizontalScrollOptional();
const {
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
} = useSidebarAnimation();
const { translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing } =
useSidebarAnimation();
const touchStartX = useSharedValue(0);
const openGestureEnabled = chromeEnabled && mobileView === "agent";
@@ -379,7 +355,7 @@ function MobileGestureWrapper({
newTranslateX,
[-windowWidth, 0],
[0, 1],
Extrapolation.CLAMP
Extrapolation.CLAMP,
);
})
.onEnd((event) => {
@@ -406,7 +382,7 @@ function MobileGestureWrapper({
isGesturing,
horizontalScroll?.isAnyScrolledRight,
touchStartX,
]
],
);
return (
@@ -472,7 +448,9 @@ function OfferLinkListener({
});
};
void Linking.getInitialURL().then(handleUrl).catch(() => undefined);
void Linking.getInitialURL()
.then(handleUrl)
.catch(() => undefined);
const subscription = Linking.addEventListener("url", (event) => {
handleUrl(event.url);
@@ -609,9 +587,7 @@ function MissingDaemonView() {
export default function RootLayout() {
return (
<GestureHandlerRootView
style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}
>
<GestureHandlerRootView style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}>
<NavigationActiveWorkspaceObserver />
<PortalProvider>
<SafeAreaProvider>
@@ -636,16 +612,13 @@ export default function RootLayout() {
>
<Stack.Screen name="index" />
<Stack.Screen name="settings" />
<Stack.Screen
name="h/[serverId]/workspace/[workspaceId]"
options={{ freezeOnBlur: true }}
/>
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen
name="h/[serverId]/agent/[agentId]"
options={{ gestureEnabled: false, freezeOnBlur: true }}
options={{ gestureEnabled: false }}
/>
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/agents" />
<Stack.Screen name="h/[serverId]/sessions" />
<Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="pair-scan" />
@@ -2,9 +2,7 @@ import { useEffect, useRef } from "react";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import {
buildHostRootRoute,
} from "@/utils/host-routes";
import { buildHostRootRoute } from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
export default function HostAgentReadyRoute() {
@@ -43,7 +41,7 @@ export default function HostAgentReadyRoute() {
serverId,
workspaceId: normalizedCwd,
target: { kind: "agent", agentId },
}) as any
}) as any,
);
}
}, [agentCwd, agentId, router, serverId]);
@@ -87,7 +85,7 @@ export default function HostAgentReadyRoute() {
serverId,
workspaceId: cwd,
target: { kind: "agent", agentId },
}) as any
}) as any,
);
return;
}
+8 -17
View File
@@ -17,11 +17,11 @@ export default function HostIndexRoute() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
const { isLoading: preferencesLoading } = useFormPreferences();
const sessionAgents = useSessionStore(
(state) => (serverId ? state.sessions[serverId]?.agents : undefined)
const sessionAgents = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.agents : undefined,
);
const sessionWorkspaces = useSessionStore(
(state) => (serverId ? state.sessions[serverId]?.workspaces : undefined)
const sessionWorkspaces = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.workspaces : undefined,
);
useEffect(() => {
@@ -44,12 +44,10 @@ export default function HostIndexRoute() {
? Array.from(sessionAgents.values()).filter((agent) => !agent.archivedAt)
: [];
visibleAgents.sort(
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime()
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime(),
);
const visibleWorkspaces = sessionWorkspaces
? Array.from(sessionWorkspaces.values())
: [];
const visibleWorkspaces = sessionWorkspaces ? Array.from(sessionWorkspaces.values()) : [];
visibleWorkspaces.sort((left, right) => {
const leftTime = left.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
const rightTime = right.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
@@ -63,7 +61,7 @@ export default function HostIndexRoute() {
serverId,
workspaceId: primaryAgent.cwd.trim(),
target: { kind: "agent", agentId: primaryAgent.id },
}) as any
}) as any,
);
return;
}
@@ -78,14 +76,7 @@ export default function HostIndexRoute() {
}, HOST_ROOT_REDIRECT_DELAY_MS);
return () => clearTimeout(timer);
}, [
pathname,
preferencesLoading,
router,
serverId,
sessionAgents,
sessionWorkspaces,
]);
}, [pathname, preferencesLoading, router, serverId, sessionAgents, sessionWorkspaces]);
return null;
}
@@ -1,9 +1,9 @@
import { useLocalSearchParams } from "expo-router";
import { AgentsScreen } from "@/screens/agents-screen";
import { SessionsScreen } from "@/screens/sessions-screen";
export default function HostAgentsRoute() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
return <AgentsScreen serverId={serverId} />;
return <SessionsScreen serverId={serverId} />;
}
@@ -1,80 +1,82 @@
import { useEffect, useRef } from 'react'
import { useGlobalSearchParams, useLocalSearchParams, useRouter } from 'expo-router'
import type { WorkspaceTabTarget } from '@/stores/workspace-tabs-store'
import { WorkspaceScreen } from '@/screens/workspace/workspace-screen'
import { useEffect, useRef } from "react";
import { useGlobalSearchParams, useLocalSearchParams, useRouter } from "expo-router";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import {
buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment,
parseWorkspaceOpenIntent,
type WorkspaceOpenIntent,
} from '@/utils/host-routes'
import { prepareWorkspaceTab } from '@/utils/workspace-navigation'
} from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
function getParamValue(value: string | string[] | undefined): string {
if (typeof value === 'string') {
return value.trim()
if (typeof value === "string") {
return value.trim();
}
if (Array.isArray(value)) {
const firstValue = value[0]
return typeof firstValue === 'string' ? firstValue.trim() : ''
const firstValue = value[0];
return typeof firstValue === "string" ? firstValue.trim() : "";
}
return ''
return "";
}
function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarget {
if (openIntent.kind === 'agent') {
return { kind: 'agent', agentId: openIntent.agentId }
if (openIntent.kind === "agent") {
return { kind: "agent", agentId: openIntent.agentId };
}
if (openIntent.kind === 'terminal') {
return { kind: 'terminal', terminalId: openIntent.terminalId }
if (openIntent.kind === "terminal") {
return { kind: "terminal", terminalId: openIntent.terminalId };
}
if (openIntent.kind === 'file') {
return { kind: 'file', path: openIntent.path }
if (openIntent.kind === "file") {
return { kind: "file", path: openIntent.path };
}
return { kind: 'draft', draftId: openIntent.draftId }
return { kind: "draft", draftId: openIntent.draftId };
}
export default function HostWorkspaceLayout() {
const router = useRouter()
const consumedIntentRef = useRef<string | null>(null)
const router = useRouter();
const consumedIntentRef = useRef<string | null>(null);
const params = useLocalSearchParams<{
serverId?: string | string[]
workspaceId?: string | string[]
}>()
serverId?: string | string[];
workspaceId?: string | string[];
}>();
const globalParams = useGlobalSearchParams<{
open?: string | string[]
}>()
const serverId = getParamValue(params.serverId)
const workspaceValue = getParamValue(params.workspaceId)
const workspaceId = workspaceValue ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? '') : ''
const openValue = getParamValue(globalParams.open)
open?: string | string[];
}>();
const serverId = getParamValue(params.serverId);
const workspaceValue = getParamValue(params.workspaceId);
const workspaceId = workspaceValue
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: "";
const openValue = getParamValue(globalParams.open);
useEffect(() => {
if (!openValue) {
return
return;
}
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
if (consumedIntentRef.current === consumptionKey) {
return
return;
}
consumedIntentRef.current = consumptionKey
consumedIntentRef.current = consumptionKey;
const openIntent = parseWorkspaceOpenIntent(openValue)
const openIntent = parseWorkspaceOpenIntent(openValue);
const route = openIntent
? prepareWorkspaceTab({
serverId,
workspaceId,
target: getOpenIntentTarget(openIntent),
pin: openIntent.kind === 'agent',
pin: openIntent.kind === "agent",
})
: buildHostWorkspaceRoute(serverId, workspaceId)
: buildHostWorkspaceRoute(serverId, workspaceId);
router.replace(route as any)
}, [openValue, router, serverId, workspaceId])
router.replace(route as any);
}, [openValue, router, serverId, workspaceId]);
if (openValue) {
return null
return null;
}
return (
@@ -83,5 +85,5 @@ export default function HostWorkspaceLayout() {
serverId={serverId}
workspaceId={workspaceId}
/>
)
);
}
+51 -51
View File
@@ -1,82 +1,82 @@
import { useEffect, useSyncExternalStore, useState } from 'react'
import { usePathname, useRouter } from 'expo-router'
import { useHosts } from '@/runtime/host-runtime'
import { shouldUseDesktopDaemon } from '@/desktop/daemon/desktop-daemon'
import { buildHostRootRoute } from '@/utils/host-routes'
import { StartupSplashScreen } from '@/screens/startup-splash-screen'
import { WelcomeScreen } from '@/components/welcome-screen'
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
import { useEffect, useSyncExternalStore, useState } from "react";
import { usePathname, useRouter } from "expo-router";
import { useHosts } from "@/runtime/host-runtime";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { buildHostRootRoute } from "@/utils/host-routes";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { WelcomeScreen } from "@/components/welcome-screen";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import {
shouldRedirectToWelcome,
shouldWaitOnStartupRace,
WELCOME_ROUTE,
} from '@/app-support/index-startup'
} from "@/app-support/index-startup";
const STARTUP_TIMEOUT_MS = 30_000
const STARTUP_TIMEOUT_MS = 30_000;
function useAnyHostOnline(serverIds: string[]): string | null {
const runtime = getHostRuntimeStore()
const runtime = getHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => {
let firstOnlineServerId: string | null = null
let firstOnlineAt: string | null = null
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId)
const lastOnlineAt = snapshot?.lastOnlineAt ?? null
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt
firstOnlineServerId = serverId
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId
return firstOnlineServerId;
},
() => {
let firstOnlineServerId: string | null = null
let firstOnlineAt: string | null = null
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId)
const lastOnlineAt = snapshot?.lastOnlineAt ?? null
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt
firstOnlineServerId = serverId
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId
}
)
return firstOnlineServerId;
},
);
}
export default function Index() {
const router = useRouter()
const pathname = usePathname()
const daemons = useHosts()
const [hasTimedOut, setHasTimedOut] = useState(false)
const isDesktopStartupRace = shouldUseDesktopDaemon()
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId))
const router = useRouter();
const pathname = usePathname();
const daemons = useHosts();
const [hasTimedOut, setHasTimedOut] = useState(false);
const isDesktopStartupRace = shouldUseDesktopDaemon();
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId));
useEffect(() => {
const timer = setTimeout(() => {
setHasTimedOut(true)
}, STARTUP_TIMEOUT_MS)
setHasTimedOut(true);
}, STARTUP_TIMEOUT_MS);
return () => {
clearTimeout(timer)
}
}, [])
clearTimeout(timer);
};
}, []);
useEffect(() => {
if (!onlineServerId) {
return
return;
}
if (pathname !== '/' && pathname !== '') {
return
if (pathname !== "/" && pathname !== "") {
return;
}
router.replace(buildHostRootRoute(onlineServerId) as any)
}, [onlineServerId, pathname, router])
router.replace(buildHostRootRoute(onlineServerId) as any);
}, [onlineServerId, pathname, router]);
useEffect(() => {
if (
@@ -88,10 +88,10 @@ export default function Index() {
daemonCount: daemons.length,
})
) {
return
return;
}
router.replace(WELCOME_ROUTE as any)
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router])
router.replace(WELCOME_ROUTE as any);
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router]);
if (
shouldWaitOnStartupRace({
@@ -102,12 +102,12 @@ export default function Index() {
pathname,
})
) {
return <StartupSplashScreen />
return <StartupSplashScreen />;
}
if (!onlineServerId) {
return <WelcomeScreen />
return <WelcomeScreen />;
}
return null
return null;
}
+21 -20
View File
@@ -11,10 +11,7 @@ import { NameHostModal } from "@/components/name-host-modal";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
import {
buildHostRootRoute,
buildHostSettingsRoute,
} from "@/utils/host-routes";
import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes";
const styles = StyleSheet.create((theme) => ({
container: {
@@ -148,8 +145,7 @@ export default function PairScanScreen() {
targetServerId?: string;
}>();
const source = typeof params.source === "string" ? params.source : "settings";
const sourceServerId =
typeof params.sourceServerId === "string" ? params.sourceServerId : null;
const sourceServerId = typeof params.sourceServerId === "string" ? params.sourceServerId : null;
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
const daemons = useHosts();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl, renameHost } = useHostMutations();
@@ -157,15 +153,22 @@ export default function PairScanScreen() {
const [permission, requestPermission] = useCameraPermissions();
const [isPairing, setIsPairing] = useState(false);
const lastScannedRef = useRef<string | null>(null);
const [pendingNameHost, setPendingNameHost] = useState<{ serverId: string; hostname: string | null } | null>(null);
const [pendingNameHost, setPendingNameHost] = useState<{
serverId: string;
hostname: string | null;
} | null>(null);
const pendingNameHostname = useSessionStore(
useCallback(
(state) => {
if (!pendingNameHost) return null;
return state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ?? pendingNameHost.hostname ?? null;
return (
state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ??
pendingNameHost.hostname ??
null
);
},
[pendingNameHost]
)
[pendingNameHost],
),
);
const returnToSource = useCallback(
@@ -190,7 +193,7 @@ export default function PairScanScreen() {
router.replace(buildHostSettingsRoute(settingsServerId) as any);
}
},
[router, source, sourceServerId, targetServerId]
[router, source, sourceServerId, targetServerId],
);
const closeToSource = useCallback(() => {
@@ -238,7 +241,10 @@ export default function PairScanScreen() {
if (targetServerId && offer.serverId !== targetServerId) {
lastScannedRef.current = null;
Alert.alert("Wrong daemon", `That QR code belongs to ${offer.serverId}, not ${targetServerId}.`);
Alert.alert(
"Wrong daemon",
`That QR code belongs to ${offer.serverId}, not ${targetServerId}.`,
);
return;
}
@@ -270,7 +276,7 @@ export default function PairScanScreen() {
setIsPairing(false);
}
},
[daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl]
[daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
);
if (Platform.OS === "web") {
@@ -334,10 +340,7 @@ export default function PairScanScreen() {
<Text style={styles.permissionBody}>
Allow camera access to scan the pairing QR code from your daemon.
</Text>
<Pressable
style={styles.permissionButton}
onPress={() => void requestPermission()}
>
<Pressable style={styles.permissionButton} onPress={() => void requestPermission()}>
<Text style={styles.permissionButtonText}>Grant permission</Text>
</Pressable>
</View>
@@ -356,9 +359,7 @@ export default function PairScanScreen() {
<View style={[styles.corner, styles.cornerBL]} />
<View style={[styles.corner, styles.cornerBR]} />
</View>
<Text style={styles.helperText}>
Point your camera at the pairing QR code.
</Text>
<Text style={styles.helperText}>Point your camera at the pairing QR code.</Text>
{isPairing ? (
<Text style={[styles.helperText, { color: theme.colors.foreground }]}>
Pairing…
+1 -3
View File
@@ -18,9 +18,7 @@ export default function LegacySettingsRoute() {
return null;
}
if (preferences.serverId) {
const match = daemons.find(
(daemon) => daemon.serverId === preferences.serverId
);
const match = daemons.find((daemon) => daemon.serverId === preferences.serverId);
if (match) {
return match.serverId;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { WelcomeScreen } from '@/components/welcome-screen'
import { WelcomeScreen } from "@/components/welcome-screen";
export default function WelcomeRoute() {
return <WelcomeScreen />
return <WelcomeScreen />;
}
@@ -86,10 +86,7 @@ export function createLocalFileAttachmentStore(params: {
storageType: Extract<AttachmentStorageType, "desktop-file" | "native-file">;
baseDirectoryName: string;
resolvePreviewUrl: (attachment: AttachmentMetadata) => Promise<string>;
releasePreviewUrl?: (input: {
attachment: AttachmentMetadata;
url: string;
}) => Promise<void>;
releasePreviewUrl?: (input: { attachment: AttachmentMetadata; url: string }) => Promise<void>;
}): AttachmentStore {
const baseDirectory = FileSystem.cacheDirectory
? `${FileSystem.cacheDirectory}${params.baseDirectoryName}/`
@@ -201,7 +198,7 @@ export function createLocalFileAttachmentStore(params: {
await FileSystem.deleteAsync(`${baseDirectory}${entryName}`, {
idempotent: true,
});
})
}),
);
},
};
+6 -8
View File
@@ -47,7 +47,7 @@ export async function persistAttachmentFromFileUri(input: {
}
export async function encodeAttachmentsForSend(
attachments: readonly AttachmentMetadata[] | undefined
attachments: readonly AttachmentMetadata[] | undefined,
): Promise<Array<{ data: string; mimeType: string }> | undefined> {
if (!attachments || attachments.length === 0) {
return undefined;
@@ -69,18 +69,16 @@ export async function encodeAttachmentsForSend(
});
return null;
}
})
}),
);
const valid = encoded.filter(
(entry): entry is { data: string; mimeType: string } => entry !== null
(entry): entry is { data: string; mimeType: string } => entry !== null,
);
return valid.length > 0 ? valid : undefined;
}
export async function resolveAttachmentPreviewUrl(
attachment: AttachmentMetadata
): Promise<string> {
export async function resolveAttachmentPreviewUrl(attachment: AttachmentMetadata): Promise<string> {
const store = await getAttachmentStore();
return await store.resolvePreviewUrl({ attachment });
}
@@ -97,7 +95,7 @@ export async function releaseAttachmentPreviewUrl(input: {
}
export async function deleteAttachments(
attachments: readonly AttachmentMetadata[] | undefined
attachments: readonly AttachmentMetadata[] | undefined,
): Promise<void> {
if (!attachments || attachments.length === 0) {
return;
@@ -113,7 +111,7 @@ export async function deleteAttachments(
error,
});
}
})
}),
);
}
+2 -6
View File
@@ -13,15 +13,11 @@ async function createAttachmentStore(): Promise<AttachmentStore> {
return createDesktopAttachmentStore();
}
const { createIndexedDbAttachmentStore } = await import(
"./web/indexeddb-attachment-store"
);
const { createIndexedDbAttachmentStore } = await import("./web/indexeddb-attachment-store");
return createIndexedDbAttachmentStore();
}
const { createNativeFileAttachmentStore } = await import(
"./native/native-file-attachment-store"
);
const { createNativeFileAttachmentStore } = await import("./native/native-file-attachment-store");
return createNativeFileAttachmentStore();
}
@@ -1,12 +1,9 @@
import { useEffect, useRef, useState } from "react";
import type { AttachmentMetadata } from "@/attachments/types";
import {
releaseAttachmentPreviewUrl,
resolveAttachmentPreviewUrl,
} from "@/attachments/service";
import { releaseAttachmentPreviewUrl, resolveAttachmentPreviewUrl } from "@/attachments/service";
export function useAttachmentPreviewUrl(
attachment: AttachmentMetadata | null | undefined
attachment: AttachmentMetadata | null | undefined,
): string | null {
const [url, setUrl] = useState<string | null>(null);
const activeAttachmentRef = useRef<AttachmentMetadata | null>(null);
@@ -52,12 +49,7 @@ export function useAttachmentPreviewUrl(
url: currentUrl,
});
};
}, [
attachment?.id,
attachment?.storageType,
attachment?.storageKey,
attachment?.mimeType,
]);
}, [attachment?.id, attachment?.storageType, attachment?.storageKey, attachment?.mimeType]);
return url;
}
@@ -53,7 +53,7 @@ function openAttachmentDb(): Promise<IDBDatabase> {
function runTx<T>(
db: IDBDatabase,
mode: IDBTransactionMode,
run: (store: IDBObjectStore) => IDBRequest<T>
run: (store: IDBObjectStore) => IDBRequest<T>,
): Promise<T> {
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, mode);
@@ -78,7 +78,10 @@ async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; m
const source = input.source;
if (source.kind === "blob") {
const mimeType = normalizeMimeType(input.mimeType ?? source.blob.type);
const blob = source.blob.type === mimeType ? source.blob : source.blob.slice(0, source.blob.size, mimeType);
const blob =
source.blob.type === mimeType
? source.blob
: source.blob.slice(0, source.blob.size, mimeType);
return { blob, mimeType };
}
@@ -104,7 +107,7 @@ async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; m
async function loadBlob(db: IDBDatabase, id: string): Promise<Blob> {
const record = await runTx<StoredBlobRecord | undefined>(db, "readonly", (store) =>
store.get(id)
store.get(id),
);
if (!record?.blob) {
throw new Error(`Attachment ${id} was not found in IndexedDB.`);
@@ -125,7 +128,7 @@ export function createIndexedDbAttachmentStore(): AttachmentStore {
try {
await runTx(db, "readwrite", (store) =>
store.put({ id, blob, createdAt, fileName } satisfies StoredBlobRecord)
store.put({ id, blob, createdAt, fileName } satisfies StoredBlobRecord),
);
} finally {
db.close();
@@ -184,7 +187,9 @@ export function createIndexedDbAttachmentStore(): AttachmentStore {
const cursorRequest = store.openCursor();
cursorRequest.onerror = () => {
reject(cursorRequest.error ?? new Error("Failed to iterate IndexedDB attachment store."));
reject(
cursorRequest.error ?? new Error("Failed to iterate IndexedDB attachment store."),
);
};
cursorRequest.onsuccess = () => {
@@ -68,16 +68,16 @@ const styles = StyleSheet.create((theme) => ({
backButtonText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: '600',
textAlign: 'center',
fontWeight: "600",
textAlign: "center",
},
scrollView: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
},
processItem: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
@@ -104,7 +104,7 @@ const styles = StyleSheet.create((theme) => ({
processText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: '500',
fontWeight: "500",
},
processTextActive: {
color: theme.colors.primaryForeground,
@@ -147,20 +147,21 @@ export function ActiveProcesses({
onPress={onSelectOrchestrator}
style={({ pressed }) => [
styles.processItem,
viewMode === 'orchestrator' ? styles.processItemActive : styles.processItemInactive,
viewMode === "orchestrator" ? styles.processItemActive : styles.processItemInactive,
pressed && { opacity: 0.7 },
]}
>
<View style={styles.agentIcon} />
<Text style={[
styles.processText,
viewMode === 'orchestrator' && styles.processTextActive,
]}>Orchestrator</Text>
<Text
style={[styles.processText, viewMode === "orchestrator" && styles.processTextActive]}
>
Orchestrator
</Text>
</Pressable>
{/* Agent pills */}
{agents.map((agent) => {
const isActive = viewMode === 'agent' && activeAgentId === agent.id;
const isActive = viewMode === "agent" && activeAgentId === agent.id;
return (
<Pressable
@@ -174,15 +175,21 @@ export function ActiveProcesses({
>
<View style={styles.agentIcon} />
<Text style={[
styles.processText,
isActive && styles.processTextActive,
]}>{agent.id.substring(0, 8)}</Text>
<Text style={[styles.processText, isActive && styles.processTextActive]}>
{agent.id.substring(0, 8)}
</Text>
<View style={[styles.statusDot, { backgroundColor: getAgentStatusColor(agent.status) }]} />
<View
style={[styles.statusDot, { backgroundColor: getAgentStatusColor(agent.status) }]}
/>
{agent.currentModeId && (
<View style={[styles.modeIndicator, { backgroundColor: getModeColor(agent.currentModeId) }]} />
<View
style={[
styles.modeIndicator,
{ backgroundColor: getModeColor(agent.currentModeId) },
]}
/>
)}
</Pressable>
);
@@ -1,15 +1,7 @@
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
import type { ReactNode } from "react";
import { createPortal } from "react-dom";
import {
Modal,
Platform,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from "react-native";
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import type { TextInputProps } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
@@ -109,6 +101,7 @@ export interface AdaptiveModalSheetProps {
onClose: () => void;
children: ReactNode;
snapPoints?: string[];
stackBehavior?: "push" | "switch" | "replace";
testID?: string;
}
@@ -118,11 +111,11 @@ export function AdaptiveModalSheet({
onClose,
children,
snapPoints,
stackBehavior,
testID,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const sheetRef = useRef<BottomSheetModal>(null);
const dismissingForVisibilityRef = useRef(false);
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
@@ -148,19 +141,14 @@ export function AdaptiveModalSheet({
onClose();
}
},
[onClose]
[onClose],
);
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.45}
/>
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
),
[]
[],
);
if (isMobile) {
@@ -173,6 +161,7 @@ export function AdaptiveModalSheet({
onChange={handleSheetChange}
backdropComponent={renderBackdrop}
enablePanDownToClose
stackBehavior={stackBehavior}
backgroundComponent={SheetBackground}
handleIndicatorStyle={styles.bottomSheetHandle}
keyboardBehavior="extend"
@@ -180,11 +169,7 @@ export function AdaptiveModalSheet({
>
<View style={styles.bottomSheetHeader}>
<Text style={styles.title}>{title}</Text>
<Pressable
accessibilityLabel="Close"
style={styles.closeButton}
onPress={onClose}
>
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
@@ -209,11 +194,7 @@ export function AdaptiveModalSheet({
<View style={styles.desktopCard}>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
<Pressable
accessibilityLabel="Close"
style={styles.closeButton}
onPress={onClose}
>
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
@@ -254,13 +235,12 @@ export function AdaptiveModalSheet({
*/
export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
function AdaptiveTextInput(props, ref) {
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
if (isMobile) {
return <BottomSheetTextInput ref={ref as any} {...props} />;
}
return <TextInput ref={ref} {...props} />;
}
},
);
@@ -60,8 +60,17 @@ export function AddHostMethodModal({
}, [onPasteLink]);
return (
<AdaptiveModalSheet title="Add connection" visible={visible} onClose={onClose} testID="add-host-method-modal">
<Pressable style={styles.option} onPress={handleDirect} accessibilityLabel="Direct connection">
<AdaptiveModalSheet
title="Add connection"
visible={visible}
onClose={onClose}
testID="add-host-method-modal"
>
<Pressable
style={styles.option}
onPress={handleDirect}
accessibilityLabel="Direct connection"
>
<Link2 size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>Direct connection</Text>
@@ -79,7 +88,11 @@ export function AddHostMethodModal({
</Pressable>
) : null}
<Pressable style={styles.option} onPress={handlePaste} accessibilityLabel="Paste pairing link">
<Pressable
style={styles.option}
onPress={handlePaste}
accessibilityLabel="Paste pairing link"
>
<ClipboardPaste size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>Paste pairing link</Text>
+43 -11
View File
@@ -60,8 +60,8 @@ function formatTechnicalTransportDetails(details: Array<string | null>): string
.map((value) => normalizeTransportMessage(value))
.filter((value): value is string => Boolean(value))
.map((value) => value.trim())
.filter((value) => value.length > 0)
)
.filter((value) => value.length > 0),
),
);
if (unique.length === 0) return null;
@@ -78,7 +78,10 @@ function formatTechnicalTransportDetails(details: Array<string | null>): string
return unique.join(" — ");
}
function buildConnectionFailureCopy(endpoint: string, error: unknown): { title: string; detail: string | null; raw: string | null } {
function buildConnectionFailureCopy(
endpoint: string,
error: unknown,
): { title: string; detail: string | null; raw: string | null } {
const title = `We failed to connect to ${endpoint}.`;
const raw = (() => {
@@ -109,8 +112,13 @@ function buildConnectionFailureCopy(endpoint: string, error: unknown): { title:
detail = "Host not found. Check the hostname and try again.";
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
detail = "Host is unreachable. Check your network and firewall.";
} else if (rawLower.includes("certificate") || rawLower.includes("tls") || rawLower.includes("ssl")) {
detail = "TLS error. Direct connections use an unencrypted local connection. Use relay for remote access.";
} else if (
rawLower.includes("certificate") ||
rawLower.includes("tls") ||
rawLower.includes("ssl")
) {
detail =
"TLS error. Direct connections use an unencrypted local connection. Use relay for remote access.";
} else if (raw) {
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
} else {
@@ -125,15 +133,25 @@ export interface AddHostModalProps {
onClose: () => void;
targetServerId?: string;
onCancel?: () => void;
onSaved?: (result: { profile: HostProfile; serverId: string; hostname: string | null; isNewHost: boolean }) => void;
onSaved?: (result: {
profile: HostProfile;
serverId: string;
hostname: string | null;
isNewHost: boolean;
}) => void;
}
export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServerId }: AddHostModalProps) {
export function AddHostModal({
visible,
onClose,
onCancel,
onSaved,
targetServerId,
}: AddHostModalProps) {
const { theme } = useUnistyles();
const daemons = useHosts();
const { upsertDirectConnection } = useHostMutations();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const hostInputRef = useRef<TextInput>(null);
@@ -220,10 +238,24 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServer
} finally {
setIsSaving(false);
}
}, [daemons, endpointRaw, handleClose, isMobile, isSaving, onSaved, targetServerId, upsertDirectConnection]);
}, [
daemons,
endpointRaw,
handleClose,
isMobile,
isSaving,
onSaved,
targetServerId,
upsertDirectConnection,
]);
return (
<AdaptiveModalSheet title="Direct connection" visible={visible} onClose={handleClose} testID="add-host-modal">
<AdaptiveModalSheet
title="Direct connection"
visible={visible}
onClose={handleClose}
testID="add-host-modal"
>
<Text style={styles.helper}>Enter the address of a Paseo server.</Text>
<View style={styles.field}>
+81 -111
View File
@@ -1,76 +1,77 @@
import { useState } from 'react';
import { View, Text, Pressable } from 'react-native';
import { StyleSheet } from 'react-native-unistyles';
import { useState } from "react";
import { View, Text, Pressable } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import type { AgentActivity, GroupedTextMessage, MergedToolCall, SessionUpdate } from '@/types/agent-activity';
import type {
AgentActivity,
GroupedTextMessage,
MergedToolCall,
SessionUpdate,
} from "@/types/agent-activity";
interface AgentActivityItemProps {
item: GroupedTextMessage | MergedToolCall | AgentActivity;
}
function formatTimestamp(date: Date): string {
return new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
return new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
}).format(date);
}
function getToolIcon(toolKind?: string): string {
switch (toolKind) {
case 'read':
return '📖';
case 'edit':
return '✏️';
case 'delete':
return '🗑️';
case 'move':
return '📦';
case 'search':
return '🔍';
case 'execute':
return '▶️';
case 'think':
return '💭';
case 'fetch':
return '🌐';
case 'switch_mode':
return '🔄';
case "read":
return "📖";
case "edit":
return "✏️";
case "delete":
return "🗑️";
case "move":
return "📦";
case "search":
return "🔍";
case "execute":
return "▶️";
case "think":
return "💭";
case "fetch":
return "🌐";
case "switch_mode":
return "🔄";
default:
return '🔧';
return "🔧";
}
}
function getStatusColor(status?: string): string {
switch (status) {
case 'pending':
return '#9ca3af';
case 'in_progress':
return '#fbbf24';
case 'completed':
return '#22c55e';
case 'failed':
return '#ef4444';
case "pending":
return "#9ca3af";
case "in_progress":
return "#fbbf24";
case "completed":
return "#22c55e";
case "failed":
return "#ef4444";
default:
return '#6b7280';
return "#6b7280";
}
}
function GroupedTextItem({ item }: { item: GroupedTextMessage }) {
const isThought = item.messageType === 'thought';
const isThought = item.messageType === "thought";
return (
<View style={[stylesheet.card, isThought && stylesheet.thoughtCard]}>
<Text style={[stylesheet.timestamp, isThought && stylesheet.thoughtTimestamp]}>
{formatTimestamp(item.startTimestamp)}
</Text>
{isThought && (
<Text style={stylesheet.thoughtLabel}>💭 Thinking</Text>
)}
<Text style={[stylesheet.text, isThought && stylesheet.thoughtText]}>
{item.text}
</Text>
{isThought && <Text style={stylesheet.thoughtLabel}>💭 Thinking</Text>}
<Text style={[stylesheet.text, isThought && stylesheet.thoughtText]}>{item.text}</Text>
</View>
);
}
@@ -80,28 +81,20 @@ function MergedToolCallItem({ item }: { item: MergedToolCall }) {
return (
<View style={stylesheet.toolCard}>
<Pressable
onPress={() => setIsExpanded(!isExpanded)}
style={stylesheet.toolHeader}
>
<Pressable onPress={() => setIsExpanded(!isExpanded)} style={stylesheet.toolHeader}>
<View style={stylesheet.toolHeaderLeft}>
<Text style={stylesheet.timestamp}>
{formatTimestamp(item.startTimestamp)}
</Text>
<Text style={stylesheet.timestamp}>{formatTimestamp(item.startTimestamp)}</Text>
<View style={stylesheet.toolTitleRow}>
<Text style={stylesheet.toolIcon}>{getToolIcon(item.toolKind)}</Text>
<Text style={stylesheet.toolTitle}>{item.title}</Text>
<View
style={[
stylesheet.statusBadge,
{ backgroundColor: getStatusColor(item.status) },
]}
style={[stylesheet.statusBadge, { backgroundColor: getStatusColor(item.status) }]}
>
<Text style={stylesheet.statusText}>{item.status}</Text>
</View>
</View>
</View>
<Text style={stylesheet.expandIcon}>{isExpanded ? '▼' : '▶'}</Text>
<Text style={stylesheet.expandIcon}>{isExpanded ? "▼" : "▶"}</Text>
</Pressable>
{isExpanded && (
@@ -109,23 +102,17 @@ function MergedToolCallItem({ item }: { item: MergedToolCall }) {
{item.input && (
<View style={stylesheet.section}>
<Text style={stylesheet.sectionTitle}>Input:</Text>
<Text style={stylesheet.code}>
{JSON.stringify(item.input, null, 2)}
</Text>
<Text style={stylesheet.code}>{JSON.stringify(item.input, null, 2)}</Text>
</View>
)}
{item.output && (
<View style={stylesheet.section}>
<Text style={stylesheet.sectionTitle}>Output:</Text>
<Text style={stylesheet.code}>
{JSON.stringify(item.output, null, 2)}
</Text>
<Text style={stylesheet.code}>{JSON.stringify(item.output, null, 2)}</Text>
</View>
)}
{!item.input && !item.output && (
<Text style={stylesheet.emptyText}>
No details available
</Text>
<Text style={stylesheet.emptyText}>No details available</Text>
)}
</View>
)}
@@ -136,36 +123,26 @@ function MergedToolCallItem({ item }: { item: MergedToolCall }) {
function PlanItem({ update, timestamp }: { update: SessionUpdate; timestamp: Date }) {
const [isExpanded, setIsExpanded] = useState(true);
if (update.kind !== 'plan') {
if (update.kind !== "plan") {
return null;
}
return (
<View style={stylesheet.planCard}>
<Pressable
onPress={() => setIsExpanded(!isExpanded)}
style={stylesheet.planHeader}
>
<Pressable onPress={() => setIsExpanded(!isExpanded)} style={stylesheet.planHeader}>
<View style={stylesheet.planHeaderLeft}>
<Text style={stylesheet.timestamp}>{formatTimestamp(timestamp)}</Text>
<Text style={stylesheet.planTitle}>
📋 Tasks ({update.entries.length})
</Text>
<Text style={stylesheet.planTitle}>📋 Tasks ({update.entries.length})</Text>
</View>
<Text style={stylesheet.expandIcon}>{isExpanded ? '▼' : '▶'}</Text>
<Text style={stylesheet.expandIcon}>{isExpanded ? "▼" : "▶"}</Text>
</Pressable>
{isExpanded && (
<View style={stylesheet.planContent}>
{update.entries.map((entry, idx) => (
<View key={idx} style={stylesheet.planEntry}>
<Text
style={[
stylesheet.planEntryStatus,
{ color: getStatusColor(entry.status) },
]}
>
{entry.status === 'completed' ? '✓' : entry.status === 'in_progress' ? '⏳' : '○'}
<Text style={[stylesheet.planEntryStatus, { color: getStatusColor(entry.status) }]}>
{entry.status === "completed" ? "✓" : entry.status === "in_progress" ? "⏳" : "○"}
</Text>
<Text style={stylesheet.planEntryText}>{entry.content}</Text>
</View>
@@ -181,10 +158,7 @@ function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; tim
return (
<View style={stylesheet.unknownCard}>
<Pressable
onPress={() => setShowDrawer(!showDrawer)}
style={stylesheet.unknownHeader}
>
<Pressable onPress={() => setShowDrawer(!showDrawer)} style={stylesheet.unknownHeader}>
<Text style={stylesheet.timestamp}>{formatTimestamp(timestamp)}</Text>
<View style={stylesheet.unknownBadge}>
<Text style={stylesheet.unknownBadgeText}>{update.kind}</Text>
@@ -193,9 +167,7 @@ function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; tim
{showDrawer && (
<View style={stylesheet.drawerContent}>
<Text style={stylesheet.code}>
{JSON.stringify(update, null, 2)}
</Text>
<Text style={stylesheet.code}>{JSON.stringify(update, null, 2)}</Text>
</View>
)}
</View>
@@ -204,12 +176,12 @@ function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; tim
export function AgentActivityItem({ item }: AgentActivityItemProps) {
// Grouped text message
if ('kind' in item && item.kind === 'grouped_text') {
if ("kind" in item && item.kind === "grouped_text") {
return <GroupedTextItem item={item} />;
}
// Merged tool call
if ('kind' in item && item.kind === 'merged_tool_call') {
if ("kind" in item && item.kind === "merged_tool_call") {
return <MergedToolCallItem item={item} />;
}
@@ -218,12 +190,12 @@ export function AgentActivityItem({ item }: AgentActivityItemProps) {
const update = activity.update;
// Tasks
if (update.kind === 'plan') {
if (update.kind === "plan") {
return <PlanItem update={update} timestamp={activity.timestamp} />;
}
// Available commands update
if (update.kind === 'available_commands_update') {
if (update.kind === "available_commands_update") {
return (
<View style={stylesheet.card}>
<Text style={stylesheet.timestamp}>{formatTimestamp(activity.timestamp)}</Text>
@@ -235,13 +207,11 @@ export function AgentActivityItem({ item }: AgentActivityItemProps) {
}
// Current mode update
if (update.kind === 'current_mode_update') {
if (update.kind === "current_mode_update") {
return (
<View style={stylesheet.card}>
<Text style={stylesheet.timestamp}>{formatTimestamp(activity.timestamp)}</Text>
<Text style={stylesheet.infoText}>
Mode changed to: {update.currentModeId}
</Text>
<Text style={stylesheet.infoText}>Mode changed to: {update.currentModeId}</Text>
</View>
);
}
@@ -284,26 +254,26 @@ const stylesheet = StyleSheet.create((theme) => ({
},
thoughtText: {
color: theme.colors.foregroundMuted,
fontStyle: 'italic',
fontStyle: "italic",
},
toolCard: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
overflow: 'hidden',
overflow: "hidden",
},
toolHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: theme.spacing[3],
},
toolHeaderLeft: {
flex: 1,
},
toolTitleRow: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
marginTop: theme.spacing[1],
},
@@ -355,18 +325,18 @@ const stylesheet = StyleSheet.create((theme) => ({
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontStyle: 'italic',
fontStyle: "italic",
},
planCard: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
overflow: 'hidden',
overflow: "hidden",
},
planHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: theme.spacing[3],
},
planHeaderLeft: {
@@ -384,8 +354,8 @@ const stylesheet = StyleSheet.create((theme) => ({
padding: theme.spacing[3],
},
planEntry: {
flexDirection: 'row',
alignItems: 'flex-start',
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[2],
marginBottom: theme.spacing[2],
},
@@ -406,7 +376,7 @@ const stylesheet = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
overflow: 'hidden',
overflow: "hidden",
},
unknownHeader: {
padding: theme.spacing[3],
@@ -417,7 +387,7 @@ const stylesheet = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md,
marginTop: theme.spacing[2],
alignSelf: 'flex-start',
alignSelf: "flex-start",
},
unknownBadgeText: {
color: theme.colors.foreground,
@@ -1,13 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactElement, ReactNode } from "react";
import {
View,
Text,
Pressable,
TextInput,
ActivityIndicator,
Platform,
} from "react-native";
import { View, Text, Pressable, TextInput, ActivityIndicator, Platform } from "react-native";
import type { StyleProp, ViewStyle, TextProps } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
@@ -17,7 +10,18 @@ import {
BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import Animated from "react-native-reanimated";
import { ChevronDown, ChevronRight, Pencil, Check, X, Bot, Brain, ShieldCheck, ShieldAlert, ShieldOff } from "lucide-react-native";
import {
ChevronDown,
ChevronRight,
Pencil,
Check,
X,
Bot,
Brain,
ShieldCheck,
ShieldAlert,
ShieldOff,
} from "lucide-react-native";
import { theme as defaultTheme } from "@/styles/theme";
import type {
AgentMode,
@@ -107,19 +111,14 @@ export function DropdownField({
testID={testID}
style={[styles.dropdownControl, disabled && styles.dropdownControlDisabled]}
>
<Text
style={value ? styles.dropdownValue : styles.dropdownPlaceholder}
numberOfLines={1}
>
<Text style={value ? styles.dropdownValue : styles.dropdownPlaceholder} numberOfLines={1}>
{value || placeholder}
</Text>
<ChevronDown size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
</Pressable>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
{!errorMessage && helperText ? (
<Text style={styles.helperText}>{helperText}</Text>
) : null}
{!errorMessage && helperText ? <Text style={styles.helperText}>{helperText}</Text> : null}
</View>
);
}
@@ -176,7 +175,7 @@ export function SelectField({
onPress();
}
},
[getWebKey, onPress, preventWebDefault]
[getWebKey, onPress, preventWebDefault],
);
const normalizedValue = (value ?? "").trim();
@@ -184,9 +183,7 @@ export function SelectField({
const hasConcreteValue =
normalizedValue.length > 0 &&
(normalizedPlaceholder.length === 0 || normalizedValue !== normalizedPlaceholder);
const displayText = hasConcreteValue
? normalizedValue
: (normalizedPlaceholder || "Select...");
const displayText = hasConcreteValue ? normalizedValue : normalizedPlaceholder || "Select...";
return (
<View style={styles.selectFieldContainer}>
@@ -231,12 +228,7 @@ interface DropdownSheetProps {
}
function DropdownSheetBackground({ style }: BottomSheetBackgroundProps) {
return (
<Animated.View
pointerEvents="none"
style={[style, styles.bottomSheetBackground]}
/>
);
return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />;
}
export function DropdownSheet({
@@ -267,19 +259,14 @@ export function DropdownSheet({
onClose();
}
},
[onClose]
[onClose],
);
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.45}
/>
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
),
[]
[],
);
return (
@@ -450,16 +437,14 @@ export function FormSelectTrigger({
onPress();
}
},
[getWebKey, onPress, preventWebDefault]
[getWebKey, onPress, preventWebDefault],
);
const normalizedValue = (value ?? "").trim();
const normalizedPlaceholder = (placeholder ?? "").trim();
const hasConcreteValue =
normalizedValue.length > 0 &&
(normalizedPlaceholder.length === 0 || normalizedValue !== normalizedPlaceholder);
const displayText = hasConcreteValue
? normalizedValue
: (normalizedPlaceholder || "Select...");
const displayText = hasConcreteValue ? normalizedValue : normalizedPlaceholder || "Select...";
return (
<Pressable
@@ -481,9 +466,7 @@ export function FormSelectTrigger({
>
{icon ? <View style={styles.compactSelectLeading}>{icon}</View> : null}
<View style={styles.compactSelectValueContainer}>
{showLabel ? (
<Text style={styles.compactSelectLabel}>{label}</Text>
) : null}
{showLabel ? <Text style={styles.compactSelectLabel}>{label}</Text> : null}
{isLoading ? (
<ActivityIndicator size="small" color={defaultTheme.colors.foregroundMuted} />
) : (
@@ -540,7 +523,7 @@ export function AgentConfigRow({
id: def.id,
label: def.label,
})),
[providerDefinitions]
[providerDefinitions],
);
const modeSelectOptions: ComboSelectOption[] = useMemo(() => {
@@ -554,9 +537,7 @@ export function AgentConfigRow({
}, [modeOptions]);
const modelSelectOptions: ComboSelectOption[] = useMemo(() => {
const opts: ComboSelectOption[] = [
{ id: "", label: "Auto" },
];
const opts: ComboSelectOption[] = [{ id: "", label: "Auto" }];
for (const model of models) {
opts.push({
id: model.id,
@@ -572,7 +553,7 @@ export function AgentConfigRow({
id: option.id,
label: option.label,
})),
[thinkingOptions]
[thinkingOptions],
);
const effectiveSelectedMode = selectedMode || (modeOptions.length > 0 ? modeOptions[0]?.id : "");
@@ -609,7 +590,9 @@ export function AgentConfigRow({
disabled={disabled}
isLoading={isModelLoading}
onSelect={onSelectModel}
icon={<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
icon={
<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
}
showLabel={false}
testID="draft-model-select"
/>
@@ -638,7 +621,9 @@ export function AgentConfigRow({
placeholder="Select..."
disabled={disabled}
onSelect={onSelectThinkingOption}
icon={<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
icon={
<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
}
showLabel={false}
/>
</View>
@@ -664,7 +649,7 @@ export function AssistantDropdown({
const anchorRef = useRef<View>(null);
const selectedDefinition = providerDefinitions.find(
(definition) => definition.id === selectedProvider
(definition) => definition.id === selectedProvider,
);
const options = useMemo(
@@ -673,7 +658,7 @@ export function AssistantDropdown({
id: def.id,
label: def.label,
})),
[providerDefinitions]
[providerDefinitions],
);
const handleOpen = useCallback(() => setIsOpen(true), []);
@@ -720,9 +705,9 @@ export function PermissionsDropdown({
const hasOptions = modeOptions.length > 0;
const selectedModeLabel = hasOptions
? modeOptions.find((mode) => mode.id === selectedMode)?.label ??
? (modeOptions.find((mode) => mode.id === selectedMode)?.label ??
modeOptions[0]?.label ??
"Default"
"Default")
: "Automatic";
const options = useMemo(
@@ -732,7 +717,7 @@ export function PermissionsDropdown({
label: mode.label,
description: mode.description,
})),
[modeOptions]
[modeOptions],
);
const handleOpen = useCallback(() => {
@@ -751,9 +736,7 @@ export function PermissionsDropdown({
onPress={handleOpen}
disabled={disabled || !hasOptions}
helperText={
hasOptions
? undefined
: "This assistant does not expose selectable permissions."
hasOptions ? undefined : "This assistant does not expose selectable permissions."
}
controlRef={anchorRef}
/>
@@ -795,7 +778,7 @@ export function ModelDropdown({
const anchorRef = useRef<View>(null);
const selectedLabel = selectedModel
? models.find((model) => model.id === selectedModel)?.label ?? selectedModel
? (models.find((model) => model.id === selectedModel)?.label ?? selectedModel)
: "Automatic";
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Automatic";
const helperText = error
@@ -834,7 +817,7 @@ export function ModelDropdown({
onSelect(id);
}
},
[onClear, onSelect]
[onClear, onSelect],
);
return (
@@ -882,7 +865,7 @@ export function WorkingDirectoryDropdown({
const options = useMemo(
() => suggestedPaths.map((path) => ({ id: path, label: path })),
[suggestedPaths]
[suggestedPaths],
);
const handleOpen = useCallback(() => setIsOpen(true), []);
@@ -957,9 +940,7 @@ export function ToggleRow({
</View>
<View style={styles.toggleTextContainer}>
<Text style={styles.toggleLabel}>{label}</Text>
{description ? (
<Text style={styles.helperText}>{description}</Text>
) : null}
{description ? <Text style={styles.helperText}>{description}</Text> : null}
</View>
</Pressable>
);
@@ -1052,9 +1033,7 @@ export function GitOptionsSection({
<View style={styles.gitOptionsContainer}>
<Pressable
testID="worktree-create-toggle"
onPress={() =>
onWorktreeModeChange(isCreateMode ? "none" : "create")
}
onPress={() => onWorktreeModeChange(isCreateMode ? "none" : "create")}
disabled={isLoading}
style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]}
>
@@ -1077,9 +1056,7 @@ export function GitOptionsSection({
<Pressable
testID="worktree-attach-toggle"
onPress={() =>
onWorktreeModeChange(isAttachMode ? "none" : "attach")
}
onPress={() => onWorktreeModeChange(isAttachMode ? "none" : "attach")}
disabled={isLoading}
style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]}
>
@@ -1143,8 +1120,15 @@ export function GitOptionsSection({
placeholderTextColor={defaultTheme.colors.foregroundMuted}
onSubmitEditing={handleConfirmEdit}
/>
<Pressable onPress={handleConfirmEdit} hitSlop={8} style={styles.baseBranchIconButton}>
<Check size={defaultTheme.iconSize.md} color={defaultTheme.colors.palette.green[500]} />
<Pressable
onPress={handleConfirmEdit}
hitSlop={8}
style={styles.baseBranchIconButton}
>
<Check
size={defaultTheme.iconSize.md}
color={defaultTheme.colors.palette.green[500]}
/>
</Pressable>
<Pressable onPress={handleCancelEdit} hitSlop={8} style={styles.baseBranchIconButton}>
<X size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
@@ -1159,17 +1143,11 @@ export function GitOptionsSection({
</View>
) : null}
{baseBranchError ? (
<Text style={styles.errorText}>{baseBranchError}</Text>
) : null}
{baseBranchError ? <Text style={styles.errorText}>{baseBranchError}</Text> : null}
{repoError ? (
<Text style={styles.errorText}>{repoError}</Text>
) : null}
{repoError ? <Text style={styles.errorText}>{repoError}</Text> : null}
{gitValidationError ? (
<Text style={styles.errorText}>{gitValidationError}</Text>
) : null}
{gitValidationError ? <Text style={styles.errorText}>{gitValidationError}</Text> : null}
</View>
);
}
@@ -1,48 +0,0 @@
import { describe, expect, it } from "vitest";
import { shouldSkipDraftPersist } from "./agent-input-area.draft-persist-guard";
describe("shouldSkipDraftPersist", () => {
it("blocks persist while hydrate for current uncontrolled generation is incomplete", () => {
expect(
shouldSkipDraftPersist({
isControlled: false,
currentGeneration: 2,
hydratedGeneration: 1,
isCurrentGeneration: true,
})
).toBe(true);
});
it("allows persist after hydrate completes for current generation", () => {
expect(
shouldSkipDraftPersist({
isControlled: false,
currentGeneration: 3,
hydratedGeneration: 3,
isCurrentGeneration: true,
})
).toBe(false);
});
it("blocks persist for stale generations", () => {
expect(
shouldSkipDraftPersist({
isControlled: false,
currentGeneration: 4,
hydratedGeneration: 4,
isCurrentGeneration: false,
})
).toBe(true);
});
it("does not block controlled draft persistence", () => {
expect(
shouldSkipDraftPersist({
isControlled: true,
currentGeneration: 0,
hydratedGeneration: 0,
isCurrentGeneration: true,
})
).toBe(false);
});
});
@@ -1,20 +0,0 @@
export function shouldSkipDraftPersist(input: {
isControlled: boolean;
currentGeneration: number;
hydratedGeneration: number;
isCurrentGeneration: boolean;
}): boolean {
if (input.isControlled) {
return false;
}
if (input.currentGeneration <= 0) {
return true;
}
if (!input.isCurrentGeneration) {
return true;
}
return input.hydratedGeneration !== input.currentGeneration;
}
@@ -1,31 +1,31 @@
import { describe, expect, it } from 'vitest'
import { resolveStatusControlMode } from './agent-input-area.status-controls'
import { describe, expect, it } from "vitest";
import { resolveStatusControlMode } from "./agent-input-area.status-controls";
describe('resolveStatusControlMode', () => {
it('uses ready mode when no controlled status controls are provided', () => {
expect(resolveStatusControlMode(undefined)).toBe('ready')
})
describe("resolveStatusControlMode", () => {
it("uses ready mode when no controlled status controls are provided", () => {
expect(resolveStatusControlMode(undefined)).toBe("ready");
});
it('uses draft mode when controlled status controls are provided', () => {
it("uses draft mode when controlled status controls are provided", () => {
expect(
resolveStatusControlMode({
providerDefinitions: [],
selectedProvider: 'codex',
selectedProvider: "codex",
onSelectProvider: () => undefined,
modeOptions: [],
selectedMode: '',
selectedMode: "",
onSelectMode: () => undefined,
models: [],
selectedModel: '',
selectedModel: "",
onSelectModel: () => undefined,
isModelLoading: false,
allProviderModels: new Map(),
isAllModelsLoading: false,
onSelectProviderAndModel: () => undefined,
thinkingOptions: [],
selectedThinkingOptionId: '',
selectedThinkingOptionId: "",
onSelectThinkingOption: () => undefined,
})
).toBe('draft')
})
})
}),
).toBe("draft");
});
});
@@ -1,5 +1,5 @@
import type { DraftAgentStatusBarProps } from './agent-status-bar'
import type { DraftAgentStatusBarProps } from "./agent-status-bar";
export function resolveStatusControlMode(statusControls?: DraftAgentStatusBarProps) {
return statusControls ? 'draft' : 'ready'
return statusControls ? "draft" : "ready";
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,140 @@
import { describe, expect, it, vi } from "vitest";
import { submitAgentInput } from "./agent-input-submit";
function createDeferredPromise<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return {
promise,
resolve,
reject,
};
}
describe("submitAgentInput", () => {
it("clears the composer before an in-flight submit resolves", async () => {
const deferred = createDeferredPromise<void>();
const queueMessage = vi.fn();
const submitMessage = vi.fn(async () => {
await deferred.promise;
});
const clearDraft = vi.fn();
const setUserInput = vi.fn();
const setSelectedImages = vi.fn();
const setSendError = vi.fn();
const setIsProcessing = vi.fn();
const submitPromise = submitAgentInput({
message: " hello world ",
isAgentRunning: false,
canSubmit: true,
queueMessage,
submitMessage,
clearDraft,
setUserInput,
setSelectedImages,
setSendError,
setIsProcessing,
});
expect(queueMessage).not.toHaveBeenCalled();
expect(submitMessage).toHaveBeenCalledWith({
message: "hello world",
imageAttachments: undefined,
});
expect(setUserInput).toHaveBeenCalledWith("");
expect(setSelectedImages).toHaveBeenCalledWith([]);
expect(setSendError).toHaveBeenCalledWith(null);
expect(setIsProcessing).toHaveBeenCalledWith(true);
expect(clearDraft).not.toHaveBeenCalled();
deferred.resolve();
await expect(submitPromise).resolves.toBe("submitted");
expect(clearDraft).toHaveBeenCalledWith("sent");
});
it("queues while the agent is running and clears the composer immediately", async () => {
const queueMessage = vi.fn();
const submitMessage = vi.fn();
const clearDraft = vi.fn();
const setUserInput = vi.fn();
const setSelectedImages = vi.fn();
const setSendError = vi.fn();
const setIsProcessing = vi.fn();
await expect(
submitAgentInput({
message: " queued message ",
imageAttachments: [{ id: "img-1" }],
isAgentRunning: true,
canSubmit: true,
queueMessage,
submitMessage,
clearDraft,
setUserInput,
setSelectedImages,
setSendError,
setIsProcessing,
}),
).resolves.toBe("queued");
expect(queueMessage).toHaveBeenCalledWith({
message: "queued message",
imageAttachments: [{ id: "img-1" }],
});
expect(submitMessage).not.toHaveBeenCalled();
expect(setUserInput).toHaveBeenCalledWith("");
expect(setSelectedImages).toHaveBeenCalledWith([]);
expect(setSendError).not.toHaveBeenCalled();
expect(setIsProcessing).not.toHaveBeenCalled();
expect(clearDraft).not.toHaveBeenCalled();
});
it("restores the composer when submit fails", async () => {
const submitError = new Error("No host selected");
const queueMessage = vi.fn();
const submitMessage = vi.fn(async () => {
throw submitError;
});
const clearDraft = vi.fn();
const setUserInput = vi.fn();
const setSelectedImages = vi.fn();
const setSendError = vi.fn();
const setIsProcessing = vi.fn();
const onSubmitError = vi.fn();
const imageAttachments = [{ id: "img-1" }];
await expect(
submitAgentInput({
message: " hello world ",
imageAttachments,
isAgentRunning: false,
canSubmit: true,
queueMessage,
submitMessage,
clearDraft,
setUserInput,
setSelectedImages,
setSendError,
setIsProcessing,
onSubmitError,
}),
).resolves.toBe("failed");
expect(onSubmitError).toHaveBeenCalledWith(submitError);
expect(setUserInput).toHaveBeenNthCalledWith(1, "");
expect(setUserInput).toHaveBeenNthCalledWith(2, "hello world");
expect(setSelectedImages).toHaveBeenNthCalledWith(1, []);
expect(setSelectedImages).toHaveBeenNthCalledWith(2, imageAttachments);
expect(setSendError).toHaveBeenNthCalledWith(1, null);
expect(setSendError).toHaveBeenNthCalledWith(2, "No host selected");
expect(setIsProcessing).toHaveBeenNthCalledWith(1, true);
expect(setIsProcessing).toHaveBeenNthCalledWith(2, false);
expect(clearDraft).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,58 @@
export type AgentInputSubmitResult = "noop" | "queued" | "submitted" | "failed";
export interface AgentInputSubmitActionInput<TImage> {
message: string;
imageAttachments?: TImage[];
forceSend?: boolean;
isAgentRunning: boolean;
canSubmit: boolean;
queueMessage: (input: { message: string; imageAttachments?: TImage[] }) => void;
submitMessage: (input: { message: string; imageAttachments?: TImage[] }) => Promise<void>;
clearDraft: (lifecycle: "sent" | "abandoned") => void;
setUserInput: (text: string) => void;
setSelectedImages: (images: TImage[]) => void;
setSendError: (message: string | null) => void;
setIsProcessing: (isProcessing: boolean) => void;
onSubmitError?: (error: unknown) => void;
}
export async function submitAgentInput<TImage>(
input: AgentInputSubmitActionInput<TImage>,
): Promise<AgentInputSubmitResult> {
const trimmedMessage = input.message.trim();
const imageAttachments = input.imageAttachments;
if (!trimmedMessage && !imageAttachments?.length) {
return "noop";
}
if (!input.canSubmit) {
return "noop";
}
if (input.isAgentRunning && !input.forceSend) {
input.queueMessage({ message: trimmedMessage, imageAttachments });
input.setUserInput("");
input.setSelectedImages([]);
return "queued";
}
// Clear immediately so optimistic stream updates and composer state stay in sync.
input.setUserInput("");
input.setSelectedImages([]);
input.setSendError(null);
input.setIsProcessing(true);
try {
await input.submitMessage({ message: trimmedMessage, imageAttachments });
input.clearDraft("sent");
return "submitted";
} catch (error) {
input.onSubmitError?.(error);
input.setUserInput(trimmedMessage);
input.setSelectedImages(imageAttachments ?? []);
input.setSendError(error instanceof Error ? error.message : "Failed to send message");
input.setIsProcessing(false);
return "failed";
}
}
+178 -222
View File
@@ -6,106 +6,107 @@ import {
RefreshControl,
FlatList,
type ListRenderItem,
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useCallback, useMemo, useState, type ReactElement } from 'react'
import { router } from 'expo-router'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { formatTimeAgo } from '@/utils/time'
import { shortenPath } from '@/utils/shorten-path'
import { type AggregatedAgent } from '@/hooks/use-aggregated-agents'
import { useSessionStore } from '@/stores/session-store'
import { AgentStatusDot } from '@/components/agent-status-dot'
import { prepareWorkspaceTab } from '@/utils/workspace-navigation'
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { router } from "expo-router";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import { Archive } from "lucide-react-native";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
interface AgentListProps {
agents: AggregatedAgent[]
showCheckoutInfo?: boolean
isRefreshing?: boolean
onRefresh?: () => void
selectedAgentId?: string
onAgentSelect?: () => void
listFooterComponent?: ReactElement | null
showAttentionIndicator?: boolean
agents: AggregatedAgent[];
showCheckoutInfo?: boolean;
isRefreshing?: boolean;
onRefresh?: () => void;
selectedAgentId?: string;
onAgentSelect?: () => void;
listFooterComponent?: ReactElement | null;
showAttentionIndicator?: boolean;
}
interface AgentListSection {
key: string
title: string
data: AggregatedAgent[]
}
type FlatListItem =
| { type: "header"; key: string; title: string }
| { type: "agent"; key: string; agent: AggregatedAgent };
function deriveDateSectionLabel(lastActivityAt: Date): string {
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000)
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000);
const activityStart = new Date(
lastActivityAt.getFullYear(),
lastActivityAt.getMonth(),
lastActivityAt.getDate()
)
lastActivityAt.getDate(),
);
if (activityStart.getTime() >= todayStart.getTime()) {
return 'Today'
return "Today";
}
if (activityStart.getTime() >= yesterdayStart.getTime()) {
return 'Yesterday'
return "Yesterday";
}
const diffTime = todayStart.getTime() - activityStart.getTime()
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24))
const diffTime = todayStart.getTime() - activityStart.getTime();
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
if (diffDays <= 7) {
return 'This week'
return "This week";
}
if (diffDays <= 30) {
return 'This month'
return "This month";
}
return 'Older'
return "Older";
}
function formatStatusLabel(status: AggregatedAgent['status']): string {
function formatStatusLabel(status: AggregatedAgent["status"]): string {
switch (status) {
case 'initializing':
return 'Starting'
case 'idle':
return 'Idle'
case 'running':
return 'Running'
case 'error':
return 'Error'
case 'closed':
return 'Closed'
case "initializing":
return "Starting";
case "idle":
return "Idle";
case "running":
return "Running";
case "error":
return "Error";
case "closed":
return "Closed";
default:
return status
return status;
}
}
function SessionBadge({
label,
tone = 'neutral',
icon,
tone = "neutral",
}: {
label: string
tone?: 'neutral' | 'warning' | 'danger'
label: string;
icon?: ReactElement;
tone?: "neutral" | "warning" | "danger";
}) {
return (
<View
style={[
styles.badge,
tone === 'warning' && styles.badgeWarning,
tone === 'danger' && styles.badgeDanger,
tone === "warning" && styles.badgeWarning,
tone === "danger" && styles.badgeDanger,
]}
>
{icon}
<Text
style={[
styles.badgeText,
tone === 'warning' && styles.badgeTextWarning,
tone === 'danger' && styles.badgeTextDanger,
tone === "warning" && styles.badgeTextWarning,
tone === "danger" && styles.badgeTextDanger,
]}
>
{label}
</Text>
</View>
)
);
}
function SessionRow({
@@ -116,18 +117,19 @@ function SessionRow({
onPress,
onLongPress,
}: {
agent: AggregatedAgent
isMobile: boolean
selectedAgentId?: string
showAttentionIndicator: boolean
onPress: (agent: AggregatedAgent) => void
onLongPress: (agent: AggregatedAgent) => void
agent: AggregatedAgent;
isMobile: boolean;
selectedAgentId?: string;
showAttentionIndicator: boolean;
onPress: (agent: AggregatedAgent) => void;
onLongPress: (agent: AggregatedAgent) => void;
}) {
const timeAgo = formatTimeAgo(agent.lastActivityAt)
const agentKey = `${agent.serverId}:${agent.id}`
const isSelected = selectedAgentId === agentKey
const statusLabel = formatStatusLabel(agent.status)
const projectPath = shortenPath(agent.cwd)
const { theme } = useUnistyles();
const timeAgo = formatTimeAgo(agent.lastActivityAt);
const agentKey = `${agent.serverId}:${agent.id}`;
const isSelected = selectedAgentId === agentKey;
const statusLabel = formatStatusLabel(agent.status);
const projectPath = shortenPath(agent.cwd);
return (
<Pressable
@@ -141,18 +143,20 @@ function SessionRow({
onLongPress={() => onLongPress(agent)}
testID={`agent-row-${agent.serverId}-${agent.id}`}
>
<View style={styles.rowLeading}>
<AgentStatusDot status={agent.status} requiresAttention={agent.requiresAttention} />
</View>
<View style={styles.rowContent}>
<View style={styles.rowTitleRow}>
<Text
style={[styles.sessionTitle, isSelected && styles.sessionTitleHighlighted]}
numberOfLines={1}
>
{agent.title || 'New session'}
{agent.title || "New session"}
</Text>
{agent.archivedAt ? <SessionBadge label="Archived" /> : null}
{agent.archivedAt ? (
<SessionBadge
label="Archived"
icon={<Archive size={theme.fontSize.xs} color={theme.colors.foregroundMuted} />}
/>
) : null}
{(agent.pendingPermissionCount ?? 0) > 0 ? (
<SessionBadge label={`${agent.pendingPermissionCount} pending`} tone="warning" />
) : null}
@@ -195,49 +199,7 @@ function SessionRow({
</View>
) : null}
</Pressable>
)
}
function SessionTableSection({
section,
isMobile,
selectedAgentId,
showAttentionIndicator,
onAgentPress,
onAgentLongPress,
}: {
section: AgentListSection
isMobile: boolean
selectedAgentId?: string
showAttentionIndicator: boolean
onAgentPress: (agent: AggregatedAgent) => void
onAgentLongPress: (agent: AggregatedAgent) => void
}) {
return (
<View style={styles.sectionBlock}>
<View style={styles.sectionHeading}>
<Text style={styles.sectionTitle}>{section.title}</Text>
</View>
<View style={styles.listCard}>
{section.data.map((agent, index) => (
<View
key={`${agent.serverId}:${agent.id}`}
style={index > 0 ? styles.rowDivider : undefined}
>
<SessionRow
agent={agent}
isMobile={isMobile}
selectedAgentId={selectedAgentId}
showAttentionIndicator={showAttentionIndicator}
onPress={onAgentPress}
onLongPress={onAgentLongPress}
/>
</View>
))}
</View>
</View>
)
);
}
export function AgentList({
@@ -249,100 +211,113 @@ export function AgentList({
listFooterComponent,
showAttentionIndicator = true,
}: AgentListProps) {
const { theme } = useUnistyles()
const insets = useSafeAreaInsets()
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null)
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const actionClient = useSessionStore((state) =>
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null
)
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,
);
const isActionSheetVisible = actionAgent !== null
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient)
const isActionSheetVisible = actionAgent !== null;
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient);
const handleAgentPress = useCallback(
(agent: AggregatedAgent) => {
if (isActionSheetVisible) {
return
return;
}
const serverId = agent.serverId
const agentId = agent.id
const serverId = agent.serverId;
const agentId = agent.id;
onAgentSelect?.()
onAgentSelect?.();
const route = prepareWorkspaceTab({
serverId,
workspaceId: agent.cwd,
target: { kind: 'agent', agentId },
})
router.navigate(route as any)
target: { kind: "agent", agentId },
pin: Boolean(agent.archivedAt),
});
router.navigate(route as any);
},
[isActionSheetVisible, onAgentSelect]
)
[isActionSheetVisible, onAgentSelect],
);
const handleAgentLongPress = useCallback((agent: AggregatedAgent) => {
setActionAgent(agent)
}, [])
setActionAgent(agent);
}, []);
const handleCloseActionSheet = useCallback(() => {
setActionAgent(null)
}, [])
setActionAgent(null);
}, []);
const handleArchiveAgent = useCallback(() => {
if (!actionAgent || !actionClient) {
return
return;
}
void actionClient.archiveAgent(actionAgent.id)
setActionAgent(null)
}, [actionAgent, actionClient])
void actionClient.archiveAgent(actionAgent.id);
setActionAgent(null);
}, [actionAgent, actionClient]);
const sections = useMemo((): AgentListSection[] => {
const order = ['Today', 'Yesterday', 'This week', 'This month', 'Older'] as const
const buckets = new Map<string, AggregatedAgent[]>()
const flatItems = useMemo((): FlatListItem[] => {
const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const;
const buckets = new Map<string, AggregatedAgent[]>();
for (const agent of agents) {
const label = deriveDateSectionLabel(agent.lastActivityAt)
const existing = buckets.get(label) ?? []
existing.push(agent)
buckets.set(label, existing)
const label = deriveDateSectionLabel(agent.lastActivityAt);
const existing = buckets.get(label) ?? [];
existing.push(agent);
buckets.set(label, existing);
}
const result: AgentListSection[] = []
const result: FlatListItem[] = [];
for (const label of order) {
const data = buckets.get(label)
const data = buckets.get(label);
if (!data || data.length === 0) {
continue
continue;
}
result.push({ type: "header", key: `header:${label}`, title: label });
for (const agent of data) {
result.push({ type: "agent", key: `${agent.serverId}:${agent.id}`, agent });
}
result.push({ key: `date:${label}`, title: label, data })
}
return result
}, [agents])
return result;
}, [agents]);
const renderSection: ListRenderItem<AgentListSection> = useCallback(
({ item: section }) => (
<SessionTableSection
section={section}
isMobile={isMobile}
selectedAgentId={selectedAgentId}
showAttentionIndicator={showAttentionIndicator}
onAgentPress={handleAgentPress}
onAgentLongPress={handleAgentLongPress}
/>
),
[handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator]
)
const renderItem: ListRenderItem<FlatListItem> = useCallback(
({ item }) => {
if (item.type === "header") {
return (
<View style={styles.sectionHeading}>
<Text style={styles.sectionTitle}>{item.title}</Text>
</View>
);
}
return (
<SessionRow
agent={item.agent}
isMobile={isMobile}
selectedAgentId={selectedAgentId}
showAttentionIndicator={showAttentionIndicator}
onPress={handleAgentPress}
onLongPress={handleAgentLongPress}
/>
);
},
[handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator],
);
const keyExtractor = useCallback((section: AgentListSection) => section.key, [])
const keyExtractor = useCallback((item: FlatListItem) => item.key, []);
return (
<>
<FlatList
data={sections}
data={flatItems}
style={styles.list}
contentContainerStyle={styles.listContent}
keyExtractor={keyExtractor}
renderItem={renderSection}
renderItem={renderItem}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
ListFooterComponent={listFooterComponent}
@@ -374,7 +349,7 @@ export function AgentList({
>
<View style={styles.sheetHandle} />
<Text style={styles.sheetTitle}>
{isActionDaemonUnavailable ? 'Host offline' : 'Archive this session?'}
{isActionDaemonUnavailable ? "Host offline" : "Archive this session?"}
</Text>
<View style={styles.sheetButtonRow}>
<Pressable
@@ -404,7 +379,7 @@ export function AgentList({
</View>
</Modal>
</>
)
);
}
const styles = StyleSheet.create((theme) => ({
@@ -417,18 +392,16 @@ const styles = StyleSheet.create((theme) => ({
xs: theme.spacing[3],
md: theme.spacing[6],
},
paddingTop: theme.spacing[2],
paddingTop: theme.spacing[4],
paddingBottom: theme.spacing[6],
gap: theme.spacing[1],
},
sectionBlock: {
marginTop: theme.spacing[2],
},
sectionHeading: {
flexDirection: 'row',
alignItems: 'center',
marginTop: theme.spacing[2],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[1],
paddingHorizontal: theme.spacing[3],
marginBottom: theme.spacing[2],
},
sectionTitle: {
@@ -436,26 +409,9 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
},
listCard: {
overflow: {
xs: 'hidden' as const,
md: 'visible' as const,
},
borderRadius: {
xs: theme.borderRadius.lg,
md: 0,
},
},
rowDivider: {
borderTopWidth: {
xs: StyleSheet.hairlineWidth,
md: 0,
},
borderTopColor: theme.colors.border,
},
row: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: {
@@ -467,23 +423,20 @@ const styles = StyleSheet.create((theme) => ({
md: 0,
},
},
rowLeading: {
marginRight: theme.spacing[3],
},
rowContent: {
flex: 1,
minWidth: 0,
},
rowTitleRow: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
flexDirection: "row",
alignItems: "center",
flexWrap: "wrap",
gap: theme.spacing[2],
},
rowMetaRow: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
flexDirection: "row",
alignItems: "center",
flexWrap: "wrap",
gap: theme.spacing[1],
marginTop: 2,
},
@@ -502,7 +455,7 @@ const styles = StyleSheet.create((theme) => ({
sessionTitle: {
flexShrink: 1,
fontSize: theme.fontSize.sm,
fontWeight: '500',
fontWeight: "400",
color: theme.colors.foreground,
opacity: 0.86,
},
@@ -510,7 +463,7 @@ const styles = StyleSheet.create((theme) => ({
opacity: 1,
},
sessionMetaText: {
maxWidth: '100%',
maxWidth: "100%",
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
@@ -532,19 +485,22 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
flexShrink: 0,
width: 72,
textAlign: 'right' as const,
textAlign: "right" as const,
},
badge: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface2,
},
badgeWarning: {
backgroundColor: 'rgba(245, 158, 11, 0.12)',
backgroundColor: "rgba(245, 158, 11, 0.12)",
},
badgeDanger: {
backgroundColor: 'rgba(239, 68, 68, 0.14)',
backgroundColor: "rgba(239, 68, 68, 0.14)",
},
badgeText: {
fontSize: theme.fontSize.xs,
@@ -559,26 +515,26 @@ const styles = StyleSheet.create((theme) => ({
},
sheetOverlay: {
flex: 1,
justifyContent: 'flex-end',
justifyContent: "flex-end",
},
sheetBackdrop: {
position: 'absolute',
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: 'rgba(0,0,0,0.35)',
backgroundColor: "rgba(0,0,0,0.35)",
},
sheetContainer: {
backgroundColor: theme.colors.surface2,
borderTopLeftRadius: theme.borderRadius['2xl'],
borderTopRightRadius: theme.borderRadius['2xl'],
borderTopLeftRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius["2xl"],
paddingHorizontal: theme.spacing[6],
paddingTop: theme.spacing[4],
gap: theme.spacing[4],
},
sheetHandle: {
alignSelf: 'center',
alignSelf: "center",
width: 40,
height: 4,
borderRadius: theme.borderRadius.full,
@@ -589,18 +545,18 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
textAlign: 'center',
textAlign: "center",
},
sheetButtonRow: {
flexDirection: 'row',
flexDirection: "row",
gap: theme.spacing[3],
},
sheetButton: {
flex: 1,
borderRadius: theme.borderRadius.lg,
paddingVertical: theme.spacing[4],
alignItems: 'center',
justifyContent: 'center',
alignItems: "center",
justifyContent: "center",
},
sheetArchiveButton: {
backgroundColor: theme.colors.primary,
@@ -621,4 +577,4 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.semibold,
fontSize: theme.fontSize.base,
},
}))
}));
@@ -1,72 +1,72 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from "vitest";
import {
getStatusSelectorHint,
normalizeModelId,
resolveAgentModelSelection,
} from './agent-status-bar.utils'
} from "./agent-status-bar.utils";
describe('getStatusSelectorHint', () => {
it('explains what each editable status control does', () => {
expect(getStatusSelectorHint('thinking')).toBe('Thinking mode')
expect(getStatusSelectorHint('model')).toBe('Change model')
expect(getStatusSelectorHint('mode')).toBe('Change permission mode')
})
})
describe("getStatusSelectorHint", () => {
it("explains what each editable status control does", () => {
expect(getStatusSelectorHint("thinking")).toBe("Thinking mode");
expect(getStatusSelectorHint("model")).toBe("Change model");
expect(getStatusSelectorHint("mode")).toBe("Change permission mode");
});
});
describe('normalizeModelId', () => {
it('treats empty and default values as unset', () => {
expect(normalizeModelId('')).toBeNull()
expect(normalizeModelId(' default ')).toBeNull()
expect(normalizeModelId(undefined)).toBeNull()
})
describe("normalizeModelId", () => {
it("treats empty and default values as unset", () => {
expect(normalizeModelId("")).toBeNull();
expect(normalizeModelId(" default ")).toBeNull();
expect(normalizeModelId(undefined)).toBeNull();
});
it('returns trimmed model ids', () => {
expect(normalizeModelId(' gpt-5.1-codex ')).toBe('gpt-5.1-codex')
})
})
it("returns trimmed model ids", () => {
expect(normalizeModelId(" gpt-5.1-codex ")).toBe("gpt-5.1-codex");
});
});
describe('resolveAgentModelSelection', () => {
it('prefers runtime model over configured model', () => {
describe("resolveAgentModelSelection", () => {
it("prefers runtime model over configured model", () => {
const selection = resolveAgentModelSelection({
models: [
{
id: 'a',
provider: 'codex',
label: 'Model A',
thinkingOptions: [{ id: 'low', label: 'Low' }],
defaultThinkingOptionId: 'low',
id: "a",
provider: "codex",
label: "Model A",
thinkingOptions: [{ id: "low", label: "Low" }],
defaultThinkingOptionId: "low",
},
],
runtimeModelId: 'a',
configuredModelId: 'b',
runtimeModelId: "a",
configuredModelId: "b",
explicitThinkingOptionId: null,
})
});
expect(selection.activeModelId).toBe('a')
expect(selection.displayModel).toBe('Model A')
expect(selection.selectedThinkingId).toBe('low')
})
expect(selection.activeModelId).toBe("a");
expect(selection.displayModel).toBe("Model A");
expect(selection.selectedThinkingId).toBe("low");
});
it('uses explicit thinking option when provided', () => {
it("uses explicit thinking option when provided", () => {
const selection = resolveAgentModelSelection({
models: [
{
id: 'a',
provider: 'codex',
label: 'Model A',
id: "a",
provider: "codex",
label: "Model A",
thinkingOptions: [
{ id: 'low', label: 'Low' },
{ id: 'high', label: 'High' },
{ id: "low", label: "Low" },
{ id: "high", label: "High" },
],
defaultThinkingOptionId: 'low',
defaultThinkingOptionId: "low",
},
],
runtimeModelId: 'a',
runtimeModelId: "a",
configuredModelId: null,
explicitThinkingOptionId: 'high',
})
explicitThinkingOptionId: "high",
});
expect(selection.selectedThinkingId).toBe('high')
expect(selection.displayThinking).toBe('High')
})
})
expect(selection.selectedThinkingId).toBe("high");
expect(selection.displayThinking).toBe("High");
});
});
+247 -231
View File
@@ -1,129 +1,130 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { View, Text, Platform, Pressable } from 'react-native'
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import {
Brain,
ChevronDown,
ShieldAlert,
ShieldCheck,
ShieldOff,
} from 'lucide-react-native'
import { getProviderIcon } from '@/components/provider-icons'
import { CombinedModelSelector } from '@/components/combined-model-selector'
import { useQuery } from '@tanstack/react-query'
import { useSessionStore } from '@/stores/session-store'
import { useCallback, useMemo, useRef, useState } from "react";
import { View, Text, Platform, Pressable, Keyboard } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Brain, ChevronDown, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons";
import { CombinedModelSelector } from "@/components/combined-model-selector";
import { useQuery } from "@tanstack/react-query";
import { useSessionStore } from "@/stores/session-store";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Combobox, ComboboxItem, type ComboboxOption } from '@/components/ui/combobox'
import { AdaptiveModalSheet } from '@/components/adaptive-modal-sheet'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
} from "@/components/ui/dropdown-menu";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type {
AgentMode,
AgentModelDefinition,
AgentProvider,
} from '@server/server/agent/agent-sdk-types'
import type { AgentProviderDefinition } from '@server/server/agent/provider-manifest'
} from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import {
getModeVisuals,
type AgentModeColorTier,
type AgentModeIcon,
} from '@server/server/agent/provider-manifest'
} from "@server/server/agent/provider-manifest";
import {
getStatusSelectorHint,
resolveAgentModelSelection,
} from '@/components/agent-status-bar.utils'
} from "@/components/agent-status-bar.utils";
type StatusOption = {
id: string
label: string
}
id: string;
label: string;
};
type StatusSelector = 'provider' | 'mode' | 'model' | 'thinking'
type StatusSelector = "provider" | "mode" | "model" | "thinking";
type ControlledAgentStatusBarProps = {
provider: string
providerOptions?: StatusOption[]
selectedProviderId?: string
onSelectProvider?: (providerId: string) => void
modeOptions?: StatusOption[]
selectedModeId?: string
onSelectMode?: (modeId: string) => void
modelOptions?: StatusOption[]
selectedModelId?: string
onSelectModel?: (modelId: string) => void
thinkingOptions?: StatusOption[]
selectedThinkingOptionId?: string
onSelectThinkingOption?: (thinkingOptionId: string) => void
disabled?: boolean
isModelLoading?: boolean
}
provider: string;
providerOptions?: StatusOption[];
selectedProviderId?: string;
onSelectProvider?: (providerId: string) => void;
modeOptions?: StatusOption[];
selectedModeId?: string;
onSelectMode?: (modeId: string) => void;
modelOptions?: StatusOption[];
selectedModelId?: string;
onSelectModel?: (modelId: string) => void;
thinkingOptions?: StatusOption[];
selectedThinkingOptionId?: string;
onSelectThinkingOption?: (thinkingOptionId: string) => void;
disabled?: boolean;
isModelLoading?: boolean;
};
export interface DraftAgentStatusBarProps {
providerDefinitions: AgentProviderDefinition[]
selectedProvider: AgentProvider
onSelectProvider: (provider: AgentProvider) => void
modeOptions: AgentMode[]
selectedMode: string
onSelectMode: (modeId: string) => void
models: AgentModelDefinition[]
selectedModel: string
onSelectModel: (modelId: string) => void
isModelLoading: boolean
allProviderModels: Map<string, AgentModelDefinition[]>
isAllModelsLoading: boolean
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void
thinkingOptions: NonNullable<AgentModelDefinition['thinkingOptions']>
selectedThinkingOptionId: string
onSelectThinkingOption: (thinkingOptionId: string) => void
disabled?: boolean
providerDefinitions: AgentProviderDefinition[];
selectedProvider: AgentProvider;
onSelectProvider: (provider: AgentProvider) => void;
modeOptions: AgentMode[];
selectedMode: string;
onSelectMode: (modeId: string) => void;
models: AgentModelDefinition[];
selectedModel: string;
onSelectModel: (modelId: string) => void;
isModelLoading: boolean;
allProviderModels: Map<string, AgentModelDefinition[]>;
isAllModelsLoading: boolean;
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void;
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
selectedThinkingOptionId: string;
onSelectThinkingOption: (thinkingOptionId: string) => void;
disabled?: boolean;
}
interface AgentStatusBarProps {
agentId: string
serverId: string
agentId: string;
serverId: string;
}
function findOptionLabel(options: StatusOption[] | undefined, selectedId: string | undefined, fallback: string) {
function findOptionLabel(
options: StatusOption[] | undefined,
selectedId: string | undefined,
fallback: string,
) {
if (!options || options.length === 0) {
return fallback
return fallback;
}
const selected = options.find((option) => option.id === selectedId)
return selected?.label ?? fallback
const selected = options.find((option) => option.id === selectedId);
return selected?.label ?? fallback;
}
const MODE_ICONS = {
ShieldCheck,
ShieldAlert,
ShieldOff,
} as const
} as const;
function getModeIconColor(
colorTier: AgentModeColorTier | undefined,
palette: { blue: { 500: string }; green: { 500: string }; amber: { 500: string }; red: { 500: string }; purple: { 500: string } }
palette: {
blue: { 500: string };
green: { 500: string };
amber: { 500: string };
red: { 500: string };
purple: { 500: string };
},
): string {
switch (colorTier) {
case 'default':
return palette.blue[500]
case 'safe':
return palette.green[500]
case 'moderate':
return palette.amber[500]
case 'dangerous':
return palette.red[500]
case 'readonly':
return palette.purple[500]
case "default":
return palette.blue[500];
case "safe":
return palette.green[500];
case "moderate":
return palette.amber[500];
case "dangerous":
return palette.red[500];
case "readonly":
return palette.purple[500];
default:
return palette.blue[500]
return palette.blue[500];
}
}
function ControlledStatusBar({
provider,
providerOptions,
@@ -141,71 +142,83 @@ function ControlledStatusBar({
disabled = false,
isModelLoading = false,
}: ControlledAgentStatusBarProps) {
const { theme } = useUnistyles()
const isWeb = Platform.OS === 'web'
const [prefsOpen, setPrefsOpen] = useState(false)
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null)
const { theme } = useUnistyles();
const isWeb = Platform.OS === "web";
const [prefsOpen, setPrefsOpen] = useState(false);
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null);
const providerAnchorRef = useRef<View>(null)
const modeAnchorRef = useRef<View>(null)
const modelAnchorRef = useRef<View>(null)
const thinkingAnchorRef = useRef<View>(null)
const providerAnchorRef = useRef<View>(null);
const modeAnchorRef = useRef<View>(null);
const modelAnchorRef = useRef<View>(null);
const thinkingAnchorRef = useRef<View>(null);
const canSelectProvider = Boolean(onSelectProvider && providerOptions && providerOptions.length > 0)
const canSelectMode = Boolean(onSelectMode && modeOptions && modeOptions.length > 0)
const canSelectModel = Boolean(onSelectModel)
const canSelectProvider = Boolean(
onSelectProvider && providerOptions && providerOptions.length > 0,
);
const canSelectMode = Boolean(onSelectMode && modeOptions && modeOptions.length > 0);
const canSelectModel = Boolean(onSelectModel);
const canSelectThinking = Boolean(
onSelectThinkingOption && thinkingOptions && thinkingOptions.length > 0
)
onSelectThinkingOption && thinkingOptions && thinkingOptions.length > 0,
);
const displayProvider = findOptionLabel(providerOptions, selectedProviderId, 'Provider')
const displayMode = findOptionLabel(modeOptions, selectedModeId, 'Default')
const displayProvider = findOptionLabel(providerOptions, selectedProviderId, "Provider");
const displayMode = findOptionLabel(modeOptions, selectedModeId, "Default");
const displayModel =
isModelLoading && (!modelOptions || modelOptions.length === 0)
? 'Loading models...'
: findOptionLabel(modelOptions, selectedModelId, 'Auto')
const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, 'auto')
? "Loading models..."
: findOptionLabel(modelOptions, selectedModelId, "Auto");
const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, "auto");
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette)
const ProviderIcon = getProviderIcon(provider)
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined;
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
const ProviderIcon = getProviderIcon(provider);
const hasAnyControl =
Boolean(providerOptions?.length) ||
Boolean(modeOptions?.length) ||
canSelectModel ||
Boolean(thinkingOptions?.length)
Boolean(thinkingOptions?.length);
if (!hasAnyControl) {
return null
return null;
}
const modelDisabled = disabled || isModelLoading || !modelOptions || modelOptions.length === 0
const modelDisabled = disabled || isModelLoading || !modelOptions || modelOptions.length === 0;
const SEARCH_THRESHOLD = 6
const SEARCH_THRESHOLD = 6;
const comboboxProviderOptions = useMemo<ComboboxOption[]>(
() => (providerOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
[providerOptions]
)
[providerOptions],
);
const comboboxModeOptions = useMemo<ComboboxOption[]>(
() => (modeOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
[modeOptions]
)
[modeOptions],
);
const comboboxModelOptions = useMemo<ComboboxOption[]>(
() => (modelOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
[modelOptions]
)
[modelOptions],
);
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
() => (thinkingOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
[thinkingOptions]
)
[thinkingOptions],
);
const renderModeOption = useCallback(
({ option, selected, active, onPress }: { option: ComboboxOption; selected: boolean; active: boolean; onPress: () => void }) => {
const visuals = getModeVisuals(provider, option.id)
const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck
({
option,
selected,
active,
onPress,
}: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => {
const visuals = getModeVisuals(provider, option.id);
const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
return (
<ComboboxItem
label={option.label}
@@ -214,24 +227,24 @@ function ControlledStatusBar({
onPress={onPress}
leadingSlot={<IconComponent size={16} color={theme.colors.foreground} />}
/>
)
);
},
[provider, theme.colors.foreground]
)
[provider, theme.colors.foreground],
);
const handleOpenChange = useCallback(
(selector: StatusSelector) => (nextOpen: boolean) => {
setOpenSelector(nextOpen ? selector : null)
setOpenSelector(nextOpen ? selector : null);
},
[]
)
[],
);
const handleSelectorPress = useCallback(
(selector: StatusSelector) => {
handleOpenChange(selector)(openSelector !== selector)
handleOpenChange(selector)(openSelector !== selector);
},
[handleOpenChange, openSelector]
)
[handleOpenChange, openSelector],
);
return (
<View style={styles.container}>
@@ -243,11 +256,11 @@ function ControlledStatusBar({
ref={providerAnchorRef}
collapsable={false}
disabled={disabled || !canSelectProvider}
onPress={() => handleSelectorPress('provider')}
onPress={() => handleSelectorPress("provider")}
style={({ pressed, hovered }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === 'provider') && styles.modeBadgePressed,
(pressed || openSelector === "provider") && styles.modeBadgePressed,
(disabled || !canSelectProvider) && styles.disabledBadge,
]}
accessibilityRole="button"
@@ -259,11 +272,11 @@ function ControlledStatusBar({
</Pressable>
<Combobox
options={comboboxProviderOptions}
value={selectedProviderId ?? ''}
value={selectedProviderId ?? ""}
onSelect={(id) => onSelectProvider?.(id)}
searchable={comboboxProviderOptions.length > SEARCH_THRESHOLD}
open={openSelector === 'provider'}
onOpenChange={handleOpenChange('provider')}
open={openSelector === "provider"}
onOpenChange={handleOpenChange("provider")}
anchorRef={providerAnchorRef}
desktopPlacement="top-start"
/>
@@ -273,7 +286,7 @@ function ControlledStatusBar({
{modeOptions && modeOptions.length > 0 ? (
<>
<Tooltip
key={`mode-${openSelector === 'mode' ? 'open' : 'closed'}`}
key={`mode-${openSelector === "mode" ? "open" : "closed"}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
@@ -283,11 +296,11 @@ function ControlledStatusBar({
ref={modeAnchorRef}
collapsable={false}
disabled={disabled || !canSelectMode}
onPress={() => handleSelectorPress('mode')}
onPress={() => handleSelectorPress("mode")}
style={({ pressed, hovered }) => [
styles.modeIconBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === 'mode') && styles.modeBadgePressed,
(pressed || openSelector === "mode") && styles.modeBadgePressed,
(disabled || !canSelectMode) && styles.disabledBadge,
]}
accessibilityRole="button"
@@ -302,16 +315,16 @@ function ControlledStatusBar({
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getStatusSelectorHint('mode')}</Text>
<Text style={styles.tooltipText}>{getStatusSelectorHint("mode")}</Text>
</TooltipContent>
</Tooltip>
<Combobox
options={comboboxModeOptions}
value={selectedModeId ?? ''}
value={selectedModeId ?? ""}
onSelect={(id) => onSelectMode?.(id)}
searchable={comboboxModeOptions.length > SEARCH_THRESHOLD}
open={openSelector === 'mode'}
onOpenChange={handleOpenChange('mode')}
open={openSelector === "mode"}
onOpenChange={handleOpenChange("mode")}
anchorRef={modeAnchorRef}
desktopPlacement="top-start"
renderOption={renderModeOption}
@@ -322,7 +335,7 @@ function ControlledStatusBar({
{canSelectModel ? (
<>
<Tooltip
key={`model-${openSelector === 'model' ? 'open' : 'closed'}`}
key={`model-${openSelector === "model" ? "open" : "closed"}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
@@ -332,11 +345,11 @@ function ControlledStatusBar({
ref={modelAnchorRef}
collapsable={false}
disabled={modelDisabled}
onPress={() => handleSelectorPress('model')}
onPress={() => handleSelectorPress("model")}
style={({ pressed, hovered }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === 'model') && styles.modeBadgePressed,
(pressed || openSelector === "model") && styles.modeBadgePressed,
modelDisabled && styles.disabledBadge,
]}
accessibilityRole="button"
@@ -349,16 +362,16 @@ function ControlledStatusBar({
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getStatusSelectorHint('model')}</Text>
<Text style={styles.tooltipText}>{getStatusSelectorHint("model")}</Text>
</TooltipContent>
</Tooltip>
<Combobox
options={comboboxModelOptions}
value={selectedModelId ?? ''}
value={selectedModelId ?? ""}
onSelect={(id) => onSelectModel?.(id)}
searchable={comboboxModelOptions.length > SEARCH_THRESHOLD}
open={openSelector === 'model'}
onOpenChange={handleOpenChange('model')}
open={openSelector === "model"}
onOpenChange={handleOpenChange("model")}
anchorRef={modelAnchorRef}
desktopPlacement="top-start"
/>
@@ -368,7 +381,7 @@ function ControlledStatusBar({
{thinkingOptions && thinkingOptions.length > 0 ? (
<>
<Tooltip
key={`thinking-${openSelector === 'thinking' ? 'open' : 'closed'}`}
key={`thinking-${openSelector === "thinking" ? "open" : "closed"}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
@@ -378,11 +391,11 @@ function ControlledStatusBar({
ref={thinkingAnchorRef}
collapsable={false}
disabled={disabled || !canSelectThinking}
onPress={() => handleSelectorPress('thinking')}
onPress={() => handleSelectorPress("thinking")}
style={({ pressed, hovered }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === 'thinking') && styles.modeBadgePressed,
(pressed || openSelector === "thinking") && styles.modeBadgePressed,
(disabled || !canSelectThinking) && styles.disabledBadge,
]}
accessibilityRole="button"
@@ -395,16 +408,16 @@ function ControlledStatusBar({
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getStatusSelectorHint('thinking')}</Text>
<Text style={styles.tooltipText}>{getStatusSelectorHint("thinking")}</Text>
</TooltipContent>
</Tooltip>
<Combobox
options={comboboxThinkingOptions}
value={selectedThinkingOptionId ?? ''}
value={selectedThinkingOptionId ?? ""}
onSelect={(id) => onSelectThinkingOption?.(id)}
searchable={comboboxThinkingOptions.length > SEARCH_THRESHOLD}
open={openSelector === 'thinking'}
onOpenChange={handleOpenChange('thinking')}
open={openSelector === "thinking"}
onOpenChange={handleOpenChange("thinking")}
anchorRef={thinkingAnchorRef}
desktopPlacement="top-start"
/>
@@ -414,30 +427,33 @@ function ControlledStatusBar({
) : (
<>
<Pressable
onPress={() => setPrefsOpen(true)}
style={({ pressed }) => [
styles.prefsButton,
pressed && styles.prefsButtonPressed,
]}
onPress={() => {
Keyboard.dismiss();
setPrefsOpen(true);
}}
style={({ pressed }) => [styles.prefsButton, pressed && styles.prefsButtonPressed]}
accessibilityRole="button"
accessibilityLabel="Agent preferences"
testID="agent-preferences-button"
>
<ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
<Text style={styles.prefsButtonText} numberOfLines={1}>{displayModel}</Text>
<Text style={styles.prefsButtonText} numberOfLines={1}>
{displayModel}
</Text>
</Pressable>
<AdaptiveModalSheet
title="Preferences"
visible={prefsOpen}
onClose={() => setPrefsOpen(false)}
stackBehavior="replace"
testID="agent-preferences-sheet"
>
{providerOptions && providerOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu
open={openSelector === 'provider'}
onOpenChange={handleOpenChange('provider')}
open={openSelector === "provider"}
onOpenChange={handleOpenChange("provider")}
>
<DropdownMenuTrigger
disabled={disabled || !canSelectProvider}
@@ -471,8 +487,8 @@ function ControlledStatusBar({
{modeOptions && modeOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu
open={openSelector === 'mode'}
onOpenChange={handleOpenChange('mode')}
open={openSelector === "mode"}
onOpenChange={handleOpenChange("mode")}
>
<DropdownMenuTrigger
disabled={disabled || !canSelectMode}
@@ -493,8 +509,8 @@ function ControlledStatusBar({
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{modeOptions.map((mode) => {
const visuals = getModeVisuals(provider, mode.id)
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck
const visuals = getModeVisuals(provider, mode.id);
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
return (
<DropdownMenuItem
key={mode.id}
@@ -504,7 +520,7 @@ function ControlledStatusBar({
>
{mode.label}
</DropdownMenuItem>
)
);
})}
</DropdownMenuContent>
</DropdownMenu>
@@ -514,8 +530,8 @@ function ControlledStatusBar({
{canSelectModel ? (
<View style={styles.sheetSection}>
<DropdownMenu
open={openSelector === 'model'}
onOpenChange={handleOpenChange('model')}
open={openSelector === "model"}
onOpenChange={handleOpenChange("model")}
>
<DropdownMenuTrigger
disabled={modelDisabled}
@@ -549,8 +565,8 @@ function ControlledStatusBar({
{thinkingOptions && thinkingOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu
open={openSelector === 'thinking'}
onOpenChange={handleOpenChange('thinking')}
open={openSelector === "thinking"}
onOpenChange={handleOpenChange("thinking")}
>
<DropdownMenuTrigger
disabled={disabled || !canSelectThinking}
@@ -584,68 +600,68 @@ function ControlledStatusBar({
</>
)}
</View>
)
);
}
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId))
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null)
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId));
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const modelsQuery = useQuery({
queryKey: [
'providerModels',
"providerModels",
serverId,
agent?.provider ?? '__missing_provider__',
agent?.cwd ?? '__missing_cwd__',
agent?.provider ?? "__missing_provider__",
agent?.cwd ?? "__missing_cwd__",
],
enabled: Boolean(client && agent?.provider),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client || !agent) {
throw new Error('Daemon client unavailable')
throw new Error("Daemon client unavailable");
}
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd })
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
if (payload.error) {
throw new Error(payload.error)
throw new Error(payload.error);
}
return payload.models ?? []
return payload.models ?? [];
},
})
});
const models = modelsQuery.data ?? null
const models = modelsQuery.data ?? null;
const displayMode =
agent?.availableModes?.find((mode) => mode.id === agent.currentModeId)?.label ||
agent?.currentModeId ||
'default'
"default";
const modelSelection = resolveAgentModelSelection({
models,
runtimeModelId: agent?.runtimeInfo?.model,
configuredModelId: agent?.model,
explicitThinkingOptionId: agent?.thinkingOptionId,
})
});
const modeOptions = useMemo<StatusOption[]>(() => {
return (agent?.availableModes ?? []).map((mode) => ({
id: mode.id,
label: mode.label,
}))
}, [agent?.availableModes])
}));
}, [agent?.availableModes]);
const modelOptions = useMemo<StatusOption[]>(() => {
return (models ?? []).map((model) => ({ id: model.id, label: model.label }))
}, [models])
return (models ?? []).map((model) => ({ id: model.id, label: model.label }));
}, [models]);
const thinkingOptions = useMemo<StatusOption[]>(() => {
return (modelSelection.thinkingOptions ?? []).map((option) => ({
id: option.id,
label: option.label,
}))
}, [modelSelection.thinkingOptions])
}));
}, [modelSelection.thinkingOptions]);
if (!agent) {
return null
return null;
}
return (
@@ -654,41 +670,41 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
modeOptions={
modeOptions.length > 0
? modeOptions
: [{ id: agent.currentModeId ?? '', label: displayMode }]
: [{ id: agent.currentModeId ?? "", label: displayMode }]
}
selectedModeId={agent.currentModeId ?? undefined}
onSelectMode={(modeId) => {
if (!client) {
return
return;
}
void client.setAgentMode(agentId, modeId).catch((error) => {
console.warn('[AgentStatusBar] setAgentMode failed', error)
})
console.warn("[AgentStatusBar] setAgentMode failed", error);
});
}}
modelOptions={modelOptions}
selectedModelId={modelSelection.activeModelId ?? undefined}
onSelectModel={(modelId) => {
if (!client) {
return
return;
}
void client.setAgentModel(agentId, modelId).catch((error) => {
console.warn('[AgentStatusBar] setAgentModel failed', error)
})
console.warn("[AgentStatusBar] setAgentModel failed", error);
});
}}
thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined}
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
onSelectThinkingOption={(thinkingOptionId) => {
if (!client) {
return
return;
}
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
console.warn('[AgentStatusBar] setAgentThinkingOption failed', error)
})
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
});
}}
isModelLoading={modelsQuery.isPending || modelsQuery.isFetching}
disabled={!client}
/>
)
);
}
export function DraftAgentStatusBar({
@@ -710,25 +726,25 @@ export function DraftAgentStatusBar({
onSelectThinkingOption,
disabled = false,
}: DraftAgentStatusBarProps) {
const isWeb = Platform.OS === 'web'
const isWeb = Platform.OS === "web";
const mappedModeOptions = useMemo<StatusOption[]>(() => {
if (modeOptions.length === 0) {
return [{ id: '', label: 'Default' }]
return [{ id: "", label: "Default" }];
}
return modeOptions.map((mode) => ({
id: mode.id,
label: mode.label,
}))
}, [modeOptions])
}));
}, [modeOptions]);
const mappedThinkingOptions = useMemo<StatusOption[]>(() => {
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }))
}, [thinkingOptions])
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }));
}, [thinkingOptions]);
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || ''
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
const effectiveSelectedThinkingOption =
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
if (isWeb) {
return (
@@ -753,17 +769,17 @@ export function DraftAgentStatusBar({
disabled={disabled}
/>
</View>
)
);
}
const providerOptions = providerDefinitions.map((definition) => ({
id: definition.id,
label: definition.label,
}))
}));
const modelOptions: StatusOption[] = [{ id: '', label: 'Auto' }]
const modelOptions: StatusOption[] = [{ id: "", label: "Auto" }];
for (const model of models) {
modelOptions.push({ id: model.id, label: model.label })
modelOptions.push({ id: model.id, label: model.label });
}
return (
@@ -784,30 +800,30 @@ export function DraftAgentStatusBar({
onSelectThinkingOption={onSelectThinkingOption}
disabled={disabled}
/>
)
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flexDirection: 'row',
alignItems: 'flex-end',
flexDirection: "row",
alignItems: "flex-end",
gap: theme.spacing[1],
},
modeBadge: {
height: 28,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'transparent',
flexDirection: "row",
alignItems: "center",
backgroundColor: "transparent",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius['2xl'],
borderRadius: theme.borderRadius["2xl"],
},
modeIconBadge: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'transparent',
alignItems: "center",
justifyContent: "center",
backgroundColor: "transparent",
borderRadius: theme.borderRadius.full,
},
modeBadgeHovered: {
@@ -831,11 +847,11 @@ const styles = StyleSheet.create((theme) => ({
},
prefsButton: {
height: 28,
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius['2xl'],
borderRadius: theme.borderRadius["2xl"],
},
prefsButtonPressed: {
backgroundColor: theme.colors.surface0,
@@ -850,9 +866,9 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
},
sheetSelect: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
@@ -873,4 +889,4 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
},
}))
}));
@@ -1,51 +1,54 @@
import type { AgentModelDefinition } from '@server/server/agent/agent-sdk-types'
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
export type ExplainedStatusSelector = 'mode' | 'model' | 'thinking'
export type ExplainedStatusSelector = "mode" | "model" | "thinking";
export function getStatusSelectorHint(selector: ExplainedStatusSelector): string {
switch (selector) {
case 'thinking':
return 'Thinking mode'
case 'model':
return 'Change model'
case 'mode':
return 'Change permission mode'
case "thinking":
return "Thinking mode";
case "model":
return "Change model";
case "mode":
return "Change permission mode";
}
}
export function normalizeModelId(modelId: string | null | undefined): string | null {
const normalized = typeof modelId === 'string' ? modelId.trim() : ''
if (!normalized || normalized.toLowerCase() === 'default') {
return null
const normalized = typeof modelId === "string" ? modelId.trim() : "";
if (!normalized || normalized.toLowerCase() === "default") {
return null;
}
return normalized
return normalized;
}
export function resolveAgentModelSelection(input: {
models: AgentModelDefinition[] | null
runtimeModelId: string | null | undefined
configuredModelId: string | null | undefined
explicitThinkingOptionId: string | null | undefined
models: AgentModelDefinition[] | null;
runtimeModelId: string | null | undefined;
configuredModelId: string | null | undefined;
explicitThinkingOptionId: string | null | undefined;
}) {
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId)
const normalizedConfiguredModelId = normalizeModelId(configuredModelId)
const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input;
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId);
const normalizedConfiguredModelId = normalizeModelId(configuredModelId);
const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId;
const selectedModel =
models && preferredModelId ? models.find((model) => model.id === preferredModelId) ?? null : null
models && preferredModelId
? (models.find((model) => model.id === preferredModelId) ?? null)
: null;
const activeModelId = selectedModel?.id ?? preferredModelId ?? null
const displayModel = selectedModel?.label ?? preferredModelId ?? 'Auto'
const activeModelId = selectedModel?.id ?? preferredModelId ?? null;
const displayModel = selectedModel?.label ?? preferredModelId ?? "Auto";
const thinkingOptions = selectedModel?.thinkingOptions ?? null
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
const selectedThinkingId =
explicitThinkingOptionId && explicitThinkingOptionId !== 'default'
explicitThinkingOptionId && explicitThinkingOptionId !== "default"
? explicitThinkingOptionId
: selectedModel?.defaultThinkingOptionId ?? null
const selectedThinking = thinkingOptions?.find((option) => option.id === selectedThinkingId) ?? null
: (selectedModel?.defaultThinkingOptionId ?? null);
const selectedThinking =
thinkingOptions?.find((option) => option.id === selectedThinkingId) ?? null;
const displayThinking =
selectedThinking?.label ??
(selectedThinkingId === 'default' ? 'Model default' : selectedThinkingId ?? 'auto')
(selectedThinkingId === "default" ? "Model default" : (selectedThinkingId ?? "auto"));
return {
selectedModel,
@@ -54,5 +57,5 @@ export function resolveAgentModelSelection(input: {
thinkingOptions,
selectedThinkingId,
displayThinking,
}
};
}
@@ -128,7 +128,7 @@ function splitOrderedTail(params: {
}
export function buildAgentStreamRenderModel(
input: BuildAgentStreamRenderModelInput
input: BuildAgentStreamRenderModelInput,
): AgentStreamRenderModel {
const strategy = resolveStreamRenderStrategy({
platform: input.platform === "web" ? "web" : "native",
@@ -78,7 +78,7 @@ describe("resolveStreamRenderStrategy", () => {
resolveBottomAnchorTransportBehavior({
strategy,
isViewportSettling: true,
})
}),
).toEqual({
verificationDelayFrames: 4,
verificationRetryMode: "recheck",
@@ -95,7 +95,7 @@ describe("resolveStreamRenderStrategy", () => {
resolveBottomAnchorTransportBehavior({
strategy,
isViewportSettling: true,
})
}),
).toEqual({
verificationDelayFrames: 0,
verificationRetryMode: "rescroll",
@@ -172,7 +172,7 @@ describe("neighbor and traversal semantics", () => {
strategy: forward,
items: chronological,
startIndex: forwardStartIndex,
})
}),
).toBe("assistant-1\n\nassistant-2");
const inverted = resolveStreamRenderStrategy({
@@ -189,7 +189,7 @@ describe("neighbor and traversal semantics", () => {
strategy: inverted,
items: invertedItems,
startIndex: invertedStartIndex,
})
}),
).toBe("assistant-1\n\nassistant-2");
});
@@ -206,7 +206,7 @@ describe("neighbor and traversal semantics", () => {
items,
index: 0,
relation: "above",
})
}),
).toBeUndefined();
expect(
getStreamNeighborItem({
@@ -214,7 +214,7 @@ describe("neighbor and traversal semantics", () => {
items,
index: 0,
relation: "below",
})
}),
).toBeUndefined();
});
});
@@ -233,7 +233,7 @@ describe("scroll/bottom calculations", () => {
viewportHeight: 300,
contentHeight: 1000,
threshold: 24,
})
}),
).toBe(true);
expect(
isNearBottomForStreamRenderStrategy({
@@ -242,7 +242,7 @@ describe("scroll/bottom calculations", () => {
viewportHeight: 300,
contentHeight: 1000,
threshold: 24,
})
}),
).toBe(false);
});
@@ -259,7 +259,7 @@ describe("scroll/bottom calculations", () => {
viewportHeight: 300,
contentHeight: 1000,
threshold: 24,
})
}),
).toBe(true);
expect(
isNearBottomForStreamRenderStrategy({
@@ -268,14 +268,14 @@ describe("scroll/bottom calculations", () => {
viewportHeight: 300,
contentHeight: 1000,
threshold: 24,
})
}),
).toBe(false);
expect(
getBottomOffsetForStreamRenderStrategy({
strategy,
viewportHeight: 300,
contentHeight: 1000,
})
}),
).toBe(0);
});
@@ -290,7 +290,7 @@ describe("scroll/bottom calculations", () => {
strategy,
viewportHeight: 320,
contentHeight: 1000,
})
}),
).toBe(680);
});
});
File diff suppressed because it is too large Load Diff
@@ -62,7 +62,7 @@ describe("findMountedWindowStart", () => {
findMountedWindowStart({
items,
minMountedCount: 50,
})
}),
).toBe(0);
});
@@ -79,7 +79,7 @@ describe("findMountedWindowStart", () => {
findMountedWindowStart({
items,
minMountedCount: 50,
})
}),
).toBe(39);
});
});
@@ -136,20 +136,16 @@ describe("web virtualization test overrides", () => {
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: unknown;
};
const previousThreshold =
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
const previousMounted =
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
const previousThreshold = globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
const previousMounted = globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
try {
delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
delete globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
expect(getWebPartialVirtualizationThreshold()).toBe(
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD
);
expect(getWebMountedRecentStreamItems()).toBe(
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
);
expect(getWebMountedRecentStreamItems()).toBe(DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS);
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 6;
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = 4;
@@ -159,14 +155,12 @@ describe("web virtualization test overrides", () => {
if (previousThreshold === undefined) {
delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
} else {
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD =
previousThreshold;
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = previousThreshold;
}
if (previousMounted === undefined) {
delete globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
} else {
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS =
previousMounted;
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = previousMounted;
}
}
});
@@ -18,16 +18,14 @@ function readPositiveIntegerOverride(value: unknown): number | null {
export function getWebPartialVirtualizationThreshold(): number {
const override = readPositiveIntegerOverride(
(globalThis as BottomAnchorE2ETestGlobals)
.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD
(globalThis as BottomAnchorE2ETestGlobals).__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
);
return override ?? DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
}
export function getWebMountedRecentStreamItems(): number {
const override = readPositiveIntegerOverride(
(globalThis as BottomAnchorE2ETestGlobals)
.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS
(globalThis as BottomAnchorE2ETestGlobals).__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS,
);
return override ?? DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS;
}
@@ -1,35 +1,35 @@
import { useState } from 'react'
import { View, Text } from 'react-native'
import { StyleSheet } from 'react-native-unistyles'
import Animated from 'react-native-reanimated'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from '@/constants/layout'
import { useHostRuntimeClient, useHostRuntimeIsConnected } from '@/runtime/host-runtime'
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
import { Button } from '@/components/ui/button'
import type { Theme } from '@/styles/theme'
import { useState } from "react";
import { View, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import Animated from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { Button } from "@/components/ui/button";
import type { Theme } from "@/styles/theme";
interface ArchivedAgentCalloutProps {
serverId: string
agentId: string
serverId: string;
agentId: string;
}
export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCalloutProps) {
const insets = useSafeAreaInsets()
const client = useHostRuntimeClient(serverId)
const isConnected = useHostRuntimeIsConnected(serverId)
const [isUnarchiving, setIsUnarchiving] = useState(false)
const insets = useSafeAreaInsets();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const [isUnarchiving, setIsUnarchiving] = useState(false);
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: 'translate' })
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: "translate" });
async function handleUnarchive() {
if (!client || !isConnected || isUnarchiving) return
setIsUnarchiving(true)
if (!client || !isConnected || isUnarchiving) return;
setIsUnarchiving(true);
try {
await client.refreshAgent(agentId)
await client.refreshAgent(agentId);
} catch (error) {
console.error('[ArchivedAgentCallout] Failed to unarchive agent:', error)
setIsUnarchiving(false)
console.error("[ArchivedAgentCallout] Failed to unarchive agent:", error);
setIsUnarchiving(false);
}
}
@@ -53,36 +53,36 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
</View>
</View>
</Animated.View>
)
);
}
const styles = StyleSheet.create(((theme: Theme) => ({
container: {
flexDirection: 'column',
position: 'relative',
flexDirection: "column",
position: "relative",
},
inputAreaContainer: {
position: 'relative',
position: "relative",
minHeight: FOOTER_HEIGHT,
marginHorizontal: 'auto',
alignItems: 'center',
width: '100%',
overflow: 'visible',
marginHorizontal: "auto",
alignItems: "center",
width: "100%",
overflow: "visible",
padding: theme.spacing[4],
},
inputAreaContent: {
width: '100%',
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
},
callout: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius['2xl'],
borderRadius: theme.borderRadius["2xl"],
paddingVertical: {
xs: theme.spacing[3],
md: theme.spacing[4],
@@ -96,4 +96,4 @@ const styles = StyleSheet.create(((theme: Theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
})) as any) as Record<string, any>
})) as any) as Record<string, any>;
@@ -1,10 +1,4 @@
import {
View,
Text,
ScrollView,
Pressable,
Modal,
} from "react-native";
import { View, Text, ScrollView, Pressable, Modal } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
@@ -185,15 +179,8 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
</Text>
</View>
<View style={styles.headerActions}>
<View
style={[
styles.badge,
typeBadgeStyles[artifact.type],
]}
>
<Text style={styles.badgeText}>
{artifact.type.toUpperCase()}
</Text>
<View style={[styles.badge, typeBadgeStyles[artifact.type]]}>
<Text style={styles.badgeText}>{artifact.type.toUpperCase()}</Text>
</View>
<Pressable onPress={onClose} style={styles.closeButton}>
<Text style={styles.closeButtonText}>×</Text>
@@ -208,12 +195,8 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
>
{artifact.type === "image" ? (
<View style={styles.imagePlaceholder}>
<Text style={styles.imagePlaceholderText}>
Image viewing not yet implemented
</Text>
<Text style={styles.imagePlaceholderSubtext}>
Base64 image data received
</Text>
<Text style={styles.imagePlaceholderText}>Image viewing not yet implemented</Text>
<Text style={styles.imagePlaceholderSubtext}>Base64 image data received</Text>
</View>
) : (
<View style={styles.codeContainer}>
@@ -244,9 +227,7 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
</View>
<View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>Size:</Text>
<Text style={styles.metadataValue}>
{content.length.toLocaleString()} characters
</Text>
<Text style={styles.metadataValue}>{content.length.toLocaleString()} characters</Text>
</View>
</View>
</View>
@@ -49,7 +49,11 @@ function formatDuration(duration?: number): string | null {
return `${seconds.toFixed(seconds >= 10 ? 0 : 1)} s`;
}
export function AudioDebugNotice({ info, onDismiss, title = "Dictation Debug" }: AudioDebugNoticeProps) {
export function AudioDebugNotice({
info,
onDismiss,
title = "Dictation Debug",
}: AudioDebugNoticeProps) {
const { theme } = useUnistyles();
const [copied, setCopied] = useState(false);
@@ -115,11 +119,12 @@ export function AudioDebugNotice({ info, onDismiss, title = "Dictation Debug" }:
</View>
{info.debugRecordingPath ? (
<Pressable style={styles.pathRow} onPress={handleCopyPath} accessibilityLabel="Copy raw audio path">
<Text
numberOfLines={2}
style={[styles.pathText, { color: theme.colors.foreground }]}
>
<Pressable
style={styles.pathRow}
onPress={handleCopyPath}
accessibilityLabel="Copy raw audio path"
>
<Text numberOfLines={2} style={[styles.pathText, { color: theme.colors.foreground }]}>
{info.debugRecordingPath}
</Text>
<View style={[styles.copyPill, { backgroundColor: theme.colors.primary }]}>
@@ -137,9 +142,7 @@ export function AudioDebugNotice({ info, onDismiss, title = "Dictation Debug" }:
)}
{stats ? (
<Text style={[styles.stats, { color: theme.colors.foregroundMuted }]}>
{stats}
</Text>
<Text style={[styles.stats, { color: theme.colors.foregroundMuted }]}>{stats}</Text>
) : null}
</View>
);
@@ -1,3 +1,12 @@
/**
* Compute the pixel width for a line-number gutter based on the highest
* line number that will be displayed. Minimum width accommodates 2 digits.
*/
export function lineNumberGutterWidth(maxLineNumber: number): number {
const digits = Math.max(2, String(maxLineNumber).length);
return digits * 8 + 12;
}
export function getCodeInsets(theme: any) {
const padding =
typeof theme.spacing?.[3] === "number"
@@ -1,24 +1,24 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { View, Text, Pressable, Platform } from 'react-native'
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import { ArrowLeft, Check, ChevronDown, ChevronRight } from 'lucide-react-native'
import type { AgentModelDefinition, AgentProvider } from '@server/server/agent/agent-sdk-types'
import type { AgentProviderDefinition } from '@server/server/agent/provider-manifest'
import { Combobox, ComboboxItem, SearchInput } from '@/components/ui/combobox'
import { getProviderIcon } from '@/components/provider-icons'
import { useCallback, useMemo, useRef, useState } from "react";
import { View, Text, Pressable, Platform } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ArrowLeft, Check, ChevronDown, ChevronRight } from "lucide-react-native";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { Combobox, ComboboxItem, SearchInput } from "@/components/ui/combobox";
import { getProviderIcon } from "@/components/provider-icons";
const INLINE_MODEL_THRESHOLD = 8
const INLINE_MODEL_THRESHOLD = 8;
type DrillDownView = { provider: string }
type DrillDownView = { provider: string };
interface CombinedModelSelectorProps {
providerDefinitions: AgentProviderDefinition[]
allProviderModels: Map<string, AgentModelDefinition[]>
selectedProvider: string
selectedModel: string
onSelect: (provider: AgentProvider, modelId: string) => void
isLoading: boolean
disabled?: boolean
providerDefinitions: AgentProviderDefinition[];
allProviderModels: Map<string, AgentModelDefinition[]>;
selectedProvider: string;
selectedModel: string;
onSelect: (provider: AgentProvider, modelId: string) => void;
isLoading: boolean;
disabled?: boolean;
}
export function CombinedModelSelector({
@@ -30,43 +30,46 @@ export function CombinedModelSelector({
isLoading,
disabled = false,
}: CombinedModelSelectorProps) {
const { theme } = useUnistyles()
const anchorRef = useRef<View>(null)
const [isOpen, setIsOpen] = useState(false)
const [view, setView] = useState<'groups' | DrillDownView>('groups')
const [searchQuery, setSearchQuery] = useState('')
const { theme } = useUnistyles();
const anchorRef = useRef<View>(null);
const [isOpen, setIsOpen] = useState(false);
const [view, setView] = useState<"groups" | DrillDownView>("groups");
const [searchQuery, setSearchQuery] = useState("");
const handleOpenChange = useCallback((open: boolean) => {
setIsOpen(open)
if (open) {
const models = allProviderModels.get(selectedProvider)
if (models && models.length > INLINE_MODEL_THRESHOLD) {
setView({ provider: selectedProvider })
const handleOpenChange = useCallback(
(open: boolean) => {
setIsOpen(open);
if (open) {
const models = allProviderModels.get(selectedProvider);
if (models && models.length > INLINE_MODEL_THRESHOLD) {
setView({ provider: selectedProvider });
}
} else {
setView("groups");
setSearchQuery("");
}
} else {
setView('groups')
setSearchQuery('')
}
}, [allProviderModels, selectedProvider])
},
[allProviderModels, selectedProvider],
);
const handleSelect = useCallback(
(provider: string, modelId: string) => {
onSelect(provider as AgentProvider, modelId)
setIsOpen(false)
setView('groups')
setSearchQuery('')
onSelect(provider as AgentProvider, modelId);
setIsOpen(false);
setView("groups");
setSearchQuery("");
},
[onSelect]
)
[onSelect],
);
const ProviderIcon = getProviderIcon(selectedProvider)
const ProviderIcon = getProviderIcon(selectedProvider);
const selectedModelLabel = useMemo(() => {
const models = allProviderModels.get(selectedProvider)
if (!models) return isLoading ? 'Loading...' : 'Auto'
const model = models.find((m) => m.id === selectedModel)
return model?.label ?? 'Auto'
}, [allProviderModels, selectedProvider, selectedModel, isLoading])
const models = allProviderModels.get(selectedProvider);
if (!models) return isLoading ? "Loading..." : "Auto";
const model = models.find((m) => m.id === selectedModel);
return model?.label ?? "Auto";
}, [allProviderModels, selectedProvider, selectedModel, isLoading]);
return (
<>
@@ -99,7 +102,7 @@ export function CombinedModelSelector({
desktopPlacement="top-start"
title="Select model"
>
{view === 'groups' ? (
{view === "groups" ? (
<GroupsView
providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels}
@@ -107,8 +110,8 @@ export function CombinedModelSelector({
selectedModel={selectedModel}
onSelect={handleSelect}
onDrillDown={(provider) => {
setView({ provider })
setSearchQuery('')
setView({ provider });
setSearchQuery("");
}}
/>
) : (
@@ -122,14 +125,14 @@ export function CombinedModelSelector({
onSearchChange={setSearchQuery}
onSelect={handleSelect}
onBack={() => {
setView('groups')
setSearchQuery('')
setView("groups");
setSearchQuery("");
}}
/>
)}
</Combobox>
</>
)
);
}
function GroupsView({
@@ -140,21 +143,21 @@ function GroupsView({
onSelect,
onDrillDown,
}: {
providerDefinitions: AgentProviderDefinition[]
allProviderModels: Map<string, AgentModelDefinition[]>
selectedProvider: string
selectedModel: string
onSelect: (provider: string, modelId: string) => void
onDrillDown: (provider: string) => void
providerDefinitions: AgentProviderDefinition[];
allProviderModels: Map<string, AgentModelDefinition[]>;
selectedProvider: string;
selectedModel: string;
onSelect: (provider: string, modelId: string) => void;
onDrillDown: (provider: string) => void;
}) {
const { theme } = useUnistyles()
const { theme } = useUnistyles();
return (
<View>
{providerDefinitions.map((def, index) => {
const models = allProviderModels.get(def.id) ?? []
const isInline = models.length <= INLINE_MODEL_THRESHOLD
const ProvIcon = getProviderIcon(def.id)
const models = allProviderModels.get(def.id) ?? [];
const isInline = models.length <= INLINE_MODEL_THRESHOLD;
const ProvIcon = getProviderIcon(def.id);
return (
<View key={def.id}>
@@ -193,10 +196,10 @@ function GroupsView({
</Pressable>
)}
</View>
)
);
})}
</View>
)
);
}
function DrillDownModelView({
@@ -210,27 +213,27 @@ function DrillDownModelView({
onSelect,
onBack,
}: {
provider: string
providerDefinitions: AgentProviderDefinition[]
models: AgentModelDefinition[]
selectedProvider: string
selectedModel: string
searchQuery: string
onSearchChange: (query: string) => void
onSelect: (provider: string, modelId: string) => void
onBack: () => void
provider: string;
providerDefinitions: AgentProviderDefinition[];
models: AgentModelDefinition[];
selectedProvider: string;
selectedModel: string;
searchQuery: string;
onSearchChange: (query: string) => void;
onSelect: (provider: string, modelId: string) => void;
onBack: () => void;
}) {
const { theme } = useUnistyles()
const ProvIcon = getProviderIcon(provider)
const providerLabel = providerDefinitions.find((d) => d.id === provider)?.label ?? provider
const { theme } = useUnistyles();
const ProvIcon = getProviderIcon(provider);
const providerLabel = providerDefinitions.find((d) => d.id === provider)?.label ?? provider;
const filteredModels = useMemo(() => {
if (!searchQuery.trim()) return models
const q = searchQuery.toLowerCase()
if (!searchQuery.trim()) return models;
const q = searchQuery.toLowerCase();
return models.filter(
(m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q)
)
}, [models, searchQuery])
(m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q),
);
}, [models, searchQuery]);
return (
<View>
@@ -251,7 +254,7 @@ function DrillDownModelView({
placeholder="Search models..."
value={searchQuery}
onChangeText={onSearchChange}
autoFocus={Platform.OS === 'web'}
autoFocus={Platform.OS === "web"}
/>
{filteredModels.map((model) => (
@@ -270,18 +273,18 @@ function DrillDownModelView({
</View>
) : null}
</View>
)
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
height: 28,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'transparent',
flexDirection: "row",
alignItems: "center",
backgroundColor: "transparent",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius['2xl'],
borderRadius: theme.borderRadius["2xl"],
},
triggerHovered: {
backgroundColor: theme.colors.surface2,
@@ -303,8 +306,8 @@ const styles = StyleSheet.create((theme) => ({
marginVertical: theme.spacing[1],
},
sectionHeading: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
@@ -315,8 +318,8 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
},
drillDownRow: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
@@ -334,8 +337,8 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
},
drillDownTrailing: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
drillDownCount: {
@@ -343,8 +346,8 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
},
backButton: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
@@ -363,10 +366,10 @@ const styles = StyleSheet.create((theme) => ({
},
emptyState: {
paddingVertical: theme.spacing[4],
alignItems: 'center',
alignItems: "center",
},
emptyStateText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
}))
}));
+17 -40
View File
@@ -1,12 +1,4 @@
import {
Modal,
Pressable,
ScrollView,
Text,
TextInput,
View,
Platform,
} from "react-native";
import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native";
import { memo, useEffect, useRef, type ReactNode } from "react";
import { Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -54,16 +46,8 @@ const CommandCenterRow = memo(function CommandCenterRow({
export function CommandCenter() {
const { theme } = useUnistyles();
const {
open,
inputRef,
query,
setQuery,
activeIndex,
items,
handleClose,
handleSelectItem,
} = useCommandCenter();
const { open, inputRef, query, setQuery, activeIndex, items, handleClose, handleSelectItem } =
useCommandCenter();
const rowRefs = useRef<Map<number, View>>(new Map());
const resultsRef = useRef<ScrollView>(null);
@@ -77,11 +61,13 @@ export function CommandCenter() {
return;
}
const scrollNode =
(resultsRef.current as
| (ScrollView & {
getScrollableNode?: () => HTMLElement | null;
})
| null)?.getScrollableNode?.() ?? null;
(
resultsRef.current as
| (ScrollView & {
getScrollableNode?: () => HTMLElement | null;
})
| null
)?.getScrollableNode?.() ?? null;
const rowEl = row as unknown as HTMLElement;
if (!scrollNode) {
@@ -110,18 +96,16 @@ export function CommandCenter() {
const agentItems = items.filter((item) => item.kind === "agent");
return (
<Modal
visible={open}
transparent
animationType="fade"
onRequestClose={handleClose}
>
<Modal visible={open} transparent animationType="fade" onRequestClose={handleClose}>
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={handleClose} />
<View
testID="command-center-panel"
style={[styles.panel, { borderColor: theme.colors.border, backgroundColor: theme.colors.surface0 }]}
style={[
styles.panel,
{ borderColor: theme.colors.border, backgroundColor: theme.colors.surface0 },
]}
>
<View style={[styles.header, { borderBottomColor: theme.colors.border }]}>
<TextInput
@@ -131,10 +115,7 @@ export function CommandCenter() {
onChangeText={setQuery}
placeholder="Type a command or search agents..."
placeholderTextColor={theme.colors.foregroundMuted}
style={[
styles.input,
{ color: theme.colors.foreground },
]}
style={[styles.input, { color: theme.colors.foreground }]}
autoCapitalize="none"
autoCorrect={false}
autoFocus
@@ -164,11 +145,7 @@ export function CommandCenter() {
const action = item.action;
const actionIcon =
action.icon === "plus" ? (
<Plus
size={16}
strokeWidth={2.4}
color={theme.colors.foregroundMuted}
/>
<Plus size={16} strokeWidth={2.4} color={theme.colors.foregroundMuted} />
) : action.icon === "settings" ? (
<Settings
size={16}
@@ -40,18 +40,8 @@ export function ConnectionStatus({ isConnected }: ConnectionStatusProps) {
return (
<View style={styles.container}>
<View style={styles.row}>
<View
style={[
styles.dot,
isConnected ? styles.dotConnected : styles.dotDisconnected,
]}
/>
<Text
style={[
styles.text,
isConnected ? styles.textConnected : styles.textDisconnected,
]}
>
<View style={[styles.dot, isConnected ? styles.dotConnected : styles.dotDisconnected]} />
<Text style={[styles.text, isConnected ? styles.textConnected : styles.textDisconnected]}>
{isConnected ? "Connected" : "Disconnected"}
</Text>
</View>
@@ -24,9 +24,7 @@ interface DictationControlsProps {
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, "0")}:${secs
.toString()
.padStart(2, "0")}`;
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
}
export function DictationControls({
@@ -66,12 +64,7 @@ export function DictationControls({
return (
<View style={styles.activeContainer}>
<View style={styles.meterWrapper}>
<VolumeMeter
volume={volume}
isMuted={false}
isSpeaking={false}
orientation="horizontal"
/>
<VolumeMeter volume={volume} isMuted={false} isSpeaking={false} orientation="horizontal" />
</View>
<Text style={[styles.timerText, { color: theme.colors.foreground }]}>
{formatDuration(duration)}
@@ -152,12 +145,7 @@ export function DictationOverlay({
}
return (
<View
style={[
overlayStyles.container,
{ backgroundColor: theme.colors.accent },
]}
>
<View style={[overlayStyles.container, { backgroundColor: theme.colors.accent }]}>
<Pressable
onPress={handleCancel}
disabled={actionsDisabled && !isFailed}
@@ -180,12 +168,7 @@ export function DictationOverlay({
orientation="horizontal"
color={theme.colors.palette.white}
/>
<Text
style={[
overlayStyles.timerText,
{ color: theme.colors.palette.white },
]}
>
<Text style={[overlayStyles.timerText, { color: theme.colors.palette.white }]}>
{formatDuration(duration)}
</Text>
</View>
@@ -205,26 +188,16 @@ export function DictationOverlay({
<View style={overlayStyles.actionButtonsContainer}>
{actionsDisabled ? (
<View style={overlayStyles.loadingContainer}>
<ActivityIndicator
size="small"
color={theme.colors.palette.white}
/>
<ActivityIndicator size="small" color={theme.colors.palette.white} />
</View>
) : isFailed ? (
<Pressable
onPress={onRetry}
accessibilityRole="button"
accessibilityLabel="Retry dictation"
style={[
overlayStyles.actionButton,
{ backgroundColor: theme.colors.palette.white },
]}
style={[overlayStyles.actionButton, { backgroundColor: theme.colors.palette.white }]}
>
<RefreshCcw
size={theme.iconSize.lg}
color={theme.colors.accent}
strokeWidth={2.5}
/>
<RefreshCcw size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
</Pressable>
) : (
<>
@@ -232,10 +205,7 @@ export function DictationOverlay({
onPress={onAccept}
accessibilityRole="button"
accessibilityLabel="Insert transcription"
style={[
overlayStyles.actionButton,
{ backgroundColor: "rgba(255, 255, 255, 0.25)" },
]}
style={[overlayStyles.actionButton, { backgroundColor: "rgba(255, 255, 255, 0.25)" }]}
>
<Pencil
size={theme.iconSize.lg}
@@ -247,16 +217,9 @@ export function DictationOverlay({
onPress={onAcceptAndSend}
accessibilityRole="button"
accessibilityLabel="Insert transcription and send"
style={[
overlayStyles.actionButton,
{ backgroundColor: theme.colors.palette.white },
]}
style={[overlayStyles.actionButton, { backgroundColor: theme.colors.palette.white }]}
>
<ArrowUp
size={theme.iconSize.lg}
color={theme.colors.accent}
strokeWidth={2.5}
/>
<ArrowUp size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
</Pressable>
</>
)}
@@ -1,6 +1,14 @@
import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AlertTriangle, CheckCircle2, Info, RefreshCcw, RotateCcw, WifiOff, X } from "lucide-react-native";
import {
AlertTriangle,
CheckCircle2,
Info,
RefreshCcw,
RotateCcw,
WifiOff,
X,
} from "lucide-react-native";
export type DictationToastVariant = "info" | "success" | "warning" | "error";
@@ -53,7 +61,8 @@ export function DictationStatusNotice({
})();
const foregroundColor = variant === "info" ? theme.colors.foreground : theme.colors.palette.white;
const secondaryColor = variant === "info" ? theme.colors.foregroundMuted : theme.colors.palette.white;
const secondaryColor =
variant === "info" ? theme.colors.foregroundMuted : theme.colors.palette.white;
return (
<View
@@ -83,11 +92,7 @@ export function DictationStatusNotice({
{(meta || (actionLabel && onAction)) && (
<View style={styles.actionsRow}>
{meta ? (
<Text style={[styles.meta, { color: secondaryColor }]}>{meta}</Text>
) : (
<View />
)}
{meta ? <Text style={[styles.meta, { color: secondaryColor }]}>{meta}</Text> : <View />}
{actionLabel && onAction ? (
<Pressable
style={[
@@ -0,0 +1,85 @@
import { useState, useCallback, useEffect, useId, useRef } from "react";
import {
type LayoutChangeEvent,
type NativeSyntheticEvent,
type NativeScrollEvent,
type StyleProp,
type ViewStyle,
} from "react-native";
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
interface DiffScrollProps {
children: React.ReactNode;
scrollViewWidth: number;
onScrollViewWidthChange: (width: number) => void;
style?: StyleProp<ViewStyle>;
contentContainerStyle?: StyleProp<ViewStyle>;
}
export function DiffScroll({
children,
scrollViewWidth,
onScrollViewWidthChange,
style,
contentContainerStyle,
}: DiffScrollProps) {
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
const horizontalScroll = useHorizontalScrollOptional();
const scrollId = useId();
const scrollViewRef = useRef<ScrollViewType>(null);
// Get the close gesture ref from animation context (may not be available outside sidebar)
let closeGestureRef: React.MutableRefObject<any> | undefined;
try {
const animation = useExplorerSidebarAnimation();
closeGestureRef = animation.closeGestureRef;
} catch {
// Not inside ExplorerSidebarAnimationProvider, which is fine
}
// Register/unregister scroll offset tracking
useEffect(() => {
if (!horizontalScroll) return;
// Start at 0 (not scrolled)
horizontalScroll.registerScrollOffset(scrollId, 0);
return () => {
horizontalScroll.unregisterScrollOffset(scrollId);
};
}, [horizontalScroll, scrollId]);
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const offsetX = event.nativeEvent.contentOffset.x;
// Track if we're at the left edge (with small threshold for float precision)
setIsAtLeftEdge(offsetX <= 1);
if (horizontalScroll) {
horizontalScroll.registerScrollOffset(scrollId, offsetX);
}
},
[horizontalScroll, scrollId],
);
return (
<ScrollView
ref={scrollViewRef}
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
bounces={false}
style={style}
contentContainerStyle={contentContainerStyle}
onScroll={handleScroll}
scrollEventThrottle={16}
onLayout={(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width)}
// When at left edge, wait for close gesture to fail before scrolling.
// The close gesture fails quickly on leftward swipes (failOffsetX=-10),
// so scrolling left works normally. On rightward swipes, close gesture
// activates and closes the sidebar.
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
>
{children}
</ScrollView>
);
}
@@ -0,0 +1,29 @@
import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
interface DiffScrollProps {
children: React.ReactNode;
scrollViewWidth: number;
onScrollViewWidthChange: (width: number) => void;
style?: StyleProp<ViewStyle>;
contentContainerStyle?: StyleProp<ViewStyle>;
}
export function DiffScroll({
children,
onScrollViewWidthChange,
style,
contentContainerStyle,
}: DiffScrollProps) {
return (
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={style}
contentContainerStyle={contentContainerStyle}
onLayout={(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width)}
>
{children}
</ScrollView>
);
}
+2 -1
View File
@@ -71,7 +71,8 @@ export function DiffViewer({
key={segIdx}
style={[
line.type === "add" ? styles.addText : styles.removeText,
segment.changed && (line.type === "add" ? styles.addHighlight : styles.removeHighlight),
segment.changed &&
(line.type === "add" ? styles.addHighlight : styles.removeHighlight),
]}
>
{segment.text}
@@ -41,7 +41,10 @@ export function DownloadToast() {
}
return (
<View style={[styles.container, { bottom: theme.spacing[4] + insets.bottom }]} pointerEvents="box-none">
<View
style={[styles.container, { bottom: theme.spacing[4] + insets.bottom }]}
pointerEvents="box-none"
>
<View style={styles.toast}>
{activeDownload.status === "downloading" ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
@@ -61,7 +64,7 @@ export function DownloadToast() {
: "Starting..."
: activeDownload.status === "complete"
? "Download complete"
: activeDownload.message ?? "Download failed"}
: (activeDownload.message ?? "Download failed")}
</Text>
{activeDownload.status === "downloading" && activeDownload.progress && (
<View style={styles.progressBar}>
@@ -5,10 +5,7 @@ import DraggableFlatList, {
type RenderItemParams,
} from "react-native-draggable-flatlist";
import { useUnistyles } from "react-native-unistyles";
import type {
DraggableListProps,
DraggableRenderItemInfo,
} from "./draggable-list.types";
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
export type { DraggableListProps, DraggableRenderItemInfo };
@@ -53,7 +50,7 @@ export function DraggableList<T>({
};
return renderItem(info);
},
[renderItem]
[renderItem],
);
const handleDragEnd = useCallback(
@@ -61,7 +58,7 @@ export function DraggableList<T>({
setIsDragging(false);
onDragEnd(newData);
},
[onDragEnd]
[onDragEnd],
);
const handleDragBegin = useCallback(() => {
@@ -74,12 +71,11 @@ export function DraggableList<T>({
}, []);
const showRefreshControl = Boolean(onRefresh) && (!isDragging || Boolean(refreshing));
const resolvedContainerStyle =
containerStyle ?? (scrollEnabled ? { flex: 1 } : undefined);
const resolvedContainerStyle = containerStyle ?? (scrollEnabled ? { flex: 1 } : undefined);
const shouldShowRefreshControl = showRefreshControl && !nestable;
const ListComponent: typeof DraggableFlatList = (nestable
? (NestableDraggableFlatList as any)
: DraggableFlatList) as any;
const ListComponent: typeof DraggableFlatList = (
nestable ? (NestableDraggableFlatList as any) : DraggableFlatList
) as any;
return (
<ListComponent
@@ -19,14 +19,8 @@ import {
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type {
DraggableListProps,
DraggableRenderItemInfo,
} from "./draggable-list.types";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "./web-desktop-scrollbar";
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
import { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } from "./web-desktop-scrollbar";
export type { DraggableListProps, DraggableRenderItemInfo };
@@ -79,7 +73,7 @@ function SortableItem<T>({
// the "ghost" stretches. Keep the dragged item's size stable by zeroing
// out the dnd-kit scaling component.
const baseTransform = CSS.Transform.toString(
transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform
transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform,
);
const scaleTransform = isDragging ? "scale(1.02)" : "";
const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
@@ -100,9 +94,7 @@ function SortableItem<T>({
? {
attributes: attributes as unknown as Record<string, unknown>,
listeners: listeners as unknown as Record<string, unknown>,
setActivatorNodeRef: setActivatorNodeRef as unknown as (
node: unknown
) => void,
setActivatorNodeRef: setActivatorNodeRef as unknown as (node: unknown) => void,
}
: undefined,
};
@@ -152,7 +144,7 @@ export function DraggableList<T>({
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
}),
);
const handleDragStart = useCallback(
@@ -161,7 +153,7 @@ export function DraggableList<T>({
setActiveId(String(event.active.id));
onDragBegin?.();
},
[data, onDragBegin]
[data, onDragBegin],
);
const handleDragEnd = useCallback(
@@ -172,12 +164,8 @@ export function DraggableList<T>({
setDragItems(null);
if (over && active.id !== over.id) {
const oldIndex = items.findIndex(
(item, i) => keyExtractor(item, i) === active.id
);
const newIndex = items.findIndex(
(item, i) => keyExtractor(item, i) === over.id
);
const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.id);
const newIndex = items.findIndex((item, i) => keyExtractor(item, i) === over.id);
if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
const newItems = arrayMove(items, oldIndex, newIndex);
@@ -185,7 +173,7 @@ export function DraggableList<T>({
}
}
},
[items, keyExtractor, onDragEnd]
[items, keyExtractor, onDragEnd],
);
const ids = items.map((item, index) => keyExtractor(item, index));
@@ -204,9 +192,7 @@ export function DraggableList<T>({
testID={testID}
style={style}
contentContainerStyle={contentContainerStyle}
showsVerticalScrollIndicator={
showCustomScrollbar ? false : showsVerticalScrollIndicator
}
showsVerticalScrollIndicator={showCustomScrollbar ? false : showsVerticalScrollIndicator}
onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined}
onContentSizeChange={
showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : undefined
+1 -4
View File
@@ -21,10 +21,7 @@ export function EmptyState({ onCreateAgent, onImportAgent }: EmptyStateProps) {
<Text style={styles.primaryButtonText}>New agent</Text>
</Pressable>
{hasImportCta ? (
<Pressable
onPress={onImportAgent}
style={[styles.button, styles.secondaryButton]}
>
<Pressable onPress={onImportAgent} style={[styles.button, styles.secondaryButton]}>
<Download size={20} color={styles.secondaryButtonText.color} />
<Text style={styles.secondaryButtonText}>Import agent</Text>
</Pressable>
@@ -2,11 +2,7 @@ import { useCallback, useEffect, useMemo, useRef } from "react";
import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useIsFocused } from "@react-navigation/native";
import Animated, {
useAnimatedStyle,
useSharedValue,
runOnJS,
} from "react-native-reanimated";
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native";
@@ -23,8 +19,7 @@ import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
const MIN_CHAT_WIDTH = 400;
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {
}
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
interface ExplorerSidebarProps {
serverId: string;
@@ -44,8 +39,7 @@ export function ExplorerSidebar({
const { theme } = useUnistyles();
const isScreenFocused = useIsFocused();
const insets = useSafeAreaInsets();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const closeToAgent = usePanelStore((state) => state.closeToAgent);
@@ -68,7 +62,7 @@ export function ExplorerSidebar({
}
const maxWidth = Math.max(
MIN_EXPLORER_SIDEBAR_WIDTH,
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH)
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
);
if (explorerWidth > maxWidth) {
setExplorerWidth(maxWidth);
@@ -102,7 +96,7 @@ export function ExplorerSidebar({
});
closeToAgent();
},
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView]
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView],
);
const enableSidebarCloseGesture = isMobile && isOpen;
@@ -111,7 +105,7 @@ export function ExplorerSidebar({
(tab: ExplorerTab) => {
setExplorerTabForCheckout({ serverId, cwd: workspaceRoot, isGit, tab });
},
[isGit, serverId, setExplorerTabForCheckout, workspaceRoot]
[isGit, serverId, setExplorerTabForCheckout, workspaceRoot],
);
// Swipe gesture to close (swipe right on mobile)
@@ -170,8 +164,7 @@ export function ExplorerSidebar({
})
.onEnd((event) => {
isGesturing.value = false;
const shouldClose =
event.translationX > windowWidth / 3 || event.velocityX > 500;
const shouldClose = event.translationX > windowWidth / 3 || event.velocityX > 500;
runOnJS(logExplorerSidebar)("closeGestureEnd", {
translationX: event.translationX,
velocityX: event.velocityX,
@@ -200,7 +193,7 @@ export function ExplorerSidebar({
closeGestureRef,
closeTouchStartX,
closeTouchStartY,
]
],
);
// Desktop resize gesture (drag left edge)
@@ -218,18 +211,15 @@ export function ExplorerSidebar({
const newWidth = startWidthRef.current - event.translationX;
const maxWidth = Math.max(
MIN_EXPLORER_SIDEBAR_WIDTH,
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH)
);
const clampedWidth = Math.max(
MIN_EXPLORER_SIDEBAR_WIDTH,
Math.min(maxWidth, newWidth)
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
);
const clampedWidth = Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth));
resizeWidth.value = clampedWidth;
})
.onEnd(() => {
runOnJS(setExplorerWidth)(resizeWidth.value);
}),
[isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth]
[isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth],
);
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
@@ -299,16 +289,11 @@ export function ExplorerSidebar({
}
return (
<Animated.View
style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}
>
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}>
{/* Resize handle - absolutely positioned over left border */}
<GestureDetector gesture={resizeGesture}>
<View
style={[
styles.resizeHandle,
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
]}
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
/>
</GestureDetector>
@@ -351,8 +336,7 @@ function SidebarContent({
onOpenFile,
}: SidebarContentProps) {
const { theme } = useUnistyles();
const resolvedTab: ExplorerTab =
!isGit && activeTab === "changes" ? "files" : activeTab;
const resolvedTab: ExplorerTab = !isGit && activeTab === "changes" ? "files" : activeTab;
return (
<View style={styles.sidebarContent} pointerEvents="auto">
@@ -365,12 +349,7 @@ function SidebarContent({
style={[styles.tab, resolvedTab === "changes" && styles.tabActive]}
onPress={() => onTabPress("changes")}
>
<Text
style={[
styles.tabText,
resolvedTab === "changes" && styles.tabTextActive,
]}
>
<Text style={[styles.tabText, resolvedTab === "changes" && styles.tabTextActive]}>
Changes
</Text>
</Pressable>
@@ -380,12 +359,7 @@ function SidebarContent({
style={[styles.tab, resolvedTab === "files" && styles.tabActive]}
onPress={() => onTabPress("files")}
>
<Text
style={[
styles.tabText,
resolvedTab === "files" && styles.tabTextActive,
]}
>
<Text style={[styles.tabText, resolvedTab === "files" && styles.tabTextActive]}>
Files
</Text>
</Pressable>
+2 -10
View File
@@ -1,10 +1,6 @@
import { View, Text, Platform } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import Animated, {
useAnimatedStyle,
withTiming,
useSharedValue,
} from "react-native-reanimated";
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
import { useEffect } from "react";
import { Upload } from "lucide-react-native";
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
@@ -18,11 +14,7 @@ interface FileDropZoneProps {
const IS_WEB = Platform.OS === "web";
export function FileDropZone({
children,
onFilesDropped,
disabled = false,
}: FileDropZoneProps) {
export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
const { theme } = useUnistyles();
const { isDragging, containerRef } = useFileDropZone({
onFilesDropped,
@@ -38,10 +38,7 @@ import {
RotateCw,
X,
} from "lucide-react-native";
import type {
AgentFileExplorerState,
ExplorerEntry,
} from "@/stores/session-store";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { useDownloadStore } from "@/stores/download-store";
@@ -54,10 +51,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import {
usePanelStore,
type SortOption,
} from "@/stores/panel-store";
import { usePanelStore, type SortOption } from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import {
@@ -102,36 +96,32 @@ export function FileExplorerPane({
onOpenFile,
}: FileExplorerPaneProps) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const daemons = useHosts();
const daemonProfile = useMemo(
() => daemons.find((daemon) => daemon.serverId === serverId),
[daemons, serverId]
);
const normalizedWorkspaceRoot = useMemo(
() => workspaceRoot.trim(),
[workspaceRoot]
[daemons, serverId],
);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const workspaceStateKey = useMemo(
() =>
buildWorkspaceExplorerStateKey({
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
}),
[normalizedWorkspaceRoot, workspaceId]
[normalizedWorkspaceRoot, workspaceId],
);
const workspaceScopeId = useMemo(
() => workspaceId?.trim() || normalizedWorkspaceRoot,
[normalizedWorkspaceRoot, workspaceId]
[normalizedWorkspaceRoot, workspaceId],
);
const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot);
const explorerState = useSessionStore((state) =>
workspaceStateKey && state.sessions[serverId]
? state.sessions[serverId]?.fileExplorer.get(workspaceStateKey)
: undefined
: undefined,
);
const {
@@ -156,9 +146,9 @@ export function FileExplorerPane({
const isDirectoryLoading = useCallback(
(path: string) =>
Boolean(
isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === path
isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === path,
),
[isExplorerLoading, pendingRequest?.mode, pendingRequest?.path]
[isExplorerLoading, pendingRequest?.mode, pendingRequest?.path],
);
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
@@ -235,7 +225,7 @@ export function FileExplorerPane({
});
}
},
[directories, expandedPaths, hasWorkspaceScope, requestDirectoryListing]
[directories, expandedPaths, hasWorkspaceScope, requestDirectoryListing],
);
const handleOpenFile = useCallback(
@@ -246,7 +236,7 @@ export function FileExplorerPane({
selectExplorerEntry(entry.path);
onOpenFile?.(entry.path);
},
[hasWorkspaceScope, onOpenFile, selectExplorerEntry]
[hasWorkspaceScope, onOpenFile, selectExplorerEntry],
);
const handleEntryPress = useCallback(
@@ -257,7 +247,7 @@ export function FileExplorerPane({
}
handleOpenFile(entry);
},
[handleOpenFile, handleToggleDirectory]
[handleOpenFile, handleToggleDirectory],
);
const handleCopyPath = useCallback(
@@ -266,10 +256,10 @@ export function FileExplorerPane({
buildAbsoluteExplorerPath({
workspaceRoot: normalizedWorkspaceRoot,
entryPath: path,
})
}),
);
},
[normalizedWorkspaceRoot]
[normalizedWorkspaceRoot],
);
const startDownload = useDownloadStore((state) => state.startDownload);
@@ -288,13 +278,7 @@ export function FileExplorerPane({
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
});
},
[
daemonProfile,
requestFileDownloadToken,
serverId,
startDownload,
workspaceScopeId,
]
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
);
const handleSortCycle = useCallback(() => {
@@ -320,7 +304,7 @@ export function FileExplorerPane({
requestDirectoryListing(path, {
recordHistory: false,
setCurrentPath: false,
})
}),
),
]);
return null;
@@ -342,7 +326,7 @@ export function FileExplorerPane({
easing: Easing.linear,
}),
-1,
false
false,
);
return;
}
@@ -366,7 +350,7 @@ export function FileExplorerPane({
if (finished) {
refreshIconRotation.value = 0;
}
}
},
);
}, [isRefreshFetching, refreshIconRotation]);
@@ -394,10 +378,7 @@ export function FileExplorerPane({
!directories.has(".") &&
Boolean(isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === ".");
const showBackFromError = Boolean(error && selectedEntryPath);
const errorRecoveryPath = useMemo(
() => getErrorRecoveryPath(explorerState),
[explorerState]
);
const errorRecoveryPath = useMemo(() => getErrorRecoveryPath(explorerState), [explorerState]);
const renderTreeRow = useCallback(
({ item }: ListRenderItemInfo<TreeRow>) => {
@@ -495,7 +476,7 @@ export function FileExplorerPane({
selectedEntryPath,
theme.colors,
theme.spacing,
]
],
);
const handleBackFromError = useCallback(() => {
@@ -515,7 +496,7 @@ export function FileExplorerPane({
treeScrollbarMetrics.onScroll(event);
}
},
[showDesktopWebScrollbar, treeScrollbarMetrics]
[showDesktopWebScrollbar, treeScrollbarMetrics],
);
const handleTreeListLayout = useCallback(
@@ -524,7 +505,7 @@ export function FileExplorerPane({
treeScrollbarMetrics.onLayout(event);
}
},
[showDesktopWebScrollbar, treeScrollbarMetrics]
[showDesktopWebScrollbar, treeScrollbarMetrics],
);
if (!hasWorkspaceScope) {
@@ -536,9 +517,7 @@ export function FileExplorerPane({
}
return (
<View
style={styles.container}
>
<View style={styles.container}>
{error ? (
<View style={styles.centerState}>
<Text style={styles.errorText}>{error}</Text>
@@ -632,16 +611,7 @@ export function FileExplorerPane({
type EntryDisplayKind = "directory" | "image" | "text" | "other";
const IMAGE_EXTENSIONS = new Set([
"png",
"jpg",
"jpeg",
"gif",
"bmp",
"svg",
"webp",
"ico",
]);
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "ico"]);
const TEXT_EXTENSIONS = new Set([
"txt",
@@ -684,7 +654,7 @@ const TEXT_EXTENSIONS = new Set([
function renderEntryIcon(
kind: EntryDisplayKind,
colors: { foreground: string; primary: string; directoryOpen?: boolean }
colors: { foreground: string; primary: string; directoryOpen?: boolean },
) {
const color = colors.foreground;
switch (kind) {
@@ -783,7 +753,7 @@ function buildTreeRows({
sortOption,
path: entry.path,
depth: depth + 1,
})
}),
);
}
}
+121 -29
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef } from "react";
import React, { useCallback, useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import {
ActivityIndicator,
@@ -11,13 +11,37 @@ import {
type NativeScrollEvent,
type NativeSyntheticEvent,
} from "react-native";
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
} from "@/components/web-desktop-scrollbar";
import {
highlightCode,
darkHighlightColors,
lightHighlightColors,
type HighlightToken,
type HighlightStyle,
} from "@getpaseo/highlight";
import { lineNumberGutterWidth } from "@/components/code-insets";
interface CodeLineProps {
tokens: HighlightToken[];
lineNumber: number;
gutterWidth: number;
colorMap: Record<HighlightStyle, string>;
baseColor: string;
}
interface FilePreviewBodyProps {
preview: ExplorerFile | null;
isLoading: boolean;
showDesktopWebScrollbar: boolean;
isMobile: boolean;
filePath: string;
}
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
@@ -37,28 +61,92 @@ function formatFileSize({ size }: { size: number }): string {
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
const CodeLine = React.memo(function CodeLine({
tokens,
lineNumber,
gutterWidth,
colorMap,
baseColor,
}: CodeLineProps) {
return (
<View style={codeLineStyles.line}>
<View style={[codeLineStyles.gutter, { width: gutterWidth }]}>
<Text style={[codeLineStyles.gutterText, { color: baseColor }]}>{String(lineNumber)}</Text>
</View>
<Text style={codeLineStyles.lineText}>
{tokens.map((token, index) => (
<Text
key={index}
style={{ color: token.style ? (colorMap[token.style] ?? baseColor) : baseColor }}
>
{token.text}
</Text>
))}
</Text>
</View>
);
});
const codeLineStyles = StyleSheet.create((theme) => ({
line: {
flexDirection: "row",
},
gutter: {
alignItems: "flex-end",
paddingRight: theme.spacing[3],
flexShrink: 0,
},
gutterText: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.45,
opacity: 0.4,
userSelect: "none",
},
lineText: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.45,
flex: 1,
},
}));
function FilePreviewBody({
preview,
isLoading,
showDesktopWebScrollbar,
isMobile,
}: {
preview: ExplorerFile | null;
isLoading: boolean;
showDesktopWebScrollbar: boolean;
isMobile: boolean;
}) {
filePath,
}: FilePreviewBodyProps) {
const { theme } = useUnistyles();
const isDark = theme.colors.surface0 === "#18181c";
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
const baseColor = isDark ? "#c9d1d9" : "#24292f";
const enablePreviewDesktopScrollbar = showDesktopWebScrollbar;
const previewScrollRef = useRef<RNScrollView>(null);
const previewScrollbarMetrics = useWebDesktopScrollbarMetrics();
const highlightedLines = useMemo(() => {
if (!preview || preview.kind !== "text") {
return null;
}
return highlightCode(preview.content ?? "", filePath);
}, [preview?.kind, preview?.content, filePath]);
const gutterWidth = useMemo(() => {
if (!highlightedLines) return 0;
return lineNumberGutterWidth(highlightedLines.length);
}, [highlightedLines]);
const handlePreviewScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (enablePreviewDesktopScrollbar) {
previewScrollbarMetrics.onScroll(event);
}
},
[enablePreviewDesktopScrollbar, previewScrollbarMetrics]
[enablePreviewDesktopScrollbar, previewScrollbarMetrics],
);
const handlePreviewLayout = useCallback(
@@ -67,7 +155,7 @@ function FilePreviewBody({
previewScrollbarMetrics.onLayout(event);
}
},
[enablePreviewDesktopScrollbar, previewScrollbarMetrics]
[enablePreviewDesktopScrollbar, previewScrollbarMetrics],
);
if (isLoading && !preview) {
@@ -88,6 +176,22 @@ function FilePreviewBody({
}
if (preview.kind === "text") {
const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
const codeLines = (
<View>
{lines.map((tokens, index) => (
<CodeLine
key={index}
tokens={tokens}
lineNumber={index + 1}
gutterWidth={gutterWidth}
colorMap={colorMap}
baseColor={baseColor}
/>
))}
</View>
);
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
@@ -96,17 +200,13 @@ function FilePreviewBody({
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
onContentSizeChange={
enablePreviewDesktopScrollbar
? previewScrollbarMetrics.onContentSizeChange
: undefined
enablePreviewDesktopScrollbar ? previewScrollbarMetrics.onContentSizeChange : undefined
}
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
>
{isMobile ? (
<View style={styles.previewCodeScrollContent}>
<Text style={styles.codeText}>{preview.content}</Text>
</View>
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
) : (
<RNScrollView
horizontal
@@ -114,7 +214,7 @@ function FilePreviewBody({
showsHorizontalScrollIndicator
contentContainerStyle={styles.previewCodeScrollContent}
>
<Text style={styles.codeText}>{preview.content}</Text>
{codeLines}
</RNScrollView>
)}
</RNScrollView>
@@ -139,9 +239,7 @@ function FilePreviewBody({
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
onContentSizeChange={
enablePreviewDesktopScrollbar
? previewScrollbarMetrics.onContentSizeChange
: undefined
enablePreviewDesktopScrollbar ? previewScrollbarMetrics.onContentSizeChange : undefined
}
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
@@ -182,8 +280,7 @@ export function FilePane({
workspaceRoot: string;
filePath: string;
}) {
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
@@ -200,7 +297,7 @@ export function FilePane({
const payload = await client.exploreFileSystem(
normalizedWorkspaceRoot,
normalizedFilePath,
"file"
"file",
);
return { file: payload.file ?? null, error: payload.error ?? null };
},
@@ -220,6 +317,7 @@ export function FilePane({
isLoading={query.isFetching}
showDesktopWebScrollbar={showDesktopWebScrollbar}
isMobile={isMobile}
filePath={filePath}
/>
</View>
);
@@ -268,12 +366,6 @@ const styles = StyleSheet.create((theme) => ({
previewCodeScrollContent: {
padding: theme.spacing[4],
},
codeText: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.45,
color: theme.colors.foreground,
},
previewImageScrollContent: {
flexGrow: 1,
padding: theme.spacing[4],
@@ -2,9 +2,7 @@ import { describe, expect, it } from "vitest";
import { buildGitActions, type BuildGitActionsInput } from "./git-actions-policy";
function createInput(
overrides: Partial<BuildGitActionsInput> = {}
): BuildGitActionsInput {
function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitActionsInput {
return {
isGit: true,
githubFeaturesEnabled: true,
@@ -68,7 +66,7 @@ describe("git-actions-policy", () => {
aheadCount: 3,
aheadOfOrigin: 2,
shipDefault: "pr",
})
}),
);
expect(noPrActions.primary).toBeNull();
@@ -117,18 +115,20 @@ describe("git-actions-policy", () => {
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);
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");
@@ -54,12 +54,7 @@ export interface BuildGitActionsInput {
runtime: Record<GitActionId, GitActionRuntimeState>;
}
const SECONDARY_ACTION_IDS: GitActionId[] = [
"merge-branch",
"pr",
"merge-from-base",
"push",
];
const SECONDARY_ACTION_IDS: GitActionId[] = ["merge-branch", "pr", "merge-from-base", "push"];
export function buildGitActions(input: BuildGitActionsInput): GitActions {
if (!input.isGit) {
@@ -134,7 +129,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
});
const primaryActionId = getPrimaryActionId(input);
const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null;
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")!);
@@ -189,10 +184,7 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
label: "Create PR",
pendingLabel: "Creating PR...",
successLabel: "PR Created",
disabled:
input.runtime.pr.disabled ||
!input.githubFeaturesEnabled ||
input.aheadCount === 0,
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled || input.aheadCount === 0,
status: input.runtime.pr.status,
description: getCreatePrDescription(input),
icon: input.runtime.pr.icon,
@@ -49,7 +49,9 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
) : (
<View style={styles.splitButtonContent}>
{gitActions.primary.icon}
<Text style={styles.splitButtonText}>{getActionDisplayLabel(gitActions.primary)}</Text>
<Text style={styles.splitButtonText}>
{getActionDisplayLabel(gitActions.primary)}
</Text>
</View>
)}
</Pressable>
+147 -196
View File
@@ -1,4 +1,12 @@
import { useState, useCallback, useEffect, useId, useMemo, useRef, memo, type ReactElement } from "react";
import {
useState,
useCallback,
useEffect,
useMemo,
useRef,
memo,
type ReactElement,
} from "react";
import { useRouter } from "expo-router";
import {
View,
@@ -11,13 +19,11 @@ import {
type NativeSyntheticEvent,
type NativeScrollEvent,
} from "react-native";
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import AsyncStorage from "@react-native-async-storage/async-storage";
import {
Archive,
ChevronDown,
GitBranch,
GitCommitHorizontal,
GitMerge,
@@ -36,8 +42,12 @@ import {
} from "@/hooks/use-checkout-diff-query";
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import { DiffScroll } from "./diff-scroll";
import {
darkHighlightColors,
lightHighlightColors,
type HighlightStyle as HighlightStyleKey,
} from "@getpaseo/highlight";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { Fonts } from "@/constants/theme";
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
@@ -50,10 +60,8 @@ import {
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { GitHubIcon } from "@/components/icons/github-icon";
import {
buildGitActions,
type GitActions,
} from "@/components/git-actions-policy";
import { buildGitActions, type GitActions } from "@/components/git-actions-policy";
import { lineNumberGutterWidth } from "@/components/code-insets";
import {
WebDesktopScrollbarOverlay,
useWebDesktopScrollbarMetrics,
@@ -76,53 +84,6 @@ interface HighlightedTextProps {
lineType: "add" | "remove" | "context" | "header";
}
// GitHub syntax highlight colors for dark/light modes
const darkHighlightColors: Record<HighlightStyle, string> = {
keyword: "#ff7b72",
comment: "#8b949e",
string: "#a5d6ff",
number: "#79c0ff",
literal: "#79c0ff",
function: "#d2a8ff",
definition: "#d2a8ff",
class: "#ffa657",
type: "#ff7b72",
tag: "#7ee787",
attribute: "#79c0ff",
property: "#79c0ff",
variable: "#c9d1d9",
operator: "#79c0ff",
punctuation: "#c9d1d9",
regexp: "#a5d6ff",
escape: "#79c0ff",
meta: "#8b949e",
heading: "#79c0ff",
link: "#a5d6ff",
};
const lightHighlightColors: Record<HighlightStyle, string> = {
keyword: "#cf222e",
comment: "#6e7781",
string: "#0a3069",
number: "#0550ae",
literal: "#0550ae",
function: "#8250df",
definition: "#8250df",
class: "#953800",
type: "#cf222e",
tag: "#116329",
attribute: "#0550ae",
property: "#0550ae",
variable: "#24292f",
operator: "#0550ae",
punctuation: "#24292f",
regexp: "#0a3069",
escape: "#0550ae",
meta: "#6e7781",
heading: "#0550ae",
link: "#0a3069",
};
function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
const { theme } = useUnistyles();
const isDark = theme.colors.surface0 === "#18181c";
@@ -132,7 +93,7 @@ function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
const baseColor = isDark ? "#c9d1d9" : "#24292f";
if (!style) return baseColor;
const colors = isDark ? darkHighlightColors : lightHighlightColors;
return colors[style] ?? baseColor;
return colors[style as HighlightStyleKey] ?? baseColor;
};
return (
@@ -146,7 +107,6 @@ function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
);
}
interface DiffFileSectionProps {
file: ParsedDiffFile;
isExpanded: boolean;
@@ -155,7 +115,15 @@ interface DiffFileSectionProps {
testID?: string;
}
function DiffLineView({ line, lineNumber, gutterWidth }: { line: DiffLine; lineNumber: number | null; gutterWidth: number }) {
function DiffLineView({
line,
lineNumber,
gutterWidth,
}: {
line: DiffLine;
lineNumber: number | null;
gutterWidth: number;
}) {
return (
<View
style={[
@@ -167,20 +135,18 @@ function DiffLineView({ line, lineNumber, gutterWidth }: { line: DiffLine; lineN
]}
>
<View style={[styles.lineNumberGutter, { width: gutterWidth }]}>
<Text style={[
styles.lineNumberText,
line.type === "add" && styles.addLineNumberText,
line.type === "remove" && styles.removeLineNumberText,
]}>
<Text
style={[
styles.lineNumberText,
line.type === "add" && styles.addLineNumberText,
line.type === "remove" && styles.removeLineNumberText,
]}
>
{lineNumber != null ? String(lineNumber) : ""}
</Text>
</View>
{line.tokens && line.type !== "header" ? (
<HighlightedText
tokens={line.tokens}
baseStyle={null}
lineType={line.type}
/>
<HighlightedText tokens={line.tokens} baseStyle={null} lineType={line.type} />
) : (
<Text
style={[
@@ -216,10 +182,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
return (
<View
style={[
styles.fileSectionHeaderContainer,
isExpanded && styles.fileSectionHeaderExpanded,
]}
style={[styles.fileSectionHeaderContainer, isExpanded && styles.fileSectionHeaderExpanded]}
onLayout={(event) => {
layoutYRef.current = event.nativeEvent.layout.y;
onHeaderHeightChange?.(file.path, event.nativeEvent.layout.height);
@@ -228,10 +191,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
>
<Pressable
testID={testID ? `${testID}-toggle` : undefined}
style={({ pressed }) => [
styles.fileHeader,
pressed && styles.fileHeaderPressed,
]}
style={({ pressed }) => [styles.fileHeader, pressed && styles.fileHeaderPressed]}
// Android: prevent parent pan/scroll gestures from canceling the tap release.
cancelable={false}
onPressIn={(event) => {
@@ -265,9 +225,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
<View style={styles.fileHeaderLeft}>
<Text style={styles.fileName}>{file.path.split("/").pop()}</Text>
<Text style={styles.fileDir} numberOfLines={1}>
{file.path.includes("/")
? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}`
: ""}
{file.path.includes("/") ? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}` : ""}
</Text>
{file.isNew && (
<View style={styles.newBadge}>
@@ -301,41 +259,6 @@ function DiffFileBody({
testID?: string;
}) {
const [scrollViewWidth, setScrollViewWidth] = useState(0);
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
const horizontalScroll = useHorizontalScrollOptional();
const scrollId = useId();
const scrollViewRef = useRef<ScrollViewType>(null);
// Get the close gesture ref from animation context (may not be available outside sidebar)
let closeGestureRef: React.MutableRefObject<any> | undefined;
try {
const animation = useExplorerSidebarAnimation();
closeGestureRef = animation.closeGestureRef;
} catch {
// Not inside ExplorerSidebarAnimationProvider, which is fine
}
// Register/unregister scroll offset tracking
useEffect(() => {
if (!horizontalScroll) return;
// Start at 0 (not scrolled)
horizontalScroll.registerScrollOffset(scrollId, 0);
return () => {
horizontalScroll.unregisterScrollOffset(scrollId);
};
}, [horizontalScroll, scrollId]);
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const offsetX = event.nativeEvent.contentOffset.x;
// Track if we're at the left edge (with small threshold for float precision)
setIsAtLeftEdge(offsetX <= 1);
if (horizontalScroll) {
horizontalScroll.registerScrollOffset(scrollId, offsetX);
}
},
[horizontalScroll, scrollId]
);
return (
<View
@@ -359,10 +282,13 @@ function DiffFileBody({
const linesContent = (() => {
let maxLineNo = 0;
for (const hunk of file.hunks) {
maxLineNo = Math.max(maxLineNo, hunk.oldStart + hunk.oldCount, hunk.newStart + hunk.newCount);
maxLineNo = Math.max(
maxLineNo,
hunk.oldStart + hunk.oldCount,
hunk.newStart + hunk.newCount,
);
}
const digitCount = Math.max(1, String(maxLineNo).length);
const gutterWidth = digitCount * 8 + 12;
const gutterWidth = lineNumberGutterWidth(maxLineNo);
return file.hunks.map((hunk, hunkIndex) => {
let oldLineNo = hunk.oldStart;
let newLineNo = hunk.newStart;
@@ -394,35 +320,24 @@ function DiffFileBody({
if (wrapLines) {
return (
<View style={styles.diffContent}>
<View style={styles.linesContainer}>
{linesContent}
</View>
<View style={styles.linesContainer}>{linesContent}</View>
</View>
);
}
return (
<ScrollView
ref={scrollViewRef}
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
bounces={false}
<DiffScroll
scrollViewWidth={scrollViewWidth}
onScrollViewWidthChange={setScrollViewWidth}
style={styles.diffContent}
contentContainerStyle={styles.diffContentInner}
onScroll={handleScroll}
scrollEventThrottle={16}
onLayout={(e) => setScrollViewWidth(e.nativeEvent.layout.width)}
// When at left edge, wait for close gesture to fail before scrolling.
// The close gesture fails quickly on leftward swipes (failOffsetX=-10),
// so scrolling left works normally. On rightward swipes, close gesture
// activates and closes the sidebar.
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
>
<View style={[styles.linesContainer, scrollViewWidth > 0 && { minWidth: scrollViewWidth }]}>
<View
style={[styles.linesContainer, scrollViewWidth > 0 && { minWidth: scrollViewWidth }]}
>
{linesContent}
</View>
</ScrollView>
</DiffScroll>
);
})()}
</View>
@@ -442,8 +357,7 @@ type DiffFlatItem =
export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const router = useRouter();
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
@@ -466,8 +380,14 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
});
}, []);
const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } =
useCheckoutStatusQuery({ serverId, cwd });
const {
status,
isLoading: isStatusLoading,
isFetching: isStatusFetching,
isError: isStatusError,
error: statusError,
refresh: refreshStatus,
} = useCheckoutStatusQuery({ serverId, cwd });
const gitStatus = status && status.isGit ? status : null;
const isGit = Boolean(gitStatus);
const notGit = status !== null && !status.isGit && !status.error;
@@ -558,7 +478,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
// Ignore persistence failures; default will reset to "merge".
}
},
[shipDefaultStorageKey]
[shipDefaultStorageKey],
);
const { flatItems, stickyHeaderIndices } = useMemo(() => {
@@ -600,7 +520,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
diffScrollbarMetrics.onScroll(event);
}
},
[diffScrollbarMetrics, showDesktopWebScrollbar]
[diffScrollbarMetrics, showDesktopWebScrollbar],
);
const handleDiffListLayout = useCallback(
@@ -614,7 +534,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
diffScrollbarMetrics.onLayout(event);
}
},
[diffScrollbarMetrics, showDesktopWebScrollbar]
[diffScrollbarMetrics, showDesktopWebScrollbar],
);
const computeHeaderOffset = useCallback(
@@ -632,7 +552,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
}
return Math.max(0, offset);
},
[expandedByPath, files]
[expandedByPath, files],
);
const handleToggleExpanded = useCallback(
@@ -666,7 +586,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
[path]: nextExpanded,
}));
},
[computeHeaderOffset, expandedByPath]
[computeHeaderOffset, expandedByPath],
);
const allExpanded = useMemo(() => {
@@ -699,22 +619,22 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
}, [autoDiffMode]);
const commitStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "commit" })
state.getStatus({ serverId, cwd, actionId: "commit" }),
);
const pushStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "push" })
state.getStatus({ serverId, cwd, actionId: "push" }),
);
const prCreateStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "create-pr" })
state.getStatus({ serverId, cwd, actionId: "create-pr" }),
);
const mergeStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "merge-branch" })
state.getStatus({ serverId, cwd, actionId: "merge-branch" }),
);
const mergeFromBaseStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "merge-from-base" })
state.getStatus({ serverId, cwd, actionId: "merge-from-base" }),
);
const archiveStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "archive-worktree" })
state.getStatus({ serverId, cwd, actionId: "archive-worktree" }),
);
const runCommit = useCheckoutGitActionsStore((state) => state.commit);
@@ -818,19 +738,19 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
/>
);
},
[handleBodyHeightChange, handleHeaderHeightChange, handleToggleExpanded, wrapLines]
[handleBodyHeightChange, handleHeaderHeightChange, handleToggleExpanded, wrapLines],
);
const flatKeyExtractor = useCallback(
(item: DiffFlatItem) => `${item.type}-${item.file.path}`,
[]
[],
);
const hasChanges = files.length > 0;
const diffErrorMessage =
diffPayloadError?.message ??
(isDiffError && diffError instanceof Error ? diffError.message : null);
const prErrorMessage = githubFeaturesEnabled ? prPayloadError?.message ?? null : null;
const prErrorMessage = githubFeaturesEnabled ? (prPayloadError?.message ?? null) : null;
const branchLabel =
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"
? gitStatus.currentBranch
@@ -850,9 +770,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
if (!branchLabel || !baseRefLabel) {
return undefined;
}
return branchLabel === baseRefLabel
? undefined
: `${branchLabel} -> ${baseRefLabel}`;
return branchLabel === baseRefLabel ? undefined : `${branchLabel} -> ${baseRefLabel}`;
}, [baseRefLabel, branchLabel]);
const hasPullRequest = Boolean(prStatus?.url);
const hasRemote = gitStatus?.hasRemote ?? false;
@@ -867,14 +785,10 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const commitDisabled = actionsDisabled || commitStatus === "pending";
const prDisabled = actionsDisabled || prCreateStatus === "pending";
const mergeDisabled =
actionsDisabled || mergeStatus === "pending";
const mergeFromBaseDisabled =
actionsDisabled || mergeFromBaseStatus === "pending";
const pushDisabled =
actionsDisabled || pushStatus === "pending";
const archiveDisabled =
actionsDisabled || archiveStatus === "pending";
const mergeDisabled = actionsDisabled || mergeStatus === "pending";
const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending";
const pushDisabled = actionsDisabled || pushStatus === "pending";
const archiveDisabled = actionsDisabled || archiveStatus === "pending";
let bodyContent: ReactElement;
@@ -932,9 +846,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
onLayout={handleDiffListLayout}
onScroll={handleDiffListScroll}
onContentSizeChange={
showDesktopWebScrollbar
? diffScrollbarMetrics.onContentSizeChange
: undefined
showDesktopWebScrollbar ? diffScrollbarMetrics.onContentSizeChange : undefined
}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
@@ -1026,11 +938,38 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
},
});
}, [
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled,
hasUncommittedChanges, aheadOfOrigin, behindOfOrigin, shipDefault, baseRefLabel, shouldPromoteArchive,
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus,
handleCommit, handlePush, handleCreatePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree,
isGit,
hasRemote,
hasPullRequest,
prStatus?.url,
aheadCount,
isPaseoOwnedWorktree,
isOnBaseBranch,
githubFeaturesEnabled,
hasUncommittedChanges,
aheadOfOrigin,
behindOfOrigin,
shipDefault,
baseRefLabel,
shouldPromoteArchive,
commitDisabled,
pushDisabled,
prDisabled,
mergeDisabled,
mergeFromBaseDisabled,
archiveDisabled,
commitStatus,
pushStatus,
prCreateStatus,
mergeStatus,
mergeFromBaseStatus,
archiveStatus,
handleCommit,
handlePush,
handleCreatePr,
handleMergeBranch,
handleMergeFromBase,
handleArchiveWorktree,
theme.colors.foregroundMuted,
]);
@@ -1046,9 +985,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
{branchLabel}
</Text>
</View>
{isGit ? (
<GitActionsSplitButton gitActions={gitActions} />
) : null}
{isGit ? <GitActionsSplitButton gitActions={gitActions} /> : null}
</View>
) : null}
@@ -1066,16 +1003,12 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
accessibilityRole="button"
accessibilityLabel="Diff mode"
>
<Text style={styles.diffStatusText}>
<Text style={styles.diffStatusText} numberOfLines={1}>
{diffMode === "uncommitted" ? "Uncommitted" : "Committed"}
</Text>
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
width={260}
testID="changes-diff-status-menu"
>
<DropdownMenuContent align="start" width={260} testID="changes-diff-status-menu">
<DropdownMenuItem
testID="changes-diff-mode-uncommitted"
selected={diffMode === "uncommitted"}
@@ -1124,9 +1057,15 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
onPress={handleToggleExpandAll}
>
{allExpanded ? (
<ListChevronsDownUp size={isMobile ? 18 : 14} color={theme.colors.foregroundMuted} />
<ListChevronsDownUp
size={isMobile ? 18 : 14}
color={theme.colors.foregroundMuted}
/>
) : (
<ListChevronsUpDown size={isMobile ? 18 : 14} color={theme.colors.foregroundMuted} />
<ListChevronsUpDown
size={isMobile ? 18 : 14}
color={theme.colors.foregroundMuted}
/>
)}
</Pressable>
</TooltipTrigger>
@@ -1143,9 +1082,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
) : null}
{actionError ? <Text style={styles.actionErrorText}>{actionError}</Text> : null}
{prErrorMessage ? (
<Text style={styles.actionErrorText}>{prErrorMessage}</Text>
) : null}
{prErrorMessage ? <Text style={styles.actionErrorText}>{prErrorMessage}</Text> : null}
<View style={styles.diffContainer}>
{bodyContent}
@@ -1207,13 +1144,18 @@ const styles = StyleSheet.create((theme) => ({
diffModeTrigger: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[1],
// Align text with header branch icon (at spacing[3] from edge, minus our horizontal padding)
marginLeft: theme.spacing[3] - theme.spacing[1],
marginVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[1],
paddingVertical: theme.spacing[1],
height: {
xs: 28,
sm: 28,
md: 24,
},
borderRadius: theme.borderRadius.base,
flexShrink: 0,
},
diffModeTriggerHovered: {
backgroundColor: theme.colors.surface2,
@@ -1226,6 +1168,7 @@ const styles = StyleSheet.create((theme) => ({
},
diffStatusText: {
fontSize: theme.fontSize.xs,
lineHeight: theme.fontSize.xs * 1.25,
color: theme.colors.foregroundMuted,
},
diffStatusIconHidden: {
@@ -1243,19 +1186,25 @@ const styles = StyleSheet.create((theme) => ({
expandAllButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[1],
marginVertical: theme.spacing[2],
minWidth: {
xs: 32,
sm: 32,
md: 24,
},
height: {
xs: 32,
sm: 32,
md: 24,
},
paddingHorizontal: {
xs: theme.spacing[2],
sm: theme.spacing[2],
md: theme.spacing[1],
},
paddingVertical: {
xs: theme.spacing[2],
sm: theme.spacing[2],
md: theme.spacing[1],
},
borderRadius: theme.borderRadius.base,
flexShrink: 0,
},
actionErrorText: {
paddingHorizontal: theme.spacing[3],
@@ -1429,6 +1378,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
fontFamily: Fonts.mono,
color: theme.colors.foregroundMuted,
userSelect: "none",
},
addLineNumberText: {
color: theme.colors.palette.green[400],
@@ -1443,6 +1393,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
fontFamily: Fonts.mono,
color: theme.colors.foreground,
userSelect: "text",
},
addLineContainer: {
backgroundColor: "rgba(46, 160, 67, 0.15)", // GitHub green
@@ -11,21 +11,14 @@ interface BackHeaderProps {
onBack?: () => void;
}
export function BackHeader({
title,
rightContent,
onBack,
}: BackHeaderProps) {
export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
const { theme } = useUnistyles();
return (
<ScreenHeader
left={
<>
<Pressable
onPress={onBack ?? (() => router.back())}
style={styles.backButton}
>
<Pressable onPress={onBack ?? (() => router.back())} style={styles.backButton}>
<ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
</Pressable>
{title && (
@@ -29,11 +29,7 @@ function MobileMenuIcon({ color }: { color: string }) {
<View style={[styles.mobileMenuLine, { backgroundColor: color }]} />
<View style={[styles.mobileMenuLine, { backgroundColor: color }]} />
<View
style={[
styles.mobileMenuLine,
styles.mobileMenuLineShort,
{ backgroundColor: color },
]}
style={[styles.mobileMenuLine, styles.mobileMenuLineShort, { backgroundColor: color }]}
/>
</View>
);
@@ -46,17 +42,15 @@ export function SidebarMenuToggle({
nativeID = "menu-button",
}: SidebarMenuToggleProps = {}) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const menuIconColor = !isMobile && isOpen
? theme.colors.foreground
: theme.colors.foregroundMuted;
const menuIconColor =
!isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted;
return (
<HeaderToggleButton
@@ -81,10 +75,7 @@ export function SidebarMenuToggle({
);
}
export function MenuHeader({
title,
rightContent,
}: MenuHeaderProps) {
export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
return (
<ScreenHeader
left={
@@ -22,12 +22,7 @@ interface ScreenHeaderProps {
* Shared frame for the home/back headers so we only maintain padding, border,
* and safe-area logic in one place.
*/
export function ScreenHeader({
left,
right,
leftStyle,
rightStyle,
}: ScreenHeaderProps) {
export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeaderProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -37,9 +32,7 @@ export function ScreenHeader({
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const baseHorizontalPadding = theme.spacing[2];
const collapsedSidebarTrafficLightInset =
!isMobile && !desktopAgentListOpen && getIsDesktopMac()
? trafficLightPadding.left
: 0;
!isMobile && !desktopAgentListOpen && getIsDesktopMac() ? trafficLightPadding.left : 0;
const dragHandlers = useDesktopDragHandlers();
@@ -25,10 +25,34 @@ export function SourceControlPanelIcon({
strokeLinejoin="round"
/>
{/* Plus */}
<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" />
<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={strokeWidth} strokeLinecap="round" />
<Line
x1={9}
y1={16}
x2={15}
y2={16}
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
</Svg>
);
}
@@ -16,7 +16,7 @@ export function KeyboardShortcutsDialog() {
const isDesktopApp = getIsDesktop();
const sections = useMemo(
() => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }),
[isDesktopApp, isMac]
[isDesktopApp, isMac],
);
return (
+335 -273
View File
@@ -9,103 +9,109 @@ import {
type Dispatch,
type RefObject,
type SetStateAction,
} from 'react'
import { View, Pressable, Text, Platform } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
} from "react";
import { View, Pressable, Text, Platform, useWindowDimensions } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
useAnimatedStyle,
interpolate,
Extrapolation,
runOnJS,
useSharedValue,
} from 'react-native-reanimated'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { MessagesSquare, Plus, Settings } from 'lucide-react-native'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Shortcut } from '@/components/ui/shortcut'
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, type SidebarProjectEntry } from '@/hooks/use-sidebar-workspaces-list'
import { useSidebarAnimation } from '@/contexts/sidebar-animation-context'
import { useDesktopDragHandlers, useTrafficLightPadding } from '@/utils/desktop-window'
import { Combobox } from '@/components/ui/combobox'
import { getHostRuntimeStore, useHosts } from '@/runtime/host-runtime'
import { formatConnectionStatus } from '@/utils/daemons'
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from '@/constants/layout'
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { MessagesSquare, Plus, Settings } from "lucide-react-native";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { router, usePathname } from "expo-router";
import { usePanelStore, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH } 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 {
buildHostAgentsRoute,
useSidebarWorkspacesList,
type SidebarProjectEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
import { Combobox } from "@/components/ui/combobox";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { formatConnectionStatus } from "@/utils/daemons";
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
import {
buildHostSessionsRoute,
buildHostSettingsRoute,
mapPathnameToServer,
parseServerIdFromPathname,
} from '@/utils/host-routes'
import { useOpenProjectPicker } from '@/hooks/use-open-project-picker'
} from "@/utils/host-routes";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
const DESKTOP_SIDEBAR_WIDTH = 320
type SidebarShortcutModel = ReturnType<typeof useSidebarShortcutModel>
type SidebarTheme = ReturnType<typeof useUnistyles>['theme']
const MIN_CHAT_WIDTH = 400;
type SidebarShortcutModel = ReturnType<typeof useSidebarShortcutModel>;
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
interface LeftSidebarProps {
selectedAgentId?: string
selectedAgentId?: string;
}
interface HostOption {
id: string
label: string
description: string
id: string;
label: string;
description: string;
}
interface SidebarSharedProps {
theme: SidebarTheme
activeServerId: string | null
activeHostLabel: string
activeHostStatusColor: string
hostOptions: HostOption[]
hostTriggerRef: RefObject<View | null>
isHostPickerOpen: boolean
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>
projects: SidebarProjectEntry[]
isInitialLoad: boolean
isRevalidating: boolean
isManualRefresh: boolean
collapsedProjectKeys: SidebarShortcutModel['collapsedProjectKeys']
shortcutIndexByWorkspaceKey: SidebarShortcutModel['shortcutIndexByWorkspaceKey']
toggleProjectCollapsed: SidebarShortcutModel['toggleProjectCollapsed']
setProjectCollapsed: SidebarShortcutModel['setProjectCollapsed']
handleRefresh: () => void
handleHostSelect: (nextServerId: string) => void
handleOpenProject: () => void
handleSettings: () => void
theme: SidebarTheme;
activeServerId: string | null;
activeHostLabel: string;
activeHostStatusColor: string;
hostOptions: HostOption[];
hostTriggerRef: RefObject<View | null>;
isHostPickerOpen: boolean;
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
projects: SidebarProjectEntry[];
isInitialLoad: boolean;
isRevalidating: boolean;
isManualRefresh: boolean;
collapsedProjectKeys: SidebarShortcutModel["collapsedProjectKeys"];
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
handleRefresh: () => void;
handleHostSelect: (nextServerId: string) => void;
handleOpenProject: () => void;
handleSettings: () => void;
}
interface MobileSidebarProps extends SidebarSharedProps {
insetsTop: number
insetsBottom: number
isOpen: boolean
closeToAgent: () => void
handleViewMoreNavigate: () => void
insetsTop: number;
insetsBottom: number;
isOpen: boolean;
closeToAgent: () => void;
handleViewMoreNavigate: () => void;
}
interface DesktopSidebarProps extends SidebarSharedProps {
isOpen: boolean
handleViewMore: () => void
isOpen: boolean;
handleViewMore: () => void;
}
export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarProps) {
void _selectedAgentId
export const LeftSidebar = memo(function LeftSidebar({
selectedAgentId: _selectedAgentId,
}: LeftSidebarProps) {
void _selectedAgentId;
const { theme } = useUnistyles()
const insets = useSafeAreaInsets()
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
const mobileView = usePanelStore((state) => state.mobileView)
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen)
const closeToAgent = usePanelStore((state) => state.closeToAgent)
const pathname = usePathname()
const daemons = useHosts()
const runtime = getHostRuntimeStore()
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const closeToAgent = usePanelStore((state) => state.closeToAgent);
const pathname = usePathname();
const daemons = useHosts();
const runtime = getHostRuntimeStore();
const runtimeConnectionStatusSignature = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() =>
@@ -113,128 +119,131 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte
.map(
(daemon) =>
`${daemon.serverId}:${
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting'
}`
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
}`,
)
.join('|'),
.join("|"),
() =>
daemons
.map(
(daemon) =>
`${daemon.serverId}:${
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting'
}`
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
}`,
)
.join('|')
)
const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname])
.join("|"),
);
const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
const activeDaemon = useMemo(() => {
if (daemons.length === 0) {
return null
return null;
}
if (activeServerIdFromPath) {
const routeMatch = daemons.find((entry) => entry.serverId === activeServerIdFromPath)
const routeMatch = daemons.find((entry) => entry.serverId === activeServerIdFromPath);
if (routeMatch) {
return routeMatch
return routeMatch;
}
}
return daemons[0] ?? null
}, [activeServerIdFromPath, daemons])
const activeServerId = activeDaemon?.serverId ?? null
return daemons[0] ?? null;
}, [activeServerIdFromPath, daemons]);
const activeServerId = activeDaemon?.serverId ?? null;
const activeHostLabel = useMemo(() => {
if (!activeDaemon) return 'No host'
const trimmed = activeDaemon.label?.trim()
return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId
}, [activeDaemon])
if (!activeDaemon) return "No host";
const trimmed = activeDaemon.label?.trim();
return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId;
}, [activeDaemon]);
const activeHostStatus = activeServerId
? (runtime.getSnapshot(activeServerId)?.connectionStatus ?? 'connecting')
: 'idle'
? (runtime.getSnapshot(activeServerId)?.connectionStatus ?? "connecting")
: "idle";
const activeHostStatusColor =
activeHostStatus === 'online'
activeHostStatus === "online"
? theme.colors.palette.green[400]
: activeHostStatus === 'connecting'
: activeHostStatus === "connecting"
? theme.colors.palette.amber[500]
: theme.colors.palette.red[500]
: theme.colors.palette.red[500];
const hostOptions = useMemo(
() =>
daemons.map((daemon) => ({
id: daemon.serverId,
label: daemon.label?.trim() || daemon.serverId,
description: formatConnectionStatus(
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting'
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting",
),
})),
[daemons, runtime, runtimeConnectionStatusSignature]
)
const hostTriggerRef = useRef<View | null>(null)
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false)
[daemons, runtime, runtimeConnectionStatusSignature],
);
const hostTriggerRef = useRef<View | null>(null);
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
const isOpen = isMobile ? mobileView === 'agent-list' : desktopAgentListOpen
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
serverId: activeServerId,
enabled: isOpen,
})
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, setProjectCollapsed } =
useSidebarShortcutModel(projects)
});
const {
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
} = useSidebarShortcutModel(projects);
const [isManualRefresh, setIsManualRefresh] = useState(false)
const [isManualRefresh, setIsManualRefresh] = useState(false);
const handleRefresh = useCallback(() => {
setIsManualRefresh(true)
refreshAll()
}, [refreshAll])
setIsManualRefresh(true);
refreshAll();
}, [refreshAll]);
useEffect(() => {
if (!isRevalidating && isManualRefresh) {
setIsManualRefresh(false)
setIsManualRefresh(false);
}
}, [isRevalidating, isManualRefresh])
}, [isRevalidating, isManualRefresh]);
const openProjectPicker = useOpenProjectPicker(activeServerId)
const openProjectPicker = useOpenProjectPicker(activeServerId);
const handleOpenProjectMobile = useCallback(() => {
closeToAgent()
void openProjectPicker()
}, [closeToAgent, openProjectPicker])
closeToAgent();
void openProjectPicker();
}, [closeToAgent, openProjectPicker]);
const handleOpenProjectDesktop = useCallback(() => {
void openProjectPicker()
}, [openProjectPicker])
void openProjectPicker();
}, [openProjectPicker]);
const handleSettingsMobile = useCallback(() => {
if (!activeServerId) {
return
return;
}
closeToAgent()
router.push(buildHostSettingsRoute(activeServerId) as any)
}, [activeServerId, closeToAgent])
closeToAgent();
router.push(buildHostSettingsRoute(activeServerId) as any);
}, [activeServerId, closeToAgent]);
const handleSettingsDesktop = useCallback(() => {
if (!activeServerId) {
return
return;
}
router.push(buildHostSettingsRoute(activeServerId) as any)
}, [activeServerId])
router.push(buildHostSettingsRoute(activeServerId) as any);
}, [activeServerId]);
const handleViewMoreNavigate = useCallback(() => {
if (!activeServerId) {
return
return;
}
router.push(buildHostAgentsRoute(activeServerId) as any)
}, [activeServerId])
router.push(buildHostSessionsRoute(activeServerId) as any);
}, [activeServerId]);
const handleHostSelect = useCallback(
(nextServerId: string) => {
if (!nextServerId) {
return
return;
}
const nextPath = mapPathnameToServer(pathname, nextServerId)
setIsHostPickerOpen(false)
router.push(nextPath as any)
const nextPath = mapPathnameToServer(pathname, nextServerId);
setIsHostPickerOpen(false);
router.push(nextPath as any);
},
[pathname]
)
[pathname],
);
const sharedProps = {
theme,
@@ -252,10 +261,9 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
setProjectCollapsed,
handleRefresh,
handleHostSelect,
}
};
if (isMobile) {
return (
@@ -269,7 +277,7 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte
handleSettings={handleSettingsMobile}
handleViewMoreNavigate={handleViewMoreNavigate}
/>
)
);
}
return (
@@ -280,8 +288,46 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte
handleSettings={handleSettingsDesktop}
handleViewMore={handleViewMoreNavigate}
/>
)
})
);
});
function SessionsButton({ onPress }: { onPress: () => void }) {
const { theme } = useUnistyles();
const pathname = usePathname();
const isActive = pathname.includes("/sessions");
return (
<Pressable
style={({ hovered }) => [
styles.newAgentButton,
hovered && styles.newAgentButtonHovered,
isActive && styles.newAgentButtonActive,
]}
testID="sidebar-sessions"
accessible
accessibilityRole="button"
accessibilityLabel="Sessions"
onPress={onPress}
>
{({ hovered }) => (
<>
<MessagesSquare
size={theme.iconSize.md}
color={hovered || isActive ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[
styles.newAgentButtonText,
(hovered || isActive) && styles.newAgentButtonTextHovered,
]}
>
Sessions
</Text>
</>
)}
</Pressable>
);
}
function MobileSidebar({
theme,
@@ -299,7 +345,6 @@ function MobileSidebar({
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
setProjectCollapsed,
handleRefresh,
handleHostSelect,
handleOpenProject,
@@ -310,6 +355,7 @@ function MobileSidebar({
closeToAgent,
handleViewMoreNavigate,
}: MobileSidebarProps) {
const newAgentKeys = useShortcutKeys("new-agent");
const {
translateX,
backdropOpacity,
@@ -318,22 +364,22 @@ function MobileSidebar({
animateToClose,
isGesturing,
closeGestureRef,
} = useSidebarAnimation()
const closeTouchStartX = useSharedValue(0)
const closeTouchStartY = useSharedValue(0)
} = useSidebarAnimation();
const closeTouchStartX = useSharedValue(0);
const closeTouchStartY = useSharedValue(0);
const handleClose = useCallback(() => {
closeToAgent()
}, [closeToAgent])
closeToAgent();
}, [closeToAgent]);
const handleViewMore = useCallback(() => {
if (!activeServerId) {
return
return;
}
translateX.value = -windowWidth
backdropOpacity.value = 0
closeToAgent()
handleViewMoreNavigate()
translateX.value = -windowWidth;
backdropOpacity.value = 0;
closeToAgent();
handleViewMoreNavigate();
}, [
activeServerId,
backdropOpacity,
@@ -341,7 +387,7 @@ function MobileSidebar({
handleViewMoreNavigate,
translateX,
windowWidth,
])
]);
const closeGesture = useMemo(
() =>
@@ -350,62 +396,62 @@ function MobileSidebar({
.enabled(isOpen)
.manualActivation(true)
.onTouchesDown((event) => {
const touch = event.changedTouches[0]
const touch = event.changedTouches[0];
if (!touch) {
return
return;
}
closeTouchStartX.value = touch.absoluteX
closeTouchStartY.value = touch.absoluteY
closeTouchStartX.value = touch.absoluteX;
closeTouchStartY.value = touch.absoluteY;
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0]
const touch = event.changedTouches[0];
if (!touch || event.numberOfTouches !== 1) {
stateManager.fail()
return
stateManager.fail();
return;
}
const deltaX = touch.absoluteX - closeTouchStartX.value
const deltaY = touch.absoluteY - closeTouchStartY.value
const absDeltaX = Math.abs(deltaX)
const absDeltaY = Math.abs(deltaY)
const deltaX = touch.absoluteX - closeTouchStartX.value;
const deltaY = touch.absoluteY - closeTouchStartY.value;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (deltaX >= 10) {
stateManager.fail()
return
stateManager.fail();
return;
}
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
stateManager.fail()
return
stateManager.fail();
return;
}
if (deltaX <= -15 && absDeltaX > absDeltaY) {
stateManager.activate()
stateManager.activate();
}
})
.onStart(() => {
isGesturing.value = true
isGesturing.value = true;
})
.onUpdate((event) => {
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX))
translateX.value = newTranslateX
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX));
translateX.value = newTranslateX;
backdropOpacity.value = interpolate(
newTranslateX,
[-windowWidth, 0],
[0, 1],
Extrapolation.CLAMP
)
Extrapolation.CLAMP,
);
})
.onEnd((event) => {
isGesturing.value = false
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500
isGesturing.value = false;
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
if (shouldClose) {
animateToClose()
runOnJS(handleClose)()
animateToClose();
runOnJS(handleClose)();
} else {
animateToOpen()
animateToOpen();
}
})
.onFinalize(() => {
isGesturing.value = false
isGesturing.value = false;
}),
[
isOpen,
@@ -419,29 +465,29 @@ function MobileSidebar({
animateToClose,
animateToOpen,
handleClose,
]
)
],
);
const mobileSidebarInsetStyle = useMemo(
() => ({ width: windowWidth, paddingTop: insetsTop, paddingBottom: insetsBottom }),
[windowWidth, insetsTop, insetsBottom]
)
[windowWidth, insetsTop, insetsBottom],
);
const hostStatusDotStyle = useMemo(
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
[activeHostStatusColor]
)
[activeHostStatusColor],
);
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}))
}));
const backdropAnimatedStyle = useAnimatedStyle(() => ({
opacity: backdropOpacity.value,
pointerEvents: backdropOpacity.value > 0.01 ? 'auto' : 'none',
}))
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none",
}));
const overlayPointerEvents = Platform.OS === 'web' ? (isOpen ? 'auto' : 'none') : 'box-none'
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
@@ -457,28 +503,7 @@ function MobileSidebar({
<View style={styles.sidebarContent} pointerEvents="auto">
<View style={styles.sidebarHeader}>
<View style={styles.sidebarHeaderRow}>
<Pressable
style={styles.newAgentButton}
testID="sidebar-sessions"
accessible
accessibilityRole="button"
accessibilityLabel="Sessions"
onPress={handleViewMore}
>
{({ hovered }) => (
<>
<MessagesSquare
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}
>
Sessions
</Text>
</>
)}
</Pressable>
<SessionsButton onPress={handleViewMore} />
</View>
</View>
@@ -489,7 +514,7 @@ function MobileSidebar({
serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
onSetProjectCollapsed={setProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
@@ -540,7 +565,7 @@ function MobileSidebar({
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Add project</Text>
<Shortcut keys={['⌘', '⇧', 'O']} />
{newAgentKeys && <Shortcut keys={newAgentKeys} />}
</View>
</TooltipContent>
</Tooltip>
@@ -564,7 +589,7 @@ function MobileSidebar({
</View>
<Combobox
options={hostOptions}
value={activeServerId ?? ''}
value={activeServerId ?? ""}
onSelect={handleHostSelect}
searchable={false}
title="Switch host"
@@ -578,7 +603,7 @@ function MobileSidebar({
</Animated.View>
</GestureDetector>
</View>
)
);
}
function DesktopSidebar({
@@ -597,7 +622,6 @@ function DesktopSidebar({
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
setProjectCollapsed,
handleRefresh,
handleHostSelect,
handleOpenProject,
@@ -605,46 +629,64 @@ function DesktopSidebar({
isOpen,
handleViewMore,
}: DesktopSidebarProps) {
const dragHandlers = useDesktopDragHandlers()
const trafficLightPadding = useTrafficLightPadding()
const newAgentKeys = useShortcutKeys("new-agent");
const dragHandlers = useDesktopDragHandlers();
const trafficLightPadding = useTrafficLightPadding();
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
const { width: viewportWidth } = useWindowDimensions();
const hostStatusDotStyle = useMemo(
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
[activeHostStatusColor]
)
[activeHostStatusColor],
);
const startWidthRef = useRef(sidebarWidth);
const resizeWidth = useSharedValue(sidebarWidth);
useEffect(() => {
resizeWidth.value = sidebarWidth;
}, [sidebarWidth, resizeWidth]);
const resizeGesture = useMemo(
() =>
Gesture.Pan()
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
.onStart(() => {
startWidthRef.current = sidebarWidth;
resizeWidth.value = sidebarWidth;
})
.onUpdate((event) => {
// Dragging right (positive translationX) increases width
const newWidth = startWidthRef.current + event.translationX;
const maxWidth = Math.max(
MIN_SIDEBAR_WIDTH,
Math.min(MAX_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
);
const clampedWidth = Math.max(MIN_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth));
resizeWidth.value = clampedWidth;
})
.onEnd(() => {
runOnJS(setSidebarWidth)(resizeWidth.value);
}),
[sidebarWidth, resizeWidth, setSidebarWidth, viewportWidth],
);
const resizeAnimatedStyle = useAnimatedStyle(() => ({
width: resizeWidth.value,
}));
if (!isOpen) {
return null
return null;
}
return (
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
{trafficLightPadding.top > 0 ? (
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
) : null}
<View style={styles.sidebarHeader} {...dragHandlers}>
<View style={styles.sidebarHeaderRow}>
<Pressable
style={styles.newAgentButton}
testID="sidebar-sessions"
accessible
accessibilityRole="button"
accessibilityLabel="Sessions"
onPress={handleViewMore}
>
{({ hovered }) => (
<>
<MessagesSquare
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}
>
Sessions
</Text>
</>
)}
</Pressable>
<SessionsButton onPress={handleViewMore} />
</View>
</View>
@@ -655,7 +697,6 @@ function DesktopSidebar({
serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
onSetProjectCollapsed={setProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
@@ -704,7 +745,7 @@ function DesktopSidebar({
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Add project</Text>
<Shortcut keys={['⌘', '⇧', 'O']} />
<Shortcut keys={["⌘", "⇧", "O"]} />
</View>
</TooltipContent>
</Tooltip>
@@ -728,7 +769,7 @@ function DesktopSidebar({
</View>
<Combobox
options={hostOptions}
value={activeServerId ?? ''}
value={activeServerId ?? ""}
onSelect={handleHostSelect}
searchable={false}
title="Switch host"
@@ -738,63 +779,78 @@ function DesktopSidebar({
anchorRef={hostTriggerRef}
/>
</View>
</View>
)
{/* Resize handle - absolutely positioned over right border */}
<GestureDetector gesture={resizeGesture}>
<View
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
/>
</GestureDetector>
</Animated.View>
);
}
const styles = StyleSheet.create((theme) => ({
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
backdropPressable: {
flex: 1,
},
mobileSidebar: {
position: 'absolute',
position: "absolute",
top: 0,
left: 0,
bottom: 0,
backgroundColor: theme.colors.surfaceSidebar,
overflow: 'hidden',
overflow: "hidden",
},
sidebarContent: {
flex: 1,
minHeight: 0,
overflow: 'hidden',
overflow: "hidden",
},
desktopSidebar: {
position: "relative",
borderRightWidth: 1,
borderRightColor: theme.colors.border,
backgroundColor: theme.colors.surfaceSidebar,
},
resizeHandle: {
position: "absolute",
right: -5,
top: 0,
bottom: 0,
width: 10,
zIndex: 10,
},
sidebarHeader: {
height: {
xs: HEADER_INNER_HEIGHT_MOBILE,
md: HEADER_INNER_HEIGHT,
},
paddingHorizontal: theme.spacing[2],
justifyContent: 'center',
justifyContent: "center",
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
userSelect: 'none',
userSelect: "none",
},
sidebarHeaderRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
},
newAgentButton: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[1],
paddingRight: theme.spacing[1],
paddingLeft: theme.spacing[3],
flexShrink: 0,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
},
newAgentButtonHovered: {},
newAgentButtonText: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
@@ -803,10 +859,16 @@ const styles = StyleSheet.create((theme) => ({
newAgentButtonTextHovered: {
color: theme.colors.foreground,
},
newAgentButtonHovered: {
backgroundColor: theme.colors.surface1,
},
newAgentButtonActive: {
backgroundColor: theme.colors.surface1,
},
hostTrigger: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
gap: theme.spacing[2],
minWidth: 0,
paddingVertical: theme.spacing[1],
@@ -828,9 +890,9 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0,
},
sidebarFooter: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderTopWidth: 1,
@@ -843,16 +905,16 @@ const styles = StyleSheet.create((theme) => ({
marginRight: theme.spacing[2],
},
footerIconRow: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flexShrink: 0,
},
footerIconButton: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
},
@@ -878,19 +940,19 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
alignItems: 'center',
alignItems: "center",
},
hostPickerCancelText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
tooltipRow: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.popoverForeground,
},
}))
}));
File diff suppressed because it is too large Load Diff
+162 -329
View File
@@ -40,11 +40,7 @@ import {
TriangleAlertIcon,
Scissors,
} from "lucide-react-native";
import {
StyleSheet,
useUnistyles,
UnistylesRuntime,
} from "react-native-unistyles";
import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
import Animated, {
Easing,
cancelAnimation,
@@ -53,23 +49,14 @@ import Animated, {
withRepeat,
withTiming,
} from "react-native-reanimated";
import Svg, {
Defs,
LinearGradient as SvgLinearGradient,
Rect,
Stop,
} from "react-native-svg";
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
import { theme } from "@/styles/theme";
import {
createMarkdownStyles,
} from "@/styles/markdown-styles";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { Colors, Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import {
buildToolCallDisplayModel,
} from "@/utils/tool-call-display";
import { buildToolCallDisplayModel } from "@/utils/tool-call-display";
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import {
hasMeaningfulToolCallDetail,
@@ -159,10 +146,7 @@ function ensureWebToolCallShimmerKeyframes() {
webToolCallShimmerRegistered = true;
}
function getWheelEventElementTarget(
event: WheelEvent,
fallback: HTMLElement
): HTMLElement {
function getWheelEventElementTarget(event: WheelEvent, fallback: HTMLElement): HTMLElement {
const { target } = event;
if (target instanceof HTMLElement) {
return target;
@@ -176,7 +160,7 @@ function getWheelEventElementTarget(
function canElementScrollInDirection(
element: HTMLElement,
axis: ScrollAxis,
delta: number
delta: number,
): boolean {
if (delta === 0) {
return false;
@@ -192,7 +176,9 @@ function canElementScrollInDirection(
const scrollPosition = axis === "x" ? element.scrollLeft : element.scrollTop;
const scrollSize =
axis === "x" ? element.scrollWidth - element.clientWidth : element.scrollHeight - element.clientHeight;
axis === "x"
? element.scrollWidth - element.clientWidth
: element.scrollHeight - element.clientHeight;
if (scrollSize <= SCROLL_EDGE_EPSILON) {
return false;
}
@@ -207,7 +193,7 @@ function canScrollInsideDetailFromTarget(
detailRoot: HTMLElement,
startElement: HTMLElement,
axis: ScrollAxis,
delta: number
delta: number,
): boolean {
if (delta === 0) {
return false;
@@ -226,14 +212,10 @@ function canScrollInsideDetailFromTarget(
return false;
}
function shouldStopDetailWheelPropagation(
detailRoot: HTMLElement,
event: WheelEvent
): boolean {
function shouldStopDetailWheelPropagation(detailRoot: HTMLElement, event: WheelEvent): boolean {
const startElement = getWheelEventElementTarget(event, detailRoot);
const verticalDelta = event.deltaY;
const horizontalDelta =
event.deltaX !== 0 ? event.deltaX : (event.shiftKey ? event.deltaY : 0);
const horizontalDelta = event.deltaX !== 0 ? event.deltaX : event.shiftKey ? event.deltaY : 0;
const hasVerticalIntent = Math.abs(verticalDelta) > SCROLL_EDGE_EPSILON;
const hasHorizontalIntent = Math.abs(horizontalDelta) > SCROLL_EDGE_EPSILON;
@@ -245,12 +227,7 @@ function shouldStopDetailWheelPropagation(
? canScrollInsideDetailFromTarget(detailRoot, startElement, "y", verticalDelta)
: false;
const canScrollHorizontally = hasHorizontalIntent
? canScrollInsideDetailFromTarget(
detailRoot,
startElement,
"x",
horizontalDelta
)
? canScrollInsideDetailFromTarget(detailRoot, startElement, "x", horizontalDelta)
: false;
if (hasVerticalIntent && hasHorizontalIntent) {
@@ -336,11 +313,7 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
},
}));
function UserMessageAttachmentThumbnail({
image,
}: {
image: UserMessageImageAttachment;
}) {
function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAttachment }) {
const uri = useAttachmentPreviewUrl(image);
if (!uri) {
return <View style={userMessageStylesheet.imageThumbnailPlaceholder} />;
@@ -358,12 +331,10 @@ export const UserMessage = memo(function UserMessage({
}: UserMessageProps) {
const [messageHovered, setMessageHovered] = useState(false);
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
const resolvedDisableOuterSpacing =
useDisableOuterSpacing(disableOuterSpacing);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const hasText = message.trim().length > 0;
const hasImages = images.length > 0;
const showCopyButton =
hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered);
const showCopyButton = hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered);
return (
<View
@@ -372,20 +343,14 @@ export const UserMessage = memo(function UserMessage({
!resolvedDisableOuterSpacing && [
isFirstInGroup && { marginTop: theme.spacing[4] },
isLastInGroup && { marginBottom: theme.spacing[4] },
!isFirstInGroup || !isLastInGroup
? { marginBottom: theme.spacing[1] }
: undefined,
!isFirstInGroup || !isLastInGroup ? { marginBottom: theme.spacing[1] } : undefined,
],
]}
>
<Pressable
style={userMessageStylesheet.content}
onHoverIn={
Platform.OS === "web" ? () => setMessageHovered(true) : undefined
}
onHoverOut={
Platform.OS === "web" ? () => setMessageHovered(false) : undefined
}
onHoverIn={Platform.OS === "web" ? () => setMessageHovered(true) : undefined}
onHoverOut={Platform.OS === "web" ? () => setMessageHovered(false) : undefined}
>
<View style={userMessageStylesheet.bubble}>
{hasImages ? (
@@ -396,10 +361,7 @@ export const UserMessage = memo(function UserMessage({
]}
>
{images.map((image, index) => (
<View
key={`${image.id}-${index}`}
style={userMessageStylesheet.imagePill}
>
<View key={`${image.id}-${index}`} style={userMessageStylesheet.imagePill}>
<UserMessageAttachmentThumbnail image={image} />
</View>
))}
@@ -475,11 +437,7 @@ function MarkdownLink({
const [hovered, setHovered] = useState(false);
if (Platform.OS !== "web") {
return (
<Text
accessibilityRole="link"
onPress={() => onPress(href)}
style={style}
>
<Text accessibilityRole="link" onPress={() => onPress(href)} style={style}>
{children}
</Text>
);
@@ -492,25 +450,25 @@ function MarkdownLink({
onHoverIn={() => setHovered(true)}
onHoverOut={() => setHovered(false)}
>
<Text style={[style, hovered && { textDecorationLine: "underline" }]}>
{children}
</Text>
<Text style={[style, hovered && { textDecorationLine: "underline" }]}>{children}</Text>
</Pressable>
);
}
function getInlineCodeAutoLinkUrl(
markdownParser: ReturnType<typeof MarkdownIt>,
content: string
content: string,
): string | null {
const trimmed = content.trim();
if (!trimmed) {
return null;
}
const matches = markdownParser.linkify.match(trimmed) as
| Array<{ index: number; lastIndex: number; url: string }>
| null;
const matches = markdownParser.linkify.match(trimmed) as Array<{
index: number;
lastIndex: number;
url: string;
}> | null;
if (!matches || matches.length !== 1) {
return null;
}
@@ -604,9 +562,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({
style={[turnCopyButtonStylesheet.container, containerStyle]}
accessibilityRole="button"
accessibilityLabel={
copied
? (copiedAccessibilityLabel ?? "Copied")
: (accessibilityLabel ?? "Copy turn")
copied ? (copiedAccessibilityLabel ?? "Copied") : (accessibilityLabel ?? "Copy turn")
}
>
{({ hovered }) => {
@@ -767,41 +723,38 @@ export const AssistantMessage = memo(function AssistantMessage({
});
const { theme, rt } = useUnistyles();
const resolvedDisableOuterSpacing =
useDisableOuterSpacing(disableOuterSpacing);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [rt.themeName]);
const markdownParser = useMemo(
() => {
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;
}
const markdownParser = useMemo(() => {
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;
},
[]
);
return defaultValidateLink(url);
};
return parser;
}, []);
const handleLinkPress = useCallback((url: string) => {
const fileTarget = onInlinePathPress
? parseAssistantFileLink(url, { workspaceRoot })
: null;
if (fileTarget) {
onInlinePathPress?.(fileTarget);
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;
}
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]);
},
[onInlinePathPress, workspaceRoot],
);
const markdownRules = useMemo(() => {
return {
@@ -810,7 +763,7 @@ export const AssistantMessage = memo(function AssistantMessage({
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {}
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.text]}>
{node.content}
@@ -821,12 +774,9 @@ export const AssistantMessage = memo(function AssistantMessage({
children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {}
inheritedStyles: any = {},
) => (
<Text
key={node.key}
style={[inheritedStyles, styles.textgroup]}
>
<Text key={node.key} style={[inheritedStyles, styles.textgroup]}>
{children}
</Text>
),
@@ -835,12 +785,9 @@ export const AssistantMessage = memo(function AssistantMessage({
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {}
inheritedStyles: any = {},
) => (
<Text
key={node.key}
style={[inheritedStyles, styles.code_block]}
>
<Text key={node.key} style={[inheritedStyles, styles.code_block]}>
{node.content}
</Text>
),
@@ -849,7 +796,7 @@ export const AssistantMessage = memo(function AssistantMessage({
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {}
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.fence]}>
{node.content}
@@ -860,16 +807,14 @@ export const AssistantMessage = memo(function AssistantMessage({
_children: ReactNode[],
parent: any,
styles: any,
inheritedStyles: any = {}
inheritedStyles: any = {},
) => {
const content = node.content ?? "";
const isLinkedInlineCode =
nodeHasParentType(parent, "link") ||
(!Array.isArray(parent) &&
typeof parent?.attributes?.href === "string");
const parsed = onInlinePathPress && !isLinkedInlineCode
? parseInlinePathToken(content)
: null;
(!Array.isArray(parent) && typeof parent?.attributes?.href === "string");
const parsed =
onInlinePathPress && !isLinkedInlineCode ? parseInlinePathToken(content) : null;
if (parsed) {
return (
@@ -877,10 +822,7 @@ export const AssistantMessage = memo(function AssistantMessage({
key={node.key}
onPress={() => parsed && onInlinePathPress?.(parsed)}
selectable={false}
style={[
assistantMessageStylesheet.pathChip,
assistantMessageStylesheet.pathChipText,
]}
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
>
{content}
</Text>
@@ -902,81 +844,42 @@ export const AssistantMessage = memo(function AssistantMessage({
}
return (
<Text
key={node.key}
style={[inheritedStyles, styles.code_inline]}
>
<Text key={node.key} style={[inheritedStyles, styles.code_inline]}>
{content}
</Text>
);
},
bullet_list: (
node: any,
children: ReactNode[],
_parent: any,
styles: any
) => (
bullet_list: (node: any, children: ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.bullet_list}>
{children}
</View>
),
ordered_list: (
node: any,
children: ReactNode[],
_parent: any,
styles: any
) => (
ordered_list: (node: any, children: ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.ordered_list}>
{children}
</View>
),
list_item: (
node: any,
children: ReactNode[],
parent: any,
styles: any
) => {
list_item: (node: any, children: ReactNode[], parent: any, styles: any) => {
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
const iconStyle = isOrdered
? styles.ordered_list_icon
: styles.bullet_list_icon;
const contentStyle = isOrdered
? styles.ordered_list_content
: styles.bullet_list_content;
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
return (
<View key={node.key} style={styles.list_item}>
<Text style={iconStyle}>{marker}</Text>
<View
style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}
>
{children}
</View>
<View style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</View>
</View>
);
},
paragraph: (
node: any,
children: ReactNode[],
parent: any,
styles: any,
) => {
paragraph: (node: any, children: ReactNode[], parent: any, styles: any) => {
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
return (
<View
key={node.key}
style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}
>
<View key={node.key} style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}>
{children}
</View>
);
},
link: (
node: any,
children: ReactNode[],
_parent: any,
styles: any,
) => (
link: (node: any, children: ReactNode[], _parent: any, styles: any) => (
<MarkdownLink
key={node.key}
href={node.attributes?.href ?? ""}
@@ -985,8 +888,10 @@ export const AssistantMessage = memo(function AssistantMessage({
>
{Children.map(children, (child) =>
isValidElement(child)
? cloneElement(child, { style: [(child.props as any).style, { color: styles.link.color }] } as any)
: child
? cloneElement(child, {
style: [(child.props as any).style, { color: styles.link.color }],
} as any)
: child,
)}
</MarkdownLink>
),
@@ -998,8 +903,7 @@ export const AssistantMessage = memo(function AssistantMessage({
testID="assistant-message"
style={[
assistantMessageStylesheet.container,
!resolvedDisableOuterSpacing &&
assistantMessageStylesheet.containerSpacing,
!resolvedDisableOuterSpacing && assistantMessageStylesheet.containerSpacing,
]}
>
<Markdown
@@ -1109,8 +1013,7 @@ export const ActivityLog = memo(function ActivityLog({
onArtifactClick,
disableOuterSpacing,
}: ActivityLogProps) {
const resolvedDisableOuterSpacing =
useDisableOuterSpacing(disableOuterSpacing);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const [isExpanded, setIsExpanded] = useState(false);
const typeConfig = {
@@ -1149,9 +1052,7 @@ export const ActivityLog = memo(function ActivityLog({
};
const displayMessage =
type === "artifact" && artifactType && title
? `${artifactType}: ${title}`
: message;
type === "artifact" && artifactType && title ? `${artifactType}: ${title}` : message;
const isInteractive = type === "artifact" || metadata;
@@ -1172,12 +1073,7 @@ export const ActivityLog = memo(function ActivityLog({
<IconComponent size={16} color={config.color} />
</View>
<View style={activityLogStylesheet.textContainer}>
<Text
style={[
activityLogStylesheet.messageText,
{ color: config.color },
]}
>
<Text style={[activityLogStylesheet.messageText, { color: config.color }]}>
{displayMessage}
</Text>
{metadata && (
@@ -1402,21 +1298,17 @@ const ExpandableBadge = memo(function ExpandableBadge({
testID,
}: ExpandableBadgeProps) {
const { theme } = useUnistyles();
const resolvedDisableOuterSpacing =
useDisableOuterSpacing(disableOuterSpacing);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const [isHovered, setIsHovered] = useState(false);
const [isPressed, setIsPressed] = useState(false);
const isInteractive = Boolean(onToggle);
const hasDetailContent = Boolean(renderDetails);
const detailContent =
hasDetailContent && isExpanded ? renderDetails?.() : null;
const detailContent = hasDetailContent && isExpanded ? renderDetails?.() : null;
const detailWrapperRef = useRef<View | null>(null);
const wheelInvestigationComponentId = `ExpandableBadgeWheel:${
testID ?? label
}`;
const wheelInvestigationComponentId = `ExpandableBadgeWheel:${testID ?? label}`;
const nativeGradientIdRef = useRef(
`shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`
`shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`,
);
const [labelRowWidth, setLabelRowWidth] = useState(0);
const [labelRowHeight, setLabelRowHeight] = useState(0);
@@ -1426,77 +1318,64 @@ const ExpandableBadge = memo(function ExpandableBadge({
const [secondaryWidth, setSecondaryWidth] = useState(0);
const shimmerTranslateX = useSharedValue(0);
const totalShimmerChars =
label.trim().length + (secondaryLabel?.trim().length ?? 0);
const totalShimmerChars = label.trim().length + (secondaryLabel?.trim().length ?? 0);
const shortTextDurationAdjustment = totalShimmerChars <= 12 ? 0.25 : 0;
const shimmerDuration = Math.max(
1,
Math.min(2.3, 1.25 + totalShimmerChars * 0.008 - shortTextDurationAdjustment)
Math.min(2.3, 1.25 + totalShimmerChars * 0.008 - shortTextDurationAdjustment),
);
const nativeShimmerPeakWidth = Math.max(
32,
Math.min(120, labelRowWidth > 0 ? labelRowWidth * 0.28 : 0)
Math.min(120, labelRowWidth > 0 ? labelRowWidth * 0.28 : 0),
);
const isWebShimmer = isLoading && Platform.OS === "web";
const shouldMeasureWebShimmer = isWebShimmer;
const shouldMeasureNativeShimmer = isLoading && Platform.OS !== "web";
const isNativeShimmer =
shouldMeasureNativeShimmer &&
labelRowWidth > 0 &&
labelRowHeight > 0;
const isNativeShimmer = shouldMeasureNativeShimmer && labelRowWidth > 0 && labelRowHeight > 0;
const webShimmerSpanStartX = labelOffsetX;
const webShimmerSpanEndX = secondaryLabel
? secondaryOffsetX + secondaryWidth
: labelOffsetX + labelWidth;
const webShimmerSpanWidth = Math.max(
1,
webShimmerSpanEndX - webShimmerSpanStartX
);
const webShimmerPeakWidth = Math.max(
42,
Math.min(120, webShimmerSpanWidth * 0.22)
);
const webShimmerSpanWidth = Math.max(1, webShimmerSpanEndX - webShimmerSpanStartX);
const webShimmerPeakWidth = Math.max(42, Math.min(120, webShimmerSpanWidth * 0.22));
const webShimmerTrackStart = webShimmerSpanStartX - webShimmerPeakWidth;
const webShimmerTrackEnd = webShimmerSpanEndX;
const handleLabelRowLayout = useCallback((event: LayoutChangeEvent) => {
if (!shouldMeasureNativeShimmer) {
return;
}
const { width, height } = event.nativeEvent.layout;
setLabelRowWidth((previous) =>
Math.abs(previous - width) > 0.5 ? width : previous
);
setLabelRowHeight((previous) =>
Math.abs(previous - height) > 0.5 ? height : previous
);
}, [shouldMeasureNativeShimmer]);
const handleLabelRowLayout = useCallback(
(event: LayoutChangeEvent) => {
if (!shouldMeasureNativeShimmer) {
return;
}
const { width, height } = event.nativeEvent.layout;
setLabelRowWidth((previous) => (Math.abs(previous - width) > 0.5 ? width : previous));
setLabelRowHeight((previous) => (Math.abs(previous - height) > 0.5 ? height : previous));
},
[shouldMeasureNativeShimmer],
);
const handleLabelLayout = useCallback((event: LayoutChangeEvent) => {
if (!shouldMeasureWebShimmer) {
return;
}
const { x, width } = event.nativeEvent.layout;
setLabelOffsetX((previous) =>
Math.abs(previous - x) > 0.5 ? x : previous
);
setLabelWidth((previous) =>
Math.abs(previous - width) > 0.5 ? width : previous
);
}, [shouldMeasureWebShimmer]);
const handleLabelLayout = useCallback(
(event: LayoutChangeEvent) => {
if (!shouldMeasureWebShimmer) {
return;
}
const { x, width } = event.nativeEvent.layout;
setLabelOffsetX((previous) => (Math.abs(previous - x) > 0.5 ? x : previous));
setLabelWidth((previous) => (Math.abs(previous - width) > 0.5 ? width : previous));
},
[shouldMeasureWebShimmer],
);
const handleSecondaryLayout = useCallback((event: LayoutChangeEvent) => {
if (!shouldMeasureWebShimmer || !secondaryLabel) {
return;
}
const { x, width } = event.nativeEvent.layout;
setSecondaryOffsetX((previous) =>
Math.abs(previous - x) > 0.5 ? x : previous
);
setSecondaryWidth((previous) =>
Math.abs(previous - width) > 0.5 ? width : previous
);
}, [shouldMeasureWebShimmer, secondaryLabel]);
const handleSecondaryLayout = useCallback(
(event: LayoutChangeEvent) => {
if (!shouldMeasureWebShimmer || !secondaryLabel) {
return;
}
const { x, width } = event.nativeEvent.layout;
setSecondaryOffsetX((previous) => (Math.abs(previous - x) > 0.5 ? x : previous));
setSecondaryWidth((previous) => (Math.abs(previous - width) > 0.5 ? width : previous));
},
[shouldMeasureWebShimmer, secondaryLabel],
);
useEffect(() => {
if (!isWebShimmer) {
@@ -1520,18 +1399,12 @@ const ExpandableBadge = memo(function ExpandableBadge({
easing: Easing.linear,
}),
-1,
false
false,
);
return () => {
cancelAnimation(shimmerTranslateX);
};
}, [
isNativeShimmer,
labelRowWidth,
nativeShimmerPeakWidth,
shimmerDuration,
shimmerTranslateX,
]);
}, [isNativeShimmer, labelRowWidth, nativeShimmerPeakWidth, shimmerDuration, shimmerTranslateX]);
useEffect(() => {
if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) {
@@ -1605,23 +1478,21 @@ const ExpandableBadge = memo(function ExpandableBadge({
: expandableBadgeStylesheet.containerSpacing),
style,
],
[isLastInSequence, resolvedDisableOuterSpacing, style]
[isLastInSequence, resolvedDisableOuterSpacing, style],
);
const pressableStyle = useMemo(
() => [
expandableBadgeStylesheet.pressable,
isPressed && isInteractive
? expandableBadgeStylesheet.pressablePressed
: null,
isPressed && isInteractive ? expandableBadgeStylesheet.pressablePressed : null,
isExpanded && expandableBadgeStylesheet.pressableExpanded,
],
[isExpanded, isInteractive, isPressed]
[isExpanded, isInteractive, isPressed],
);
const accessibilityState = useMemo(
() => (isInteractive ? { expanded: isExpanded } : undefined),
[isExpanded, isInteractive]
[isExpanded, isInteractive],
);
const isActive = isHovered || isExpanded;
@@ -1632,7 +1503,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
isActive && expandableBadgeStylesheet.labelActive,
isLoading && expandableBadgeStylesheet.labelLoading,
],
[isActive, isLoading]
[isActive, isLoading],
);
const secondaryLabelStyle = useMemo(
@@ -1640,7 +1511,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.secondaryLabel,
isActive && expandableBadgeStylesheet.secondaryLabelActive,
],
[isActive]
[isActive],
);
const shimmerLabelTextStyle = useMemo(
@@ -1650,7 +1521,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.shimmerText,
shimmerLabelStyle,
],
[isLoading, shimmerLabelStyle]
[isLoading, shimmerLabelStyle],
);
const shimmerSecondaryTextStyle = useMemo(
@@ -1659,7 +1530,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.shimmerText,
shimmerSecondaryStyle,
],
[shimmerSecondaryStyle]
[shimmerSecondaryStyle],
);
const nativeShimmerTrackStyle = useMemo(
@@ -1667,7 +1538,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.nativeShimmerTrack,
{ width: labelRowWidth, height: labelRowHeight },
],
[labelRowHeight, labelRowWidth]
[labelRowHeight, labelRowWidth],
);
const nativeShimmerMaskStyle = useMemo(
@@ -1675,20 +1546,17 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.shimmerMaskRow,
{ width: labelRowWidth, height: labelRowHeight },
],
[labelRowHeight, labelRowWidth]
[labelRowHeight, labelRowWidth],
);
const nativeLabelMaskStyle = useMemo(
() => [expandableBadgeStylesheet.label, { color: "#000000", opacity: 1 }],
[]
[],
);
const nativeSecondaryMaskStyle = useMemo(
() => [
expandableBadgeStylesheet.secondaryLabel,
{ color: "#000000", opacity: 1 },
],
[]
() => [expandableBadgeStylesheet.secondaryLabel, { color: "#000000", opacity: 1 }],
[],
);
const nativeShimmerPeakCombinedStyle = useMemo(
@@ -1697,7 +1565,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
nativeShimmerPeakStyle,
{ width: nativeShimmerPeakWidth, height: labelRowHeight },
],
[labelRowHeight, nativeShimmerPeakStyle, nativeShimmerPeakWidth]
[labelRowHeight, nativeShimmerPeakStyle, nativeShimmerPeakWidth],
);
const chevronStyle = useMemo(
@@ -1705,7 +1573,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
expandableBadgeStylesheet.chevron,
isExpanded && expandableBadgeStylesheet.chevronExpanded,
],
[isExpanded]
[isExpanded],
);
const IconComponent = icon;
@@ -1723,10 +1591,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
}
return (
<View
style={containerStyle}
testID={testID}
>
<View style={containerStyle} testID={testID}>
<Pressable
onPress={isInteractive ? onToggle : undefined}
onHoverIn={isInteractive ? () => setIsHovered(true) : undefined}
@@ -1770,21 +1635,12 @@ const ExpandableBadge = memo(function ExpandableBadge({
<View style={expandableBadgeStylesheet.spacer} />
)}
{isWebShimmer ? (
<View
style={expandableBadgeStylesheet.shimmerOverlay}
pointerEvents="none"
>
<Text
style={shimmerLabelTextStyle}
numberOfLines={1}
>
<View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none">
<Text style={shimmerLabelTextStyle} numberOfLines={1}>
{label}
</Text>
{secondaryLabel ? (
<Text
style={shimmerSecondaryTextStyle}
numberOfLines={1}
>
<Text style={shimmerSecondaryTextStyle} numberOfLines={1}>
{secondaryLabel}
</Text>
) : (
@@ -1793,25 +1649,16 @@ const ExpandableBadge = memo(function ExpandableBadge({
</View>
) : null}
{isNativeShimmer ? (
<View
style={expandableBadgeStylesheet.shimmerOverlay}
pointerEvents="none"
>
<View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none">
<MaskedView
style={nativeShimmerTrackStyle}
maskElement={
<View style={nativeShimmerMaskStyle}>
<Text
style={nativeLabelMaskStyle}
numberOfLines={1}
>
<Text style={nativeLabelMaskStyle} numberOfLines={1}>
{label}
</Text>
{secondaryLabel ? (
<Text
style={nativeSecondaryMaskStyle}
numberOfLines={1}
>
<Text style={nativeSecondaryMaskStyle} numberOfLines={1}>
{secondaryLabel}
</Text>
) : (
@@ -1820,9 +1667,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
</View>
}
>
<View
style={nativeShimmerTrackStyle}
>
<View style={nativeShimmerTrackStyle}>
<Animated.View style={nativeShimmerPeakCombinedStyle}>
<Svg width="100%" height="100%" preserveAspectRatio="none">
<Defs>
@@ -1853,11 +1698,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
) : null}
</View>
{isInteractive && isHovered ? (
<ChevronRight
size={14}
color={theme.colors.foreground}
style={chevronStyle}
/>
<ChevronRight size={14} color={theme.colors.foreground} style={chevronStyle} />
) : null}
</View>
</Pressable>
@@ -1875,10 +1716,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
);
}, areExpandableBadgePropsEqual);
function areExpandableBadgePropsEqual(
previous: ExpandableBadgeProps,
next: ExpandableBadgeProps
) {
function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: ExpandableBadgeProps) {
if (previous.label !== next.label) return false;
if (previous.secondaryLabel !== next.secondaryLabel) return false;
if (previous.icon !== next.icon) return false;
@@ -1932,9 +1770,7 @@ export const ToolCall = memo(function ToolCall({
const [isExpanded, setIsExpanded] = useState(false);
// Check if we're on mobile (use bottom sheet) or desktop (inline expand)
const isMobile =
UnistylesRuntime.breakpoint === "xs" ||
UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const effectiveDetail = useMemo<ToolCallDetail | undefined>(() => {
if (detail) {
@@ -1950,12 +1786,11 @@ export const ToolCall = memo(function ToolCall({
return undefined;
}, [detail, args, result]);
const displayDetail =
effectiveDetail ?? {
type: "unknown",
input: null,
output: null,
};
const displayDetail = effectiveDetail ?? {
type: "unknown",
input: null,
output: null,
};
const displayModel = useMemo(
() =>
@@ -1967,7 +1802,7 @@ export const ToolCall = memo(function ToolCall({
metadata,
cwd,
}),
[toolName, status, error, displayDetail, metadata, cwd]
[toolName, status, error, displayDetail, metadata, cwd],
);
const displayName = displayModel.displayName;
const summary = displayModel.summary;
@@ -1981,9 +1816,7 @@ export const ToolCall = memo(function ToolCall({
const secondaryLabel = summary;
// Check if there's any content to display
const hasDetails =
Boolean(error) ||
hasMeaningfulToolCallDetail(effectiveDetail);
const hasDetails = Boolean(error) || hasMeaningfulToolCallDetail(effectiveDetail);
const canOpenDetails = hasDetails || isLoadingDetails;
const handleToggle = useCallback(() => {
@@ -18,12 +18,7 @@ export function ModeSelectorModal({
const { theme } = useUnistyles();
return (
<Modal
visible={visible}
animationType="fade"
transparent={true}
onRequestClose={onClose}
>
<Modal visible={visible} animationType="fade" transparent={true} onRequestClose={onClose}>
<Pressable style={styles.modalOverlay} onPress={onClose}>
<View style={styles.modeSelectorContent}>
{agent?.availableModes?.map((mode) => {
@@ -35,26 +30,13 @@ export function ModeSelectorModal({
onModeChange(mode.id);
onClose();
}}
style={[
styles.modeItem,
isActive && styles.modeItemActive,
]}
style={[styles.modeItem, isActive && styles.modeItemActive]}
>
<Text
style={[
styles.modeName,
isActive && styles.modeNameActive,
]}
>
<Text style={[styles.modeName, isActive && styles.modeNameActive]}>
{mode.label}
</Text>
{mode.description && (
<Text
style={[
styles.modeDescription,
isActive && styles.modeDescriptionActive,
]}
>
<Text style={[styles.modeDescription, isActive && styles.modeDescriptionActive]}>
{mode.description}
</Text>
)}
@@ -81,10 +81,13 @@ export function NameHostModal({ visible, serverId, hostname, onSkip, onSave }: N
}, [label, onSave, onSkip]);
return (
<AdaptiveModalSheet title="Name this host" visible={visible} onClose={onSkip} testID="name-host-modal">
<Text style={styles.helper}>
Optional. You can rename this later in Settings.
</Text>
<AdaptiveModalSheet
title="Name this host"
visible={visible}
onClose={onSkip}
testID="name-host-modal"
>
<Text style={styles.helper}>Optional. You can rename this later in Settings.</Text>
<View style={styles.field}>
<Text style={styles.label}>Label</Text>
@@ -48,15 +48,25 @@ export interface PairLinkModalProps {
onClose: () => void;
targetServerId?: string;
onCancel?: () => void;
onSaved?: (result: { profile: HostProfile; serverId: string; hostname: string | null; isNewHost: boolean }) => void;
onSaved?: (result: {
profile: HostProfile;
serverId: string;
hostname: string | null;
isNewHost: boolean;
}) => void;
}
export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServerId }: PairLinkModalProps) {
export function PairLinkModal({
visible,
onClose,
onCancel,
onSaved,
targetServerId,
}: PairLinkModalProps) {
const { theme } = useUnistyles();
const daemons = useHosts();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const [offerUrl, setOfferUrl] = useState("");
const [isSaving, setIsSaving] = useState(false);
@@ -148,10 +158,24 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServe
} finally {
setIsSaving(false);
}
}, [daemons, handleClose, isMobile, isSaving, offerUrl, onSaved, targetServerId, upsertDaemonFromOfferUrl]);
}, [
daemons,
handleClose,
isMobile,
isSaving,
offerUrl,
onSaved,
targetServerId,
upsertDaemonFromOfferUrl,
]);
return (
<AdaptiveModalSheet title="Paste pairing link" visible={visible} onClose={handleClose} testID="pair-link-modal">
<AdaptiveModalSheet
title="Paste pairing link"
visible={visible}
onClose={handleClose}
testID="pair-link-modal"
>
<Text style={styles.helper}>Paste the pairing link from your server.</Text>
<View style={styles.field}>
@@ -1,13 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Modal,
Pressable,
ScrollView,
Text,
TextInput,
View,
Platform,
} from "react-native";
import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native";
import { Folder } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQuery } from "@tanstack/react-query";
@@ -37,7 +29,7 @@ export function ProjectPickerModal() {
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const workspaces = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.workspaces : undefined
serverId ? state.sessions[serverId]?.workspaces : undefined,
);
const inputRef = useRef<TextInput>(null);
@@ -49,7 +41,7 @@ export function ProjectPickerModal() {
const recommendedPaths = useMemo(() => {
if (!workspaces) return [];
return Array.from(workspaces.values()).map(
(workspace) => workspace.projectRootPath || workspace.id
(workspace) => workspace.projectRootPath || workspace.id,
);
}, [workspaces]);
@@ -64,9 +56,7 @@ export function ProjectPickerModal() {
limit: 30,
});
return (
result.entries?.flatMap((entry) =>
entry.kind === "directory" ? [entry.path] : []
) ?? []
result.entries?.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])) ?? []
);
},
enabled: Boolean(client) && isConnected && open,
@@ -81,7 +71,7 @@ export function ProjectPickerModal() {
serverPaths: directorySuggestionsQuery.data ?? [],
query,
}),
[query, directorySuggestionsQuery.data, recommendedPaths]
[query, directorySuggestionsQuery.data, recommendedPaths],
);
const handleClose = useCallback(() => {
@@ -103,7 +93,7 @@ export function ProjectPickerModal() {
setIsSubmitting(false);
}
},
[client, openProject, serverId, setOpen]
[client, openProject, serverId, setOpen],
);
const handleSubmitCustom = useCallback(() => {
@@ -136,13 +126,7 @@ export function ProjectPickerModal() {
function handler(event: KeyboardEvent) {
const key = event.key;
if (
key !== "ArrowDown" &&
key !== "ArrowUp" &&
key !== "Enter" &&
key !== "Escape"
)
return;
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") return;
if (key === "Escape") {
event.preventDefault();
@@ -180,12 +164,7 @@ export function ProjectPickerModal() {
if (!serverId) return null;
return (
<Modal
visible={open}
transparent
animationType="fade"
onRequestClose={handleClose}
>
<Modal visible={open} transparent animationType="fade" onRequestClose={handleClose}>
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={handleClose} />
@@ -198,9 +177,7 @@ export function ProjectPickerModal() {
},
]}
>
<View
style={[styles.header, { borderBottomColor: theme.colors.border }]}
>
<View style={[styles.header, { borderBottomColor: theme.colors.border }]}>
<TextInput
ref={inputRef}
value={query}
@@ -225,21 +202,11 @@ export function ProjectPickerModal() {
showsVerticalScrollIndicator={false}
>
{isSubmitting ? (
<Text
style={[
styles.emptyText,
{ color: theme.colors.foregroundMuted },
]}
>
<Text style={[styles.emptyText, { color: theme.colors.foregroundMuted }]}>
Opening project...
</Text>
) : options.length === 0 && !query.trim() ? (
<Text
style={[
styles.emptyText,
{ color: theme.colors.foregroundMuted },
]}
>
<Text style={[styles.emptyText, { color: theme.colors.foregroundMuted }]}>
Start typing a path
</Text>
) : (
@@ -266,10 +233,7 @@ export function ProjectPickerModal() {
/>
</View>
<Text
style={[
styles.rowText,
{ color: theme.colors.foreground },
]}
style={[styles.rowText, { color: theme.colors.foreground }]}
numberOfLines={1}
>
{shortenPath(path)}
@@ -1,12 +1,12 @@
import { Bot } from 'lucide-react-native'
import { ClaudeIcon } from '@/components/icons/claude-icon'
import { CodexIcon } from '@/components/icons/codex-icon'
import { Bot } from "lucide-react-native";
import { ClaudeIcon } from "@/components/icons/claude-icon";
import { CodexIcon } from "@/components/icons/codex-icon";
const PROVIDER_ICONS: Record<string, typeof Bot> = {
claude: ClaudeIcon as unknown as typeof Bot,
codex: CodexIcon as unknown as typeof Bot,
}
};
export function getProviderIcon(provider: string): typeof Bot {
return PROVIDER_ICONS[provider] ?? Bot
return PROVIDER_ICONS[provider] ?? Bot;
}
@@ -1,12 +1,5 @@
import { useState, useCallback } from "react";
import {
View,
Text,
TextInput,
Pressable,
ActivityIndicator,
Platform,
} from "react-native";
import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native";
import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
import { Check, CircleHelp, X } from "lucide-react-native";
import type { PendingPermission } from "@/types/shared";
@@ -68,52 +61,42 @@ interface QuestionFormCardProps {
const IS_WEB = Platform.OS === "web";
export function QuestionFormCard({
permission,
onRespond,
isResponding,
}: QuestionFormCardProps) {
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const questions = parseQuestions(permission.request.input);
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
const [respondingAction, setRespondingAction] = useState<
"submit" | "dismiss" | null
>(null);
const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
const toggleOption = useCallback(
(qIndex: number, optIndex: number, multiSelect: boolean) => {
setSelections((prev) => {
const current = prev[qIndex] ?? new Set<number>();
const next = new Set(current);
if (multiSelect) {
if (next.has(optIndex)) {
next.delete(optIndex);
} else {
next.add(optIndex);
}
const toggleOption = useCallback((qIndex: number, optIndex: number, multiSelect: boolean) => {
setSelections((prev) => {
const current = prev[qIndex] ?? new Set<number>();
const next = new Set(current);
if (multiSelect) {
if (next.has(optIndex)) {
next.delete(optIndex);
} else {
if (next.has(optIndex)) {
next.clear();
} else {
next.clear();
next.add(optIndex);
}
next.add(optIndex);
}
return { ...prev, [qIndex]: next };
});
setOtherTexts((prev) => {
if (!prev[qIndex]) return prev;
const next = { ...prev };
delete next[qIndex];
return next;
});
},
[]
);
} else {
if (next.has(optIndex)) {
next.clear();
} else {
next.clear();
next.add(optIndex);
}
}
return { ...prev, [qIndex]: next };
});
setOtherTexts((prev) => {
if (!prev[qIndex]) return prev;
const next = { ...prev };
delete next[qIndex];
return next;
});
}, []);
const setOtherText = useCallback((qIndex: number, text: string) => {
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
@@ -182,9 +165,7 @@ export function QuestionFormCard({
return (
<View key={qIndex} style={styles.questionBlock}>
<View style={styles.questionHeader}>
<Text
style={[styles.questionText, { color: theme.colors.foreground }]}
>
<Text style={[styles.questionText, { color: theme.colors.foreground }]}>
{q.question}
</Text>
<CircleHelp size={14} color={theme.colors.foregroundMuted} />
@@ -202,19 +183,12 @@ export function QuestionFormCard({
},
pressed && styles.optionItemPressed,
]}
onPress={() =>
toggleOption(qIndex, optIndex, q.multiSelect)
}
onPress={() => toggleOption(qIndex, optIndex, q.multiSelect)}
disabled={isResponding}
>
<View style={styles.optionItemContent}>
<View style={styles.optionTextBlock}>
<Text
style={[
styles.optionLabel,
{ color: theme.colors.foreground },
]}
>
<Text style={[styles.optionLabel, { color: theme.colors.foreground }]}>
{opt.label}
</Text>
{opt.description ? (
@@ -230,10 +204,7 @@ export function QuestionFormCard({
</View>
{isSelected ? (
<View style={styles.optionCheckSlot}>
<Check
size={16}
color={theme.colors.foregroundMuted}
/>
<Check size={16} color={theme.colors.foregroundMuted} />
</View>
) : null}
</View>
@@ -245,9 +216,8 @@ export function QuestionFormCard({
style={[
styles.otherInput,
{
borderColor: otherText.length > 0
? theme.colors.borderAccent
: theme.colors.border,
borderColor:
otherText.length > 0 ? theme.colors.borderAccent : theme.colors.border,
color: theme.colors.foreground,
backgroundColor: theme.colors.surface2,
},
@@ -264,19 +234,12 @@ export function QuestionFormCard({
);
})}
<View
style={[
styles.actionsContainer,
!isMobile && styles.actionsContainerDesktop,
]}
>
<View style={[styles.actionsContainer, !isMobile && styles.actionsContainerDesktop]}>
<Pressable
style={({ pressed, hovered = false }) => [
styles.actionButton,
{
backgroundColor: hovered
? theme.colors.surface2
: theme.colors.surface1,
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
pressed && styles.optionItemPressed,
@@ -285,19 +248,11 @@ export function QuestionFormCard({
disabled={isResponding}
>
{respondingAction === "dismiss" ? (
<ActivityIndicator
size="small"
color={theme.colors.foregroundMuted}
/>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
<View style={styles.actionContent}>
<X size={14} color={theme.colors.foregroundMuted} />
<Text
style={[
styles.actionText,
{ color: theme.colors.foregroundMuted },
]}
>
<Text style={[styles.actionText, { color: theme.colors.foregroundMuted }]}>
Dismiss
</Text>
</View>
@@ -311,12 +266,8 @@ export function QuestionFormCard({
styles.actionButton,
{
backgroundColor:
hovered && !disabled
? theme.colors.surface2
: theme.colors.surface1,
borderColor: disabled
? theme.colors.border
: theme.colors.borderAccent,
hovered && !disabled ? theme.colors.surface2 : theme.colors.surface1,
borderColor: disabled ? theme.colors.border : theme.colors.borderAccent,
opacity: disabled ? 0.5 : 1,
},
pressed && !disabled ? styles.optionItemPressed : null,
@@ -326,27 +277,18 @@ export function QuestionFormCard({
disabled={!allAnswered || isResponding}
>
{respondingAction === "submit" ? (
<ActivityIndicator
size="small"
color={theme.colors.foreground}
/>
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : (
<View style={styles.actionContent}>
<Check
size={14}
color={
allAnswered
? theme.colors.foreground
: theme.colors.foregroundMuted
}
color={allAnswered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[
styles.actionText,
{
color: allAnswered
? theme.colors.foreground
: theme.colors.foregroundMuted,
color: allAnswered ? theme.colors.foreground : theme.colors.foregroundMuted,
},
]}
>
@@ -73,8 +73,7 @@ export function ResizeHandle({
return;
}
const pointerCurrent =
direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
const pointerCurrent = direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
const deltaRatio =
(pointerCurrent - pointerState.pointerStart) / pointerState.containerSize;
@@ -91,7 +90,7 @@ export function ResizeHandle({
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp, { once: true });
},
[direction, groupId, index, onResizeSplit, sizes]
[direction, groupId, index, onResizeSplit, sizes],
);
return (
@@ -107,9 +106,7 @@ export function ResizeHandle({
pointerEvents="none"
style={[
styles.highlight,
direction === "horizontal"
? styles.highlightHorizontal
: styles.highlightVertical,
direction === "horizontal" ? styles.highlightHorizontal : styles.highlightVertical,
{ backgroundColor: theme.colors.accent },
]}
/>
@@ -2,13 +2,7 @@ import { useEffect, useRef } from "react";
import { Animated, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
function SkeletonPulse({
pulse,
style,
}: {
pulse: Animated.Value;
style: StyleProp<ViewStyle>;
}) {
function SkeletonPulse({ pulse, style }: { pulse: Animated.Value; style: StyleProp<ViewStyle> }) {
const opacity = pulse.interpolate({
inputRange: [0, 1],
outputRange: [0.4, 0.8],
@@ -33,7 +27,7 @@ export function SidebarAgentListSkeleton() {
duration: 1000,
useNativeDriver: true,
}),
])
]),
);
animation.start();
@@ -55,10 +49,7 @@ export function SidebarAgentListSkeleton() {
<View style={styles.rows}>
{Array.from({ length: 3 }).map((__, rowIdx) => (
<View
key={`skeleton-row-${sectionIdx}-${rowIdx}`}
style={styles.row}
>
<View key={`skeleton-row-${sectionIdx}-${rowIdx}`} style={styles.row}>
<SkeletonPulse pulse={pulse} style={styles.rowDot} />
<SkeletonPulse pulse={pulse} style={styles.rowTitle} />
<SkeletonPulse pulse={pulse} style={styles.rowBadge} />
File diff suppressed because it is too large Load Diff
@@ -5,4 +5,3 @@
// Metro's platform-specific extensions will pick the right one at runtime.
export * from "./sortable-inline-list.native";
@@ -66,7 +66,7 @@ function SortableItem<T>({
const baseTransform = externalDndContext
? undefined
: CSS.Transform.toString(
transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform
transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform,
);
const scaleTransform = !externalDndContext && isDragging ? "scale(1.01)" : "";
const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
@@ -87,9 +87,7 @@ function SortableItem<T>({
? {
attributes: attributes as unknown as Record<string, unknown>,
listeners: listeners as unknown as Record<string, unknown>,
setActivatorNodeRef: setActivatorNodeRef as unknown as (
node: unknown
) => void,
setActivatorNodeRef: setActivatorNodeRef as unknown as (node: unknown) => void,
}
: undefined,
};
@@ -132,7 +130,7 @@ export function SortableInlineList<T>({
}): ReactElement {
const [activeId, setActiveId] = useState<string | null>(null);
const [dragItems, setDragItems] = useState<T[] | null>(null);
const items = externalDndContext ? data : dragItems ?? data;
const items = externalDndContext ? data : (dragItems ?? data);
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -142,7 +140,7 @@ export function SortableInlineList<T>({
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
}),
);
const handleDragStart = useCallback(
@@ -154,7 +152,7 @@ export function SortableInlineList<T>({
setActiveId(String(event.active.id));
onDragBegin?.();
},
[data, disabled, onDragBegin]
[data, disabled, onDragBegin],
);
const handleDragEnd = useCallback(
@@ -169,21 +167,16 @@ export function SortableInlineList<T>({
}
if (over && active.id !== over.id) {
const oldIndex = items.findIndex(
(item, i) => keyExtractor(item, i) === active.id
);
const newIndex = items.findIndex(
(item, i) => keyExtractor(item, i) === over.id
);
const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.id);
const newIndex = items.findIndex((item, i) => keyExtractor(item, i) === over.id);
if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
const newItems = arrayMove(items, oldIndex, newIndex);
onDragEnd?.(newItems);
}
}
},
[disabled, items, keyExtractor, onDragEnd]
[disabled, items, keyExtractor, onDragEnd],
);
const ids = items.map((item, index) => keyExtractor(item, index));

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