Switch to double quotes and reformat codebase with Biome
This commit is contained in:
@@ -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
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"semi": false,
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "es5",
|
|
||||||
"tabWidth": 2,
|
|
||||||
"printWidth": 100
|
|
||||||
}
|
|
||||||
+32
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+200
-37
@@ -24,10 +24,10 @@
|
|||||||
"@modelcontextprotocol/sdk": "^1.27.1"
|
"@modelcontextprotocol/sdk": "^1.27.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2.4.8",
|
||||||
"get-port-cli": "^3.0.0",
|
"get-port-cli": "^3.0.0",
|
||||||
"knip": "^5.82.1",
|
"knip": "^5.82.1",
|
||||||
"patch-package": "^8.0.1",
|
"patch-package": "^8.0.1",
|
||||||
"prettier": "^3.5.3",
|
|
||||||
"react": "19.1.4",
|
"react": "19.1.4",
|
||||||
"react-dom": "19.1.4",
|
"react-dom": "19.1.4",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
@@ -2559,11 +2559,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/biome": {
|
"node_modules/@biomejs/biome": {
|
||||||
"version": "1.9.4",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.8.tgz",
|
||||||
"integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==",
|
"integrity": "sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"biome": "bin/biome"
|
"biome": "bin/biome"
|
||||||
@@ -2576,20 +2575,20 @@
|
|||||||
"url": "https://opencollective.com/biome"
|
"url": "https://opencollective.com/biome"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@biomejs/cli-darwin-arm64": "1.9.4",
|
"@biomejs/cli-darwin-arm64": "2.4.8",
|
||||||
"@biomejs/cli-darwin-x64": "1.9.4",
|
"@biomejs/cli-darwin-x64": "2.4.8",
|
||||||
"@biomejs/cli-linux-arm64": "1.9.4",
|
"@biomejs/cli-linux-arm64": "2.4.8",
|
||||||
"@biomejs/cli-linux-arm64-musl": "1.9.4",
|
"@biomejs/cli-linux-arm64-musl": "2.4.8",
|
||||||
"@biomejs/cli-linux-x64": "1.9.4",
|
"@biomejs/cli-linux-x64": "2.4.8",
|
||||||
"@biomejs/cli-linux-x64-musl": "1.9.4",
|
"@biomejs/cli-linux-x64-musl": "2.4.8",
|
||||||
"@biomejs/cli-win32-arm64": "1.9.4",
|
"@biomejs/cli-win32-arm64": "2.4.8",
|
||||||
"@biomejs/cli-win32-x64": "1.9.4"
|
"@biomejs/cli-win32-x64": "2.4.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||||
"version": "1.9.4",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.8.tgz",
|
||||||
"integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==",
|
"integrity": "sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2604,9 +2603,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-darwin-x64": {
|
"node_modules/@biomejs/cli-darwin-x64": {
|
||||||
"version": "1.9.4",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.8.tgz",
|
||||||
"integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==",
|
"integrity": "sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2621,9 +2620,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-arm64": {
|
"node_modules/@biomejs/cli-linux-arm64": {
|
||||||
"version": "1.9.4",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.8.tgz",
|
||||||
"integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==",
|
"integrity": "sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2638,9 +2637,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||||
"version": "1.9.4",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.8.tgz",
|
||||||
"integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==",
|
"integrity": "sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2655,9 +2654,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-x64": {
|
"node_modules/@biomejs/cli-linux-x64": {
|
||||||
"version": "1.9.4",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.8.tgz",
|
||||||
"integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==",
|
"integrity": "sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2672,9 +2671,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||||
"version": "1.9.4",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.8.tgz",
|
||||||
"integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==",
|
"integrity": "sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2689,9 +2688,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-win32-arm64": {
|
"node_modules/@biomejs/cli-win32-arm64": {
|
||||||
"version": "1.9.4",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.8.tgz",
|
||||||
"integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==",
|
"integrity": "sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2706,9 +2705,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-win32-x64": {
|
"node_modules/@biomejs/cli-win32-x64": {
|
||||||
"version": "1.9.4",
|
"version": "2.4.8",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.8.tgz",
|
||||||
"integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==",
|
"integrity": "sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -34907,6 +34906,170 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"packages/expo-two-way-audio/node_modules/@types/react": {
|
||||||
"version": "18.3.28",
|
"version": "18.3.28",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
||||||
|
|||||||
+3
-3
@@ -23,8 +23,8 @@
|
|||||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
"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",
|
"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",
|
"test": "npm run test --workspaces --if-present",
|
||||||
"format": "prettier --write .",
|
"format": "biome format --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "biome format .",
|
||||||
"start": "npm run start --workspace=@getpaseo/server",
|
"start": "npm run start --workspace=@getpaseo/server",
|
||||||
"android": "npm run android --workspace=@getpaseo/app",
|
"android": "npm run android --workspace=@getpaseo/app",
|
||||||
"android:development": "npm run android:development --workspace=@getpaseo/app",
|
"android:development": "npm run android:development --workspace=@getpaseo/app",
|
||||||
@@ -57,10 +57,10 @@
|
|||||||
"release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push"
|
"release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2.4.8",
|
||||||
"get-port-cli": "^3.0.0",
|
"get-port-cli": "^3.0.0",
|
||||||
"knip": "^5.82.1",
|
"knip": "^5.82.1",
|
||||||
"patch-package": "^8.0.1",
|
"patch-package": "^8.0.1",
|
||||||
"prettier": "^3.5.3",
|
|
||||||
"react": "19.1.4",
|
"react": "19.1.4",
|
||||||
"react-dom": "19.1.4",
|
"react-dom": "19.1.4",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
|
|||||||
@@ -65,8 +65,7 @@ export default {
|
|||||||
ios: {
|
ios: {
|
||||||
supportsTablet: true,
|
supportsTablet: true,
|
||||||
infoPlist: {
|
infoPlist: {
|
||||||
NSMicrophoneUsageDescription:
|
NSMicrophoneUsageDescription: "This app needs access to the microphone for voice commands.",
|
||||||
"This app needs access to the microphone for voice commands.",
|
|
||||||
ITSAppUsesNonExemptEncryption: false,
|
ITSAppUsesNonExemptEncryption: false,
|
||||||
},
|
},
|
||||||
bundleIdentifier: variant.packageId,
|
bundleIdentifier: variant.packageId,
|
||||||
@@ -92,9 +91,7 @@ export default {
|
|||||||
"android.permission.CAMERA",
|
"android.permission.CAMERA",
|
||||||
],
|
],
|
||||||
package: variant.packageId,
|
package: variant.packageId,
|
||||||
...(variant.googleServicesFile
|
...(variant.googleServicesFile ? { googleServicesFile: variant.googleServicesFile } : {}),
|
||||||
? { googleServicesFile: variant.googleServicesFile }
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
web: {
|
web: {
|
||||||
output: "single",
|
output: "single",
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import { test as base, expect, type Page } from '@playwright/test';
|
import { test as base, expect, type Page } from "@playwright/test";
|
||||||
import {
|
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
||||||
buildCreateAgentPreferences,
|
|
||||||
buildSeededHost,
|
|
||||||
} from './helpers/daemon-registry';
|
|
||||||
|
|
||||||
// Extend base test to provide dynamic baseURL from global-setup
|
// Extend base test to provide dynamic baseURL from global-setup
|
||||||
const test = base.extend({
|
const test = base.extend({
|
||||||
baseURL: async ({}, use) => {
|
baseURL: async ({}, use) => {
|
||||||
const metroPort = process.env.E2E_METRO_PORT;
|
const metroPort = process.env.E2E_METRO_PORT;
|
||||||
if (!metroPort) {
|
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}`);
|
await use(`http://localhost:${metroPort}`);
|
||||||
},
|
},
|
||||||
@@ -22,19 +19,19 @@ test.beforeEach(async ({ page }) => {
|
|||||||
const metroPort = process.env.E2E_METRO_PORT;
|
const metroPort = process.env.E2E_METRO_PORT;
|
||||||
if (!daemonPort) {
|
if (!daemonPort) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). ' +
|
"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.'
|
"Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (daemonPort === '6767') {
|
if (daemonPort === "6767") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. ' +
|
"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.'
|
"Fix Playwright globalSetup to start an isolated test daemon and export its port.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!metroPort) {
|
if (!metroPort) {
|
||||||
throw new Error(
|
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).
|
// This blocks both HTTP and WS attempts to :6767 (before any navigation).
|
||||||
await page.route(/:(6767)\b/, (route) => route.abort());
|
await page.route(/:(6767)\b/, (route) => route.abort());
|
||||||
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
|
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[] = [];
|
const entries: string[] = [];
|
||||||
consoleEntries.set(page, entries);
|
consoleEntries.set(page, entries);
|
||||||
|
|
||||||
page.on('console', (message) => {
|
page.on("console", (message) => {
|
||||||
entries.push(`[console:${message.type()}] ${message.text()}`);
|
entries.push(`[console:${message.type()}] ${message.text()}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
page.on('pageerror', (error) => {
|
page.on("pageerror", (error) => {
|
||||||
entries.push(`[pageerror] ${error.message}`);
|
entries.push(`[pageerror] ${error.message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,7 +57,7 @@ test.beforeEach(async ({ page }) => {
|
|||||||
const seedNonce = Math.random().toString(36).slice(2);
|
const seedNonce = Math.random().toString(36).slice(2);
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
if (!serverId) {
|
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({
|
const testDaemon = buildSeededHost({
|
||||||
serverId,
|
serverId,
|
||||||
@@ -74,7 +71,7 @@ test.beforeEach(async ({ page }) => {
|
|||||||
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
// `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
|
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
||||||
// setting this flag before the reload.
|
// 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);
|
const disableValue = localStorage.getItem(disableOnceKey);
|
||||||
if (disableValue) {
|
if (disableValue) {
|
||||||
localStorage.removeItem(disableOnceKey);
|
localStorage.removeItem(disableOnceKey);
|
||||||
@@ -83,15 +80,15 @@ test.beforeEach(async ({ page }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem('@paseo:e2e', '1');
|
localStorage.setItem("@paseo:e2e", "1");
|
||||||
localStorage.setItem('@paseo:e2e-seed-nonce', seedNonce);
|
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
|
||||||
|
|
||||||
// Hard-reset anything that could point to a developer's real daemon.
|
// Hard-reset anything that could point to a developer's real daemon.
|
||||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||||
localStorage.removeItem('@paseo:settings');
|
localStorage.removeItem("@paseo:settings");
|
||||||
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await testInfo.attach('browser-console', {
|
await testInfo.attach("browser-console", {
|
||||||
body: entries.join('\n'),
|
body: entries.join("\n"),
|
||||||
contentType: 'text/plain',
|
contentType: "text/plain",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+128
-105
@@ -1,11 +1,11 @@
|
|||||||
import { spawn, type ChildProcess, execSync } from 'node:child_process';
|
import { spawn, type ChildProcess, execSync } from "node:child_process";
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from "node:fs";
|
||||||
import { mkdtemp, rm } from 'node:fs/promises';
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from "node:os";
|
||||||
import path from 'node:path';
|
import path from "node:path";
|
||||||
import net from 'node:net';
|
import net from "node:net";
|
||||||
import { Buffer } from 'node:buffer';
|
import { Buffer } from "node:buffer";
|
||||||
import dotenv from 'dotenv';
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
type WaitForServerOptions = {
|
type WaitForServerOptions = {
|
||||||
host?: string;
|
host?: string;
|
||||||
@@ -18,11 +18,11 @@ type WaitForServerOptions = {
|
|||||||
async function getAvailablePort(): Promise<number> {
|
async function getAvailablePort(): Promise<number> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const server = net.createServer();
|
const server = net.createServer();
|
||||||
server.once('error', reject);
|
server.once("error", reject);
|
||||||
server.listen(0, () => {
|
server.listen(0, () => {
|
||||||
const address = server.address();
|
const address = server.address();
|
||||||
if (!address || typeof address === 'string') {
|
if (!address || typeof address === "string") {
|
||||||
server.close(() => reject(new Error('Failed to acquire port')));
|
server.close(() => reject(new Error("Failed to acquire port")));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
server.close(() => resolve(address.port));
|
server.close(() => resolve(address.port));
|
||||||
@@ -40,18 +40,18 @@ function createLineBuffer(maxLines = 120): { add: (line: string) => void; dump:
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
dump() {
|
dump() {
|
||||||
return lines.join('\n');
|
return lines.join("\n");
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRecentOutput(getRecentOutput?: () => string): string {
|
function formatRecentOutput(getRecentOutput?: () => string): string {
|
||||||
if (!getRecentOutput) {
|
if (!getRecentOutput) {
|
||||||
return '';
|
return "";
|
||||||
}
|
}
|
||||||
const output = getRecentOutput().trim();
|
const output = getRecentOutput().trim();
|
||||||
if (!output) {
|
if (!output) {
|
||||||
return '';
|
return "";
|
||||||
}
|
}
|
||||||
return `\nRecent output:\n${output}`;
|
return `\nRecent output:\n${output}`;
|
||||||
}
|
}
|
||||||
@@ -61,21 +61,15 @@ function sleep(ms: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function waitForServer(port: number, options: WaitForServerOptions): Promise<void> {
|
async function waitForServer(port: number, options: WaitForServerOptions): Promise<void> {
|
||||||
const {
|
const { host = "127.0.0.1", timeoutMs = 15000, label, childProcess, getRecentOutput } = options;
|
||||||
host = '127.0.0.1',
|
|
||||||
timeoutMs = 15000,
|
|
||||||
label,
|
|
||||||
childProcess,
|
|
||||||
getRecentOutput,
|
|
||||||
} = options;
|
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
let lastConnectionError: unknown = null;
|
let lastConnectionError: unknown = null;
|
||||||
|
|
||||||
while (Date.now() - start < timeoutMs) {
|
while (Date.now() - start < timeoutMs) {
|
||||||
if (childProcess && childProcess.exitCode !== null) {
|
if (childProcess && childProcess.exitCode !== null) {
|
||||||
const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : '';
|
const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : "";
|
||||||
throw new Error(
|
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();
|
socket.destroy();
|
||||||
reject(new Error(`Connection timed out to ${host}:${port}`));
|
reject(new Error(`Connection timed out to ${host}:${port}`));
|
||||||
});
|
});
|
||||||
socket.on('error', reject);
|
socket.on("error", reject);
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -99,9 +93,11 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reason =
|
const reason =
|
||||||
lastConnectionError instanceof Error ? ` Last connection error: ${lastConnectionError.message}` : '';
|
lastConnectionError instanceof Error
|
||||||
|
? ` Last connection error: ${lastConnectionError.message}`
|
||||||
|
: "";
|
||||||
throw new Error(
|
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) {
|
if (child.exitCode !== null || child.signalCode !== null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
child.kill('SIGTERM');
|
child.kill("SIGTERM");
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
if (child.exitCode === null && child.signalCode === null) {
|
if (child.exitCode === null && child.signalCode === null) {
|
||||||
child.kill('SIGKILL');
|
child.kill("SIGKILL");
|
||||||
}
|
}
|
||||||
resolve();
|
resolve();
|
||||||
}, 5000);
|
}, 5000);
|
||||||
child.once('exit', () => {
|
child.once("exit", () => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
@@ -144,7 +140,7 @@ async function stopProcess(child: ChildProcess | null): Promise<void> {
|
|||||||
function summarizeOpenAiErrorBody(body: string): string {
|
function summarizeOpenAiErrorBody(body: string): string {
|
||||||
const trimmed = body.trim();
|
const trimmed = body.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return 'empty response body';
|
return "empty response body";
|
||||||
}
|
}
|
||||||
if (trimmed.length <= 240) {
|
if (trimmed.length <= 240) {
|
||||||
return trimmed;
|
return trimmed;
|
||||||
@@ -159,8 +155,8 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('https://api.openai.com/v1/models?limit=1', {
|
const response = await fetch("https://api.openai.com/v1/models?limit=1", {
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${key}`,
|
Authorization: `Bearer ${key}`,
|
||||||
},
|
},
|
||||||
@@ -170,14 +166,14 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
|
|||||||
}
|
}
|
||||||
const body = await response.text();
|
const body = await response.text();
|
||||||
console.warn(
|
console.warn(
|
||||||
`[e2e] OPENAI_API_KEY probe failed (${response.status}): ${summarizeOpenAiErrorBody(body)}`
|
`[e2e] OPENAI_API_KEY probe failed (${response.status}): ${summarizeOpenAiErrorBody(body)}`,
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[e2e] OPENAI_API_KEY probe request failed: ${
|
`[e2e] OPENAI_API_KEY probe request failed: ${
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
}`
|
}`,
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -196,42 +192,42 @@ type OfferPayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function stripAnsi(input: string): string {
|
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 {
|
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)) {
|
if (existsSync(relayDistEntry)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[e2e] Building @getpaseo/relay for daemon startup');
|
console.log("[e2e] Building @getpaseo/relay for daemon startup");
|
||||||
execSync('npm run build --workspace=@getpaseo/relay', {
|
execSync("npm run build --workspace=@getpaseo/relay", {
|
||||||
cwd: repoRoot,
|
cwd: repoRoot,
|
||||||
stdio: 'inherit',
|
stdio: "inherit",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeOfferFromFragmentUrl(url: string): OfferPayload {
|
function decodeOfferFromFragmentUrl(url: string): OfferPayload {
|
||||||
const marker = '#offer=';
|
const marker = "#offer=";
|
||||||
const idx = url.indexOf(marker);
|
const idx = url.indexOf(marker);
|
||||||
if (idx === -1) {
|
if (idx === -1) {
|
||||||
throw new Error(`missing ${marker} fragment: ${url}`);
|
throw new Error(`missing ${marker} fragment: ${url}`);
|
||||||
}
|
}
|
||||||
const encoded = url.slice(idx + marker.length);
|
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>;
|
const offer = JSON.parse(json) as Partial<OfferPayload>;
|
||||||
if (offer.v !== 2) throw new Error('offer.v missing/invalid');
|
if (offer.v !== 2) throw new Error("offer.v missing/invalid");
|
||||||
if (!offer.serverId) throw new Error('offer.serverId missing');
|
if (!offer.serverId) throw new Error("offer.serverId missing");
|
||||||
if (!offer.daemonPublicKeyB64) throw new Error('offer.daemonPublicKeyB64 missing');
|
if (!offer.daemonPublicKeyB64) throw new Error("offer.daemonPublicKeyB64 missing");
|
||||||
if (!offer.relay?.endpoint) throw new Error('offer.relay.endpoint missing');
|
if (!offer.relay?.endpoint) throw new Error("offer.relay.endpoint missing");
|
||||||
return offer as OfferPayload;
|
return offer as OfferPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function globalSetup() {
|
export default async function globalSetup() {
|
||||||
const repoRoot = path.resolve(__dirname, '../../..');
|
const repoRoot = path.resolve(__dirname, "../../..");
|
||||||
ensureRelayBuildArtifact(repoRoot);
|
ensureRelayBuildArtifact(repoRoot);
|
||||||
const envTestPath = path.join(repoRoot, '.env.test');
|
const envTestPath = path.join(repoRoot, ".env.test");
|
||||||
if (existsSync(envTestPath)) {
|
if (existsSync(envTestPath)) {
|
||||||
dotenv.config({ path: envTestPath });
|
dotenv.config({ path: envTestPath });
|
||||||
}
|
}
|
||||||
@@ -239,13 +235,17 @@ export default async function globalSetup() {
|
|||||||
const port = await getAvailablePort();
|
const port = await getAvailablePort();
|
||||||
let relayPort = 0;
|
let relayPort = 0;
|
||||||
const metroPort = await getAvailablePort();
|
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();
|
let relayLineBuffer = createLineBuffer();
|
||||||
const metroLineBuffer = createLineBuffer();
|
const metroLineBuffer = createLineBuffer();
|
||||||
const daemonLineBuffer = createLineBuffer();
|
const daemonLineBuffer = createLineBuffer();
|
||||||
|
|
||||||
const cleanup = async () => {
|
const cleanup = async () => {
|
||||||
await Promise.all([stopProcess(daemonProcess), stopProcess(metroProcess), stopProcess(relayProcess)]);
|
await Promise.all([
|
||||||
|
stopProcess(daemonProcess),
|
||||||
|
stopProcess(metroProcess),
|
||||||
|
stopProcess(relayProcess),
|
||||||
|
]);
|
||||||
daemonProcess = null;
|
daemonProcess = null;
|
||||||
metroProcess = null;
|
metroProcess = null;
|
||||||
relayProcess = null;
|
relayProcess = null;
|
||||||
@@ -256,24 +256,30 @@ export default async function globalSetup() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
|
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
|
||||||
const defaultLocalModelsDir = path.join(process.env.HOME ?? '', '.paseo', 'models', 'local-speech');
|
const defaultLocalModelsDir = path.join(
|
||||||
const hasDefaultLocalModelsDir = defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
|
process.env.HOME ?? "",
|
||||||
const dictationProvider = openAiUsable ? 'openai' : 'local';
|
".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(
|
throw new Error(
|
||||||
'OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. ' +
|
"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.'
|
"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(
|
console.log(
|
||||||
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? '' : ' (OpenAI probe failed)'}`
|
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const relayDir = path.resolve(__dirname, '..', '..', 'relay');
|
const relayDir = path.resolve(__dirname, "..", "..", "relay");
|
||||||
const maxRelayStartupAttempts = 5;
|
const maxRelayStartupAttempts = 5;
|
||||||
let relayStarted = false;
|
let relayStarted = false;
|
||||||
let lastRelayStartupError: unknown = null;
|
let lastRelayStartupError: unknown = null;
|
||||||
@@ -285,18 +291,21 @@ export default async function globalSetup() {
|
|||||||
let relayReadyForSelectedPort = false;
|
let relayReadyForSelectedPort = false;
|
||||||
|
|
||||||
relayProcess = spawn(
|
relayProcess = spawn(
|
||||||
'npx',
|
"npx",
|
||||||
['wrangler', 'dev', '--local', '--ip', '127.0.0.1', '--port', String(relayPort)],
|
["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)],
|
||||||
{
|
{
|
||||||
cwd: relayDir,
|
cwd: relayDir,
|
||||||
env: { ...process.env },
|
env: { ...process.env },
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
detached: false,
|
detached: false,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
relayProcess.stdout?.on('data', (data: Buffer) => {
|
relayProcess.stdout?.on("data", (data: Buffer) => {
|
||||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
const lines = data
|
||||||
|
.toString()
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim());
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
relayLineBuffer.add(`[stdout] ${line}`);
|
relayLineBuffer.add(`[stdout] ${line}`);
|
||||||
const failure = parseRelayStartupFailure(line);
|
const failure = parseRelayStartupFailure(line);
|
||||||
@@ -311,8 +320,11 @@ export default async function globalSetup() {
|
|||||||
console.log(`[relay] ${line}`);
|
console.log(`[relay] ${line}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
relayProcess.stderr?.on('data', (data: Buffer) => {
|
relayProcess.stderr?.on("data", (data: Buffer) => {
|
||||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
const lines = data
|
||||||
|
.toString()
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim());
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
relayLineBuffer.add(`[stderr] ${line}`);
|
relayLineBuffer.add(`[stderr] ${line}`);
|
||||||
const failure = parseRelayStartupFailure(line);
|
const failure = parseRelayStartupFailure(line);
|
||||||
@@ -330,7 +342,7 @@ export default async function globalSetup() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await waitForServer(relayPort, {
|
await waitForServer(relayPort, {
|
||||||
label: 'Relay dev server',
|
label: "Relay dev server",
|
||||||
timeoutMs: 30000,
|
timeoutMs: 30000,
|
||||||
childProcess: relayProcess,
|
childProcess: relayProcess,
|
||||||
getRecentOutput: relayLineBuffer.dump,
|
getRecentOutput: relayLineBuffer.dump,
|
||||||
@@ -353,15 +365,15 @@ export default async function globalSetup() {
|
|||||||
if (!relayReadyForSelectedPort) {
|
if (!relayReadyForSelectedPort) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput(
|
`Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput(
|
||||||
relayLineBuffer.dump
|
relayLineBuffer.dump,
|
||||||
)}`
|
)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (relayProcess.exitCode !== null || relayProcess.signalCode !== null) {
|
if (relayProcess.exitCode !== null || relayProcess.signalCode !== null) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Relay process exited before startup completed (exit code ${relayProcess.exitCode}, signal ${relayProcess.signalCode}).${formatRecentOutput(
|
`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
|
? lastRelayStartupError.message
|
||||||
: String(lastRelayStartupError);
|
: String(lastRelayStartupError);
|
||||||
throw new Error(
|
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
|
// Start Metro bundler on dynamic port
|
||||||
const appDir = path.resolve(__dirname, '..');
|
const appDir = path.resolve(__dirname, "..");
|
||||||
metroProcess = spawn('npx', ['expo', 'start', '--web', '--port', String(metroPort)], {
|
metroProcess = spawn("npx", ["expo", "start", "--web", "--port", String(metroPort)], {
|
||||||
cwd: appDir,
|
cwd: appDir,
|
||||||
env: {
|
env: {
|
||||||
...process.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,
|
detached: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
metroProcess.stdout?.on('data', (data: Buffer) => {
|
metroProcess.stdout?.on("data", (data: Buffer) => {
|
||||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
const lines = data
|
||||||
|
.toString()
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim());
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
metroLineBuffer.add(`[stdout] ${line}`);
|
metroLineBuffer.add(`[stdout] ${line}`);
|
||||||
console.log(`[metro] ${line}`);
|
console.log(`[metro] ${line}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
metroProcess.stderr?.on('data', (data: Buffer) => {
|
metroProcess.stderr?.on("data", (data: Buffer) => {
|
||||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
const lines = data
|
||||||
|
.toString()
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim());
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
metroLineBuffer.add(`[stderr] ${line}`);
|
metroLineBuffer.add(`[stderr] ${line}`);
|
||||||
console.error(`[metro] ${line}`);
|
console.error(`[metro] ${line}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const serverDir = path.resolve(__dirname, '../../..', 'packages/server');
|
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
|
||||||
const tsxBin = execSync('which tsx').toString().trim();
|
const tsxBin = execSync("which tsx").toString().trim();
|
||||||
|
|
||||||
let offerPayload: OfferPayload | null = null;
|
let offerPayload: OfferPayload | null = null;
|
||||||
let offerResolve: (() => void) | null = null;
|
let offerResolve: (() => void) | null = null;
|
||||||
@@ -421,33 +439,33 @@ export default async function globalSetup() {
|
|||||||
offerResolve = resolve;
|
offerResolve = resolve;
|
||||||
});
|
});
|
||||||
|
|
||||||
daemonProcess = spawn(tsxBin, ['src/server/index.ts'], {
|
daemonProcess = spawn(tsxBin, ["src/server/index.ts"], {
|
||||||
cwd: serverDir,
|
cwd: serverDir,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
PASEO_HOME: paseoHome,
|
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_LISTEN: `0.0.0.0:${port}`,
|
||||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
|
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
|
||||||
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
||||||
// Use OpenAI speech providers in e2e to avoid local model bootstrapping delays.
|
// Use OpenAI speech providers in e2e to avoid local model bootstrapping delays.
|
||||||
PASEO_DICTATION_ENABLED: '1',
|
PASEO_DICTATION_ENABLED: "1",
|
||||||
PASEO_VOICE_MODE_ENABLED: '1',
|
PASEO_VOICE_MODE_ENABLED: "1",
|
||||||
PASEO_DICTATION_STT_PROVIDER: dictationProvider,
|
PASEO_DICTATION_STT_PROVIDER: dictationProvider,
|
||||||
PASEO_VOICE_STT_PROVIDER: 'openai',
|
PASEO_VOICE_STT_PROVIDER: "openai",
|
||||||
PASEO_VOICE_TTS_PROVIDER: 'openai',
|
PASEO_VOICE_TTS_PROVIDER: "openai",
|
||||||
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
|
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
|
||||||
NODE_ENV: 'development',
|
NODE_ENV: "development",
|
||||||
},
|
},
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
detached: false,
|
detached: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
let stdoutBuffer = '';
|
let stdoutBuffer = "";
|
||||||
daemonProcess.stdout?.on('data', (data: Buffer) => {
|
daemonProcess.stdout?.on("data", (data: Buffer) => {
|
||||||
stdoutBuffer += data.toString('utf8');
|
stdoutBuffer += data.toString("utf8");
|
||||||
const lines = stdoutBuffer.split('\n');
|
const lines = stdoutBuffer.split("\n");
|
||||||
stdoutBuffer = lines.pop() ?? '';
|
stdoutBuffer = lines.pop() ?? "";
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
@@ -456,13 +474,13 @@ export default async function globalSetup() {
|
|||||||
const clean = stripAnsi(trimmed);
|
const clean = stripAnsi(trimmed);
|
||||||
try {
|
try {
|
||||||
const obj = JSON.parse(clean) as { msg?: string; url?: string };
|
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);
|
offerPayload = decodeOfferFromFragmentUrl(obj.url);
|
||||||
offerResolve?.();
|
offerResolve?.();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
|
const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
|
||||||
if (match && clean.includes('pairing_offer')) {
|
if (match && clean.includes("pairing_offer")) {
|
||||||
try {
|
try {
|
||||||
offerPayload = decodeOfferFromFragmentUrl(match[0]);
|
offerPayload = decodeOfferFromFragmentUrl(match[0]);
|
||||||
offerResolve?.();
|
offerResolve?.();
|
||||||
@@ -476,8 +494,11 @@ export default async function globalSetup() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
daemonProcess.stderr?.on('data', (data: Buffer) => {
|
daemonProcess.stderr?.on("data", (data: Buffer) => {
|
||||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
const lines = data
|
||||||
|
.toString()
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim());
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
daemonLineBuffer.add(`[stderr] ${line}`);
|
daemonLineBuffer.add(`[stderr] ${line}`);
|
||||||
console.error(`[daemon] ${line}`);
|
console.error(`[daemon] ${line}`);
|
||||||
@@ -487,12 +508,12 @@ export default async function globalSetup() {
|
|||||||
// Wait for both daemon and Metro to be ready
|
// Wait for both daemon and Metro to be ready
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
waitForServer(port, {
|
waitForServer(port, {
|
||||||
label: 'Paseo daemon',
|
label: "Paseo daemon",
|
||||||
childProcess: daemonProcess,
|
childProcess: daemonProcess,
|
||||||
getRecentOutput: daemonLineBuffer.dump,
|
getRecentOutput: daemonLineBuffer.dump,
|
||||||
}),
|
}),
|
||||||
waitForServer(metroPort, {
|
waitForServer(metroPort, {
|
||||||
label: 'Metro web server',
|
label: "Metro web server",
|
||||||
timeoutMs: 120000, // Metro can take longer to start
|
timeoutMs: 120000, // Metro can take longer to start
|
||||||
childProcess: metroProcess,
|
childProcess: metroProcess,
|
||||||
getRecentOutput: metroLineBuffer.dump,
|
getRecentOutput: metroLineBuffer.dump,
|
||||||
@@ -503,11 +524,11 @@ export default async function globalSetup() {
|
|||||||
await Promise.race([
|
await Promise.race([
|
||||||
offerPromise,
|
offerPromise,
|
||||||
new Promise((_, reject) =>
|
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) {
|
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;
|
const offer = offerPayload as OfferPayload;
|
||||||
|
|
||||||
@@ -516,11 +537,13 @@ export default async function globalSetup() {
|
|||||||
process.env.E2E_SERVER_ID = offer.serverId;
|
process.env.E2E_SERVER_ID = offer.serverId;
|
||||||
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
|
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
|
||||||
process.env.E2E_METRO_PORT = String(metroPort);
|
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 () => {
|
return async () => {
|
||||||
await cleanup();
|
await cleanup();
|
||||||
console.log('[e2e] Test daemon stopped');
|
console.log("[e2e] Test daemon stopped");
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await cleanup();
|
await cleanup();
|
||||||
|
|||||||
@@ -34,10 +34,7 @@ export type DaemonClientInstance = {
|
|||||||
initialPrompt: string;
|
initialPrompt: string;
|
||||||
}): Promise<{ id: string }>;
|
}): Promise<{ id: string }>;
|
||||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||||
waitForFinish(
|
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
|
||||||
agentId: string,
|
|
||||||
timeout?: number
|
|
||||||
): Promise<{ status: string }>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function getDaemonWsUrl(): string {
|
function getDaemonWsUrl(): string {
|
||||||
@@ -86,14 +83,16 @@ export function createReplyTurn(label: string): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDaemonClientConstructor(): Promise<new (config: {
|
async function loadDaemonClientConstructor(): Promise<
|
||||||
url: string;
|
new (config: {
|
||||||
clientId: string;
|
url: string;
|
||||||
clientType: "cli";
|
clientId: string;
|
||||||
}) => DaemonClientInstance> {
|
clientType: "cli";
|
||||||
|
}) => DaemonClientInstance
|
||||||
|
> {
|
||||||
const repoRoot = path.resolve(process.cwd(), "../..");
|
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||||
const moduleUrl = pathToFileURL(
|
const moduleUrl = pathToFileURL(
|
||||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js")
|
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||||
).href;
|
).href;
|
||||||
const mod = (await import(moduleUrl)) as {
|
const mod = (await import(moduleUrl)) as {
|
||||||
DaemonClient: new (config: {
|
DaemonClient: new (config: {
|
||||||
@@ -138,7 +137,7 @@ export async function seedBottomAnchorAgent(input: {
|
|||||||
const initialFinish = await input.client.waitForFinish(created.id, 120000);
|
const initialFinish = await input.client.waitForFinish(created.id, 120000);
|
||||||
if (initialFinish.status !== "idle") {
|
if (initialFinish.status !== "idle") {
|
||||||
throw new Error(
|
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);
|
const finish = await input.client.waitForFinish(created.id, 120000);
|
||||||
if (finish.status !== "idle") {
|
if (finish.status !== "idle") {
|
||||||
throw new Error(
|
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 =
|
const scrollElement =
|
||||||
candidates.sort(
|
candidates.sort(
|
||||||
(left, right) =>
|
(left, right) =>
|
||||||
right.scrollHeight -
|
right.scrollHeight - right.clientHeight - (left.scrollHeight - left.clientHeight),
|
||||||
right.clientHeight -
|
|
||||||
(left.scrollHeight - left.clientHeight)
|
|
||||||
)[0] ?? (root as HTMLElement);
|
)[0] ?? (root as HTMLElement);
|
||||||
|
|
||||||
const offsetY = Math.max(0, scrollElement.scrollTop);
|
const offsetY = Math.max(0, scrollElement.scrollTop);
|
||||||
const contentHeight = Math.max(0, scrollElement.scrollHeight);
|
const contentHeight = Math.max(0, scrollElement.scrollHeight);
|
||||||
const viewportHeight = Math.max(0, scrollElement.clientHeight);
|
const viewportHeight = Math.max(0, scrollElement.clientHeight);
|
||||||
const distanceFromBottom = Math.max(
|
const distanceFromBottom = Math.max(0, contentHeight - (offsetY + viewportHeight));
|
||||||
0,
|
|
||||||
contentHeight - (offsetY + viewportHeight)
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
offsetY,
|
offsetY,
|
||||||
@@ -218,7 +212,7 @@ export async function scrollUpFromBottom(page: Page, pixels: number): Promise<vo
|
|||||||
deltaY: -step,
|
deltaY: -step,
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
cancelable: true,
|
cancelable: true,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - step);
|
scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - step);
|
||||||
scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true }));
|
scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||||
@@ -267,7 +261,7 @@ export async function expectDetachedFromBottom(page: Page): Promise<void> {
|
|||||||
|
|
||||||
export async function waitForContentGrowth(
|
export async function waitForContentGrowth(
|
||||||
page: Page,
|
page: Page,
|
||||||
previousContentHeight: number
|
previousContentHeight: number,
|
||||||
): Promise<ScrollMetrics> {
|
): Promise<ScrollMetrics> {
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
@@ -280,8 +274,8 @@ export async function waitForContentGrowth(
|
|||||||
|
|
||||||
export async function getChatContainerKey(page: Page): Promise<string | null> {
|
export async function getChatContainerKey(page: Page): Promise<string | null> {
|
||||||
return getVisibleChatScroll(page).evaluate((element) => {
|
return getVisibleChatScroll(page).evaluate((element) => {
|
||||||
const nativeId = (element as HTMLElement).id;
|
const nativeId = (element as HTMLElement).id;
|
||||||
const prefix = "agent-chat-scroll-";
|
const prefix = "agent-chat-scroll-";
|
||||||
return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null;
|
return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+185
-148
@@ -1,20 +1,19 @@
|
|||||||
import { expect, type Page } from '@playwright/test';
|
import { expect, type Page } from "@playwright/test";
|
||||||
import {
|
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||||
buildCreateAgentPreferences,
|
|
||||||
buildSeededHost,
|
|
||||||
} from './daemon-registry';
|
|
||||||
|
|
||||||
function escapeRegex(value: string): string {
|
function escapeRegex(value: string): string {
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getE2EDaemonPort(): string {
|
function getE2EDaemonPort(): string {
|
||||||
const port = process.env.E2E_DAEMON_PORT;
|
const port = process.env.E2E_DAEMON_PORT;
|
||||||
if (!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') {
|
if (port === "6767") {
|
||||||
throw new Error('E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.');
|
throw new Error(
|
||||||
|
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return port;
|
return port;
|
||||||
}
|
}
|
||||||
@@ -24,25 +23,38 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
|
|||||||
const expectedEndpoint = `127.0.0.1:${port}`;
|
const expectedEndpoint = `127.0.0.1:${port}`;
|
||||||
const expectedServerId = process.env.E2E_SERVER_ID;
|
const expectedServerId = process.env.E2E_SERVER_ID;
|
||||||
if (!expectedServerId) {
|
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 needsReset = await page.evaluate(
|
||||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
({ expectedEndpoint, expectedServerId }) => {
|
||||||
if (!raw) return true;
|
const raw = localStorage.getItem("@paseo:daemon-registry");
|
||||||
try {
|
if (!raw) return true;
|
||||||
const parsed = JSON.parse(raw);
|
try {
|
||||||
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
|
const parsed = JSON.parse(raw);
|
||||||
const entry = parsed[0] as any;
|
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
|
||||||
if (entry?.serverId !== expectedServerId) return true;
|
const entry = parsed[0] as any;
|
||||||
const connections = entry?.connections;
|
if (entry?.serverId !== expectedServerId) return true;
|
||||||
if (!Array.isArray(connections)) return true;
|
const connections = entry?.connections;
|
||||||
if (connections.some((c: any) => c?.type === 'directTcp' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) return true;
|
if (!Array.isArray(connections)) return true;
|
||||||
return !connections.some((c: any) => c?.type === 'directTcp' && c?.endpoint === expectedEndpoint);
|
if (
|
||||||
} catch {
|
connections.some(
|
||||||
return true;
|
(c: any) =>
|
||||||
}
|
c?.type === "directTcp" &&
|
||||||
}, { expectedEndpoint, expectedServerId });
|
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) {
|
if (!needsReset) {
|
||||||
return;
|
return;
|
||||||
@@ -57,12 +69,12 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
|
|||||||
const preferences = buildCreateAgentPreferences(expectedServerId);
|
const preferences = buildCreateAgentPreferences(expectedServerId);
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
({ daemon, preferences }) => {
|
({ daemon, preferences }) => {
|
||||||
localStorage.setItem('@paseo:e2e', '1');
|
localStorage.setItem("@paseo:e2e", "1");
|
||||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||||
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
|
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||||
localStorage.removeItem('@paseo:settings');
|
localStorage.removeItem("@paseo:settings");
|
||||||
},
|
},
|
||||||
{ daemon, preferences }
|
{ daemon, preferences },
|
||||||
);
|
);
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
@@ -73,81 +85,98 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
|
|||||||
const expectedEndpoint = `127.0.0.1:${port}`;
|
const expectedEndpoint = `127.0.0.1:${port}`;
|
||||||
const expectedServerId = process.env.E2E_SERVER_ID;
|
const expectedServerId = process.env.E2E_SERVER_ID;
|
||||||
if (!expectedServerId) {
|
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 snapshot = await page.evaluate(() => {
|
||||||
const registryRaw = localStorage.getItem('@paseo:daemon-registry');
|
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
|
||||||
const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences');
|
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
|
||||||
return { registryRaw, prefsRaw };
|
return { registryRaw, prefsRaw };
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!snapshot.registryRaw) {
|
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;
|
let registry: any;
|
||||||
try {
|
try {
|
||||||
registry = JSON.parse(snapshot.registryRaw);
|
registry = JSON.parse(snapshot.registryRaw);
|
||||||
} catch {
|
} 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) {
|
if (!Array.isArray(registry) || registry.length !== 1) {
|
||||||
throw new Error(
|
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];
|
const daemon = registry[0];
|
||||||
if (typeof daemon?.serverId !== 'string' || daemon.serverId.length === 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)}).`);
|
throw new Error(
|
||||||
|
`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (daemon.serverId !== expectedServerId) {
|
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;
|
const connections: unknown = daemon?.connections;
|
||||||
if (
|
if (
|
||||||
!Array.isArray(connections) ||
|
!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(
|
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))) {
|
if (
|
||||||
throw new Error(`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`);
|
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) {
|
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 {
|
try {
|
||||||
const prefs = JSON.parse(snapshot.prefsRaw) as any;
|
const prefs = JSON.parse(snapshot.prefsRaw) as any;
|
||||||
if (prefs?.serverId !== daemon.serverId) {
|
if (prefs?.serverId !== daemon.serverId) {
|
||||||
throw new Error(
|
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) {
|
} catch (error) {
|
||||||
if (error instanceof Error) throw 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) => {
|
export const gotoAppShell = async (page: Page) => {
|
||||||
await page.goto('/');
|
await page.goto("/");
|
||||||
await ensureE2EStorageSeeded(page);
|
await ensureE2EStorageSeeded(page);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const gotoHome = async (page: Page) => {
|
export const gotoHome = async (page: Page) => {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
const composer = page.getByRole('textbox', { name: 'Message agent...' });
|
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
if (!(await composer.first().isVisible().catch(() => false))) {
|
if (
|
||||||
const addProjectCta = page.getByText('Add a project', { exact: true }).first();
|
!(await composer
|
||||||
const addProjectSidebar = page.getByText('Add project', { exact: true }).first();
|
.first()
|
||||||
const newAgentButton = page.getByText('New agent', { exact: true }).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
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
@@ -155,7 +184,7 @@ export const gotoHome = async (page: Page) => {
|
|||||||
(await addProjectCta.isVisible().catch(() => false)) ||
|
(await addProjectCta.isVisible().catch(() => false)) ||
|
||||||
(await addProjectSidebar.isVisible().catch(() => false)) ||
|
(await addProjectSidebar.isVisible().catch(() => false)) ||
|
||||||
(await newAgentButton.isVisible().catch(() => false)),
|
(await newAgentButton.isVisible().catch(() => false)),
|
||||||
{ timeout: 10000 }
|
{ timeout: 10000 },
|
||||||
)
|
)
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
|
||||||
@@ -173,7 +202,7 @@ export const gotoHome = async (page: Page) => {
|
|||||||
export const openSettings = async (page: Page) => {
|
export const openSettings = async (page: Page) => {
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
if (!serverId) {
|
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.
|
// 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();
|
.first();
|
||||||
await expect(workingDirectorySelect).toBeVisible({ timeout: 30000 });
|
await expect(workingDirectorySelect).toBeVisible({ timeout: 30000 });
|
||||||
|
|
||||||
const legacyInput = page.getByRole('textbox', { name: '/path/to/project' }).first();
|
const legacyInput = page.getByRole("textbox", { name: "/path/to/project" }).first();
|
||||||
const directorySearchInput = page.getByRole('textbox', { name: /search directories/i }).first();
|
const directorySearchInput = page.getByRole("textbox", { name: /search directories/i }).first();
|
||||||
const worktreePicker = page.getByTestId('worktree-attach-picker');
|
const worktreePicker = page.getByTestId("worktree-attach-picker");
|
||||||
const worktreeSheetTitle = page.getByText('Select worktree', { exact: true }).first();
|
const worktreeSheetTitle = page.getByText("Select worktree", { exact: true }).first();
|
||||||
const closeBottomSheet = async () => {
|
const closeBottomSheet = async () => {
|
||||||
const bottomSheetBackdrop = page
|
const bottomSheetBackdrop = page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
|
||||||
.getByRole('button', { name: 'Bottom sheet backdrop' })
|
const bottomSheetHandle = page.getByRole("slider", { name: "Bottom sheet handle" }).first();
|
||||||
.first();
|
|
||||||
const bottomSheetHandle = page
|
|
||||||
.getByRole('slider', { name: 'Bottom sheet handle' })
|
|
||||||
.first();
|
|
||||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
if (!(await bottomSheetBackdrop.isVisible())) {
|
if (!(await bottomSheetBackdrop.isVisible())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await bottomSheetBackdrop.click({ force: true });
|
await bottomSheetBackdrop.click({ force: true });
|
||||||
await page.keyboard.press('Escape').catch(() => undefined);
|
await page.keyboard.press("Escape").catch(() => undefined);
|
||||||
await page.waitForTimeout(200);
|
await page.waitForTimeout(200);
|
||||||
}
|
}
|
||||||
if (await bottomSheetBackdrop.isVisible()) {
|
if (await bottomSheetBackdrop.isVisible()) {
|
||||||
@@ -225,7 +250,7 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
|||||||
if (!(await worktreeSheetTitle.isVisible()) && !(await worktreePicker.isVisible())) {
|
if (!(await worktreeSheetTitle.isVisible()) && !(await worktreePicker.isVisible())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const attachToggle = page.getByTestId('worktree-attach-toggle');
|
const attachToggle = page.getByTestId("worktree-attach-toggle");
|
||||||
if (await attachToggle.isVisible()) {
|
if (await attachToggle.isVisible()) {
|
||||||
await attachToggle.click({ force: true });
|
await attachToggle.click({ force: true });
|
||||||
await page.waitForTimeout(200);
|
await page.waitForTimeout(200);
|
||||||
@@ -246,26 +271,23 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
|||||||
await closeBottomSheet();
|
await closeBottomSheet();
|
||||||
await workingDirectorySelect.click({ force: true });
|
await workingDirectorySelect.click({ force: true });
|
||||||
}
|
}
|
||||||
await expect
|
await expect.poll(async () => pickerInputVisible(), { timeout: 10000 }).toBe(true);
|
||||||
.poll(async () => pickerInputVisible(), { timeout: 10000 })
|
|
||||||
.toBe(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmedDirectory = directory.replace(/\/+$/, '');
|
const trimmedDirectory = directory.replace(/\/+$/, "");
|
||||||
const activeInput =
|
const activeInput = (await directorySearchInput.isVisible().catch(() => false))
|
||||||
(await directorySearchInput.isVisible().catch(() => false))
|
? directorySearchInput
|
||||||
? directorySearchInput
|
: legacyInput;
|
||||||
: legacyInput;
|
|
||||||
|
|
||||||
await activeInput.fill(trimmedDirectory);
|
await activeInput.fill(trimmedDirectory);
|
||||||
|
|
||||||
if (activeInput === directorySearchInput) {
|
if (activeInput === directorySearchInput) {
|
||||||
// Combobox custom rows can be either plain path labels or prefixed labels.
|
// Combobox custom rows can be either plain path labels or prefixed labels.
|
||||||
const plainOption = page
|
const plainOption = page
|
||||||
.getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, 'i'))
|
.getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, "i"))
|
||||||
.first();
|
.first();
|
||||||
const prefixedUseOption = page
|
const prefixedUseOption = page
|
||||||
.getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, 'i'))
|
.getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, "i"))
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (await plainOption.isVisible().catch(() => false)) {
|
if (await plainOption.isVisible().catch(() => false)) {
|
||||||
@@ -274,33 +296,38 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
|||||||
await prefixedUseOption.click({ force: true });
|
await prefixedUseOption.click({ force: true });
|
||||||
} else {
|
} else {
|
||||||
// Fallback: accept highlighted option (directory suggestion).
|
// Fallback: accept highlighted option (directory suggestion).
|
||||||
await activeInput.press('Enter');
|
await activeInput.press("Enter");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Legacy path picker fallback.
|
// Legacy path picker fallback.
|
||||||
await activeInput.press('Enter');
|
await activeInput.press("Enter");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for picker to close.
|
// Wait for picker to close.
|
||||||
await expect(activeInput).not.toBeVisible({ timeout: 10000 });
|
await expect(activeInput).not.toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const directoryCandidates = new Set<string>([trimmedDirectory]);
|
const directoryCandidates = new Set<string>([trimmedDirectory]);
|
||||||
if (trimmedDirectory.startsWith('/var/')) {
|
if (trimmedDirectory.startsWith("/var/")) {
|
||||||
directoryCandidates.add(`/private${trimmedDirectory}`);
|
directoryCandidates.add(`/private${trimmedDirectory}`);
|
||||||
}
|
}
|
||||||
if (trimmedDirectory.startsWith('/private/var/')) {
|
if (trimmedDirectory.startsWith("/private/var/")) {
|
||||||
directoryCandidates.add(trimmedDirectory.replace(/^\/private/, ''));
|
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 () => {
|
await expect
|
||||||
const text = await workingDirectorySelect.innerText().catch(() => '');
|
.poll(
|
||||||
if (text.includes(basename)) return true;
|
async () => {
|
||||||
for (const candidate of directoryCandidates) {
|
const text = await workingDirectorySelect.innerText().catch(() => "");
|
||||||
if (text.includes(candidate)) return true;
|
if (text.includes(basename)) return true;
|
||||||
}
|
for (const candidate of directoryCandidates) {
|
||||||
return false;
|
if (text.includes(candidate)) return true;
|
||||||
}, { timeout: 30000 }).toBe(true);
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
{ timeout: 30000 },
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ensureHostSelected = async (page: Page) => {
|
export const ensureHostSelected = async (page: Page) => {
|
||||||
@@ -318,19 +345,21 @@ export const ensureHostSelected = async (page: Page) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fix = await page.evaluate(() => {
|
const fix = await page.evaluate(() => {
|
||||||
const registryRaw = localStorage.getItem('@paseo:daemon-registry');
|
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
|
||||||
const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences');
|
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
|
||||||
if (!registryRaw || !prefsRaw) return { ok: false, reason: 'missing storage' } as const;
|
if (!registryRaw || !prefsRaw) return { ok: false, reason: "missing storage" } as const;
|
||||||
const registry = JSON.parse(registryRaw) as any[];
|
const registry = JSON.parse(registryRaw) as any[];
|
||||||
const prefs = JSON.parse(prefsRaw) 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;
|
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;
|
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.
|
// Prevent the fixture's init-script from overwriting the corrected prefs on reload.
|
||||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce") ?? "1";
|
||||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
|
||||||
return { ok: true } as const;
|
return { ok: true } as const;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -342,20 +371,22 @@ export const ensureHostSelected = async (page: Page) => {
|
|||||||
await assertE2EUsesSeededTestDaemon(page);
|
await assertE2EUsesSeededTestDaemon(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
await expect(input).toBeVisible();
|
await expect(input).toBeVisible();
|
||||||
|
|
||||||
if (await input.isEditable()) {
|
if (await input.isEditable()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectHostLabel = page.getByText('Select host', { exact: true });
|
const selectHostLabel = page.getByText("Select host", { exact: true });
|
||||||
if (await selectHostLabel.isVisible()) {
|
if (await selectHostLabel.isVisible()) {
|
||||||
await selectHostLabel.click();
|
await selectHostLabel.click();
|
||||||
|
|
||||||
// E2E safety: we enforce a single seeded daemon, so the option should be unambiguous.
|
// E2E safety: we enforce a single seeded daemon, so the option should be unambiguous.
|
||||||
const localhostOption = page.getByText('localhost', { 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();
|
const daemonIdOption = page
|
||||||
|
.getByText(process.env.E2E_SERVER_ID ?? "srv_e2e_test_daemon", { exact: true })
|
||||||
|
.first();
|
||||||
|
|
||||||
if (await localhostOption.isVisible()) {
|
if (await localhostOption.isVisible()) {
|
||||||
await localhostOption.click();
|
await localhostOption.click();
|
||||||
@@ -369,11 +400,11 @@ export const ensureHostSelected = async (page: Page) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createAgent = async (page: Page, message: string) => {
|
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 expect(input).toBeEditable();
|
||||||
await preferFastThinkingOption(page);
|
await preferFastThinkingOption(page);
|
||||||
await input.fill(message);
|
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,
|
// 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.
|
// 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> {
|
async function preferFastThinkingOption(page: Page): Promise<void> {
|
||||||
const providerTrigger = page
|
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();
|
.first();
|
||||||
if (await providerTrigger.isVisible().catch(() => false)) {
|
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)) {
|
if (!/codex/i.test(providerText)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const thinkingTrigger = page.getByTestId('agent-thinking-selector').first();
|
const thinkingTrigger = page.getByTestId("agent-thinking-selector").first();
|
||||||
if (!(await thinkingTrigger.isVisible().catch(() => false))) {
|
if (!(await thinkingTrigger.isVisible().catch(() => false))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentThinkingLabel = ((await thinkingTrigger.innerText().catch(() => '')) ?? '')
|
const currentThinkingLabel = ((await thinkingTrigger.innerText().catch(() => "")) ?? "")
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
if (/\b(low|minimal|off)\b/.test(currentThinkingLabel)) {
|
if (/\b(low|minimal|off)\b/.test(currentThinkingLabel)) {
|
||||||
@@ -407,16 +440,16 @@ async function preferFastThinkingOption(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await thinkingTrigger.click();
|
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))) {
|
if (!(await menu.isVisible().catch(() => false))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const preferredLabels = ['low', 'minimal', 'off', 'medium'];
|
const preferredLabels = ["low", "minimal", "off", "medium"];
|
||||||
let selected = false;
|
let selected = false;
|
||||||
for (const label of preferredLabels) {
|
for (const label of preferredLabels) {
|
||||||
const option = menu
|
const option = menu
|
||||||
.getByRole('button', { name: new RegExp(`^${escapeRegex(label)}$`, 'i') })
|
.getByRole("button", { name: new RegExp(`^${escapeRegex(label)}$`, "i") })
|
||||||
.first();
|
.first();
|
||||||
if (await option.isVisible().catch(() => false)) {
|
if (await option.isVisible().catch(() => false)) {
|
||||||
await option.click({ force: true });
|
await option.click({ force: true });
|
||||||
@@ -426,11 +459,11 @@ async function preferFastThinkingOption(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
const options = menu.getByRole('button');
|
const options = menu.getByRole("button");
|
||||||
const count = await options.count();
|
const count = await options.count();
|
||||||
for (let index = 0; index < count; index += 1) {
|
for (let index = 0; index < count; index += 1) {
|
||||||
const option = options.nth(index);
|
const option = options.nth(index);
|
||||||
const label = ((await option.innerText().catch(() => '')) ?? '').trim();
|
const label = ((await option.innerText().catch(() => "")) ?? "").trim();
|
||||||
if (!label) {
|
if (!label) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -444,7 +477,7 @@ async function preferFastThinkingOption(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
await page.keyboard.press('Escape').catch(() => undefined);
|
await page.keyboard.press("Escape").catch(() => undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,15 +495,17 @@ export interface AgentConfig {
|
|||||||
export const selectProvider = async (page: Page, provider: string) => {
|
export const selectProvider = async (page: Page, provider: string) => {
|
||||||
const normalizedProvider = provider.trim();
|
const normalizedProvider = provider.trim();
|
||||||
if (!normalizedProvider) {
|
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
|
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();
|
.first();
|
||||||
if (
|
if (
|
||||||
await providerTrigger
|
await providerTrigger
|
||||||
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i'))
|
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, "i"))
|
||||||
.first()
|
.first()
|
||||||
.isVisible()
|
.isVisible()
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
@@ -481,20 +516,18 @@ export const selectProvider = async (page: Page, provider: string) => {
|
|||||||
if (await providerTrigger.isVisible().catch(() => false)) {
|
if (await providerTrigger.isVisible().catch(() => false)) {
|
||||||
await providerTrigger.click();
|
await providerTrigger.click();
|
||||||
} else {
|
} else {
|
||||||
const providerLabel = page.getByText('PROVIDER', { exact: true }).first();
|
const providerLabel = page.getByText("PROVIDER", { exact: true }).first();
|
||||||
await expect(providerLabel).toBeVisible();
|
await expect(providerLabel).toBeVisible();
|
||||||
await providerLabel.click();
|
await providerLabel.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
const dialog = page.getByRole('dialog').last();
|
const dialog = page.getByRole("dialog").last();
|
||||||
const searchInput = dialog.getByRole('textbox', { name: /search provider/i }).first();
|
const searchInput = dialog.getByRole("textbox", { name: /search provider/i }).first();
|
||||||
if (await searchInput.isVisible().catch(() => false)) {
|
if (await searchInput.isVisible().catch(() => false)) {
|
||||||
await searchInput.fill(normalizedProvider);
|
await searchInput.fill(normalizedProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
const option = dialog
|
const option = dialog.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, "i")).first();
|
||||||
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i'))
|
|
||||||
.first();
|
|
||||||
await expect(option).toBeVisible();
|
await expect(option).toBeVisible();
|
||||||
await option.click();
|
await option.click();
|
||||||
};
|
};
|
||||||
@@ -502,15 +535,17 @@ export const selectProvider = async (page: Page, provider: string) => {
|
|||||||
export const selectModel = async (page: Page, model: string) => {
|
export const selectModel = async (page: Page, model: string) => {
|
||||||
const normalizedModel = model.trim();
|
const normalizedModel = model.trim();
|
||||||
if (!normalizedModel) {
|
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
|
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();
|
.first();
|
||||||
if (
|
if (
|
||||||
await modelTrigger
|
await modelTrigger
|
||||||
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i'))
|
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, "i"))
|
||||||
.first()
|
.first()
|
||||||
.isVisible()
|
.isVisible()
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
@@ -521,21 +556,21 @@ export const selectModel = async (page: Page, model: string) => {
|
|||||||
if (await modelTrigger.isVisible().catch(() => false)) {
|
if (await modelTrigger.isVisible().catch(() => false)) {
|
||||||
await modelTrigger.click();
|
await modelTrigger.click();
|
||||||
} else {
|
} else {
|
||||||
const modelLabel = page.getByText('MODEL', { exact: true }).first();
|
const modelLabel = page.getByText("MODEL", { exact: true }).first();
|
||||||
await expect(modelLabel).toBeVisible();
|
await expect(modelLabel).toBeVisible();
|
||||||
await modelLabel.click();
|
await modelLabel.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for the model dropdown to open
|
// 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 });
|
await expect(searchInput).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Type to search/filter models
|
// Type to search/filter models
|
||||||
await searchInput.fill(normalizedModel);
|
await searchInput.fill(normalizedModel);
|
||||||
|
|
||||||
const dialog = page.getByRole('dialog');
|
const dialog = page.getByRole("dialog");
|
||||||
const exactOption = dialog
|
const exactOption = dialog
|
||||||
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i'))
|
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, "i"))
|
||||||
.first();
|
.first();
|
||||||
const exactVisible = await exactOption.isVisible().catch(() => false);
|
const exactVisible = await exactOption.isVisible().catch(() => false);
|
||||||
if (exactVisible) {
|
if (exactVisible) {
|
||||||
@@ -543,39 +578,39 @@ export const selectModel = async (page: Page, model: string) => {
|
|||||||
} else {
|
} else {
|
||||||
// Modern labels include version suffixes (for example "Haiku 4.5"), so
|
// Modern labels include version suffixes (for example "Haiku 4.5"), so
|
||||||
// select the first filtered result using keyboard confirm.
|
// select the first filtered result using keyboard confirm.
|
||||||
await searchInput.press('Enter');
|
await searchInput.press("Enter");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for dropdown to close
|
// Wait for dropdown to close
|
||||||
if (await searchInput.isVisible().catch(() => false)) {
|
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 });
|
await expect(searchInput).not.toBeVisible({ timeout: 5000 });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const selectMode = async (page: Page, mode: string) => {
|
export const selectMode = async (page: Page, mode: string) => {
|
||||||
const modeTrigger = page
|
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();
|
.first();
|
||||||
if (await modeTrigger.isVisible().catch(() => false)) {
|
if (await modeTrigger.isVisible().catch(() => false)) {
|
||||||
await modeTrigger.click();
|
await modeTrigger.click();
|
||||||
} else {
|
} else {
|
||||||
const modeLabel = page.getByText('MODE', { exact: true }).first();
|
const modeLabel = page.getByText("MODE", { exact: true }).first();
|
||||||
await expect(modeLabel).toBeVisible();
|
await expect(modeLabel).toBeVisible();
|
||||||
await modeLabel.click();
|
await modeLabel.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for the mode dropdown to open
|
// 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 });
|
await expect(searchInput).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Type to filter modes
|
// Type to filter modes
|
||||||
await searchInput.fill(mode);
|
await searchInput.fill(mode);
|
||||||
|
|
||||||
const dialog = page.getByRole('dialog');
|
const dialog = page.getByRole("dialog");
|
||||||
const option = dialog
|
const option = dialog.getByText(new RegExp(`^${escapeRegex(mode)}$`, "i")).first();
|
||||||
.getByText(new RegExp(`^${escapeRegex(mode)}$`, 'i'))
|
|
||||||
.first();
|
|
||||||
await expect(option).toBeVisible();
|
await expect(option).toBeVisible();
|
||||||
await option.click({ force: true });
|
await option.click({ force: true });
|
||||||
|
|
||||||
@@ -605,7 +640,7 @@ export const createAgentWithConfig = async (page: Page, config: AgentConfig) =>
|
|||||||
|
|
||||||
export const createAgentInRepo = async (
|
export const createAgentInRepo = async (
|
||||||
page: Page,
|
page: Page,
|
||||||
config: Pick<AgentConfig, 'directory' | 'prompt'>
|
config: Pick<AgentConfig, "directory" | "prompt">,
|
||||||
) => {
|
) => {
|
||||||
await gotoHome(page);
|
await gotoHome(page);
|
||||||
await ensureHostSelected(page);
|
await ensureHostSelected(page);
|
||||||
@@ -614,25 +649,25 @@ export const createAgentInRepo = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => {
|
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 });
|
await expect(promptText).toBeVisible({ timeout });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const allowPermission = async (page: Page) => {
|
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 expect(acceptButton).toBeVisible({ timeout: 5000 });
|
||||||
await acceptButton.click();
|
await acceptButton.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const denyPermission = async (page: Page) => {
|
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 expect(denyButton).toBeVisible({ timeout: 5000 });
|
||||||
await denyButton.click();
|
await denyButton.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
|
export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
|
||||||
// Wait for the stop button to disappear
|
// 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
|
// First, let's debug what's happening - wait a bit to see the state
|
||||||
await page.waitForTimeout(2000);
|
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);
|
const toolCallResult = page.getByText(/permission.*denied|denied|blocked/i);
|
||||||
|
|
||||||
// Wait for the tool call result to appear
|
// Wait for the tool call result to appear
|
||||||
await expect(toolCallResult).toBeVisible({ timeout: 10000 }).catch(() => {
|
await expect(toolCallResult)
|
||||||
// If no specific message, just wait for the button to disappear
|
.toBeVisible({ timeout: 10000 })
|
||||||
});
|
.catch(() => {
|
||||||
|
// If no specific message, just wait for the button to disappear
|
||||||
|
});
|
||||||
|
|
||||||
// Now wait for the stop button to disappear
|
// Now wait for the stop button to disappear
|
||||||
await expect(stopButton).not.toBeVisible({ timeout });
|
await expect(stopButton).not.toBeVisible({ timeout });
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export async function ensureWorkspaceAgentPaneVisible(page: Page): Promise<void>
|
|||||||
|
|
||||||
export async function sampleWorkspaceTabIds(
|
export async function sampleWorkspaceTabIds(
|
||||||
page: Page,
|
page: Page,
|
||||||
options: { durationMs?: number; intervalMs?: number } = {}
|
options: { durationMs?: number; intervalMs?: number } = {},
|
||||||
): Promise<string[][]> {
|
): Promise<string[][]> {
|
||||||
const durationMs = options.durationMs ?? 2_500;
|
const durationMs = options.durationMs ?? 2_500;
|
||||||
const intervalMs = options.intervalMs ?? 50;
|
const intervalMs = options.intervalMs ?? 50;
|
||||||
|
|||||||
@@ -1,38 +1,38 @@
|
|||||||
import { expect, type Page } from '@playwright/test';
|
import { expect, type Page } from "@playwright/test";
|
||||||
import { buildHostWorkspaceRoute } from '@/utils/host-routes';
|
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
import { gotoHome } from './app';
|
import { gotoHome } from "./app";
|
||||||
|
|
||||||
export async function openNewAgentComposer(page: Page): Promise<void> {
|
export async function openNewAgentComposer(page: Page): Promise<void> {
|
||||||
await gotoHome(page);
|
await gotoHome(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function workspaceLabelFromPath(value: string): string {
|
export function workspaceLabelFromPath(value: string): string {
|
||||||
const normalized = value.replace(/\\/g, '/').replace(/\/+$/, '');
|
const normalized = value.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||||
const parts = normalized.split('/').filter(Boolean);
|
const parts = normalized.split("/").filter(Boolean);
|
||||||
return parts[parts.length - 1] ?? normalized;
|
return parts[parts.length - 1] ?? normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeRegex(value: string): string {
|
function escapeRegex(value: string): string {
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
|
|
||||||
function candidateWorkspaceIds(inputPath: string): string[] {
|
function candidateWorkspaceIds(inputPath: string): string[] {
|
||||||
const trimmed = inputPath.replace(/\/+$/, '');
|
const trimmed = inputPath.replace(/\/+$/, "");
|
||||||
const candidates = new Set<string>([trimmed]);
|
const candidates = new Set<string>([trimmed]);
|
||||||
if (trimmed.startsWith('/var/')) {
|
if (trimmed.startsWith("/var/")) {
|
||||||
candidates.add(`/private${trimmed}`);
|
candidates.add(`/private${trimmed}`);
|
||||||
}
|
}
|
||||||
if (trimmed.startsWith('/private/var/')) {
|
if (trimmed.startsWith("/private/var/")) {
|
||||||
candidates.add(trimmed.replace(/^\/private/, ''));
|
candidates.add(trimmed.replace(/^\/private/, ""));
|
||||||
}
|
}
|
||||||
return Array.from(candidates);
|
return Array.from(candidates);
|
||||||
}
|
}
|
||||||
|
|
||||||
function workspaceRowLocator(page: Page, serverId: string, workspacePath: string) {
|
function workspaceRowLocator(page: Page, serverId: string, workspacePath: string) {
|
||||||
const ids = candidateWorkspaceIds(workspacePath).map(
|
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: {
|
export async function switchWorkspaceViaSidebar(input: {
|
||||||
@@ -52,10 +52,10 @@ export async function switchWorkspaceViaSidebar(input: {
|
|||||||
|
|
||||||
export async function expectWorkspaceHeader(
|
export async function expectWorkspaceHeader(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { title: string; subtitle: string }
|
input: { title: string; subtitle: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const titleLocator = page.getByTestId('workspace-header-title');
|
const titleLocator = page.getByTestId("workspace-header-title");
|
||||||
const subtitleLocator = page.getByTestId('workspace-header-subtitle');
|
const subtitleLocator = page.getByTestId("workspace-header-subtitle");
|
||||||
|
|
||||||
await expect(titleLocator.first()).toHaveText(input.title, {
|
await expect(titleLocator.first()).toHaveText(input.title, {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
@@ -66,9 +66,9 @@ export async function expectWorkspaceHeader(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function seedWorkspaceActivity(page: Page, marker: string): Promise<void> {
|
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 expect(input).toBeEditable({ timeout: 30_000 });
|
||||||
await input.fill(marker);
|
await input.fill(marker);
|
||||||
await input.press('Enter');
|
await input.press("Enter");
|
||||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { execSync } from 'node:child_process';
|
import { execSync } from "node:child_process";
|
||||||
import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises';
|
import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from "node:os";
|
||||||
import path from 'node:path';
|
import path from "node:path";
|
||||||
|
|
||||||
type TempRepo = {
|
type TempRepo = {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -9,29 +9,29 @@ type TempRepo = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createTempGitRepo = async (
|
export const createTempGitRepo = async (
|
||||||
prefix = 'paseo-e2e-',
|
prefix = "paseo-e2e-",
|
||||||
options?: { withRemote?: boolean }
|
options?: { withRemote?: boolean },
|
||||||
): Promise<TempRepo> => {
|
): Promise<TempRepo> => {
|
||||||
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
|
// 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 repoPath = await mkdtemp(path.join(tempRoot, prefix));
|
||||||
const withRemote = options?.withRemote ?? false;
|
const withRemote = options?.withRemote ?? false;
|
||||||
|
|
||||||
execSync('git init -b main', { 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.email "e2e@paseo.test"', { cwd: repoPath, stdio: "ignore" });
|
||||||
execSync('git config user.name "Paseo E2E"', { 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' });
|
execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" });
|
||||||
await writeFile(path.join(repoPath, 'README.md'), '# Temp Repo\n');
|
await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n");
|
||||||
execSync('git add README.md', { cwd: repoPath, stdio: 'ignore' });
|
execSync("git add README.md", { cwd: repoPath, stdio: "ignore" });
|
||||||
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: 'ignore' });
|
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
|
||||||
|
|
||||||
if (withRemote) {
|
if (withRemote) {
|
||||||
// Deterministic local remote to avoid relying on external auth/network in e2e.
|
// 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 });
|
await mkdir(remoteDir, { recursive: true });
|
||||||
execSync(`git init --bare -b main ${remoteDir}`, { 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 remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||||
execSync('git push -u origin main', { cwd: repoPath, stdio: 'ignore' });
|
execSync("git push -u origin main", { cwd: repoPath, stdio: "ignore" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
// https://docs.expo.dev/guides/using-eslint/
|
// https://docs.expo.dev/guides/using-eslint/
|
||||||
const { defineConfig } = require('eslint/config');
|
const { defineConfig } = require("eslint/config");
|
||||||
const expoConfig = require('eslint-config-expo/flat');
|
const expoConfig = require("eslint-config-expo/flat");
|
||||||
|
|
||||||
module.exports = defineConfig([
|
module.exports = defineConfig([
|
||||||
expoConfig,
|
expoConfig,
|
||||||
{
|
{
|
||||||
ignores: ['dist/*'],
|
ignores: ["dist/*"],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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({
|
export default defineConfig({
|
||||||
testDir: './e2e',
|
testDir: "./e2e",
|
||||||
globalSetup: './e2e/global-setup.ts',
|
globalSetup: "./e2e/global-setup.ts",
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
expect: {
|
expect: {
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
@@ -16,17 +17,17 @@ export default defineConfig({
|
|||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
retries: process.env.CI ? 1 : 0,
|
retries: process.env.CI ? 1 : 0,
|
||||||
reporter: [['list']],
|
reporter: [["list"]],
|
||||||
use: {
|
use: {
|
||||||
baseURL,
|
baseURL,
|
||||||
trace: 'retain-on-failure',
|
trace: "retain-on-failure",
|
||||||
screenshot: 'only-on-failure',
|
screenshot: "only-on-failure",
|
||||||
video: 'retain-on-failure',
|
video: "retain-on-failure",
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'Desktop Chrome',
|
name: "Desktop Chrome",
|
||||||
use: { ...devices['Desktop Chrome'] },
|
use: { ...devices["Desktop Chrome"] },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// Note: Metro is started by global-setup.ts on a dynamic port to allow parallel test runs
|
// Note: Metro is started by global-setup.ts on a dynamic port to allow parallel test runs
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
const baseURL =
|
const baseURL =
|
||||||
process.env.E2E_BASE_URL ??
|
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
|
||||||
`http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test'
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
const baseURL =
|
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({
|
export default defineConfig({
|
||||||
testDir: './e2e',
|
testDir: "./e2e",
|
||||||
globalSetup: './e2e/global-setup.ts',
|
globalSetup: "./e2e/global-setup.ts",
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
expect: {
|
expect: {
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
@@ -13,17 +13,17 @@ export default defineConfig({
|
|||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
reporter: [['list']],
|
reporter: [["list"]],
|
||||||
use: {
|
use: {
|
||||||
baseURL,
|
baseURL,
|
||||||
trace: 'retain-on-failure',
|
trace: "retain-on-failure",
|
||||||
screenshot: 'only-on-failure',
|
screenshot: "only-on-failure",
|
||||||
video: 'retain-on-failure',
|
video: "retain-on-failure",
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'Desktop Safari',
|
name: "Desktop Safari",
|
||||||
use: { ...devices['Desktop Safari'] },
|
use: { ...devices["Desktop Safari"] },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ const moveDirectories = async (userInput) => {
|
|||||||
userInput === "y"
|
userInput === "y"
|
||||||
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
|
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
|
||||||
: ""
|
: ""
|
||||||
}`
|
}`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ Error during script execution: ${error.message}`);
|
console.error(`❌ Error during script execution: ${error.message}`);
|
||||||
@@ -108,5 +108,5 @@ rl.question(
|
|||||||
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
|
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
|
||||||
rl.close();
|
rl.close();
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,36 +1,36 @@
|
|||||||
export const WELCOME_ROUTE = '/welcome'
|
export const WELCOME_ROUTE = "/welcome";
|
||||||
|
|
||||||
export function shouldWaitOnStartupRace(input: {
|
export function shouldWaitOnStartupRace(input: {
|
||||||
onlineServerId: string | null
|
onlineServerId: string | null;
|
||||||
hasTimedOut: boolean
|
hasTimedOut: boolean;
|
||||||
isDesktopStartupRace: boolean
|
isDesktopStartupRace: boolean;
|
||||||
daemonCount: number
|
daemonCount: number;
|
||||||
pathname: string
|
pathname: string;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
if (input.onlineServerId) {
|
if (input.onlineServerId) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
if (input.pathname === WELCOME_ROUTE) {
|
if (input.pathname === WELCOME_ROUTE) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
if (input.hasTimedOut) {
|
if (input.hasTimedOut) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
return input.isDesktopStartupRace || input.daemonCount > 0
|
return input.isDesktopStartupRace || input.daemonCount > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldRedirectToWelcome(input: {
|
export function shouldRedirectToWelcome(input: {
|
||||||
onlineServerId: string | null
|
onlineServerId: string | null;
|
||||||
hasTimedOut: boolean
|
hasTimedOut: boolean;
|
||||||
pathname: string
|
pathname: string;
|
||||||
isDesktopStartupRace: boolean
|
isDesktopStartupRace: boolean;
|
||||||
daemonCount: number
|
daemonCount: number;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
if (input.onlineServerId || !input.hasTimedOut) {
|
if (input.onlineServerId || !input.hasTimedOut) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
if (input.pathname !== '/' && input.pathname !== '') {
|
if (input.pathname !== "/" && input.pathname !== "") {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
return input.isDesktopStartupRace || input.daemonCount > 0
|
return input.isDesktopStartupRace || input.daemonCount > 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ import { useEffect, useRef } from "react";
|
|||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import {
|
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||||
buildHostRootRoute,
|
|
||||||
} from "@/utils/host-routes";
|
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
|
||||||
export default function HostAgentReadyRoute() {
|
export default function HostAgentReadyRoute() {
|
||||||
@@ -43,7 +41,7 @@ export default function HostAgentReadyRoute() {
|
|||||||
serverId,
|
serverId,
|
||||||
workspaceId: normalizedCwd,
|
workspaceId: normalizedCwd,
|
||||||
target: { kind: "agent", agentId },
|
target: { kind: "agent", agentId },
|
||||||
}) as any
|
}) as any,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [agentCwd, agentId, router, serverId]);
|
}, [agentCwd, agentId, router, serverId]);
|
||||||
@@ -87,7 +85,7 @@ export default function HostAgentReadyRoute() {
|
|||||||
serverId,
|
serverId,
|
||||||
workspaceId: cwd,
|
workspaceId: cwd,
|
||||||
target: { kind: "agent", agentId },
|
target: { kind: "agent", agentId },
|
||||||
}) as any
|
}) as any,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ export default function HostIndexRoute() {
|
|||||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||||
const { isLoading: preferencesLoading } = useFormPreferences();
|
const { isLoading: preferencesLoading } = useFormPreferences();
|
||||||
const sessionAgents = useSessionStore(
|
const sessionAgents = useSessionStore((state) =>
|
||||||
(state) => (serverId ? state.sessions[serverId]?.agents : undefined)
|
serverId ? state.sessions[serverId]?.agents : undefined,
|
||||||
);
|
);
|
||||||
const sessionWorkspaces = useSessionStore(
|
const sessionWorkspaces = useSessionStore((state) =>
|
||||||
(state) => (serverId ? state.sessions[serverId]?.workspaces : undefined)
|
serverId ? state.sessions[serverId]?.workspaces : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -44,12 +44,10 @@ export default function HostIndexRoute() {
|
|||||||
? Array.from(sessionAgents.values()).filter((agent) => !agent.archivedAt)
|
? Array.from(sessionAgents.values()).filter((agent) => !agent.archivedAt)
|
||||||
: [];
|
: [];
|
||||||
visibleAgents.sort(
|
visibleAgents.sort(
|
||||||
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime()
|
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const visibleWorkspaces = sessionWorkspaces
|
const visibleWorkspaces = sessionWorkspaces ? Array.from(sessionWorkspaces.values()) : [];
|
||||||
? Array.from(sessionWorkspaces.values())
|
|
||||||
: [];
|
|
||||||
visibleWorkspaces.sort((left, right) => {
|
visibleWorkspaces.sort((left, right) => {
|
||||||
const leftTime = left.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
|
const leftTime = left.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
|
||||||
const rightTime = right.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
|
const rightTime = right.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
|
||||||
@@ -63,7 +61,7 @@ export default function HostIndexRoute() {
|
|||||||
serverId,
|
serverId,
|
||||||
workspaceId: primaryAgent.cwd.trim(),
|
workspaceId: primaryAgent.cwd.trim(),
|
||||||
target: { kind: "agent", agentId: primaryAgent.id },
|
target: { kind: "agent", agentId: primaryAgent.id },
|
||||||
}) as any
|
}) as any,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -78,14 +76,7 @@ export default function HostIndexRoute() {
|
|||||||
}, HOST_ROOT_REDIRECT_DELAY_MS);
|
}, HOST_ROOT_REDIRECT_DELAY_MS);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [
|
}, [pathname, preferencesLoading, router, serverId, sessionAgents, sessionWorkspaces]);
|
||||||
pathname,
|
|
||||||
preferencesLoading,
|
|
||||||
router,
|
|
||||||
serverId,
|
|
||||||
sessionAgents,
|
|
||||||
sessionWorkspaces,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,80 +1,82 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from "react";
|
||||||
import { useGlobalSearchParams, useLocalSearchParams, useRouter } from 'expo-router'
|
import { useGlobalSearchParams, useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import type { WorkspaceTabTarget } from '@/stores/workspace-tabs-store'
|
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||||
import { WorkspaceScreen } from '@/screens/workspace/workspace-screen'
|
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||||
import {
|
import {
|
||||||
buildHostWorkspaceRoute,
|
buildHostWorkspaceRoute,
|
||||||
decodeWorkspaceIdFromPathSegment,
|
decodeWorkspaceIdFromPathSegment,
|
||||||
parseWorkspaceOpenIntent,
|
parseWorkspaceOpenIntent,
|
||||||
type WorkspaceOpenIntent,
|
type WorkspaceOpenIntent,
|
||||||
} from '@/utils/host-routes'
|
} from "@/utils/host-routes";
|
||||||
import { prepareWorkspaceTab } from '@/utils/workspace-navigation'
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
|
||||||
function getParamValue(value: string | string[] | undefined): string {
|
function getParamValue(value: string | string[] | undefined): string {
|
||||||
if (typeof value === 'string') {
|
if (typeof value === "string") {
|
||||||
return value.trim()
|
return value.trim();
|
||||||
}
|
}
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
const firstValue = value[0]
|
const firstValue = value[0];
|
||||||
return typeof firstValue === 'string' ? firstValue.trim() : ''
|
return typeof firstValue === "string" ? firstValue.trim() : "";
|
||||||
}
|
}
|
||||||
return ''
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarget {
|
function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarget {
|
||||||
if (openIntent.kind === 'agent') {
|
if (openIntent.kind === "agent") {
|
||||||
return { kind: 'agent', agentId: openIntent.agentId }
|
return { kind: "agent", agentId: openIntent.agentId };
|
||||||
}
|
}
|
||||||
if (openIntent.kind === 'terminal') {
|
if (openIntent.kind === "terminal") {
|
||||||
return { kind: 'terminal', terminalId: openIntent.terminalId }
|
return { kind: "terminal", terminalId: openIntent.terminalId };
|
||||||
}
|
}
|
||||||
if (openIntent.kind === 'file') {
|
if (openIntent.kind === "file") {
|
||||||
return { kind: 'file', path: openIntent.path }
|
return { kind: "file", path: openIntent.path };
|
||||||
}
|
}
|
||||||
return { kind: 'draft', draftId: openIntent.draftId }
|
return { kind: "draft", draftId: openIntent.draftId };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HostWorkspaceLayout() {
|
export default function HostWorkspaceLayout() {
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const consumedIntentRef = useRef<string | null>(null)
|
const consumedIntentRef = useRef<string | null>(null);
|
||||||
const params = useLocalSearchParams<{
|
const params = useLocalSearchParams<{
|
||||||
serverId?: string | string[]
|
serverId?: string | string[];
|
||||||
workspaceId?: string | string[]
|
workspaceId?: string | string[];
|
||||||
}>()
|
}>();
|
||||||
const globalParams = useGlobalSearchParams<{
|
const globalParams = useGlobalSearchParams<{
|
||||||
open?: string | string[]
|
open?: string | string[];
|
||||||
}>()
|
}>();
|
||||||
const serverId = getParamValue(params.serverId)
|
const serverId = getParamValue(params.serverId);
|
||||||
const workspaceValue = getParamValue(params.workspaceId)
|
const workspaceValue = getParamValue(params.workspaceId);
|
||||||
const workspaceId = workspaceValue ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? '') : ''
|
const workspaceId = workspaceValue
|
||||||
const openValue = getParamValue(globalParams.open)
|
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
||||||
|
: "";
|
||||||
|
const openValue = getParamValue(globalParams.open);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!openValue) {
|
if (!openValue) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`
|
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
|
||||||
if (consumedIntentRef.current === consumptionKey) {
|
if (consumedIntentRef.current === consumptionKey) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
consumedIntentRef.current = consumptionKey
|
consumedIntentRef.current = consumptionKey;
|
||||||
|
|
||||||
const openIntent = parseWorkspaceOpenIntent(openValue)
|
const openIntent = parseWorkspaceOpenIntent(openValue);
|
||||||
const route = openIntent
|
const route = openIntent
|
||||||
? prepareWorkspaceTab({
|
? prepareWorkspaceTab({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
target: getOpenIntentTarget(openIntent),
|
target: getOpenIntentTarget(openIntent),
|
||||||
pin: openIntent.kind === 'agent',
|
pin: openIntent.kind === "agent",
|
||||||
})
|
})
|
||||||
: buildHostWorkspaceRoute(serverId, workspaceId)
|
: buildHostWorkspaceRoute(serverId, workspaceId);
|
||||||
|
|
||||||
router.replace(route as any)
|
router.replace(route as any);
|
||||||
}, [openValue, router, serverId, workspaceId])
|
}, [openValue, router, serverId, workspaceId]);
|
||||||
|
|
||||||
if (openValue) {
|
if (openValue) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -83,5 +85,5 @@ export default function HostWorkspaceLayout() {
|
|||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,82 +1,82 @@
|
|||||||
import { useEffect, useSyncExternalStore, useState } from 'react'
|
import { useEffect, useSyncExternalStore, useState } from "react";
|
||||||
import { usePathname, useRouter } from 'expo-router'
|
import { usePathname, useRouter } from "expo-router";
|
||||||
import { useHosts } from '@/runtime/host-runtime'
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { shouldUseDesktopDaemon } from '@/desktop/daemon/desktop-daemon'
|
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
import { buildHostRootRoute } from '@/utils/host-routes'
|
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||||
import { StartupSplashScreen } from '@/screens/startup-splash-screen'
|
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||||
import { WelcomeScreen } from '@/components/welcome-screen'
|
import { WelcomeScreen } from "@/components/welcome-screen";
|
||||||
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
|
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
|
||||||
import {
|
import {
|
||||||
shouldRedirectToWelcome,
|
shouldRedirectToWelcome,
|
||||||
shouldWaitOnStartupRace,
|
shouldWaitOnStartupRace,
|
||||||
WELCOME_ROUTE,
|
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 {
|
function useAnyHostOnline(serverIds: string[]): string | null {
|
||||||
const runtime = getHostRuntimeStore()
|
const runtime = getHostRuntimeStore();
|
||||||
return useSyncExternalStore(
|
return useSyncExternalStore(
|
||||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||||
() => {
|
() => {
|
||||||
let firstOnlineServerId: string | null = null
|
let firstOnlineServerId: string | null = null;
|
||||||
let firstOnlineAt: string | null = null
|
let firstOnlineAt: string | null = null;
|
||||||
for (const serverId of serverIds) {
|
for (const serverId of serverIds) {
|
||||||
const snapshot = runtime.getSnapshot(serverId)
|
const snapshot = runtime.getSnapshot(serverId);
|
||||||
const lastOnlineAt = snapshot?.lastOnlineAt ?? null
|
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
|
||||||
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
|
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
|
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
|
||||||
firstOnlineAt = lastOnlineAt
|
firstOnlineAt = lastOnlineAt;
|
||||||
firstOnlineServerId = serverId
|
firstOnlineServerId = serverId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return firstOnlineServerId
|
return firstOnlineServerId;
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
let firstOnlineServerId: string | null = null
|
let firstOnlineServerId: string | null = null;
|
||||||
let firstOnlineAt: string | null = null
|
let firstOnlineAt: string | null = null;
|
||||||
for (const serverId of serverIds) {
|
for (const serverId of serverIds) {
|
||||||
const snapshot = runtime.getSnapshot(serverId)
|
const snapshot = runtime.getSnapshot(serverId);
|
||||||
const lastOnlineAt = snapshot?.lastOnlineAt ?? null
|
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
|
||||||
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
|
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
|
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
|
||||||
firstOnlineAt = lastOnlineAt
|
firstOnlineAt = lastOnlineAt;
|
||||||
firstOnlineServerId = serverId
|
firstOnlineServerId = serverId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return firstOnlineServerId
|
return firstOnlineServerId;
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const pathname = usePathname()
|
const pathname = usePathname();
|
||||||
const daemons = useHosts()
|
const daemons = useHosts();
|
||||||
const [hasTimedOut, setHasTimedOut] = useState(false)
|
const [hasTimedOut, setHasTimedOut] = useState(false);
|
||||||
const isDesktopStartupRace = shouldUseDesktopDaemon()
|
const isDesktopStartupRace = shouldUseDesktopDaemon();
|
||||||
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId))
|
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId));
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setHasTimedOut(true)
|
setHasTimedOut(true);
|
||||||
}, STARTUP_TIMEOUT_MS)
|
}, STARTUP_TIMEOUT_MS);
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer);
|
||||||
}
|
};
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onlineServerId) {
|
if (!onlineServerId) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (pathname !== '/' && pathname !== '') {
|
if (pathname !== "/" && pathname !== "") {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
router.replace(buildHostRootRoute(onlineServerId) as any)
|
router.replace(buildHostRootRoute(onlineServerId) as any);
|
||||||
}, [onlineServerId, pathname, router])
|
}, [onlineServerId, pathname, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@@ -88,10 +88,10 @@ export default function Index() {
|
|||||||
daemonCount: daemons.length,
|
daemonCount: daemons.length,
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
router.replace(WELCOME_ROUTE as any)
|
router.replace(WELCOME_ROUTE as any);
|
||||||
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router])
|
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router]);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
shouldWaitOnStartupRace({
|
shouldWaitOnStartupRace({
|
||||||
@@ -102,12 +102,12 @@ export default function Index() {
|
|||||||
pathname,
|
pathname,
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
return <StartupSplashScreen />
|
return <StartupSplashScreen />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!onlineServerId) {
|
if (!onlineServerId) {
|
||||||
return <WelcomeScreen />
|
return <WelcomeScreen />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,7 @@ import { NameHostModal } from "@/components/name-host-modal";
|
|||||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||||
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
||||||
import {
|
import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes";
|
||||||
buildHostRootRoute,
|
|
||||||
buildHostSettingsRoute,
|
|
||||||
} from "@/utils/host-routes";
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
@@ -148,8 +145,7 @@ export default function PairScanScreen() {
|
|||||||
targetServerId?: string;
|
targetServerId?: string;
|
||||||
}>();
|
}>();
|
||||||
const source = typeof params.source === "string" ? params.source : "settings";
|
const source = typeof params.source === "string" ? params.source : "settings";
|
||||||
const sourceServerId =
|
const sourceServerId = typeof params.sourceServerId === "string" ? params.sourceServerId : null;
|
||||||
typeof params.sourceServerId === "string" ? params.sourceServerId : null;
|
|
||||||
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
|
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl, renameHost } = useHostMutations();
|
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl, renameHost } = useHostMutations();
|
||||||
@@ -157,15 +153,22 @@ export default function PairScanScreen() {
|
|||||||
const [permission, requestPermission] = useCameraPermissions();
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
const [isPairing, setIsPairing] = useState(false);
|
const [isPairing, setIsPairing] = useState(false);
|
||||||
const lastScannedRef = useRef<string | null>(null);
|
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(
|
const pendingNameHostname = useSessionStore(
|
||||||
useCallback(
|
useCallback(
|
||||||
(state) => {
|
(state) => {
|
||||||
if (!pendingNameHost) return null;
|
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(
|
const returnToSource = useCallback(
|
||||||
@@ -190,7 +193,7 @@ export default function PairScanScreen() {
|
|||||||
router.replace(buildHostSettingsRoute(settingsServerId) as any);
|
router.replace(buildHostSettingsRoute(settingsServerId) as any);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[router, source, sourceServerId, targetServerId]
|
[router, source, sourceServerId, targetServerId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeToSource = useCallback(() => {
|
const closeToSource = useCallback(() => {
|
||||||
@@ -238,7 +241,10 @@ export default function PairScanScreen() {
|
|||||||
|
|
||||||
if (targetServerId && offer.serverId !== targetServerId) {
|
if (targetServerId && offer.serverId !== targetServerId) {
|
||||||
lastScannedRef.current = null;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,7 +276,7 @@ export default function PairScanScreen() {
|
|||||||
setIsPairing(false);
|
setIsPairing(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl]
|
[daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (Platform.OS === "web") {
|
if (Platform.OS === "web") {
|
||||||
@@ -334,10 +340,7 @@ export default function PairScanScreen() {
|
|||||||
<Text style={styles.permissionBody}>
|
<Text style={styles.permissionBody}>
|
||||||
Allow camera access to scan the pairing QR code from your daemon.
|
Allow camera access to scan the pairing QR code from your daemon.
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable
|
<Pressable style={styles.permissionButton} onPress={() => void requestPermission()}>
|
||||||
style={styles.permissionButton}
|
|
||||||
onPress={() => void requestPermission()}
|
|
||||||
>
|
|
||||||
<Text style={styles.permissionButtonText}>Grant permission</Text>
|
<Text style={styles.permissionButtonText}>Grant permission</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -356,9 +359,7 @@ export default function PairScanScreen() {
|
|||||||
<View style={[styles.corner, styles.cornerBL]} />
|
<View style={[styles.corner, styles.cornerBL]} />
|
||||||
<View style={[styles.corner, styles.cornerBR]} />
|
<View style={[styles.corner, styles.cornerBR]} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.helperText}>
|
<Text style={styles.helperText}>Point your camera at the pairing QR code.</Text>
|
||||||
Point your camera at the pairing QR code.
|
|
||||||
</Text>
|
|
||||||
{isPairing ? (
|
{isPairing ? (
|
||||||
<Text style={[styles.helperText, { color: theme.colors.foreground }]}>
|
<Text style={[styles.helperText, { color: theme.colors.foreground }]}>
|
||||||
Pairing…
|
Pairing…
|
||||||
|
|||||||
@@ -18,9 +18,7 @@ export default function LegacySettingsRoute() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (preferences.serverId) {
|
if (preferences.serverId) {
|
||||||
const match = daemons.find(
|
const match = daemons.find((daemon) => daemon.serverId === preferences.serverId);
|
||||||
(daemon) => daemon.serverId === preferences.serverId
|
|
||||||
);
|
|
||||||
if (match) {
|
if (match) {
|
||||||
return match.serverId;
|
return match.serverId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { WelcomeScreen } from '@/components/welcome-screen'
|
import { WelcomeScreen } from "@/components/welcome-screen";
|
||||||
|
|
||||||
export default function WelcomeRoute() {
|
export default function WelcomeRoute() {
|
||||||
return <WelcomeScreen />
|
return <WelcomeScreen />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,10 +86,7 @@ export function createLocalFileAttachmentStore(params: {
|
|||||||
storageType: Extract<AttachmentStorageType, "desktop-file" | "native-file">;
|
storageType: Extract<AttachmentStorageType, "desktop-file" | "native-file">;
|
||||||
baseDirectoryName: string;
|
baseDirectoryName: string;
|
||||||
resolvePreviewUrl: (attachment: AttachmentMetadata) => Promise<string>;
|
resolvePreviewUrl: (attachment: AttachmentMetadata) => Promise<string>;
|
||||||
releasePreviewUrl?: (input: {
|
releasePreviewUrl?: (input: { attachment: AttachmentMetadata; url: string }) => Promise<void>;
|
||||||
attachment: AttachmentMetadata;
|
|
||||||
url: string;
|
|
||||||
}) => Promise<void>;
|
|
||||||
}): AttachmentStore {
|
}): AttachmentStore {
|
||||||
const baseDirectory = FileSystem.cacheDirectory
|
const baseDirectory = FileSystem.cacheDirectory
|
||||||
? `${FileSystem.cacheDirectory}${params.baseDirectoryName}/`
|
? `${FileSystem.cacheDirectory}${params.baseDirectoryName}/`
|
||||||
@@ -201,7 +198,7 @@ export function createLocalFileAttachmentStore(params: {
|
|||||||
await FileSystem.deleteAsync(`${baseDirectory}${entryName}`, {
|
await FileSystem.deleteAsync(`${baseDirectory}${entryName}`, {
|
||||||
idempotent: true,
|
idempotent: true,
|
||||||
});
|
});
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export async function persistAttachmentFromFileUri(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function encodeAttachmentsForSend(
|
export async function encodeAttachmentsForSend(
|
||||||
attachments: readonly AttachmentMetadata[] | undefined
|
attachments: readonly AttachmentMetadata[] | undefined,
|
||||||
): Promise<Array<{ data: string; mimeType: string }> | undefined> {
|
): Promise<Array<{ data: string; mimeType: string }> | undefined> {
|
||||||
if (!attachments || attachments.length === 0) {
|
if (!attachments || attachments.length === 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -69,18 +69,16 @@ export async function encodeAttachmentsForSend(
|
|||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const valid = encoded.filter(
|
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;
|
return valid.length > 0 ? valid : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveAttachmentPreviewUrl(
|
export async function resolveAttachmentPreviewUrl(attachment: AttachmentMetadata): Promise<string> {
|
||||||
attachment: AttachmentMetadata
|
|
||||||
): Promise<string> {
|
|
||||||
const store = await getAttachmentStore();
|
const store = await getAttachmentStore();
|
||||||
return await store.resolvePreviewUrl({ attachment });
|
return await store.resolvePreviewUrl({ attachment });
|
||||||
}
|
}
|
||||||
@@ -97,7 +95,7 @@ export async function releaseAttachmentPreviewUrl(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteAttachments(
|
export async function deleteAttachments(
|
||||||
attachments: readonly AttachmentMetadata[] | undefined
|
attachments: readonly AttachmentMetadata[] | undefined,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!attachments || attachments.length === 0) {
|
if (!attachments || attachments.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -113,7 +111,7 @@ export async function deleteAttachments(
|
|||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,15 +13,11 @@ async function createAttachmentStore(): Promise<AttachmentStore> {
|
|||||||
return createDesktopAttachmentStore();
|
return createDesktopAttachmentStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { createIndexedDbAttachmentStore } = await import(
|
const { createIndexedDbAttachmentStore } = await import("./web/indexeddb-attachment-store");
|
||||||
"./web/indexeddb-attachment-store"
|
|
||||||
);
|
|
||||||
return createIndexedDbAttachmentStore();
|
return createIndexedDbAttachmentStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { createNativeFileAttachmentStore } = await import(
|
const { createNativeFileAttachmentStore } = await import("./native/native-file-attachment-store");
|
||||||
"./native/native-file-attachment-store"
|
|
||||||
);
|
|
||||||
return createNativeFileAttachmentStore();
|
return createNativeFileAttachmentStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { AttachmentMetadata } from "@/attachments/types";
|
import type { AttachmentMetadata } from "@/attachments/types";
|
||||||
import {
|
import { releaseAttachmentPreviewUrl, resolveAttachmentPreviewUrl } from "@/attachments/service";
|
||||||
releaseAttachmentPreviewUrl,
|
|
||||||
resolveAttachmentPreviewUrl,
|
|
||||||
} from "@/attachments/service";
|
|
||||||
|
|
||||||
export function useAttachmentPreviewUrl(
|
export function useAttachmentPreviewUrl(
|
||||||
attachment: AttachmentMetadata | null | undefined
|
attachment: AttachmentMetadata | null | undefined,
|
||||||
): string | null {
|
): string | null {
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
const activeAttachmentRef = useRef<AttachmentMetadata | null>(null);
|
const activeAttachmentRef = useRef<AttachmentMetadata | null>(null);
|
||||||
@@ -52,12 +49,7 @@ export function useAttachmentPreviewUrl(
|
|||||||
url: currentUrl,
|
url: currentUrl,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}, [
|
}, [attachment?.id, attachment?.storageType, attachment?.storageKey, attachment?.mimeType]);
|
||||||
attachment?.id,
|
|
||||||
attachment?.storageType,
|
|
||||||
attachment?.storageKey,
|
|
||||||
attachment?.mimeType,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ function openAttachmentDb(): Promise<IDBDatabase> {
|
|||||||
function runTx<T>(
|
function runTx<T>(
|
||||||
db: IDBDatabase,
|
db: IDBDatabase,
|
||||||
mode: IDBTransactionMode,
|
mode: IDBTransactionMode,
|
||||||
run: (store: IDBObjectStore) => IDBRequest<T>
|
run: (store: IDBObjectStore) => IDBRequest<T>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = db.transaction(STORE_NAME, mode);
|
const transaction = db.transaction(STORE_NAME, mode);
|
||||||
@@ -78,7 +78,10 @@ async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; m
|
|||||||
const source = input.source;
|
const source = input.source;
|
||||||
if (source.kind === "blob") {
|
if (source.kind === "blob") {
|
||||||
const mimeType = normalizeMimeType(input.mimeType ?? source.blob.type);
|
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 };
|
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> {
|
async function loadBlob(db: IDBDatabase, id: string): Promise<Blob> {
|
||||||
const record = await runTx<StoredBlobRecord | undefined>(db, "readonly", (store) =>
|
const record = await runTx<StoredBlobRecord | undefined>(db, "readonly", (store) =>
|
||||||
store.get(id)
|
store.get(id),
|
||||||
);
|
);
|
||||||
if (!record?.blob) {
|
if (!record?.blob) {
|
||||||
throw new Error(`Attachment ${id} was not found in IndexedDB.`);
|
throw new Error(`Attachment ${id} was not found in IndexedDB.`);
|
||||||
@@ -125,7 +128,7 @@ export function createIndexedDbAttachmentStore(): AttachmentStore {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await runTx(db, "readwrite", (store) =>
|
await runTx(db, "readwrite", (store) =>
|
||||||
store.put({ id, blob, createdAt, fileName } satisfies StoredBlobRecord)
|
store.put({ id, blob, createdAt, fileName } satisfies StoredBlobRecord),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
db.close();
|
||||||
@@ -184,7 +187,9 @@ export function createIndexedDbAttachmentStore(): AttachmentStore {
|
|||||||
const cursorRequest = store.openCursor();
|
const cursorRequest = store.openCursor();
|
||||||
|
|
||||||
cursorRequest.onerror = () => {
|
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 = () => {
|
cursorRequest.onsuccess = () => {
|
||||||
|
|||||||
@@ -68,16 +68,16 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
backButtonText: {
|
backButtonText: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: '600',
|
fontWeight: "600",
|
||||||
textAlign: 'center',
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
scrollView: {
|
scrollView: {
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
paddingVertical: theme.spacing[3],
|
paddingVertical: theme.spacing[3],
|
||||||
},
|
},
|
||||||
processItem: {
|
processItem: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
@@ -104,7 +104,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
processText: {
|
processText: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: '500',
|
fontWeight: "500",
|
||||||
},
|
},
|
||||||
processTextActive: {
|
processTextActive: {
|
||||||
color: theme.colors.primaryForeground,
|
color: theme.colors.primaryForeground,
|
||||||
@@ -147,20 +147,21 @@ export function ActiveProcesses({
|
|||||||
onPress={onSelectOrchestrator}
|
onPress={onSelectOrchestrator}
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
styles.processItem,
|
styles.processItem,
|
||||||
viewMode === 'orchestrator' ? styles.processItemActive : styles.processItemInactive,
|
viewMode === "orchestrator" ? styles.processItemActive : styles.processItemInactive,
|
||||||
pressed && { opacity: 0.7 },
|
pressed && { opacity: 0.7 },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View style={styles.agentIcon} />
|
<View style={styles.agentIcon} />
|
||||||
<Text style={[
|
<Text
|
||||||
styles.processText,
|
style={[styles.processText, viewMode === "orchestrator" && styles.processTextActive]}
|
||||||
viewMode === 'orchestrator' && styles.processTextActive,
|
>
|
||||||
]}>Orchestrator</Text>
|
Orchestrator
|
||||||
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
{/* Agent pills */}
|
{/* Agent pills */}
|
||||||
{agents.map((agent) => {
|
{agents.map((agent) => {
|
||||||
const isActive = viewMode === 'agent' && activeAgentId === agent.id;
|
const isActive = viewMode === "agent" && activeAgentId === agent.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -174,15 +175,21 @@ export function ActiveProcesses({
|
|||||||
>
|
>
|
||||||
<View style={styles.agentIcon} />
|
<View style={styles.agentIcon} />
|
||||||
|
|
||||||
<Text style={[
|
<Text style={[styles.processText, isActive && styles.processTextActive]}>
|
||||||
styles.processText,
|
{agent.id.substring(0, 8)}
|
||||||
isActive && styles.processTextActive,
|
</Text>
|
||||||
]}>{agent.id.substring(0, 8)}</Text>
|
|
||||||
|
|
||||||
<View style={[styles.statusDot, { backgroundColor: getAgentStatusColor(agent.status) }]} />
|
<View
|
||||||
|
style={[styles.statusDot, { backgroundColor: getAgentStatusColor(agent.status) }]}
|
||||||
|
/>
|
||||||
|
|
||||||
{agent.currentModeId && (
|
{agent.currentModeId && (
|
||||||
<View style={[styles.modeIndicator, { backgroundColor: getModeColor(agent.currentModeId) }]} />
|
<View
|
||||||
|
style={[
|
||||||
|
styles.modeIndicator,
|
||||||
|
{ backgroundColor: getModeColor(agent.currentModeId) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import {
|
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||||
Modal,
|
|
||||||
Platform,
|
|
||||||
Pressable,
|
|
||||||
ScrollView,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import type { TextInputProps } from "react-native";
|
import type { TextInputProps } from "react-native";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
|
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
|
||||||
@@ -121,8 +113,7 @@ export function AdaptiveModalSheet({
|
|||||||
testID,
|
testID,
|
||||||
}: AdaptiveModalSheetProps) {
|
}: AdaptiveModalSheetProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile =
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
|
||||||
const sheetRef = useRef<BottomSheetModal>(null);
|
const sheetRef = useRef<BottomSheetModal>(null);
|
||||||
const dismissingForVisibilityRef = useRef(false);
|
const dismissingForVisibilityRef = useRef(false);
|
||||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||||
@@ -148,19 +139,14 @@ export function AdaptiveModalSheet({
|
|||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onClose]
|
[onClose],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||||
<BottomSheetBackdrop
|
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
||||||
{...props}
|
|
||||||
disappearsOnIndex={-1}
|
|
||||||
appearsOnIndex={0}
|
|
||||||
opacity={0.45}
|
|
||||||
/>
|
|
||||||
),
|
),
|
||||||
[]
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
@@ -180,11 +166,7 @@ export function AdaptiveModalSheet({
|
|||||||
>
|
>
|
||||||
<View style={styles.bottomSheetHeader}>
|
<View style={styles.bottomSheetHeader}>
|
||||||
<Text style={styles.title}>{title}</Text>
|
<Text style={styles.title}>{title}</Text>
|
||||||
<Pressable
|
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
|
||||||
accessibilityLabel="Close"
|
|
||||||
style={styles.closeButton}
|
|
||||||
onPress={onClose}
|
|
||||||
>
|
|
||||||
<X size={16} color={theme.colors.foregroundMuted} />
|
<X size={16} color={theme.colors.foregroundMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -209,11 +191,7 @@ export function AdaptiveModalSheet({
|
|||||||
<View style={styles.desktopCard}>
|
<View style={styles.desktopCard}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.title}>{title}</Text>
|
<Text style={styles.title}>{title}</Text>
|
||||||
<Pressable
|
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
|
||||||
accessibilityLabel="Close"
|
|
||||||
style={styles.closeButton}
|
|
||||||
onPress={onClose}
|
|
||||||
>
|
|
||||||
<X size={16} color={theme.colors.foregroundMuted} />
|
<X size={16} color={theme.colors.foregroundMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -254,13 +232,12 @@ export function AdaptiveModalSheet({
|
|||||||
*/
|
*/
|
||||||
export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
|
export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
|
||||||
function AdaptiveTextInput(props, ref) {
|
function AdaptiveTextInput(props, ref) {
|
||||||
const isMobile =
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return <BottomSheetTextInput ref={ref as any} {...props} />;
|
return <BottomSheetTextInput ref={ref as any} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <TextInput ref={ref} {...props} />;
|
return <TextInput ref={ref} {...props} />;
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,8 +60,17 @@ export function AddHostMethodModal({
|
|||||||
}, [onPasteLink]);
|
}, [onPasteLink]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdaptiveModalSheet title="Add connection" visible={visible} onClose={onClose} testID="add-host-method-modal">
|
<AdaptiveModalSheet
|
||||||
<Pressable style={styles.option} onPress={handleDirect} accessibilityLabel="Direct connection">
|
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} />
|
<Link2 size={18} color={theme.colors.foreground} />
|
||||||
<View style={styles.optionBody}>
|
<View style={styles.optionBody}>
|
||||||
<Text style={styles.optionText}>Direct connection</Text>
|
<Text style={styles.optionText}>Direct connection</Text>
|
||||||
@@ -79,7 +88,11 @@ export function AddHostMethodModal({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : 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} />
|
<ClipboardPaste size={18} color={theme.colors.foreground} />
|
||||||
<View style={styles.optionBody}>
|
<View style={styles.optionBody}>
|
||||||
<Text style={styles.optionText}>Paste pairing link</Text>
|
<Text style={styles.optionText}>Paste pairing link</Text>
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ function formatTechnicalTransportDetails(details: Array<string | null>): string
|
|||||||
.map((value) => normalizeTransportMessage(value))
|
.map((value) => normalizeTransportMessage(value))
|
||||||
.filter((value): value is string => Boolean(value))
|
.filter((value): value is string => Boolean(value))
|
||||||
.map((value) => value.trim())
|
.map((value) => value.trim())
|
||||||
.filter((value) => value.length > 0)
|
.filter((value) => value.length > 0),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (unique.length === 0) return null;
|
if (unique.length === 0) return null;
|
||||||
@@ -78,7 +78,10 @@ function formatTechnicalTransportDetails(details: Array<string | null>): string
|
|||||||
return unique.join(" — ");
|
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 title = `We failed to connect to ${endpoint}.`;
|
||||||
|
|
||||||
const raw = (() => {
|
const raw = (() => {
|
||||||
@@ -109,8 +112,13 @@ function buildConnectionFailureCopy(endpoint: string, error: unknown): { title:
|
|||||||
detail = "Host not found. Check the hostname and try again.";
|
detail = "Host not found. Check the hostname and try again.";
|
||||||
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
|
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
|
||||||
detail = "Host is unreachable. Check your network and firewall.";
|
detail = "Host is unreachable. Check your network and firewall.";
|
||||||
} else if (rawLower.includes("certificate") || rawLower.includes("tls") || rawLower.includes("ssl")) {
|
} else if (
|
||||||
detail = "TLS error. Direct connections use an unencrypted local connection. Use relay for remote access.";
|
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) {
|
} else if (raw) {
|
||||||
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
|
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
|
||||||
} else {
|
} else {
|
||||||
@@ -125,15 +133,25 @@ export interface AddHostModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
targetServerId?: string;
|
targetServerId?: string;
|
||||||
onCancel?: () => void;
|
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 { theme } = useUnistyles();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { upsertDirectConnection } = useHostMutations();
|
const { upsertDirectConnection } = useHostMutations();
|
||||||
const isMobile =
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
|
||||||
|
|
||||||
const hostInputRef = useRef<TextInput>(null);
|
const hostInputRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
@@ -220,10 +238,24 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServer
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [daemons, endpointRaw, handleClose, isMobile, isSaving, onSaved, targetServerId, upsertDirectConnection]);
|
}, [
|
||||||
|
daemons,
|
||||||
|
endpointRaw,
|
||||||
|
handleClose,
|
||||||
|
isMobile,
|
||||||
|
isSaving,
|
||||||
|
onSaved,
|
||||||
|
targetServerId,
|
||||||
|
upsertDirectConnection,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
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>
|
<Text style={styles.helper}>Enter the address of a Paseo server.</Text>
|
||||||
|
|
||||||
<View style={styles.field}>
|
<View style={styles.field}>
|
||||||
|
|||||||
@@ -1,76 +1,77 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import { View, Text, Pressable } from 'react-native';
|
import { View, Text, Pressable } from "react-native";
|
||||||
import { StyleSheet } from 'react-native-unistyles';
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { Fonts } from "@/constants/theme";
|
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 {
|
interface AgentActivityItemProps {
|
||||||
item: GroupedTextMessage | MergedToolCall | AgentActivity;
|
item: GroupedTextMessage | MergedToolCall | AgentActivity;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimestamp(date: Date): string {
|
function formatTimestamp(date: Date): string {
|
||||||
return new Intl.DateTimeFormat('en-US', {
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
hour: 'numeric',
|
hour: "numeric",
|
||||||
minute: '2-digit',
|
minute: "2-digit",
|
||||||
second: '2-digit',
|
second: "2-digit",
|
||||||
hour12: true,
|
hour12: true,
|
||||||
}).format(date);
|
}).format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getToolIcon(toolKind?: string): string {
|
function getToolIcon(toolKind?: string): string {
|
||||||
switch (toolKind) {
|
switch (toolKind) {
|
||||||
case 'read':
|
case "read":
|
||||||
return '📖';
|
return "📖";
|
||||||
case 'edit':
|
case "edit":
|
||||||
return '✏️';
|
return "✏️";
|
||||||
case 'delete':
|
case "delete":
|
||||||
return '🗑️';
|
return "🗑️";
|
||||||
case 'move':
|
case "move":
|
||||||
return '📦';
|
return "📦";
|
||||||
case 'search':
|
case "search":
|
||||||
return '🔍';
|
return "🔍";
|
||||||
case 'execute':
|
case "execute":
|
||||||
return '▶️';
|
return "▶️";
|
||||||
case 'think':
|
case "think":
|
||||||
return '💭';
|
return "💭";
|
||||||
case 'fetch':
|
case "fetch":
|
||||||
return '🌐';
|
return "🌐";
|
||||||
case 'switch_mode':
|
case "switch_mode":
|
||||||
return '🔄';
|
return "🔄";
|
||||||
default:
|
default:
|
||||||
return '🔧';
|
return "🔧";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusColor(status?: string): string {
|
function getStatusColor(status?: string): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'pending':
|
case "pending":
|
||||||
return '#9ca3af';
|
return "#9ca3af";
|
||||||
case 'in_progress':
|
case "in_progress":
|
||||||
return '#fbbf24';
|
return "#fbbf24";
|
||||||
case 'completed':
|
case "completed":
|
||||||
return '#22c55e';
|
return "#22c55e";
|
||||||
case 'failed':
|
case "failed":
|
||||||
return '#ef4444';
|
return "#ef4444";
|
||||||
default:
|
default:
|
||||||
return '#6b7280';
|
return "#6b7280";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function GroupedTextItem({ item }: { item: GroupedTextMessage }) {
|
function GroupedTextItem({ item }: { item: GroupedTextMessage }) {
|
||||||
const isThought = item.messageType === 'thought';
|
const isThought = item.messageType === "thought";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[stylesheet.card, isThought && stylesheet.thoughtCard]}>
|
<View style={[stylesheet.card, isThought && stylesheet.thoughtCard]}>
|
||||||
<Text style={[stylesheet.timestamp, isThought && stylesheet.thoughtTimestamp]}>
|
<Text style={[stylesheet.timestamp, isThought && stylesheet.thoughtTimestamp]}>
|
||||||
{formatTimestamp(item.startTimestamp)}
|
{formatTimestamp(item.startTimestamp)}
|
||||||
</Text>
|
</Text>
|
||||||
{isThought && (
|
{isThought && <Text style={stylesheet.thoughtLabel}>💭 Thinking</Text>}
|
||||||
<Text style={stylesheet.thoughtLabel}>💭 Thinking</Text>
|
<Text style={[stylesheet.text, isThought && stylesheet.thoughtText]}>{item.text}</Text>
|
||||||
)}
|
|
||||||
<Text style={[stylesheet.text, isThought && stylesheet.thoughtText]}>
|
|
||||||
{item.text}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -80,28 +81,20 @@ function MergedToolCallItem({ item }: { item: MergedToolCall }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={stylesheet.toolCard}>
|
<View style={stylesheet.toolCard}>
|
||||||
<Pressable
|
<Pressable onPress={() => setIsExpanded(!isExpanded)} style={stylesheet.toolHeader}>
|
||||||
onPress={() => setIsExpanded(!isExpanded)}
|
|
||||||
style={stylesheet.toolHeader}
|
|
||||||
>
|
|
||||||
<View style={stylesheet.toolHeaderLeft}>
|
<View style={stylesheet.toolHeaderLeft}>
|
||||||
<Text style={stylesheet.timestamp}>
|
<Text style={stylesheet.timestamp}>{formatTimestamp(item.startTimestamp)}</Text>
|
||||||
{formatTimestamp(item.startTimestamp)}
|
|
||||||
</Text>
|
|
||||||
<View style={stylesheet.toolTitleRow}>
|
<View style={stylesheet.toolTitleRow}>
|
||||||
<Text style={stylesheet.toolIcon}>{getToolIcon(item.toolKind)}</Text>
|
<Text style={stylesheet.toolIcon}>{getToolIcon(item.toolKind)}</Text>
|
||||||
<Text style={stylesheet.toolTitle}>{item.title}</Text>
|
<Text style={stylesheet.toolTitle}>{item.title}</Text>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[stylesheet.statusBadge, { backgroundColor: getStatusColor(item.status) }]}
|
||||||
stylesheet.statusBadge,
|
|
||||||
{ backgroundColor: getStatusColor(item.status) },
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
<Text style={stylesheet.statusText}>{item.status}</Text>
|
<Text style={stylesheet.statusText}>{item.status}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Text style={stylesheet.expandIcon}>{isExpanded ? '▼' : '▶'}</Text>
|
<Text style={stylesheet.expandIcon}>{isExpanded ? "▼" : "▶"}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
@@ -109,23 +102,17 @@ function MergedToolCallItem({ item }: { item: MergedToolCall }) {
|
|||||||
{item.input && (
|
{item.input && (
|
||||||
<View style={stylesheet.section}>
|
<View style={stylesheet.section}>
|
||||||
<Text style={stylesheet.sectionTitle}>Input:</Text>
|
<Text style={stylesheet.sectionTitle}>Input:</Text>
|
||||||
<Text style={stylesheet.code}>
|
<Text style={stylesheet.code}>{JSON.stringify(item.input, null, 2)}</Text>
|
||||||
{JSON.stringify(item.input, null, 2)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{item.output && (
|
{item.output && (
|
||||||
<View style={stylesheet.section}>
|
<View style={stylesheet.section}>
|
||||||
<Text style={stylesheet.sectionTitle}>Output:</Text>
|
<Text style={stylesheet.sectionTitle}>Output:</Text>
|
||||||
<Text style={stylesheet.code}>
|
<Text style={stylesheet.code}>{JSON.stringify(item.output, null, 2)}</Text>
|
||||||
{JSON.stringify(item.output, null, 2)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{!item.input && !item.output && (
|
{!item.input && !item.output && (
|
||||||
<Text style={stylesheet.emptyText}>
|
<Text style={stylesheet.emptyText}>No details available</Text>
|
||||||
No details available
|
|
||||||
</Text>
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -136,36 +123,26 @@ function MergedToolCallItem({ item }: { item: MergedToolCall }) {
|
|||||||
function PlanItem({ update, timestamp }: { update: SessionUpdate; timestamp: Date }) {
|
function PlanItem({ update, timestamp }: { update: SessionUpdate; timestamp: Date }) {
|
||||||
const [isExpanded, setIsExpanded] = useState(true);
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
|
||||||
if (update.kind !== 'plan') {
|
if (update.kind !== "plan") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={stylesheet.planCard}>
|
<View style={stylesheet.planCard}>
|
||||||
<Pressable
|
<Pressable onPress={() => setIsExpanded(!isExpanded)} style={stylesheet.planHeader}>
|
||||||
onPress={() => setIsExpanded(!isExpanded)}
|
|
||||||
style={stylesheet.planHeader}
|
|
||||||
>
|
|
||||||
<View style={stylesheet.planHeaderLeft}>
|
<View style={stylesheet.planHeaderLeft}>
|
||||||
<Text style={stylesheet.timestamp}>{formatTimestamp(timestamp)}</Text>
|
<Text style={stylesheet.timestamp}>{formatTimestamp(timestamp)}</Text>
|
||||||
<Text style={stylesheet.planTitle}>
|
<Text style={stylesheet.planTitle}>📋 Tasks ({update.entries.length})</Text>
|
||||||
📋 Tasks ({update.entries.length})
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<Text style={stylesheet.expandIcon}>{isExpanded ? '▼' : '▶'}</Text>
|
<Text style={stylesheet.expandIcon}>{isExpanded ? "▼" : "▶"}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<View style={stylesheet.planContent}>
|
<View style={stylesheet.planContent}>
|
||||||
{update.entries.map((entry, idx) => (
|
{update.entries.map((entry, idx) => (
|
||||||
<View key={idx} style={stylesheet.planEntry}>
|
<View key={idx} style={stylesheet.planEntry}>
|
||||||
<Text
|
<Text style={[stylesheet.planEntryStatus, { color: getStatusColor(entry.status) }]}>
|
||||||
style={[
|
{entry.status === "completed" ? "✓" : entry.status === "in_progress" ? "⏳" : "○"}
|
||||||
stylesheet.planEntryStatus,
|
|
||||||
{ color: getStatusColor(entry.status) },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{entry.status === 'completed' ? '✓' : entry.status === 'in_progress' ? '⏳' : '○'}
|
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={stylesheet.planEntryText}>{entry.content}</Text>
|
<Text style={stylesheet.planEntryText}>{entry.content}</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -181,10 +158,7 @@ function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; tim
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={stylesheet.unknownCard}>
|
<View style={stylesheet.unknownCard}>
|
||||||
<Pressable
|
<Pressable onPress={() => setShowDrawer(!showDrawer)} style={stylesheet.unknownHeader}>
|
||||||
onPress={() => setShowDrawer(!showDrawer)}
|
|
||||||
style={stylesheet.unknownHeader}
|
|
||||||
>
|
|
||||||
<Text style={stylesheet.timestamp}>{formatTimestamp(timestamp)}</Text>
|
<Text style={stylesheet.timestamp}>{formatTimestamp(timestamp)}</Text>
|
||||||
<View style={stylesheet.unknownBadge}>
|
<View style={stylesheet.unknownBadge}>
|
||||||
<Text style={stylesheet.unknownBadgeText}>{update.kind}</Text>
|
<Text style={stylesheet.unknownBadgeText}>{update.kind}</Text>
|
||||||
@@ -193,9 +167,7 @@ function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; tim
|
|||||||
|
|
||||||
{showDrawer && (
|
{showDrawer && (
|
||||||
<View style={stylesheet.drawerContent}>
|
<View style={stylesheet.drawerContent}>
|
||||||
<Text style={stylesheet.code}>
|
<Text style={stylesheet.code}>{JSON.stringify(update, null, 2)}</Text>
|
||||||
{JSON.stringify(update, null, 2)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -204,12 +176,12 @@ function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; tim
|
|||||||
|
|
||||||
export function AgentActivityItem({ item }: AgentActivityItemProps) {
|
export function AgentActivityItem({ item }: AgentActivityItemProps) {
|
||||||
// Grouped text message
|
// Grouped text message
|
||||||
if ('kind' in item && item.kind === 'grouped_text') {
|
if ("kind" in item && item.kind === "grouped_text") {
|
||||||
return <GroupedTextItem item={item} />;
|
return <GroupedTextItem item={item} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merged tool call
|
// 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} />;
|
return <MergedToolCallItem item={item} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,12 +190,12 @@ export function AgentActivityItem({ item }: AgentActivityItemProps) {
|
|||||||
const update = activity.update;
|
const update = activity.update;
|
||||||
|
|
||||||
// Tasks
|
// Tasks
|
||||||
if (update.kind === 'plan') {
|
if (update.kind === "plan") {
|
||||||
return <PlanItem update={update} timestamp={activity.timestamp} />;
|
return <PlanItem update={update} timestamp={activity.timestamp} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Available commands update
|
// Available commands update
|
||||||
if (update.kind === 'available_commands_update') {
|
if (update.kind === "available_commands_update") {
|
||||||
return (
|
return (
|
||||||
<View style={stylesheet.card}>
|
<View style={stylesheet.card}>
|
||||||
<Text style={stylesheet.timestamp}>{formatTimestamp(activity.timestamp)}</Text>
|
<Text style={stylesheet.timestamp}>{formatTimestamp(activity.timestamp)}</Text>
|
||||||
@@ -235,13 +207,11 @@ export function AgentActivityItem({ item }: AgentActivityItemProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Current mode update
|
// Current mode update
|
||||||
if (update.kind === 'current_mode_update') {
|
if (update.kind === "current_mode_update") {
|
||||||
return (
|
return (
|
||||||
<View style={stylesheet.card}>
|
<View style={stylesheet.card}>
|
||||||
<Text style={stylesheet.timestamp}>{formatTimestamp(activity.timestamp)}</Text>
|
<Text style={stylesheet.timestamp}>{formatTimestamp(activity.timestamp)}</Text>
|
||||||
<Text style={stylesheet.infoText}>
|
<Text style={stylesheet.infoText}>Mode changed to: {update.currentModeId}</Text>
|
||||||
Mode changed to: {update.currentModeId}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -284,26 +254,26 @@ const stylesheet = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
thoughtText: {
|
thoughtText: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontStyle: 'italic',
|
fontStyle: "italic",
|
||||||
},
|
},
|
||||||
toolCard: {
|
toolCard: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
marginBottom: theme.spacing[2],
|
marginBottom: theme.spacing[2],
|
||||||
overflow: 'hidden',
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
toolHeader: {
|
toolHeader: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'space-between',
|
justifyContent: "space-between",
|
||||||
padding: theme.spacing[3],
|
padding: theme.spacing[3],
|
||||||
},
|
},
|
||||||
toolHeaderLeft: {
|
toolHeaderLeft: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
toolTitleRow: {
|
toolTitleRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
marginTop: theme.spacing[1],
|
marginTop: theme.spacing[1],
|
||||||
},
|
},
|
||||||
@@ -355,18 +325,18 @@ const stylesheet = StyleSheet.create((theme) => ({
|
|||||||
emptyText: {
|
emptyText: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontStyle: 'italic',
|
fontStyle: "italic",
|
||||||
},
|
},
|
||||||
planCard: {
|
planCard: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
marginBottom: theme.spacing[2],
|
marginBottom: theme.spacing[2],
|
||||||
overflow: 'hidden',
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
planHeader: {
|
planHeader: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'space-between',
|
justifyContent: "space-between",
|
||||||
padding: theme.spacing[3],
|
padding: theme.spacing[3],
|
||||||
},
|
},
|
||||||
planHeaderLeft: {
|
planHeaderLeft: {
|
||||||
@@ -384,8 +354,8 @@ const stylesheet = StyleSheet.create((theme) => ({
|
|||||||
padding: theme.spacing[3],
|
padding: theme.spacing[3],
|
||||||
},
|
},
|
||||||
planEntry: {
|
planEntry: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'flex-start',
|
alignItems: "flex-start",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
marginBottom: theme.spacing[2],
|
marginBottom: theme.spacing[2],
|
||||||
},
|
},
|
||||||
@@ -406,7 +376,7 @@ const stylesheet = StyleSheet.create((theme) => ({
|
|||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
marginBottom: theme.spacing[2],
|
marginBottom: theme.spacing[2],
|
||||||
overflow: 'hidden',
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
unknownHeader: {
|
unknownHeader: {
|
||||||
padding: theme.spacing[3],
|
padding: theme.spacing[3],
|
||||||
@@ -417,7 +387,7 @@ const stylesheet = StyleSheet.create((theme) => ({
|
|||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
marginTop: theme.spacing[2],
|
marginTop: theme.spacing[2],
|
||||||
alignSelf: 'flex-start',
|
alignSelf: "flex-start",
|
||||||
},
|
},
|
||||||
unknownBadgeText: {
|
unknownBadgeText: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { ReactElement, ReactNode } from "react";
|
import type { ReactElement, ReactNode } from "react";
|
||||||
import {
|
import { View, Text, Pressable, TextInput, ActivityIndicator, Platform } from "react-native";
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
Pressable,
|
|
||||||
TextInput,
|
|
||||||
ActivityIndicator,
|
|
||||||
Platform,
|
|
||||||
} from "react-native";
|
|
||||||
import type { StyleProp, ViewStyle, TextProps } from "react-native";
|
import type { StyleProp, ViewStyle, TextProps } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +10,18 @@ import {
|
|||||||
BottomSheetBackgroundProps,
|
BottomSheetBackgroundProps,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import Animated from "react-native-reanimated";
|
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 { theme as defaultTheme } from "@/styles/theme";
|
||||||
import type {
|
import type {
|
||||||
AgentMode,
|
AgentMode,
|
||||||
@@ -107,19 +111,14 @@ export function DropdownField({
|
|||||||
testID={testID}
|
testID={testID}
|
||||||
style={[styles.dropdownControl, disabled && styles.dropdownControlDisabled]}
|
style={[styles.dropdownControl, disabled && styles.dropdownControlDisabled]}
|
||||||
>
|
>
|
||||||
<Text
|
<Text style={value ? styles.dropdownValue : styles.dropdownPlaceholder} numberOfLines={1}>
|
||||||
style={value ? styles.dropdownValue : styles.dropdownPlaceholder}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{value || placeholder}
|
{value || placeholder}
|
||||||
</Text>
|
</Text>
|
||||||
<ChevronDown size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
<ChevronDown size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||||
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
|
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
|
||||||
{!errorMessage && helperText ? (
|
{!errorMessage && helperText ? <Text style={styles.helperText}>{helperText}</Text> : null}
|
||||||
<Text style={styles.helperText}>{helperText}</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -176,7 +175,7 @@ export function SelectField({
|
|||||||
onPress();
|
onPress();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getWebKey, onPress, preventWebDefault]
|
[getWebKey, onPress, preventWebDefault],
|
||||||
);
|
);
|
||||||
|
|
||||||
const normalizedValue = (value ?? "").trim();
|
const normalizedValue = (value ?? "").trim();
|
||||||
@@ -184,9 +183,7 @@ export function SelectField({
|
|||||||
const hasConcreteValue =
|
const hasConcreteValue =
|
||||||
normalizedValue.length > 0 &&
|
normalizedValue.length > 0 &&
|
||||||
(normalizedPlaceholder.length === 0 || normalizedValue !== normalizedPlaceholder);
|
(normalizedPlaceholder.length === 0 || normalizedValue !== normalizedPlaceholder);
|
||||||
const displayText = hasConcreteValue
|
const displayText = hasConcreteValue ? normalizedValue : normalizedPlaceholder || "Select...";
|
||||||
? normalizedValue
|
|
||||||
: (normalizedPlaceholder || "Select...");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.selectFieldContainer}>
|
<View style={styles.selectFieldContainer}>
|
||||||
@@ -231,12 +228,7 @@ interface DropdownSheetProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DropdownSheetBackground({ style }: BottomSheetBackgroundProps) {
|
function DropdownSheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||||
return (
|
return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />;
|
||||||
<Animated.View
|
|
||||||
pointerEvents="none"
|
|
||||||
style={[style, styles.bottomSheetBackground]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DropdownSheet({
|
export function DropdownSheet({
|
||||||
@@ -267,19 +259,14 @@ export function DropdownSheet({
|
|||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onClose]
|
[onClose],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||||
<BottomSheetBackdrop
|
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
||||||
{...props}
|
|
||||||
disappearsOnIndex={-1}
|
|
||||||
appearsOnIndex={0}
|
|
||||||
opacity={0.45}
|
|
||||||
/>
|
|
||||||
),
|
),
|
||||||
[]
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -450,16 +437,14 @@ export function FormSelectTrigger({
|
|||||||
onPress();
|
onPress();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getWebKey, onPress, preventWebDefault]
|
[getWebKey, onPress, preventWebDefault],
|
||||||
);
|
);
|
||||||
const normalizedValue = (value ?? "").trim();
|
const normalizedValue = (value ?? "").trim();
|
||||||
const normalizedPlaceholder = (placeholder ?? "").trim();
|
const normalizedPlaceholder = (placeholder ?? "").trim();
|
||||||
const hasConcreteValue =
|
const hasConcreteValue =
|
||||||
normalizedValue.length > 0 &&
|
normalizedValue.length > 0 &&
|
||||||
(normalizedPlaceholder.length === 0 || normalizedValue !== normalizedPlaceholder);
|
(normalizedPlaceholder.length === 0 || normalizedValue !== normalizedPlaceholder);
|
||||||
const displayText = hasConcreteValue
|
const displayText = hasConcreteValue ? normalizedValue : normalizedPlaceholder || "Select...";
|
||||||
? normalizedValue
|
|
||||||
: (normalizedPlaceholder || "Select...");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -481,9 +466,7 @@ export function FormSelectTrigger({
|
|||||||
>
|
>
|
||||||
{icon ? <View style={styles.compactSelectLeading}>{icon}</View> : null}
|
{icon ? <View style={styles.compactSelectLeading}>{icon}</View> : null}
|
||||||
<View style={styles.compactSelectValueContainer}>
|
<View style={styles.compactSelectValueContainer}>
|
||||||
{showLabel ? (
|
{showLabel ? <Text style={styles.compactSelectLabel}>{label}</Text> : null}
|
||||||
<Text style={styles.compactSelectLabel}>{label}</Text>
|
|
||||||
) : null}
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<ActivityIndicator size="small" color={defaultTheme.colors.foregroundMuted} />
|
<ActivityIndicator size="small" color={defaultTheme.colors.foregroundMuted} />
|
||||||
) : (
|
) : (
|
||||||
@@ -540,7 +523,7 @@ export function AgentConfigRow({
|
|||||||
id: def.id,
|
id: def.id,
|
||||||
label: def.label,
|
label: def.label,
|
||||||
})),
|
})),
|
||||||
[providerDefinitions]
|
[providerDefinitions],
|
||||||
);
|
);
|
||||||
|
|
||||||
const modeSelectOptions: ComboSelectOption[] = useMemo(() => {
|
const modeSelectOptions: ComboSelectOption[] = useMemo(() => {
|
||||||
@@ -554,9 +537,7 @@ export function AgentConfigRow({
|
|||||||
}, [modeOptions]);
|
}, [modeOptions]);
|
||||||
|
|
||||||
const modelSelectOptions: ComboSelectOption[] = useMemo(() => {
|
const modelSelectOptions: ComboSelectOption[] = useMemo(() => {
|
||||||
const opts: ComboSelectOption[] = [
|
const opts: ComboSelectOption[] = [{ id: "", label: "Auto" }];
|
||||||
{ id: "", label: "Auto" },
|
|
||||||
];
|
|
||||||
for (const model of models) {
|
for (const model of models) {
|
||||||
opts.push({
|
opts.push({
|
||||||
id: model.id,
|
id: model.id,
|
||||||
@@ -572,7 +553,7 @@ export function AgentConfigRow({
|
|||||||
id: option.id,
|
id: option.id,
|
||||||
label: option.label,
|
label: option.label,
|
||||||
})),
|
})),
|
||||||
[thinkingOptions]
|
[thinkingOptions],
|
||||||
);
|
);
|
||||||
|
|
||||||
const effectiveSelectedMode = selectedMode || (modeOptions.length > 0 ? modeOptions[0]?.id : "");
|
const effectiveSelectedMode = selectedMode || (modeOptions.length > 0 ? modeOptions[0]?.id : "");
|
||||||
@@ -609,7 +590,9 @@ export function AgentConfigRow({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
isLoading={isModelLoading}
|
isLoading={isModelLoading}
|
||||||
onSelect={onSelectModel}
|
onSelect={onSelectModel}
|
||||||
icon={<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
|
icon={
|
||||||
|
<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
||||||
|
}
|
||||||
showLabel={false}
|
showLabel={false}
|
||||||
testID="draft-model-select"
|
testID="draft-model-select"
|
||||||
/>
|
/>
|
||||||
@@ -638,7 +621,9 @@ export function AgentConfigRow({
|
|||||||
placeholder="Select..."
|
placeholder="Select..."
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onSelect={onSelectThinkingOption}
|
onSelect={onSelectThinkingOption}
|
||||||
icon={<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
|
icon={
|
||||||
|
<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
||||||
|
}
|
||||||
showLabel={false}
|
showLabel={false}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -664,7 +649,7 @@ export function AssistantDropdown({
|
|||||||
const anchorRef = useRef<View>(null);
|
const anchorRef = useRef<View>(null);
|
||||||
|
|
||||||
const selectedDefinition = providerDefinitions.find(
|
const selectedDefinition = providerDefinitions.find(
|
||||||
(definition) => definition.id === selectedProvider
|
(definition) => definition.id === selectedProvider,
|
||||||
);
|
);
|
||||||
|
|
||||||
const options = useMemo(
|
const options = useMemo(
|
||||||
@@ -673,7 +658,7 @@ export function AssistantDropdown({
|
|||||||
id: def.id,
|
id: def.id,
|
||||||
label: def.label,
|
label: def.label,
|
||||||
})),
|
})),
|
||||||
[providerDefinitions]
|
[providerDefinitions],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpen = useCallback(() => setIsOpen(true), []);
|
const handleOpen = useCallback(() => setIsOpen(true), []);
|
||||||
@@ -720,9 +705,9 @@ export function PermissionsDropdown({
|
|||||||
|
|
||||||
const hasOptions = modeOptions.length > 0;
|
const hasOptions = modeOptions.length > 0;
|
||||||
const selectedModeLabel = hasOptions
|
const selectedModeLabel = hasOptions
|
||||||
? modeOptions.find((mode) => mode.id === selectedMode)?.label ??
|
? (modeOptions.find((mode) => mode.id === selectedMode)?.label ??
|
||||||
modeOptions[0]?.label ??
|
modeOptions[0]?.label ??
|
||||||
"Default"
|
"Default")
|
||||||
: "Automatic";
|
: "Automatic";
|
||||||
|
|
||||||
const options = useMemo(
|
const options = useMemo(
|
||||||
@@ -732,7 +717,7 @@ export function PermissionsDropdown({
|
|||||||
label: mode.label,
|
label: mode.label,
|
||||||
description: mode.description,
|
description: mode.description,
|
||||||
})),
|
})),
|
||||||
[modeOptions]
|
[modeOptions],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpen = useCallback(() => {
|
const handleOpen = useCallback(() => {
|
||||||
@@ -751,9 +736,7 @@ export function PermissionsDropdown({
|
|||||||
onPress={handleOpen}
|
onPress={handleOpen}
|
||||||
disabled={disabled || !hasOptions}
|
disabled={disabled || !hasOptions}
|
||||||
helperText={
|
helperText={
|
||||||
hasOptions
|
hasOptions ? undefined : "This assistant does not expose selectable permissions."
|
||||||
? undefined
|
|
||||||
: "This assistant does not expose selectable permissions."
|
|
||||||
}
|
}
|
||||||
controlRef={anchorRef}
|
controlRef={anchorRef}
|
||||||
/>
|
/>
|
||||||
@@ -795,7 +778,7 @@ export function ModelDropdown({
|
|||||||
const anchorRef = useRef<View>(null);
|
const anchorRef = useRef<View>(null);
|
||||||
|
|
||||||
const selectedLabel = selectedModel
|
const selectedLabel = selectedModel
|
||||||
? models.find((model) => model.id === selectedModel)?.label ?? selectedModel
|
? (models.find((model) => model.id === selectedModel)?.label ?? selectedModel)
|
||||||
: "Automatic";
|
: "Automatic";
|
||||||
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Automatic";
|
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Automatic";
|
||||||
const helperText = error
|
const helperText = error
|
||||||
@@ -834,7 +817,7 @@ export function ModelDropdown({
|
|||||||
onSelect(id);
|
onSelect(id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onClear, onSelect]
|
[onClear, onSelect],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -882,7 +865,7 @@ export function WorkingDirectoryDropdown({
|
|||||||
|
|
||||||
const options = useMemo(
|
const options = useMemo(
|
||||||
() => suggestedPaths.map((path) => ({ id: path, label: path })),
|
() => suggestedPaths.map((path) => ({ id: path, label: path })),
|
||||||
[suggestedPaths]
|
[suggestedPaths],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpen = useCallback(() => setIsOpen(true), []);
|
const handleOpen = useCallback(() => setIsOpen(true), []);
|
||||||
@@ -957,9 +940,7 @@ export function ToggleRow({
|
|||||||
</View>
|
</View>
|
||||||
<View style={styles.toggleTextContainer}>
|
<View style={styles.toggleTextContainer}>
|
||||||
<Text style={styles.toggleLabel}>{label}</Text>
|
<Text style={styles.toggleLabel}>{label}</Text>
|
||||||
{description ? (
|
{description ? <Text style={styles.helperText}>{description}</Text> : null}
|
||||||
<Text style={styles.helperText}>{description}</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
@@ -1052,9 +1033,7 @@ export function GitOptionsSection({
|
|||||||
<View style={styles.gitOptionsContainer}>
|
<View style={styles.gitOptionsContainer}>
|
||||||
<Pressable
|
<Pressable
|
||||||
testID="worktree-create-toggle"
|
testID="worktree-create-toggle"
|
||||||
onPress={() =>
|
onPress={() => onWorktreeModeChange(isCreateMode ? "none" : "create")}
|
||||||
onWorktreeModeChange(isCreateMode ? "none" : "create")
|
|
||||||
}
|
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]}
|
style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]}
|
||||||
>
|
>
|
||||||
@@ -1077,9 +1056,7 @@ export function GitOptionsSection({
|
|||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
testID="worktree-attach-toggle"
|
testID="worktree-attach-toggle"
|
||||||
onPress={() =>
|
onPress={() => onWorktreeModeChange(isAttachMode ? "none" : "attach")}
|
||||||
onWorktreeModeChange(isAttachMode ? "none" : "attach")
|
|
||||||
}
|
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]}
|
style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]}
|
||||||
>
|
>
|
||||||
@@ -1143,8 +1120,15 @@ export function GitOptionsSection({
|
|||||||
placeholderTextColor={defaultTheme.colors.foregroundMuted}
|
placeholderTextColor={defaultTheme.colors.foregroundMuted}
|
||||||
onSubmitEditing={handleConfirmEdit}
|
onSubmitEditing={handleConfirmEdit}
|
||||||
/>
|
/>
|
||||||
<Pressable onPress={handleConfirmEdit} hitSlop={8} style={styles.baseBranchIconButton}>
|
<Pressable
|
||||||
<Check size={defaultTheme.iconSize.md} color={defaultTheme.colors.palette.green[500]} />
|
onPress={handleConfirmEdit}
|
||||||
|
hitSlop={8}
|
||||||
|
style={styles.baseBranchIconButton}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
size={defaultTheme.iconSize.md}
|
||||||
|
color={defaultTheme.colors.palette.green[500]}
|
||||||
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable onPress={handleCancelEdit} hitSlop={8} style={styles.baseBranchIconButton}>
|
<Pressable onPress={handleCancelEdit} hitSlop={8} style={styles.baseBranchIconButton}>
|
||||||
<X size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
<X size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
||||||
@@ -1159,17 +1143,11 @@ export function GitOptionsSection({
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{baseBranchError ? (
|
{baseBranchError ? <Text style={styles.errorText}>{baseBranchError}</Text> : null}
|
||||||
<Text style={styles.errorText}>{baseBranchError}</Text>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{repoError ? (
|
{repoError ? <Text style={styles.errorText}>{repoError}</Text> : null}
|
||||||
<Text style={styles.errorText}>{repoError}</Text>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{gitValidationError ? (
|
{gitValidationError ? <Text style={styles.errorText}>{gitValidationError}</Text> : null}
|
||||||
<Text style={styles.errorText}>{gitValidationError}</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from "vitest";
|
||||||
import { resolveStatusControlMode } from './agent-input-area.status-controls'
|
import { resolveStatusControlMode } from "./agent-input-area.status-controls";
|
||||||
|
|
||||||
describe('resolveStatusControlMode', () => {
|
describe("resolveStatusControlMode", () => {
|
||||||
it('uses ready mode when no controlled status controls are provided', () => {
|
it("uses ready mode when no controlled status controls are provided", () => {
|
||||||
expect(resolveStatusControlMode(undefined)).toBe('ready')
|
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(
|
expect(
|
||||||
resolveStatusControlMode({
|
resolveStatusControlMode({
|
||||||
providerDefinitions: [],
|
providerDefinitions: [],
|
||||||
selectedProvider: 'codex',
|
selectedProvider: "codex",
|
||||||
onSelectProvider: () => undefined,
|
onSelectProvider: () => undefined,
|
||||||
modeOptions: [],
|
modeOptions: [],
|
||||||
selectedMode: '',
|
selectedMode: "",
|
||||||
onSelectMode: () => undefined,
|
onSelectMode: () => undefined,
|
||||||
models: [],
|
models: [],
|
||||||
selectedModel: '',
|
selectedModel: "",
|
||||||
onSelectModel: () => undefined,
|
onSelectModel: () => undefined,
|
||||||
isModelLoading: false,
|
isModelLoading: false,
|
||||||
allProviderModels: new Map(),
|
allProviderModels: new Map(),
|
||||||
isAllModelsLoading: false,
|
isAllModelsLoading: false,
|
||||||
onSelectProviderAndModel: () => undefined,
|
onSelectProviderAndModel: () => undefined,
|
||||||
thinkingOptions: [],
|
thinkingOptions: [],
|
||||||
selectedThinkingOptionId: '',
|
selectedThinkingOptionId: "",
|
||||||
onSelectThinkingOption: () => undefined,
|
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) {
|
export function resolveStatusControlMode(statusControls?: DraftAgentStatusBarProps) {
|
||||||
return statusControls ? 'draft' : 'ready'
|
return statusControls ? "draft" : "ready";
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,107 +6,107 @@ import {
|
|||||||
RefreshControl,
|
RefreshControl,
|
||||||
FlatList,
|
FlatList,
|
||||||
type ListRenderItem,
|
type ListRenderItem,
|
||||||
} from 'react-native'
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useCallback, useMemo, useState, type ReactElement } from 'react'
|
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||||
import { router } from 'expo-router'
|
import { router } from "expo-router";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { formatTimeAgo } from '@/utils/time'
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
import { shortenPath } from '@/utils/shorten-path'
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
import { type AggregatedAgent } from '@/hooks/use-aggregated-agents'
|
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
import { useSessionStore } from '@/stores/session-store'
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { Archive } from 'lucide-react-native'
|
import { Archive } from "lucide-react-native";
|
||||||
import { prepareWorkspaceTab } from '@/utils/workspace-navigation'
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
|
||||||
interface AgentListProps {
|
interface AgentListProps {
|
||||||
agents: AggregatedAgent[]
|
agents: AggregatedAgent[];
|
||||||
showCheckoutInfo?: boolean
|
showCheckoutInfo?: boolean;
|
||||||
isRefreshing?: boolean
|
isRefreshing?: boolean;
|
||||||
onRefresh?: () => void
|
onRefresh?: () => void;
|
||||||
selectedAgentId?: string
|
selectedAgentId?: string;
|
||||||
onAgentSelect?: () => void
|
onAgentSelect?: () => void;
|
||||||
listFooterComponent?: ReactElement | null
|
listFooterComponent?: ReactElement | null;
|
||||||
showAttentionIndicator?: boolean
|
showAttentionIndicator?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type FlatListItem =
|
type FlatListItem =
|
||||||
| { type: 'header'; key: string; title: string }
|
| { type: "header"; key: string; title: string }
|
||||||
| { type: 'agent'; key: string; agent: AggregatedAgent }
|
| { type: "agent"; key: string; agent: AggregatedAgent };
|
||||||
|
|
||||||
function deriveDateSectionLabel(lastActivityAt: Date): string {
|
function deriveDateSectionLabel(lastActivityAt: Date): string {
|
||||||
const now = new Date()
|
const now = new Date();
|
||||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000)
|
const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000);
|
||||||
const activityStart = new Date(
|
const activityStart = new Date(
|
||||||
lastActivityAt.getFullYear(),
|
lastActivityAt.getFullYear(),
|
||||||
lastActivityAt.getMonth(),
|
lastActivityAt.getMonth(),
|
||||||
lastActivityAt.getDate()
|
lastActivityAt.getDate(),
|
||||||
)
|
);
|
||||||
|
|
||||||
if (activityStart.getTime() >= todayStart.getTime()) {
|
if (activityStart.getTime() >= todayStart.getTime()) {
|
||||||
return 'Today'
|
return "Today";
|
||||||
}
|
}
|
||||||
if (activityStart.getTime() >= yesterdayStart.getTime()) {
|
if (activityStart.getTime() >= yesterdayStart.getTime()) {
|
||||||
return 'Yesterday'
|
return "Yesterday";
|
||||||
}
|
}
|
||||||
|
|
||||||
const diffTime = todayStart.getTime() - activityStart.getTime()
|
const diffTime = todayStart.getTime() - activityStart.getTime();
|
||||||
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24))
|
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||||
if (diffDays <= 7) {
|
if (diffDays <= 7) {
|
||||||
return 'This week'
|
return "This week";
|
||||||
}
|
}
|
||||||
if (diffDays <= 30) {
|
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) {
|
switch (status) {
|
||||||
case 'initializing':
|
case "initializing":
|
||||||
return 'Starting'
|
return "Starting";
|
||||||
case 'idle':
|
case "idle":
|
||||||
return 'Idle'
|
return "Idle";
|
||||||
case 'running':
|
case "running":
|
||||||
return 'Running'
|
return "Running";
|
||||||
case 'error':
|
case "error":
|
||||||
return 'Error'
|
return "Error";
|
||||||
case 'closed':
|
case "closed":
|
||||||
return 'Closed'
|
return "Closed";
|
||||||
default:
|
default:
|
||||||
return status
|
return status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function SessionBadge({
|
function SessionBadge({
|
||||||
label,
|
label,
|
||||||
icon,
|
icon,
|
||||||
tone = 'neutral',
|
tone = "neutral",
|
||||||
}: {
|
}: {
|
||||||
label: string
|
label: string;
|
||||||
icon?: ReactElement
|
icon?: ReactElement;
|
||||||
tone?: 'neutral' | 'warning' | 'danger'
|
tone?: "neutral" | "warning" | "danger";
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.badge,
|
styles.badge,
|
||||||
tone === 'warning' && styles.badgeWarning,
|
tone === "warning" && styles.badgeWarning,
|
||||||
tone === 'danger' && styles.badgeDanger,
|
tone === "danger" && styles.badgeDanger,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.badgeText,
|
styles.badgeText,
|
||||||
tone === 'warning' && styles.badgeTextWarning,
|
tone === "warning" && styles.badgeTextWarning,
|
||||||
tone === 'danger' && styles.badgeTextDanger,
|
tone === "danger" && styles.badgeTextDanger,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SessionRow({
|
function SessionRow({
|
||||||
@@ -117,19 +117,19 @@ function SessionRow({
|
|||||||
onPress,
|
onPress,
|
||||||
onLongPress,
|
onLongPress,
|
||||||
}: {
|
}: {
|
||||||
agent: AggregatedAgent
|
agent: AggregatedAgent;
|
||||||
isMobile: boolean
|
isMobile: boolean;
|
||||||
selectedAgentId?: string
|
selectedAgentId?: string;
|
||||||
showAttentionIndicator: boolean
|
showAttentionIndicator: boolean;
|
||||||
onPress: (agent: AggregatedAgent) => void
|
onPress: (agent: AggregatedAgent) => void;
|
||||||
onLongPress: (agent: AggregatedAgent) => void
|
onLongPress: (agent: AggregatedAgent) => void;
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles()
|
const { theme } = useUnistyles();
|
||||||
const timeAgo = formatTimeAgo(agent.lastActivityAt)
|
const timeAgo = formatTimeAgo(agent.lastActivityAt);
|
||||||
const agentKey = `${agent.serverId}:${agent.id}`
|
const agentKey = `${agent.serverId}:${agent.id}`;
|
||||||
const isSelected = selectedAgentId === agentKey
|
const isSelected = selectedAgentId === agentKey;
|
||||||
const statusLabel = formatStatusLabel(agent.status)
|
const statusLabel = formatStatusLabel(agent.status);
|
||||||
const projectPath = shortenPath(agent.cwd)
|
const projectPath = shortenPath(agent.cwd);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -149,7 +149,7 @@ function SessionRow({
|
|||||||
style={[styles.sessionTitle, isSelected && styles.sessionTitleHighlighted]}
|
style={[styles.sessionTitle, isSelected && styles.sessionTitleHighlighted]}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{agent.title || 'New session'}
|
{agent.title || "New session"}
|
||||||
</Text>
|
</Text>
|
||||||
{agent.archivedAt ? (
|
{agent.archivedAt ? (
|
||||||
<SessionBadge
|
<SessionBadge
|
||||||
@@ -199,10 +199,9 @@ function SessionRow({
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function AgentList({
|
export function AgentList({
|
||||||
agents,
|
agents,
|
||||||
isRefreshing = false,
|
isRefreshing = false,
|
||||||
@@ -212,88 +211,88 @@ export function AgentList({
|
|||||||
listFooterComponent,
|
listFooterComponent,
|
||||||
showAttentionIndicator = true,
|
showAttentionIndicator = true,
|
||||||
}: AgentListProps) {
|
}: AgentListProps) {
|
||||||
const { theme } = useUnistyles()
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets();
|
||||||
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null)
|
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
||||||
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
|
|
||||||
const actionClient = useSessionStore((state) =>
|
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 isActionSheetVisible = actionAgent !== null;
|
||||||
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient)
|
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient);
|
||||||
|
|
||||||
const handleAgentPress = useCallback(
|
const handleAgentPress = useCallback(
|
||||||
(agent: AggregatedAgent) => {
|
(agent: AggregatedAgent) => {
|
||||||
if (isActionSheetVisible) {
|
if (isActionSheetVisible) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverId = agent.serverId
|
const serverId = agent.serverId;
|
||||||
const agentId = agent.id
|
const agentId = agent.id;
|
||||||
|
|
||||||
onAgentSelect?.()
|
onAgentSelect?.();
|
||||||
|
|
||||||
const route = prepareWorkspaceTab({
|
const route = prepareWorkspaceTab({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId: agent.cwd,
|
workspaceId: agent.cwd,
|
||||||
target: { kind: 'agent', agentId },
|
target: { kind: "agent", agentId },
|
||||||
pin: Boolean(agent.archivedAt),
|
pin: Boolean(agent.archivedAt),
|
||||||
})
|
});
|
||||||
router.navigate(route as any)
|
router.navigate(route as any);
|
||||||
},
|
},
|
||||||
[isActionSheetVisible, onAgentSelect]
|
[isActionSheetVisible, onAgentSelect],
|
||||||
)
|
);
|
||||||
|
|
||||||
const handleAgentLongPress = useCallback((agent: AggregatedAgent) => {
|
const handleAgentLongPress = useCallback((agent: AggregatedAgent) => {
|
||||||
setActionAgent(agent)
|
setActionAgent(agent);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const handleCloseActionSheet = useCallback(() => {
|
const handleCloseActionSheet = useCallback(() => {
|
||||||
setActionAgent(null)
|
setActionAgent(null);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const handleArchiveAgent = useCallback(() => {
|
const handleArchiveAgent = useCallback(() => {
|
||||||
if (!actionAgent || !actionClient) {
|
if (!actionAgent || !actionClient) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
void actionClient.archiveAgent(actionAgent.id)
|
void actionClient.archiveAgent(actionAgent.id);
|
||||||
setActionAgent(null)
|
setActionAgent(null);
|
||||||
}, [actionAgent, actionClient])
|
}, [actionAgent, actionClient]);
|
||||||
|
|
||||||
const flatItems = useMemo((): FlatListItem[] => {
|
const flatItems = useMemo((): FlatListItem[] => {
|
||||||
const order = ['Today', 'Yesterday', 'This week', 'This month', 'Older'] as const
|
const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const;
|
||||||
const buckets = new Map<string, AggregatedAgent[]>()
|
const buckets = new Map<string, AggregatedAgent[]>();
|
||||||
for (const agent of agents) {
|
for (const agent of agents) {
|
||||||
const label = deriveDateSectionLabel(agent.lastActivityAt)
|
const label = deriveDateSectionLabel(agent.lastActivityAt);
|
||||||
const existing = buckets.get(label) ?? []
|
const existing = buckets.get(label) ?? [];
|
||||||
existing.push(agent)
|
existing.push(agent);
|
||||||
buckets.set(label, existing)
|
buckets.set(label, existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: FlatListItem[] = []
|
const result: FlatListItem[] = [];
|
||||||
for (const label of order) {
|
for (const label of order) {
|
||||||
const data = buckets.get(label)
|
const data = buckets.get(label);
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
result.push({ type: 'header', key: `header:${label}`, title: label })
|
result.push({ type: "header", key: `header:${label}`, title: label });
|
||||||
for (const agent of data) {
|
for (const agent of data) {
|
||||||
result.push({ type: 'agent', key: `${agent.serverId}:${agent.id}`, agent })
|
result.push({ type: "agent", key: `${agent.serverId}:${agent.id}`, agent });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result;
|
||||||
}, [agents])
|
}, [agents]);
|
||||||
|
|
||||||
const renderItem: ListRenderItem<FlatListItem> = useCallback(
|
const renderItem: ListRenderItem<FlatListItem> = useCallback(
|
||||||
({ item }) => {
|
({ item }) => {
|
||||||
if (item.type === 'header') {
|
if (item.type === "header") {
|
||||||
return (
|
return (
|
||||||
<View style={styles.sectionHeading}>
|
<View style={styles.sectionHeading}>
|
||||||
<Text style={styles.sectionTitle}>{item.title}</Text>
|
<Text style={styles.sectionTitle}>{item.title}</Text>
|
||||||
</View>
|
</View>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<SessionRow
|
<SessionRow
|
||||||
@@ -304,12 +303,12 @@ export function AgentList({
|
|||||||
onPress={handleAgentPress}
|
onPress={handleAgentPress}
|
||||||
onLongPress={handleAgentLongPress}
|
onLongPress={handleAgentLongPress}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
[handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator]
|
[handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator],
|
||||||
)
|
);
|
||||||
|
|
||||||
const keyExtractor = useCallback((item: FlatListItem) => item.key, [])
|
const keyExtractor = useCallback((item: FlatListItem) => item.key, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -350,7 +349,7 @@ export function AgentList({
|
|||||||
>
|
>
|
||||||
<View style={styles.sheetHandle} />
|
<View style={styles.sheetHandle} />
|
||||||
<Text style={styles.sheetTitle}>
|
<Text style={styles.sheetTitle}>
|
||||||
{isActionDaemonUnavailable ? 'Host offline' : 'Archive this session?'}
|
{isActionDaemonUnavailable ? "Host offline" : "Archive this session?"}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.sheetButtonRow}>
|
<View style={styles.sheetButtonRow}>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -380,7 +379,7 @@ export function AgentList({
|
|||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
@@ -399,8 +398,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
sectionHeading: {
|
sectionHeading: {
|
||||||
marginTop: theme.spacing[2],
|
marginTop: theme.spacing[2],
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
gap: theme.spacing[3],
|
gap: theme.spacing[3],
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
marginBottom: theme.spacing[2],
|
marginBottom: theme.spacing[2],
|
||||||
@@ -411,8 +410,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
row: {
|
row: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
@@ -429,15 +428,15 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
rowTitleRow: {
|
rowTitleRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
flexWrap: 'wrap',
|
flexWrap: "wrap",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
rowMetaRow: {
|
rowMetaRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
flexWrap: 'wrap',
|
flexWrap: "wrap",
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
@@ -456,7 +455,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
sessionTitle: {
|
sessionTitle: {
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: '400',
|
fontWeight: "400",
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
opacity: 0.86,
|
opacity: 0.86,
|
||||||
},
|
},
|
||||||
@@ -464,7 +463,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
opacity: 1,
|
opacity: 1,
|
||||||
},
|
},
|
||||||
sessionMetaText: {
|
sessionMetaText: {
|
||||||
maxWidth: '100%',
|
maxWidth: "100%",
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
@@ -486,11 +485,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
width: 72,
|
width: 72,
|
||||||
textAlign: 'right' as const,
|
textAlign: "right" as const,
|
||||||
},
|
},
|
||||||
badge: {
|
badge: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
@@ -498,10 +497,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
badgeWarning: {
|
badgeWarning: {
|
||||||
backgroundColor: 'rgba(245, 158, 11, 0.12)',
|
backgroundColor: "rgba(245, 158, 11, 0.12)",
|
||||||
},
|
},
|
||||||
badgeDanger: {
|
badgeDanger: {
|
||||||
backgroundColor: 'rgba(239, 68, 68, 0.14)',
|
backgroundColor: "rgba(239, 68, 68, 0.14)",
|
||||||
},
|
},
|
||||||
badgeText: {
|
badgeText: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
@@ -516,26 +515,26 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
sheetOverlay: {
|
sheetOverlay: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: 'flex-end',
|
justifyContent: "flex-end",
|
||||||
},
|
},
|
||||||
sheetBackdrop: {
|
sheetBackdrop: {
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
backgroundColor: 'rgba(0,0,0,0.35)',
|
backgroundColor: "rgba(0,0,0,0.35)",
|
||||||
},
|
},
|
||||||
sheetContainer: {
|
sheetContainer: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
borderTopLeftRadius: theme.borderRadius['2xl'],
|
borderTopLeftRadius: theme.borderRadius["2xl"],
|
||||||
borderTopRightRadius: theme.borderRadius['2xl'],
|
borderTopRightRadius: theme.borderRadius["2xl"],
|
||||||
paddingHorizontal: theme.spacing[6],
|
paddingHorizontal: theme.spacing[6],
|
||||||
paddingTop: theme.spacing[4],
|
paddingTop: theme.spacing[4],
|
||||||
gap: theme.spacing[4],
|
gap: theme.spacing[4],
|
||||||
},
|
},
|
||||||
sheetHandle: {
|
sheetHandle: {
|
||||||
alignSelf: 'center',
|
alignSelf: "center",
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 4,
|
height: 4,
|
||||||
borderRadius: theme.borderRadius.full,
|
borderRadius: theme.borderRadius.full,
|
||||||
@@ -546,18 +545,18 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.lg,
|
fontSize: theme.fontSize.lg,
|
||||||
fontWeight: theme.fontWeight.semibold,
|
fontWeight: theme.fontWeight.semibold,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
textAlign: 'center',
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
sheetButtonRow: {
|
sheetButtonRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
gap: theme.spacing[3],
|
gap: theme.spacing[3],
|
||||||
},
|
},
|
||||||
sheetButton: {
|
sheetButton: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
paddingVertical: theme.spacing[4],
|
paddingVertical: theme.spacing[4],
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
sheetArchiveButton: {
|
sheetArchiveButton: {
|
||||||
backgroundColor: theme.colors.primary,
|
backgroundColor: theme.colors.primary,
|
||||||
@@ -578,4 +577,4 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontWeight: theme.fontWeight.semibold,
|
fontWeight: theme.fontWeight.semibold,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
},
|
},
|
||||||
}))
|
}));
|
||||||
|
|||||||
@@ -1,72 +1,72 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
getStatusSelectorHint,
|
getStatusSelectorHint,
|
||||||
normalizeModelId,
|
normalizeModelId,
|
||||||
resolveAgentModelSelection,
|
resolveAgentModelSelection,
|
||||||
} from './agent-status-bar.utils'
|
} from "./agent-status-bar.utils";
|
||||||
|
|
||||||
describe('getStatusSelectorHint', () => {
|
describe("getStatusSelectorHint", () => {
|
||||||
it('explains what each editable status control does', () => {
|
it("explains what each editable status control does", () => {
|
||||||
expect(getStatusSelectorHint('thinking')).toBe('Thinking mode')
|
expect(getStatusSelectorHint("thinking")).toBe("Thinking mode");
|
||||||
expect(getStatusSelectorHint('model')).toBe('Change model')
|
expect(getStatusSelectorHint("model")).toBe("Change model");
|
||||||
expect(getStatusSelectorHint('mode')).toBe('Change permission mode')
|
expect(getStatusSelectorHint("mode")).toBe("Change permission mode");
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('normalizeModelId', () => {
|
describe("normalizeModelId", () => {
|
||||||
it('treats empty and default values as unset', () => {
|
it("treats empty and default values as unset", () => {
|
||||||
expect(normalizeModelId('')).toBeNull()
|
expect(normalizeModelId("")).toBeNull();
|
||||||
expect(normalizeModelId(' default ')).toBeNull()
|
expect(normalizeModelId(" default ")).toBeNull();
|
||||||
expect(normalizeModelId(undefined)).toBeNull()
|
expect(normalizeModelId(undefined)).toBeNull();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('returns trimmed model ids', () => {
|
it("returns trimmed model ids", () => {
|
||||||
expect(normalizeModelId(' gpt-5.1-codex ')).toBe('gpt-5.1-codex')
|
expect(normalizeModelId(" gpt-5.1-codex ")).toBe("gpt-5.1-codex");
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('resolveAgentModelSelection', () => {
|
describe("resolveAgentModelSelection", () => {
|
||||||
it('prefers runtime model over configured model', () => {
|
it("prefers runtime model over configured model", () => {
|
||||||
const selection = resolveAgentModelSelection({
|
const selection = resolveAgentModelSelection({
|
||||||
models: [
|
models: [
|
||||||
{
|
{
|
||||||
id: 'a',
|
id: "a",
|
||||||
provider: 'codex',
|
provider: "codex",
|
||||||
label: 'Model A',
|
label: "Model A",
|
||||||
thinkingOptions: [{ id: 'low', label: 'Low' }],
|
thinkingOptions: [{ id: "low", label: "Low" }],
|
||||||
defaultThinkingOptionId: 'low',
|
defaultThinkingOptionId: "low",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
runtimeModelId: 'a',
|
runtimeModelId: "a",
|
||||||
configuredModelId: 'b',
|
configuredModelId: "b",
|
||||||
explicitThinkingOptionId: null,
|
explicitThinkingOptionId: null,
|
||||||
})
|
});
|
||||||
|
|
||||||
expect(selection.activeModelId).toBe('a')
|
expect(selection.activeModelId).toBe("a");
|
||||||
expect(selection.displayModel).toBe('Model A')
|
expect(selection.displayModel).toBe("Model A");
|
||||||
expect(selection.selectedThinkingId).toBe('low')
|
expect(selection.selectedThinkingId).toBe("low");
|
||||||
})
|
});
|
||||||
|
|
||||||
it('uses explicit thinking option when provided', () => {
|
it("uses explicit thinking option when provided", () => {
|
||||||
const selection = resolveAgentModelSelection({
|
const selection = resolveAgentModelSelection({
|
||||||
models: [
|
models: [
|
||||||
{
|
{
|
||||||
id: 'a',
|
id: "a",
|
||||||
provider: 'codex',
|
provider: "codex",
|
||||||
label: 'Model A',
|
label: "Model A",
|
||||||
thinkingOptions: [
|
thinkingOptions: [
|
||||||
{ id: 'low', label: 'Low' },
|
{ id: "low", label: "Low" },
|
||||||
{ id: 'high', label: 'High' },
|
{ id: "high", label: "High" },
|
||||||
],
|
],
|
||||||
defaultThinkingOptionId: 'low',
|
defaultThinkingOptionId: "low",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
runtimeModelId: 'a',
|
runtimeModelId: "a",
|
||||||
configuredModelId: null,
|
configuredModelId: null,
|
||||||
explicitThinkingOptionId: 'high',
|
explicitThinkingOptionId: "high",
|
||||||
})
|
});
|
||||||
|
|
||||||
expect(selection.selectedThinkingId).toBe('high')
|
expect(selection.selectedThinkingId).toBe("high");
|
||||||
expect(selection.displayThinking).toBe('High')
|
expect(selection.displayThinking).toBe("High");
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,129 +1,130 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { View, Text, Platform, Pressable } from 'react-native'
|
import { View, Text, Platform, Pressable } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import {
|
import { Brain, ChevronDown, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react-native";
|
||||||
Brain,
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
ChevronDown,
|
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||||
ShieldAlert,
|
import { useQuery } from "@tanstack/react-query";
|
||||||
ShieldCheck,
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
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 {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Combobox, ComboboxItem, type ComboboxOption } from '@/components/ui/combobox'
|
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
import { AdaptiveModalSheet } from '@/components/adaptive-modal-sheet'
|
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import type {
|
import type {
|
||||||
AgentMode,
|
AgentMode,
|
||||||
AgentModelDefinition,
|
AgentModelDefinition,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
} from '@server/server/agent/agent-sdk-types'
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
import type { AgentProviderDefinition } from '@server/server/agent/provider-manifest'
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
import {
|
import {
|
||||||
getModeVisuals,
|
getModeVisuals,
|
||||||
type AgentModeColorTier,
|
type AgentModeColorTier,
|
||||||
type AgentModeIcon,
|
type AgentModeIcon,
|
||||||
} from '@server/server/agent/provider-manifest'
|
} from "@server/server/agent/provider-manifest";
|
||||||
import {
|
import {
|
||||||
getStatusSelectorHint,
|
getStatusSelectorHint,
|
||||||
resolveAgentModelSelection,
|
resolveAgentModelSelection,
|
||||||
} from '@/components/agent-status-bar.utils'
|
} from "@/components/agent-status-bar.utils";
|
||||||
|
|
||||||
type StatusOption = {
|
type StatusOption = {
|
||||||
id: string
|
id: string;
|
||||||
label: string
|
label: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
type StatusSelector = 'provider' | 'mode' | 'model' | 'thinking'
|
type StatusSelector = "provider" | "mode" | "model" | "thinking";
|
||||||
|
|
||||||
type ControlledAgentStatusBarProps = {
|
type ControlledAgentStatusBarProps = {
|
||||||
provider: string
|
provider: string;
|
||||||
providerOptions?: StatusOption[]
|
providerOptions?: StatusOption[];
|
||||||
selectedProviderId?: string
|
selectedProviderId?: string;
|
||||||
onSelectProvider?: (providerId: string) => void
|
onSelectProvider?: (providerId: string) => void;
|
||||||
modeOptions?: StatusOption[]
|
modeOptions?: StatusOption[];
|
||||||
selectedModeId?: string
|
selectedModeId?: string;
|
||||||
onSelectMode?: (modeId: string) => void
|
onSelectMode?: (modeId: string) => void;
|
||||||
modelOptions?: StatusOption[]
|
modelOptions?: StatusOption[];
|
||||||
selectedModelId?: string
|
selectedModelId?: string;
|
||||||
onSelectModel?: (modelId: string) => void
|
onSelectModel?: (modelId: string) => void;
|
||||||
thinkingOptions?: StatusOption[]
|
thinkingOptions?: StatusOption[];
|
||||||
selectedThinkingOptionId?: string
|
selectedThinkingOptionId?: string;
|
||||||
onSelectThinkingOption?: (thinkingOptionId: string) => void
|
onSelectThinkingOption?: (thinkingOptionId: string) => void;
|
||||||
disabled?: boolean
|
disabled?: boolean;
|
||||||
isModelLoading?: boolean
|
isModelLoading?: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
export interface DraftAgentStatusBarProps {
|
export interface DraftAgentStatusBarProps {
|
||||||
providerDefinitions: AgentProviderDefinition[]
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
selectedProvider: AgentProvider
|
selectedProvider: AgentProvider;
|
||||||
onSelectProvider: (provider: AgentProvider) => void
|
onSelectProvider: (provider: AgentProvider) => void;
|
||||||
modeOptions: AgentMode[]
|
modeOptions: AgentMode[];
|
||||||
selectedMode: string
|
selectedMode: string;
|
||||||
onSelectMode: (modeId: string) => void
|
onSelectMode: (modeId: string) => void;
|
||||||
models: AgentModelDefinition[]
|
models: AgentModelDefinition[];
|
||||||
selectedModel: string
|
selectedModel: string;
|
||||||
onSelectModel: (modelId: string) => void
|
onSelectModel: (modelId: string) => void;
|
||||||
isModelLoading: boolean
|
isModelLoading: boolean;
|
||||||
allProviderModels: Map<string, AgentModelDefinition[]>
|
allProviderModels: Map<string, AgentModelDefinition[]>;
|
||||||
isAllModelsLoading: boolean
|
isAllModelsLoading: boolean;
|
||||||
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void
|
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void;
|
||||||
thinkingOptions: NonNullable<AgentModelDefinition['thinkingOptions']>
|
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
||||||
selectedThinkingOptionId: string
|
selectedThinkingOptionId: string;
|
||||||
onSelectThinkingOption: (thinkingOptionId: string) => void
|
onSelectThinkingOption: (thinkingOptionId: string) => void;
|
||||||
disabled?: boolean
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AgentStatusBarProps {
|
interface AgentStatusBarProps {
|
||||||
agentId: string
|
agentId: string;
|
||||||
serverId: 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) {
|
if (!options || options.length === 0) {
|
||||||
return fallback
|
return fallback;
|
||||||
}
|
}
|
||||||
const selected = options.find((option) => option.id === selectedId)
|
const selected = options.find((option) => option.id === selectedId);
|
||||||
return selected?.label ?? fallback
|
return selected?.label ?? fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MODE_ICONS = {
|
const MODE_ICONS = {
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
ShieldOff,
|
ShieldOff,
|
||||||
} as const
|
} as const;
|
||||||
|
|
||||||
|
|
||||||
function getModeIconColor(
|
function getModeIconColor(
|
||||||
colorTier: AgentModeColorTier | undefined,
|
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 {
|
): string {
|
||||||
switch (colorTier) {
|
switch (colorTier) {
|
||||||
case 'default':
|
case "default":
|
||||||
return palette.blue[500]
|
return palette.blue[500];
|
||||||
case 'safe':
|
case "safe":
|
||||||
return palette.green[500]
|
return palette.green[500];
|
||||||
case 'moderate':
|
case "moderate":
|
||||||
return palette.amber[500]
|
return palette.amber[500];
|
||||||
case 'dangerous':
|
case "dangerous":
|
||||||
return palette.red[500]
|
return palette.red[500];
|
||||||
case 'readonly':
|
case "readonly":
|
||||||
return palette.purple[500]
|
return palette.purple[500];
|
||||||
default:
|
default:
|
||||||
return palette.blue[500]
|
return palette.blue[500];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function ControlledStatusBar({
|
function ControlledStatusBar({
|
||||||
provider,
|
provider,
|
||||||
providerOptions,
|
providerOptions,
|
||||||
@@ -141,71 +142,83 @@ function ControlledStatusBar({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
isModelLoading = false,
|
isModelLoading = false,
|
||||||
}: ControlledAgentStatusBarProps) {
|
}: ControlledAgentStatusBarProps) {
|
||||||
const { theme } = useUnistyles()
|
const { theme } = useUnistyles();
|
||||||
const isWeb = Platform.OS === 'web'
|
const isWeb = Platform.OS === "web";
|
||||||
const [prefsOpen, setPrefsOpen] = useState(false)
|
const [prefsOpen, setPrefsOpen] = useState(false);
|
||||||
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null)
|
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null);
|
||||||
|
|
||||||
const providerAnchorRef = useRef<View>(null)
|
const providerAnchorRef = useRef<View>(null);
|
||||||
const modeAnchorRef = useRef<View>(null)
|
const modeAnchorRef = useRef<View>(null);
|
||||||
const modelAnchorRef = useRef<View>(null)
|
const modelAnchorRef = useRef<View>(null);
|
||||||
const thinkingAnchorRef = useRef<View>(null)
|
const thinkingAnchorRef = useRef<View>(null);
|
||||||
|
|
||||||
const canSelectProvider = Boolean(onSelectProvider && providerOptions && providerOptions.length > 0)
|
const canSelectProvider = Boolean(
|
||||||
const canSelectMode = Boolean(onSelectMode && modeOptions && modeOptions.length > 0)
|
onSelectProvider && providerOptions && providerOptions.length > 0,
|
||||||
const canSelectModel = Boolean(onSelectModel)
|
);
|
||||||
|
const canSelectMode = Boolean(onSelectMode && modeOptions && modeOptions.length > 0);
|
||||||
|
const canSelectModel = Boolean(onSelectModel);
|
||||||
const canSelectThinking = Boolean(
|
const canSelectThinking = Boolean(
|
||||||
onSelectThinkingOption && thinkingOptions && thinkingOptions.length > 0
|
onSelectThinkingOption && thinkingOptions && thinkingOptions.length > 0,
|
||||||
)
|
);
|
||||||
|
|
||||||
const displayProvider = findOptionLabel(providerOptions, selectedProviderId, 'Provider')
|
const displayProvider = findOptionLabel(providerOptions, selectedProviderId, "Provider");
|
||||||
const displayMode = findOptionLabel(modeOptions, selectedModeId, 'Default')
|
const displayMode = findOptionLabel(modeOptions, selectedModeId, "Default");
|
||||||
const displayModel =
|
const displayModel =
|
||||||
isModelLoading && (!modelOptions || modelOptions.length === 0)
|
isModelLoading && (!modelOptions || modelOptions.length === 0)
|
||||||
? 'Loading models...'
|
? "Loading models..."
|
||||||
: findOptionLabel(modelOptions, selectedModelId, 'Auto')
|
: findOptionLabel(modelOptions, selectedModelId, "Auto");
|
||||||
const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, 'auto')
|
const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, "auto");
|
||||||
|
|
||||||
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined
|
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined;
|
||||||
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null
|
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
|
||||||
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette)
|
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
|
||||||
const ProviderIcon = getProviderIcon(provider)
|
const ProviderIcon = getProviderIcon(provider);
|
||||||
|
|
||||||
const hasAnyControl =
|
const hasAnyControl =
|
||||||
Boolean(providerOptions?.length) ||
|
Boolean(providerOptions?.length) ||
|
||||||
Boolean(modeOptions?.length) ||
|
Boolean(modeOptions?.length) ||
|
||||||
canSelectModel ||
|
canSelectModel ||
|
||||||
Boolean(thinkingOptions?.length)
|
Boolean(thinkingOptions?.length);
|
||||||
|
|
||||||
if (!hasAnyControl) {
|
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[]>(
|
const comboboxProviderOptions = useMemo<ComboboxOption[]>(
|
||||||
() => (providerOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
() => (providerOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
||||||
[providerOptions]
|
[providerOptions],
|
||||||
)
|
);
|
||||||
const comboboxModeOptions = useMemo<ComboboxOption[]>(
|
const comboboxModeOptions = useMemo<ComboboxOption[]>(
|
||||||
() => (modeOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
() => (modeOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
||||||
[modeOptions]
|
[modeOptions],
|
||||||
)
|
);
|
||||||
const comboboxModelOptions = useMemo<ComboboxOption[]>(
|
const comboboxModelOptions = useMemo<ComboboxOption[]>(
|
||||||
() => (modelOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
() => (modelOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
||||||
[modelOptions]
|
[modelOptions],
|
||||||
)
|
);
|
||||||
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
|
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
|
||||||
() => (thinkingOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
() => (thinkingOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
||||||
[thinkingOptions]
|
[thinkingOptions],
|
||||||
)
|
);
|
||||||
|
|
||||||
const renderModeOption = useCallback(
|
const renderModeOption = useCallback(
|
||||||
({ option, selected, active, onPress }: { option: ComboboxOption; selected: boolean; active: boolean; onPress: () => void }) => {
|
({
|
||||||
const visuals = getModeVisuals(provider, option.id)
|
option,
|
||||||
const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck
|
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 (
|
return (
|
||||||
<ComboboxItem
|
<ComboboxItem
|
||||||
label={option.label}
|
label={option.label}
|
||||||
@@ -214,24 +227,24 @@ function ControlledStatusBar({
|
|||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
leadingSlot={<IconComponent size={16} color={theme.colors.foreground} />}
|
leadingSlot={<IconComponent size={16} color={theme.colors.foreground} />}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
[provider, theme.colors.foreground]
|
[provider, theme.colors.foreground],
|
||||||
)
|
);
|
||||||
|
|
||||||
const handleOpenChange = useCallback(
|
const handleOpenChange = useCallback(
|
||||||
(selector: StatusSelector) => (nextOpen: boolean) => {
|
(selector: StatusSelector) => (nextOpen: boolean) => {
|
||||||
setOpenSelector(nextOpen ? selector : null)
|
setOpenSelector(nextOpen ? selector : null);
|
||||||
},
|
},
|
||||||
[]
|
[],
|
||||||
)
|
);
|
||||||
|
|
||||||
const handleSelectorPress = useCallback(
|
const handleSelectorPress = useCallback(
|
||||||
(selector: StatusSelector) => {
|
(selector: StatusSelector) => {
|
||||||
handleOpenChange(selector)(openSelector !== selector)
|
handleOpenChange(selector)(openSelector !== selector);
|
||||||
},
|
},
|
||||||
[handleOpenChange, openSelector]
|
[handleOpenChange, openSelector],
|
||||||
)
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@@ -243,11 +256,11 @@ function ControlledStatusBar({
|
|||||||
ref={providerAnchorRef}
|
ref={providerAnchorRef}
|
||||||
collapsable={false}
|
collapsable={false}
|
||||||
disabled={disabled || !canSelectProvider}
|
disabled={disabled || !canSelectProvider}
|
||||||
onPress={() => handleSelectorPress('provider')}
|
onPress={() => handleSelectorPress("provider")}
|
||||||
style={({ pressed, hovered }) => [
|
style={({ pressed, hovered }) => [
|
||||||
styles.modeBadge,
|
styles.modeBadge,
|
||||||
hovered && styles.modeBadgeHovered,
|
hovered && styles.modeBadgeHovered,
|
||||||
(pressed || openSelector === 'provider') && styles.modeBadgePressed,
|
(pressed || openSelector === "provider") && styles.modeBadgePressed,
|
||||||
(disabled || !canSelectProvider) && styles.disabledBadge,
|
(disabled || !canSelectProvider) && styles.disabledBadge,
|
||||||
]}
|
]}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -259,11 +272,11 @@ function ControlledStatusBar({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
<Combobox
|
<Combobox
|
||||||
options={comboboxProviderOptions}
|
options={comboboxProviderOptions}
|
||||||
value={selectedProviderId ?? ''}
|
value={selectedProviderId ?? ""}
|
||||||
onSelect={(id) => onSelectProvider?.(id)}
|
onSelect={(id) => onSelectProvider?.(id)}
|
||||||
searchable={comboboxProviderOptions.length > SEARCH_THRESHOLD}
|
searchable={comboboxProviderOptions.length > SEARCH_THRESHOLD}
|
||||||
open={openSelector === 'provider'}
|
open={openSelector === "provider"}
|
||||||
onOpenChange={handleOpenChange('provider')}
|
onOpenChange={handleOpenChange("provider")}
|
||||||
anchorRef={providerAnchorRef}
|
anchorRef={providerAnchorRef}
|
||||||
desktopPlacement="top-start"
|
desktopPlacement="top-start"
|
||||||
/>
|
/>
|
||||||
@@ -273,7 +286,7 @@ function ControlledStatusBar({
|
|||||||
{modeOptions && modeOptions.length > 0 ? (
|
{modeOptions && modeOptions.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
key={`mode-${openSelector === 'mode' ? 'open' : 'closed'}`}
|
key={`mode-${openSelector === "mode" ? "open" : "closed"}`}
|
||||||
delayDuration={0}
|
delayDuration={0}
|
||||||
enabledOnDesktop
|
enabledOnDesktop
|
||||||
enabledOnMobile={false}
|
enabledOnMobile={false}
|
||||||
@@ -283,11 +296,11 @@ function ControlledStatusBar({
|
|||||||
ref={modeAnchorRef}
|
ref={modeAnchorRef}
|
||||||
collapsable={false}
|
collapsable={false}
|
||||||
disabled={disabled || !canSelectMode}
|
disabled={disabled || !canSelectMode}
|
||||||
onPress={() => handleSelectorPress('mode')}
|
onPress={() => handleSelectorPress("mode")}
|
||||||
style={({ pressed, hovered }) => [
|
style={({ pressed, hovered }) => [
|
||||||
styles.modeIconBadge,
|
styles.modeIconBadge,
|
||||||
hovered && styles.modeBadgeHovered,
|
hovered && styles.modeBadgeHovered,
|
||||||
(pressed || openSelector === 'mode') && styles.modeBadgePressed,
|
(pressed || openSelector === "mode") && styles.modeBadgePressed,
|
||||||
(disabled || !canSelectMode) && styles.disabledBadge,
|
(disabled || !canSelectMode) && styles.disabledBadge,
|
||||||
]}
|
]}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -302,16 +315,16 @@ function ControlledStatusBar({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" align="center" offset={8}>
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
<Text style={styles.tooltipText}>{getStatusSelectorHint('mode')}</Text>
|
<Text style={styles.tooltipText}>{getStatusSelectorHint("mode")}</Text>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Combobox
|
<Combobox
|
||||||
options={comboboxModeOptions}
|
options={comboboxModeOptions}
|
||||||
value={selectedModeId ?? ''}
|
value={selectedModeId ?? ""}
|
||||||
onSelect={(id) => onSelectMode?.(id)}
|
onSelect={(id) => onSelectMode?.(id)}
|
||||||
searchable={comboboxModeOptions.length > SEARCH_THRESHOLD}
|
searchable={comboboxModeOptions.length > SEARCH_THRESHOLD}
|
||||||
open={openSelector === 'mode'}
|
open={openSelector === "mode"}
|
||||||
onOpenChange={handleOpenChange('mode')}
|
onOpenChange={handleOpenChange("mode")}
|
||||||
anchorRef={modeAnchorRef}
|
anchorRef={modeAnchorRef}
|
||||||
desktopPlacement="top-start"
|
desktopPlacement="top-start"
|
||||||
renderOption={renderModeOption}
|
renderOption={renderModeOption}
|
||||||
@@ -322,7 +335,7 @@ function ControlledStatusBar({
|
|||||||
{canSelectModel ? (
|
{canSelectModel ? (
|
||||||
<>
|
<>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
key={`model-${openSelector === 'model' ? 'open' : 'closed'}`}
|
key={`model-${openSelector === "model" ? "open" : "closed"}`}
|
||||||
delayDuration={0}
|
delayDuration={0}
|
||||||
enabledOnDesktop
|
enabledOnDesktop
|
||||||
enabledOnMobile={false}
|
enabledOnMobile={false}
|
||||||
@@ -332,11 +345,11 @@ function ControlledStatusBar({
|
|||||||
ref={modelAnchorRef}
|
ref={modelAnchorRef}
|
||||||
collapsable={false}
|
collapsable={false}
|
||||||
disabled={modelDisabled}
|
disabled={modelDisabled}
|
||||||
onPress={() => handleSelectorPress('model')}
|
onPress={() => handleSelectorPress("model")}
|
||||||
style={({ pressed, hovered }) => [
|
style={({ pressed, hovered }) => [
|
||||||
styles.modeBadge,
|
styles.modeBadge,
|
||||||
hovered && styles.modeBadgeHovered,
|
hovered && styles.modeBadgeHovered,
|
||||||
(pressed || openSelector === 'model') && styles.modeBadgePressed,
|
(pressed || openSelector === "model") && styles.modeBadgePressed,
|
||||||
modelDisabled && styles.disabledBadge,
|
modelDisabled && styles.disabledBadge,
|
||||||
]}
|
]}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -349,16 +362,16 @@ function ControlledStatusBar({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" align="center" offset={8}>
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
<Text style={styles.tooltipText}>{getStatusSelectorHint('model')}</Text>
|
<Text style={styles.tooltipText}>{getStatusSelectorHint("model")}</Text>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Combobox
|
<Combobox
|
||||||
options={comboboxModelOptions}
|
options={comboboxModelOptions}
|
||||||
value={selectedModelId ?? ''}
|
value={selectedModelId ?? ""}
|
||||||
onSelect={(id) => onSelectModel?.(id)}
|
onSelect={(id) => onSelectModel?.(id)}
|
||||||
searchable={comboboxModelOptions.length > SEARCH_THRESHOLD}
|
searchable={comboboxModelOptions.length > SEARCH_THRESHOLD}
|
||||||
open={openSelector === 'model'}
|
open={openSelector === "model"}
|
||||||
onOpenChange={handleOpenChange('model')}
|
onOpenChange={handleOpenChange("model")}
|
||||||
anchorRef={modelAnchorRef}
|
anchorRef={modelAnchorRef}
|
||||||
desktopPlacement="top-start"
|
desktopPlacement="top-start"
|
||||||
/>
|
/>
|
||||||
@@ -368,7 +381,7 @@ function ControlledStatusBar({
|
|||||||
{thinkingOptions && thinkingOptions.length > 0 ? (
|
{thinkingOptions && thinkingOptions.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
key={`thinking-${openSelector === 'thinking' ? 'open' : 'closed'}`}
|
key={`thinking-${openSelector === "thinking" ? "open" : "closed"}`}
|
||||||
delayDuration={0}
|
delayDuration={0}
|
||||||
enabledOnDesktop
|
enabledOnDesktop
|
||||||
enabledOnMobile={false}
|
enabledOnMobile={false}
|
||||||
@@ -378,11 +391,11 @@ function ControlledStatusBar({
|
|||||||
ref={thinkingAnchorRef}
|
ref={thinkingAnchorRef}
|
||||||
collapsable={false}
|
collapsable={false}
|
||||||
disabled={disabled || !canSelectThinking}
|
disabled={disabled || !canSelectThinking}
|
||||||
onPress={() => handleSelectorPress('thinking')}
|
onPress={() => handleSelectorPress("thinking")}
|
||||||
style={({ pressed, hovered }) => [
|
style={({ pressed, hovered }) => [
|
||||||
styles.modeBadge,
|
styles.modeBadge,
|
||||||
hovered && styles.modeBadgeHovered,
|
hovered && styles.modeBadgeHovered,
|
||||||
(pressed || openSelector === 'thinking') && styles.modeBadgePressed,
|
(pressed || openSelector === "thinking") && styles.modeBadgePressed,
|
||||||
(disabled || !canSelectThinking) && styles.disabledBadge,
|
(disabled || !canSelectThinking) && styles.disabledBadge,
|
||||||
]}
|
]}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -395,16 +408,16 @@ function ControlledStatusBar({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" align="center" offset={8}>
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
<Text style={styles.tooltipText}>{getStatusSelectorHint('thinking')}</Text>
|
<Text style={styles.tooltipText}>{getStatusSelectorHint("thinking")}</Text>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Combobox
|
<Combobox
|
||||||
options={comboboxThinkingOptions}
|
options={comboboxThinkingOptions}
|
||||||
value={selectedThinkingOptionId ?? ''}
|
value={selectedThinkingOptionId ?? ""}
|
||||||
onSelect={(id) => onSelectThinkingOption?.(id)}
|
onSelect={(id) => onSelectThinkingOption?.(id)}
|
||||||
searchable={comboboxThinkingOptions.length > SEARCH_THRESHOLD}
|
searchable={comboboxThinkingOptions.length > SEARCH_THRESHOLD}
|
||||||
open={openSelector === 'thinking'}
|
open={openSelector === "thinking"}
|
||||||
onOpenChange={handleOpenChange('thinking')}
|
onOpenChange={handleOpenChange("thinking")}
|
||||||
anchorRef={thinkingAnchorRef}
|
anchorRef={thinkingAnchorRef}
|
||||||
desktopPlacement="top-start"
|
desktopPlacement="top-start"
|
||||||
/>
|
/>
|
||||||
@@ -415,16 +428,15 @@ function ControlledStatusBar({
|
|||||||
<>
|
<>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setPrefsOpen(true)}
|
onPress={() => setPrefsOpen(true)}
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [styles.prefsButton, pressed && styles.prefsButtonPressed]}
|
||||||
styles.prefsButton,
|
|
||||||
pressed && styles.prefsButtonPressed,
|
|
||||||
]}
|
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Agent preferences"
|
accessibilityLabel="Agent preferences"
|
||||||
testID="agent-preferences-button"
|
testID="agent-preferences-button"
|
||||||
>
|
>
|
||||||
<ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
<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>
|
</Pressable>
|
||||||
|
|
||||||
<AdaptiveModalSheet
|
<AdaptiveModalSheet
|
||||||
@@ -436,8 +448,8 @@ function ControlledStatusBar({
|
|||||||
{providerOptions && providerOptions.length > 0 ? (
|
{providerOptions && providerOptions.length > 0 ? (
|
||||||
<View style={styles.sheetSection}>
|
<View style={styles.sheetSection}>
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
open={openSelector === 'provider'}
|
open={openSelector === "provider"}
|
||||||
onOpenChange={handleOpenChange('provider')}
|
onOpenChange={handleOpenChange("provider")}
|
||||||
>
|
>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
disabled={disabled || !canSelectProvider}
|
disabled={disabled || !canSelectProvider}
|
||||||
@@ -471,8 +483,8 @@ function ControlledStatusBar({
|
|||||||
{modeOptions && modeOptions.length > 0 ? (
|
{modeOptions && modeOptions.length > 0 ? (
|
||||||
<View style={styles.sheetSection}>
|
<View style={styles.sheetSection}>
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
open={openSelector === 'mode'}
|
open={openSelector === "mode"}
|
||||||
onOpenChange={handleOpenChange('mode')}
|
onOpenChange={handleOpenChange("mode")}
|
||||||
>
|
>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
disabled={disabled || !canSelectMode}
|
disabled={disabled || !canSelectMode}
|
||||||
@@ -493,8 +505,8 @@ function ControlledStatusBar({
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent side="top" align="start">
|
<DropdownMenuContent side="top" align="start">
|
||||||
{modeOptions.map((mode) => {
|
{modeOptions.map((mode) => {
|
||||||
const visuals = getModeVisuals(provider, mode.id)
|
const visuals = getModeVisuals(provider, mode.id);
|
||||||
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck
|
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={mode.id}
|
key={mode.id}
|
||||||
@@ -504,7 +516,7 @@ function ControlledStatusBar({
|
|||||||
>
|
>
|
||||||
{mode.label}
|
{mode.label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -514,8 +526,8 @@ function ControlledStatusBar({
|
|||||||
{canSelectModel ? (
|
{canSelectModel ? (
|
||||||
<View style={styles.sheetSection}>
|
<View style={styles.sheetSection}>
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
open={openSelector === 'model'}
|
open={openSelector === "model"}
|
||||||
onOpenChange={handleOpenChange('model')}
|
onOpenChange={handleOpenChange("model")}
|
||||||
>
|
>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
disabled={modelDisabled}
|
disabled={modelDisabled}
|
||||||
@@ -549,8 +561,8 @@ function ControlledStatusBar({
|
|||||||
{thinkingOptions && thinkingOptions.length > 0 ? (
|
{thinkingOptions && thinkingOptions.length > 0 ? (
|
||||||
<View style={styles.sheetSection}>
|
<View style={styles.sheetSection}>
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
open={openSelector === 'thinking'}
|
open={openSelector === "thinking"}
|
||||||
onOpenChange={handleOpenChange('thinking')}
|
onOpenChange={handleOpenChange("thinking")}
|
||||||
>
|
>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
disabled={disabled || !canSelectThinking}
|
disabled={disabled || !canSelectThinking}
|
||||||
@@ -584,68 +596,68 @@ function ControlledStatusBar({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||||
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId))
|
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId));
|
||||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null)
|
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||||
|
|
||||||
const modelsQuery = useQuery({
|
const modelsQuery = useQuery({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
'providerModels',
|
"providerModels",
|
||||||
serverId,
|
serverId,
|
||||||
agent?.provider ?? '__missing_provider__',
|
agent?.provider ?? "__missing_provider__",
|
||||||
agent?.cwd ?? '__missing_cwd__',
|
agent?.cwd ?? "__missing_cwd__",
|
||||||
],
|
],
|
||||||
enabled: Boolean(client && agent?.provider),
|
enabled: Boolean(client && agent?.provider),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!client || !agent) {
|
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) {
|
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 =
|
const displayMode =
|
||||||
agent?.availableModes?.find((mode) => mode.id === agent.currentModeId)?.label ||
|
agent?.availableModes?.find((mode) => mode.id === agent.currentModeId)?.label ||
|
||||||
agent?.currentModeId ||
|
agent?.currentModeId ||
|
||||||
'default'
|
"default";
|
||||||
|
|
||||||
const modelSelection = resolveAgentModelSelection({
|
const modelSelection = resolveAgentModelSelection({
|
||||||
models,
|
models,
|
||||||
runtimeModelId: agent?.runtimeInfo?.model,
|
runtimeModelId: agent?.runtimeInfo?.model,
|
||||||
configuredModelId: agent?.model,
|
configuredModelId: agent?.model,
|
||||||
explicitThinkingOptionId: agent?.thinkingOptionId,
|
explicitThinkingOptionId: agent?.thinkingOptionId,
|
||||||
})
|
});
|
||||||
|
|
||||||
const modeOptions = useMemo<StatusOption[]>(() => {
|
const modeOptions = useMemo<StatusOption[]>(() => {
|
||||||
return (agent?.availableModes ?? []).map((mode) => ({
|
return (agent?.availableModes ?? []).map((mode) => ({
|
||||||
id: mode.id,
|
id: mode.id,
|
||||||
label: mode.label,
|
label: mode.label,
|
||||||
}))
|
}));
|
||||||
}, [agent?.availableModes])
|
}, [agent?.availableModes]);
|
||||||
|
|
||||||
const modelOptions = useMemo<StatusOption[]>(() => {
|
const modelOptions = useMemo<StatusOption[]>(() => {
|
||||||
return (models ?? []).map((model) => ({ id: model.id, label: model.label }))
|
return (models ?? []).map((model) => ({ id: model.id, label: model.label }));
|
||||||
}, [models])
|
}, [models]);
|
||||||
|
|
||||||
const thinkingOptions = useMemo<StatusOption[]>(() => {
|
const thinkingOptions = useMemo<StatusOption[]>(() => {
|
||||||
return (modelSelection.thinkingOptions ?? []).map((option) => ({
|
return (modelSelection.thinkingOptions ?? []).map((option) => ({
|
||||||
id: option.id,
|
id: option.id,
|
||||||
label: option.label,
|
label: option.label,
|
||||||
}))
|
}));
|
||||||
}, [modelSelection.thinkingOptions])
|
}, [modelSelection.thinkingOptions]);
|
||||||
|
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -654,41 +666,41 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
|||||||
modeOptions={
|
modeOptions={
|
||||||
modeOptions.length > 0
|
modeOptions.length > 0
|
||||||
? modeOptions
|
? modeOptions
|
||||||
: [{ id: agent.currentModeId ?? '', label: displayMode }]
|
: [{ id: agent.currentModeId ?? "", label: displayMode }]
|
||||||
}
|
}
|
||||||
selectedModeId={agent.currentModeId ?? undefined}
|
selectedModeId={agent.currentModeId ?? undefined}
|
||||||
onSelectMode={(modeId) => {
|
onSelectMode={(modeId) => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
void client.setAgentMode(agentId, modeId).catch((error) => {
|
void client.setAgentMode(agentId, modeId).catch((error) => {
|
||||||
console.warn('[AgentStatusBar] setAgentMode failed', error)
|
console.warn("[AgentStatusBar] setAgentMode failed", error);
|
||||||
})
|
});
|
||||||
}}
|
}}
|
||||||
modelOptions={modelOptions}
|
modelOptions={modelOptions}
|
||||||
selectedModelId={modelSelection.activeModelId ?? undefined}
|
selectedModelId={modelSelection.activeModelId ?? undefined}
|
||||||
onSelectModel={(modelId) => {
|
onSelectModel={(modelId) => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
void client.setAgentModel(agentId, modelId).catch((error) => {
|
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}
|
thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined}
|
||||||
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
|
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
|
||||||
onSelectThinkingOption={(thinkingOptionId) => {
|
onSelectThinkingOption={(thinkingOptionId) => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
|
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
|
||||||
console.warn('[AgentStatusBar] setAgentThinkingOption failed', error)
|
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
|
||||||
})
|
});
|
||||||
}}
|
}}
|
||||||
isModelLoading={modelsQuery.isPending || modelsQuery.isFetching}
|
isModelLoading={modelsQuery.isPending || modelsQuery.isFetching}
|
||||||
disabled={!client}
|
disabled={!client}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DraftAgentStatusBar({
|
export function DraftAgentStatusBar({
|
||||||
@@ -710,25 +722,25 @@ export function DraftAgentStatusBar({
|
|||||||
onSelectThinkingOption,
|
onSelectThinkingOption,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: DraftAgentStatusBarProps) {
|
}: DraftAgentStatusBarProps) {
|
||||||
const isWeb = Platform.OS === 'web'
|
const isWeb = Platform.OS === "web";
|
||||||
|
|
||||||
const mappedModeOptions = useMemo<StatusOption[]>(() => {
|
const mappedModeOptions = useMemo<StatusOption[]>(() => {
|
||||||
if (modeOptions.length === 0) {
|
if (modeOptions.length === 0) {
|
||||||
return [{ id: '', label: 'Default' }]
|
return [{ id: "", label: "Default" }];
|
||||||
}
|
}
|
||||||
return modeOptions.map((mode) => ({
|
return modeOptions.map((mode) => ({
|
||||||
id: mode.id,
|
id: mode.id,
|
||||||
label: mode.label,
|
label: mode.label,
|
||||||
}))
|
}));
|
||||||
}, [modeOptions])
|
}, [modeOptions]);
|
||||||
|
|
||||||
const mappedThinkingOptions = useMemo<StatusOption[]>(() => {
|
const mappedThinkingOptions = useMemo<StatusOption[]>(() => {
|
||||||
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }))
|
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }));
|
||||||
}, [thinkingOptions])
|
}, [thinkingOptions]);
|
||||||
|
|
||||||
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || ''
|
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
|
||||||
const effectiveSelectedThinkingOption =
|
const effectiveSelectedThinkingOption =
|
||||||
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined
|
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
|
||||||
|
|
||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
return (
|
return (
|
||||||
@@ -753,17 +765,17 @@ export function DraftAgentStatusBar({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerOptions = providerDefinitions.map((definition) => ({
|
const providerOptions = providerDefinitions.map((definition) => ({
|
||||||
id: definition.id,
|
id: definition.id,
|
||||||
label: definition.label,
|
label: definition.label,
|
||||||
}))
|
}));
|
||||||
|
|
||||||
const modelOptions: StatusOption[] = [{ id: '', label: 'Auto' }]
|
const modelOptions: StatusOption[] = [{ id: "", label: "Auto" }];
|
||||||
for (const model of models) {
|
for (const model of models) {
|
||||||
modelOptions.push({ id: model.id, label: model.label })
|
modelOptions.push({ id: model.id, label: model.label });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -784,30 +796,30 @@ export function DraftAgentStatusBar({
|
|||||||
onSelectThinkingOption={onSelectThinkingOption}
|
onSelectThinkingOption={onSelectThinkingOption}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'flex-end',
|
alignItems: "flex-end",
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
},
|
},
|
||||||
modeBadge: {
|
modeBadge: {
|
||||||
height: 28,
|
height: 28,
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: "transparent",
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
borderRadius: theme.borderRadius['2xl'],
|
borderRadius: theme.borderRadius["2xl"],
|
||||||
},
|
},
|
||||||
modeIconBadge: {
|
modeIconBadge: {
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: "transparent",
|
||||||
borderRadius: theme.borderRadius.full,
|
borderRadius: theme.borderRadius.full,
|
||||||
},
|
},
|
||||||
modeBadgeHovered: {
|
modeBadgeHovered: {
|
||||||
@@ -831,11 +843,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
prefsButton: {
|
prefsButton: {
|
||||||
height: 28,
|
height: 28,
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
borderRadius: theme.borderRadius['2xl'],
|
borderRadius: theme.borderRadius["2xl"],
|
||||||
},
|
},
|
||||||
prefsButtonPressed: {
|
prefsButtonPressed: {
|
||||||
backgroundColor: theme.colors.surface0,
|
backgroundColor: theme.colors.surface0,
|
||||||
@@ -850,9 +862,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
sheetSelect: {
|
sheetSelect: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'space-between',
|
justifyContent: "space-between",
|
||||||
gap: theme.spacing[3],
|
gap: theme.spacing[3],
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
paddingVertical: theme.spacing[3],
|
paddingVertical: theme.spacing[3],
|
||||||
@@ -873,4 +885,4 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
fontWeight: theme.fontWeight.semibold,
|
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 {
|
export function getStatusSelectorHint(selector: ExplainedStatusSelector): string {
|
||||||
switch (selector) {
|
switch (selector) {
|
||||||
case 'thinking':
|
case "thinking":
|
||||||
return 'Thinking mode'
|
return "Thinking mode";
|
||||||
case 'model':
|
case "model":
|
||||||
return 'Change model'
|
return "Change model";
|
||||||
case 'mode':
|
case "mode":
|
||||||
return 'Change permission mode'
|
return "Change permission mode";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeModelId(modelId: string | null | undefined): string | null {
|
export function normalizeModelId(modelId: string | null | undefined): string | null {
|
||||||
const normalized = typeof modelId === 'string' ? modelId.trim() : ''
|
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
||||||
if (!normalized || normalized.toLowerCase() === 'default') {
|
if (!normalized || normalized.toLowerCase() === "default") {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
return normalized
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveAgentModelSelection(input: {
|
export function resolveAgentModelSelection(input: {
|
||||||
models: AgentModelDefinition[] | null
|
models: AgentModelDefinition[] | null;
|
||||||
runtimeModelId: string | null | undefined
|
runtimeModelId: string | null | undefined;
|
||||||
configuredModelId: string | null | undefined
|
configuredModelId: string | null | undefined;
|
||||||
explicitThinkingOptionId: string | null | undefined
|
explicitThinkingOptionId: string | null | undefined;
|
||||||
}) {
|
}) {
|
||||||
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input
|
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input;
|
||||||
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId)
|
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId);
|
||||||
const normalizedConfiguredModelId = normalizeModelId(configuredModelId)
|
const normalizedConfiguredModelId = normalizeModelId(configuredModelId);
|
||||||
const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId
|
const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId;
|
||||||
const selectedModel =
|
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 activeModelId = selectedModel?.id ?? preferredModelId ?? null;
|
||||||
const displayModel = selectedModel?.label ?? preferredModelId ?? 'Auto'
|
const displayModel = selectedModel?.label ?? preferredModelId ?? "Auto";
|
||||||
|
|
||||||
const thinkingOptions = selectedModel?.thinkingOptions ?? null
|
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
|
||||||
const selectedThinkingId =
|
const selectedThinkingId =
|
||||||
explicitThinkingOptionId && explicitThinkingOptionId !== 'default'
|
explicitThinkingOptionId && explicitThinkingOptionId !== "default"
|
||||||
? explicitThinkingOptionId
|
? explicitThinkingOptionId
|
||||||
: selectedModel?.defaultThinkingOptionId ?? null
|
: (selectedModel?.defaultThinkingOptionId ?? null);
|
||||||
const selectedThinking = thinkingOptions?.find((option) => option.id === selectedThinkingId) ?? null
|
const selectedThinking =
|
||||||
|
thinkingOptions?.find((option) => option.id === selectedThinkingId) ?? null;
|
||||||
const displayThinking =
|
const displayThinking =
|
||||||
selectedThinking?.label ??
|
selectedThinking?.label ??
|
||||||
(selectedThinkingId === 'default' ? 'Model default' : selectedThinkingId ?? 'auto')
|
(selectedThinkingId === "default" ? "Model default" : (selectedThinkingId ?? "auto"));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
selectedModel,
|
selectedModel,
|
||||||
@@ -54,5 +57,5 @@ export function resolveAgentModelSelection(input: {
|
|||||||
thinkingOptions,
|
thinkingOptions,
|
||||||
selectedThinkingId,
|
selectedThinkingId,
|
||||||
displayThinking,
|
displayThinking,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ function splitOrderedTail(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildAgentStreamRenderModel(
|
export function buildAgentStreamRenderModel(
|
||||||
input: BuildAgentStreamRenderModelInput
|
input: BuildAgentStreamRenderModelInput,
|
||||||
): AgentStreamRenderModel {
|
): AgentStreamRenderModel {
|
||||||
const strategy = resolveStreamRenderStrategy({
|
const strategy = resolveStreamRenderStrategy({
|
||||||
platform: input.platform === "web" ? "web" : "native",
|
platform: input.platform === "web" ? "web" : "native",
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ describe("resolveStreamRenderStrategy", () => {
|
|||||||
resolveBottomAnchorTransportBehavior({
|
resolveBottomAnchorTransportBehavior({
|
||||||
strategy,
|
strategy,
|
||||||
isViewportSettling: true,
|
isViewportSettling: true,
|
||||||
})
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
verificationDelayFrames: 4,
|
verificationDelayFrames: 4,
|
||||||
verificationRetryMode: "recheck",
|
verificationRetryMode: "recheck",
|
||||||
@@ -95,7 +95,7 @@ describe("resolveStreamRenderStrategy", () => {
|
|||||||
resolveBottomAnchorTransportBehavior({
|
resolveBottomAnchorTransportBehavior({
|
||||||
strategy,
|
strategy,
|
||||||
isViewportSettling: true,
|
isViewportSettling: true,
|
||||||
})
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
verificationDelayFrames: 0,
|
verificationDelayFrames: 0,
|
||||||
verificationRetryMode: "rescroll",
|
verificationRetryMode: "rescroll",
|
||||||
@@ -172,7 +172,7 @@ describe("neighbor and traversal semantics", () => {
|
|||||||
strategy: forward,
|
strategy: forward,
|
||||||
items: chronological,
|
items: chronological,
|
||||||
startIndex: forwardStartIndex,
|
startIndex: forwardStartIndex,
|
||||||
})
|
}),
|
||||||
).toBe("assistant-1\n\nassistant-2");
|
).toBe("assistant-1\n\nassistant-2");
|
||||||
|
|
||||||
const inverted = resolveStreamRenderStrategy({
|
const inverted = resolveStreamRenderStrategy({
|
||||||
@@ -189,7 +189,7 @@ describe("neighbor and traversal semantics", () => {
|
|||||||
strategy: inverted,
|
strategy: inverted,
|
||||||
items: invertedItems,
|
items: invertedItems,
|
||||||
startIndex: invertedStartIndex,
|
startIndex: invertedStartIndex,
|
||||||
})
|
}),
|
||||||
).toBe("assistant-1\n\nassistant-2");
|
).toBe("assistant-1\n\nassistant-2");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -206,7 +206,7 @@ describe("neighbor and traversal semantics", () => {
|
|||||||
items,
|
items,
|
||||||
index: 0,
|
index: 0,
|
||||||
relation: "above",
|
relation: "above",
|
||||||
})
|
}),
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
expect(
|
expect(
|
||||||
getStreamNeighborItem({
|
getStreamNeighborItem({
|
||||||
@@ -214,7 +214,7 @@ describe("neighbor and traversal semantics", () => {
|
|||||||
items,
|
items,
|
||||||
index: 0,
|
index: 0,
|
||||||
relation: "below",
|
relation: "below",
|
||||||
})
|
}),
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -233,7 +233,7 @@ describe("scroll/bottom calculations", () => {
|
|||||||
viewportHeight: 300,
|
viewportHeight: 300,
|
||||||
contentHeight: 1000,
|
contentHeight: 1000,
|
||||||
threshold: 24,
|
threshold: 24,
|
||||||
})
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
isNearBottomForStreamRenderStrategy({
|
isNearBottomForStreamRenderStrategy({
|
||||||
@@ -242,7 +242,7 @@ describe("scroll/bottom calculations", () => {
|
|||||||
viewportHeight: 300,
|
viewportHeight: 300,
|
||||||
contentHeight: 1000,
|
contentHeight: 1000,
|
||||||
threshold: 24,
|
threshold: 24,
|
||||||
})
|
}),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -259,7 +259,7 @@ describe("scroll/bottom calculations", () => {
|
|||||||
viewportHeight: 300,
|
viewportHeight: 300,
|
||||||
contentHeight: 1000,
|
contentHeight: 1000,
|
||||||
threshold: 24,
|
threshold: 24,
|
||||||
})
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
isNearBottomForStreamRenderStrategy({
|
isNearBottomForStreamRenderStrategy({
|
||||||
@@ -268,14 +268,14 @@ describe("scroll/bottom calculations", () => {
|
|||||||
viewportHeight: 300,
|
viewportHeight: 300,
|
||||||
contentHeight: 1000,
|
contentHeight: 1000,
|
||||||
threshold: 24,
|
threshold: 24,
|
||||||
})
|
}),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
expect(
|
expect(
|
||||||
getBottomOffsetForStreamRenderStrategy({
|
getBottomOffsetForStreamRenderStrategy({
|
||||||
strategy,
|
strategy,
|
||||||
viewportHeight: 300,
|
viewportHeight: 300,
|
||||||
contentHeight: 1000,
|
contentHeight: 1000,
|
||||||
})
|
}),
|
||||||
).toBe(0);
|
).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -290,7 +290,7 @@ describe("scroll/bottom calculations", () => {
|
|||||||
strategy,
|
strategy,
|
||||||
viewportHeight: 320,
|
viewportHeight: 320,
|
||||||
contentHeight: 1000,
|
contentHeight: 1000,
|
||||||
})
|
}),
|
||||||
).toBe(680);
|
).toBe(680);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -62,7 +62,7 @@ describe("findMountedWindowStart", () => {
|
|||||||
findMountedWindowStart({
|
findMountedWindowStart({
|
||||||
items,
|
items,
|
||||||
minMountedCount: 50,
|
minMountedCount: 50,
|
||||||
})
|
}),
|
||||||
).toBe(0);
|
).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ describe("findMountedWindowStart", () => {
|
|||||||
findMountedWindowStart({
|
findMountedWindowStart({
|
||||||
items,
|
items,
|
||||||
minMountedCount: 50,
|
minMountedCount: 50,
|
||||||
})
|
}),
|
||||||
).toBe(39);
|
).toBe(39);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -136,20 +136,16 @@ describe("web virtualization test overrides", () => {
|
|||||||
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
|
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
|
||||||
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: unknown;
|
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: unknown;
|
||||||
};
|
};
|
||||||
const previousThreshold =
|
const previousThreshold = globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
|
||||||
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
|
const previousMounted = globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
|
||||||
const previousMounted =
|
|
||||||
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
|
delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
|
||||||
delete globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
|
delete globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
|
||||||
expect(getWebPartialVirtualizationThreshold()).toBe(
|
expect(getWebPartialVirtualizationThreshold()).toBe(
|
||||||
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD
|
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
|
||||||
);
|
|
||||||
expect(getWebMountedRecentStreamItems()).toBe(
|
|
||||||
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS
|
|
||||||
);
|
);
|
||||||
|
expect(getWebMountedRecentStreamItems()).toBe(DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS);
|
||||||
|
|
||||||
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 6;
|
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 6;
|
||||||
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = 4;
|
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = 4;
|
||||||
@@ -159,14 +155,12 @@ describe("web virtualization test overrides", () => {
|
|||||||
if (previousThreshold === undefined) {
|
if (previousThreshold === undefined) {
|
||||||
delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
|
delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
|
||||||
} else {
|
} else {
|
||||||
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD =
|
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = previousThreshold;
|
||||||
previousThreshold;
|
|
||||||
}
|
}
|
||||||
if (previousMounted === undefined) {
|
if (previousMounted === undefined) {
|
||||||
delete globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
|
delete globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
|
||||||
} else {
|
} else {
|
||||||
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS =
|
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = previousMounted;
|
||||||
previousMounted;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,16 +18,14 @@ function readPositiveIntegerOverride(value: unknown): number | null {
|
|||||||
|
|
||||||
export function getWebPartialVirtualizationThreshold(): number {
|
export function getWebPartialVirtualizationThreshold(): number {
|
||||||
const override = readPositiveIntegerOverride(
|
const override = readPositiveIntegerOverride(
|
||||||
(globalThis as BottomAnchorE2ETestGlobals)
|
(globalThis as BottomAnchorE2ETestGlobals).__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
|
||||||
.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD
|
|
||||||
);
|
);
|
||||||
return override ?? DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
|
return override ?? DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWebMountedRecentStreamItems(): number {
|
export function getWebMountedRecentStreamItems(): number {
|
||||||
const override = readPositiveIntegerOverride(
|
const override = readPositiveIntegerOverride(
|
||||||
(globalThis as BottomAnchorE2ETestGlobals)
|
(globalThis as BottomAnchorE2ETestGlobals).__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS,
|
||||||
.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS
|
|
||||||
);
|
);
|
||||||
return override ?? DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS;
|
return override ?? DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from "react";
|
||||||
import { View, Text } from 'react-native'
|
import { View, Text } from "react-native";
|
||||||
import { StyleSheet } from 'react-native-unistyles'
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import Animated from 'react-native-reanimated'
|
import Animated from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from '@/constants/layout'
|
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from '@/runtime/host-runtime'
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from "@/components/ui/button";
|
||||||
import type { Theme } from '@/styles/theme'
|
import type { Theme } from "@/styles/theme";
|
||||||
|
|
||||||
interface ArchivedAgentCalloutProps {
|
interface ArchivedAgentCalloutProps {
|
||||||
serverId: string
|
serverId: string;
|
||||||
agentId: string
|
agentId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCalloutProps) {
|
export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCalloutProps) {
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets();
|
||||||
const client = useHostRuntimeClient(serverId)
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId)
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
const [isUnarchiving, setIsUnarchiving] = useState(false)
|
const [isUnarchiving, setIsUnarchiving] = useState(false);
|
||||||
|
|
||||||
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: 'translate' })
|
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: "translate" });
|
||||||
|
|
||||||
async function handleUnarchive() {
|
async function handleUnarchive() {
|
||||||
if (!client || !isConnected || isUnarchiving) return
|
if (!client || !isConnected || isUnarchiving) return;
|
||||||
setIsUnarchiving(true)
|
setIsUnarchiving(true);
|
||||||
try {
|
try {
|
||||||
await client.refreshAgent(agentId)
|
await client.refreshAgent(agentId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ArchivedAgentCallout] Failed to unarchive agent:', error)
|
console.error("[ArchivedAgentCallout] Failed to unarchive agent:", error);
|
||||||
setIsUnarchiving(false)
|
setIsUnarchiving(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,36 +53,36 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create(((theme: Theme) => ({
|
const styles = StyleSheet.create(((theme: Theme) => ({
|
||||||
container: {
|
container: {
|
||||||
flexDirection: 'column',
|
flexDirection: "column",
|
||||||
position: 'relative',
|
position: "relative",
|
||||||
},
|
},
|
||||||
inputAreaContainer: {
|
inputAreaContainer: {
|
||||||
position: 'relative',
|
position: "relative",
|
||||||
minHeight: FOOTER_HEIGHT,
|
minHeight: FOOTER_HEIGHT,
|
||||||
marginHorizontal: 'auto',
|
marginHorizontal: "auto",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
width: '100%',
|
width: "100%",
|
||||||
overflow: 'visible',
|
overflow: "visible",
|
||||||
padding: theme.spacing[4],
|
padding: theme.spacing[4],
|
||||||
},
|
},
|
||||||
inputAreaContent: {
|
inputAreaContent: {
|
||||||
width: '100%',
|
width: "100%",
|
||||||
maxWidth: MAX_CONTENT_WIDTH,
|
maxWidth: MAX_CONTENT_WIDTH,
|
||||||
},
|
},
|
||||||
callout: {
|
callout: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
gap: theme.spacing[3],
|
gap: theme.spacing[3],
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
borderWidth: theme.borderWidth[1],
|
borderWidth: theme.borderWidth[1],
|
||||||
borderColor: theme.colors.borderAccent,
|
borderColor: theme.colors.borderAccent,
|
||||||
borderRadius: theme.borderRadius['2xl'],
|
borderRadius: theme.borderRadius["2xl"],
|
||||||
paddingVertical: {
|
paddingVertical: {
|
||||||
xs: theme.spacing[3],
|
xs: theme.spacing[3],
|
||||||
md: theme.spacing[4],
|
md: theme.spacing[4],
|
||||||
@@ -96,4 +96,4 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
},
|
},
|
||||||
})) as any) as Record<string, any>
|
})) as any) as Record<string, any>;
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import {
|
import { View, Text, ScrollView, Pressable, Modal } from "react-native";
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
ScrollView,
|
|
||||||
Pressable,
|
|
||||||
Modal,
|
|
||||||
} from "react-native";
|
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
@@ -185,15 +179,8 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.headerActions}>
|
<View style={styles.headerActions}>
|
||||||
<View
|
<View style={[styles.badge, typeBadgeStyles[artifact.type]]}>
|
||||||
style={[
|
<Text style={styles.badgeText}>{artifact.type.toUpperCase()}</Text>
|
||||||
styles.badge,
|
|
||||||
typeBadgeStyles[artifact.type],
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={styles.badgeText}>
|
|
||||||
{artifact.type.toUpperCase()}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<Pressable onPress={onClose} style={styles.closeButton}>
|
<Pressable onPress={onClose} style={styles.closeButton}>
|
||||||
<Text style={styles.closeButtonText}>×</Text>
|
<Text style={styles.closeButtonText}>×</Text>
|
||||||
@@ -208,12 +195,8 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
|
|||||||
>
|
>
|
||||||
{artifact.type === "image" ? (
|
{artifact.type === "image" ? (
|
||||||
<View style={styles.imagePlaceholder}>
|
<View style={styles.imagePlaceholder}>
|
||||||
<Text style={styles.imagePlaceholderText}>
|
<Text style={styles.imagePlaceholderText}>Image viewing not yet implemented</Text>
|
||||||
Image viewing not yet implemented
|
<Text style={styles.imagePlaceholderSubtext}>Base64 image data received</Text>
|
||||||
</Text>
|
|
||||||
<Text style={styles.imagePlaceholderSubtext}>
|
|
||||||
Base64 image data received
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.codeContainer}>
|
<View style={styles.codeContainer}>
|
||||||
@@ -244,9 +227,7 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
|
|||||||
</View>
|
</View>
|
||||||
<View style={styles.metadataRow}>
|
<View style={styles.metadataRow}>
|
||||||
<Text style={styles.metadataLabel}>Size:</Text>
|
<Text style={styles.metadataLabel}>Size:</Text>
|
||||||
<Text style={styles.metadataValue}>
|
<Text style={styles.metadataValue}>{content.length.toLocaleString()} characters</Text>
|
||||||
{content.length.toLocaleString()} characters
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -49,7 +49,11 @@ function formatDuration(duration?: number): string | null {
|
|||||||
return `${seconds.toFixed(seconds >= 10 ? 0 : 1)} s`;
|
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 { theme } = useUnistyles();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
@@ -115,11 +119,12 @@ export function AudioDebugNotice({ info, onDismiss, title = "Dictation Debug" }:
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{info.debugRecordingPath ? (
|
{info.debugRecordingPath ? (
|
||||||
<Pressable style={styles.pathRow} onPress={handleCopyPath} accessibilityLabel="Copy raw audio path">
|
<Pressable
|
||||||
<Text
|
style={styles.pathRow}
|
||||||
numberOfLines={2}
|
onPress={handleCopyPath}
|
||||||
style={[styles.pathText, { color: theme.colors.foreground }]}
|
accessibilityLabel="Copy raw audio path"
|
||||||
>
|
>
|
||||||
|
<Text numberOfLines={2} style={[styles.pathText, { color: theme.colors.foreground }]}>
|
||||||
{info.debugRecordingPath}
|
{info.debugRecordingPath}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={[styles.copyPill, { backgroundColor: theme.colors.primary }]}>
|
<View style={[styles.copyPill, { backgroundColor: theme.colors.primary }]}>
|
||||||
@@ -137,9 +142,7 @@ export function AudioDebugNotice({ info, onDismiss, title = "Dictation Debug" }:
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{stats ? (
|
{stats ? (
|
||||||
<Text style={[styles.stats, { color: theme.colors.foregroundMuted }]}>
|
<Text style={[styles.stats, { color: theme.colors.foregroundMuted }]}>{stats}</Text>
|
||||||
{stats}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { View, Text, Pressable, Platform } from 'react-native'
|
import { View, Text, Pressable, Platform } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { ArrowLeft, Check, ChevronDown, ChevronRight } from 'lucide-react-native'
|
import { ArrowLeft, Check, ChevronDown, ChevronRight } from "lucide-react-native";
|
||||||
import type { AgentModelDefinition, AgentProvider } from '@server/server/agent/agent-sdk-types'
|
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||||
import type { AgentProviderDefinition } from '@server/server/agent/provider-manifest'
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
import { Combobox, ComboboxItem, SearchInput } from '@/components/ui/combobox'
|
import { Combobox, ComboboxItem, SearchInput } from "@/components/ui/combobox";
|
||||||
import { getProviderIcon } from '@/components/provider-icons'
|
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 {
|
interface CombinedModelSelectorProps {
|
||||||
providerDefinitions: AgentProviderDefinition[]
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
allProviderModels: Map<string, AgentModelDefinition[]>
|
allProviderModels: Map<string, AgentModelDefinition[]>;
|
||||||
selectedProvider: string
|
selectedProvider: string;
|
||||||
selectedModel: string
|
selectedModel: string;
|
||||||
onSelect: (provider: AgentProvider, modelId: string) => void
|
onSelect: (provider: AgentProvider, modelId: string) => void;
|
||||||
isLoading: boolean
|
isLoading: boolean;
|
||||||
disabled?: boolean
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CombinedModelSelector({
|
export function CombinedModelSelector({
|
||||||
@@ -30,43 +30,46 @@ export function CombinedModelSelector({
|
|||||||
isLoading,
|
isLoading,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: CombinedModelSelectorProps) {
|
}: CombinedModelSelectorProps) {
|
||||||
const { theme } = useUnistyles()
|
const { theme } = useUnistyles();
|
||||||
const anchorRef = useRef<View>(null)
|
const anchorRef = useRef<View>(null);
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [view, setView] = useState<'groups' | DrillDownView>('groups')
|
const [view, setView] = useState<"groups" | DrillDownView>("groups");
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
const handleOpenChange = useCallback((open: boolean) => {
|
const handleOpenChange = useCallback(
|
||||||
setIsOpen(open)
|
(open: boolean) => {
|
||||||
if (open) {
|
setIsOpen(open);
|
||||||
const models = allProviderModels.get(selectedProvider)
|
if (open) {
|
||||||
if (models && models.length > INLINE_MODEL_THRESHOLD) {
|
const models = allProviderModels.get(selectedProvider);
|
||||||
setView({ provider: selectedProvider })
|
if (models && models.length > INLINE_MODEL_THRESHOLD) {
|
||||||
|
setView({ provider: selectedProvider });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setView("groups");
|
||||||
|
setSearchQuery("");
|
||||||
}
|
}
|
||||||
} else {
|
},
|
||||||
setView('groups')
|
[allProviderModels, selectedProvider],
|
||||||
setSearchQuery('')
|
);
|
||||||
}
|
|
||||||
}, [allProviderModels, selectedProvider])
|
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(provider: string, modelId: string) => {
|
(provider: string, modelId: string) => {
|
||||||
onSelect(provider as AgentProvider, modelId)
|
onSelect(provider as AgentProvider, modelId);
|
||||||
setIsOpen(false)
|
setIsOpen(false);
|
||||||
setView('groups')
|
setView("groups");
|
||||||
setSearchQuery('')
|
setSearchQuery("");
|
||||||
},
|
},
|
||||||
[onSelect]
|
[onSelect],
|
||||||
)
|
);
|
||||||
|
|
||||||
const ProviderIcon = getProviderIcon(selectedProvider)
|
const ProviderIcon = getProviderIcon(selectedProvider);
|
||||||
|
|
||||||
const selectedModelLabel = useMemo(() => {
|
const selectedModelLabel = useMemo(() => {
|
||||||
const models = allProviderModels.get(selectedProvider)
|
const models = allProviderModels.get(selectedProvider);
|
||||||
if (!models) return isLoading ? 'Loading...' : 'Auto'
|
if (!models) return isLoading ? "Loading..." : "Auto";
|
||||||
const model = models.find((m) => m.id === selectedModel)
|
const model = models.find((m) => m.id === selectedModel);
|
||||||
return model?.label ?? 'Auto'
|
return model?.label ?? "Auto";
|
||||||
}, [allProviderModels, selectedProvider, selectedModel, isLoading])
|
}, [allProviderModels, selectedProvider, selectedModel, isLoading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -99,7 +102,7 @@ export function CombinedModelSelector({
|
|||||||
desktopPlacement="top-start"
|
desktopPlacement="top-start"
|
||||||
title="Select model"
|
title="Select model"
|
||||||
>
|
>
|
||||||
{view === 'groups' ? (
|
{view === "groups" ? (
|
||||||
<GroupsView
|
<GroupsView
|
||||||
providerDefinitions={providerDefinitions}
|
providerDefinitions={providerDefinitions}
|
||||||
allProviderModels={allProviderModels}
|
allProviderModels={allProviderModels}
|
||||||
@@ -107,8 +110,8 @@ export function CombinedModelSelector({
|
|||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onDrillDown={(provider) => {
|
onDrillDown={(provider) => {
|
||||||
setView({ provider })
|
setView({ provider });
|
||||||
setSearchQuery('')
|
setSearchQuery("");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -122,14 +125,14 @@ export function CombinedModelSelector({
|
|||||||
onSearchChange={setSearchQuery}
|
onSearchChange={setSearchQuery}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onBack={() => {
|
onBack={() => {
|
||||||
setView('groups')
|
setView("groups");
|
||||||
setSearchQuery('')
|
setSearchQuery("");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Combobox>
|
</Combobox>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GroupsView({
|
function GroupsView({
|
||||||
@@ -140,21 +143,21 @@ function GroupsView({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onDrillDown,
|
onDrillDown,
|
||||||
}: {
|
}: {
|
||||||
providerDefinitions: AgentProviderDefinition[]
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
allProviderModels: Map<string, AgentModelDefinition[]>
|
allProviderModels: Map<string, AgentModelDefinition[]>;
|
||||||
selectedProvider: string
|
selectedProvider: string;
|
||||||
selectedModel: string
|
selectedModel: string;
|
||||||
onSelect: (provider: string, modelId: string) => void
|
onSelect: (provider: string, modelId: string) => void;
|
||||||
onDrillDown: (provider: string) => void
|
onDrillDown: (provider: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles()
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
{providerDefinitions.map((def, index) => {
|
{providerDefinitions.map((def, index) => {
|
||||||
const models = allProviderModels.get(def.id) ?? []
|
const models = allProviderModels.get(def.id) ?? [];
|
||||||
const isInline = models.length <= INLINE_MODEL_THRESHOLD
|
const isInline = models.length <= INLINE_MODEL_THRESHOLD;
|
||||||
const ProvIcon = getProviderIcon(def.id)
|
const ProvIcon = getProviderIcon(def.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View key={def.id}>
|
<View key={def.id}>
|
||||||
@@ -193,10 +196,10 @@ function GroupsView({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DrillDownModelView({
|
function DrillDownModelView({
|
||||||
@@ -210,27 +213,27 @@ function DrillDownModelView({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onBack,
|
onBack,
|
||||||
}: {
|
}: {
|
||||||
provider: string
|
provider: string;
|
||||||
providerDefinitions: AgentProviderDefinition[]
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
models: AgentModelDefinition[]
|
models: AgentModelDefinition[];
|
||||||
selectedProvider: string
|
selectedProvider: string;
|
||||||
selectedModel: string
|
selectedModel: string;
|
||||||
searchQuery: string
|
searchQuery: string;
|
||||||
onSearchChange: (query: string) => void
|
onSearchChange: (query: string) => void;
|
||||||
onSelect: (provider: string, modelId: string) => void
|
onSelect: (provider: string, modelId: string) => void;
|
||||||
onBack: () => void
|
onBack: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles()
|
const { theme } = useUnistyles();
|
||||||
const ProvIcon = getProviderIcon(provider)
|
const ProvIcon = getProviderIcon(provider);
|
||||||
const providerLabel = providerDefinitions.find((d) => d.id === provider)?.label ?? provider
|
const providerLabel = providerDefinitions.find((d) => d.id === provider)?.label ?? provider;
|
||||||
|
|
||||||
const filteredModels = useMemo(() => {
|
const filteredModels = useMemo(() => {
|
||||||
if (!searchQuery.trim()) return models
|
if (!searchQuery.trim()) return models;
|
||||||
const q = searchQuery.toLowerCase()
|
const q = searchQuery.toLowerCase();
|
||||||
return models.filter(
|
return models.filter(
|
||||||
(m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q)
|
(m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q),
|
||||||
)
|
);
|
||||||
}, [models, searchQuery])
|
}, [models, searchQuery]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
@@ -251,7 +254,7 @@ function DrillDownModelView({
|
|||||||
placeholder="Search models..."
|
placeholder="Search models..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChangeText={onSearchChange}
|
onChangeText={onSearchChange}
|
||||||
autoFocus={Platform.OS === 'web'}
|
autoFocus={Platform.OS === "web"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{filteredModels.map((model) => (
|
{filteredModels.map((model) => (
|
||||||
@@ -270,18 +273,18 @@ function DrillDownModelView({
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
trigger: {
|
trigger: {
|
||||||
height: 28,
|
height: 28,
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: "transparent",
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
borderRadius: theme.borderRadius['2xl'],
|
borderRadius: theme.borderRadius["2xl"],
|
||||||
},
|
},
|
||||||
triggerHovered: {
|
triggerHovered: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
@@ -303,8 +306,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
marginVertical: theme.spacing[1],
|
marginVertical: theme.spacing[1],
|
||||||
},
|
},
|
||||||
sectionHeading: {
|
sectionHeading: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
@@ -315,8 +318,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
drillDownRow: {
|
drillDownRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
@@ -334,8 +337,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
drillDownTrailing: {
|
drillDownTrailing: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
},
|
},
|
||||||
drillDownCount: {
|
drillDownCount: {
|
||||||
@@ -343,8 +346,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
backButton: {
|
backButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
@@ -363,10 +366,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
emptyState: {
|
emptyState: {
|
||||||
paddingVertical: theme.spacing[4],
|
paddingVertical: theme.spacing[4],
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
emptyStateText: {
|
emptyStateText: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
}))
|
}));
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
import {
|
import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native";
|
||||||
Modal,
|
|
||||||
Pressable,
|
|
||||||
ScrollView,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
View,
|
|
||||||
Platform,
|
|
||||||
} from "react-native";
|
|
||||||
import { memo, useEffect, useRef, type ReactNode } from "react";
|
import { memo, useEffect, useRef, type ReactNode } from "react";
|
||||||
import { Plus, Settings } from "lucide-react-native";
|
import { Plus, Settings } from "lucide-react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -54,16 +46,8 @@ const CommandCenterRow = memo(function CommandCenterRow({
|
|||||||
|
|
||||||
export function CommandCenter() {
|
export function CommandCenter() {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const {
|
const { open, inputRef, query, setQuery, activeIndex, items, handleClose, handleSelectItem } =
|
||||||
open,
|
useCommandCenter();
|
||||||
inputRef,
|
|
||||||
query,
|
|
||||||
setQuery,
|
|
||||||
activeIndex,
|
|
||||||
items,
|
|
||||||
handleClose,
|
|
||||||
handleSelectItem,
|
|
||||||
} = useCommandCenter();
|
|
||||||
|
|
||||||
const rowRefs = useRef<Map<number, View>>(new Map());
|
const rowRefs = useRef<Map<number, View>>(new Map());
|
||||||
const resultsRef = useRef<ScrollView>(null);
|
const resultsRef = useRef<ScrollView>(null);
|
||||||
@@ -77,11 +61,13 @@ export function CommandCenter() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const scrollNode =
|
const scrollNode =
|
||||||
(resultsRef.current as
|
(
|
||||||
| (ScrollView & {
|
resultsRef.current as
|
||||||
getScrollableNode?: () => HTMLElement | null;
|
| (ScrollView & {
|
||||||
})
|
getScrollableNode?: () => HTMLElement | null;
|
||||||
| null)?.getScrollableNode?.() ?? null;
|
})
|
||||||
|
| null
|
||||||
|
)?.getScrollableNode?.() ?? null;
|
||||||
const rowEl = row as unknown as HTMLElement;
|
const rowEl = row as unknown as HTMLElement;
|
||||||
|
|
||||||
if (!scrollNode) {
|
if (!scrollNode) {
|
||||||
@@ -110,18 +96,16 @@ export function CommandCenter() {
|
|||||||
const agentItems = items.filter((item) => item.kind === "agent");
|
const agentItems = items.filter((item) => item.kind === "agent");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal visible={open} transparent animationType="fade" onRequestClose={handleClose}>
|
||||||
visible={open}
|
|
||||||
transparent
|
|
||||||
animationType="fade"
|
|
||||||
onRequestClose={handleClose}
|
|
||||||
>
|
|
||||||
<View style={styles.overlay}>
|
<View style={styles.overlay}>
|
||||||
<Pressable style={styles.backdrop} onPress={handleClose} />
|
<Pressable style={styles.backdrop} onPress={handleClose} />
|
||||||
|
|
||||||
<View
|
<View
|
||||||
testID="command-center-panel"
|
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 }]}>
|
<View style={[styles.header, { borderBottomColor: theme.colors.border }]}>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -131,10 +115,7 @@ export function CommandCenter() {
|
|||||||
onChangeText={setQuery}
|
onChangeText={setQuery}
|
||||||
placeholder="Type a command or search agents..."
|
placeholder="Type a command or search agents..."
|
||||||
placeholderTextColor={theme.colors.foregroundMuted}
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
style={[
|
style={[styles.input, { color: theme.colors.foreground }]}
|
||||||
styles.input,
|
|
||||||
{ color: theme.colors.foreground },
|
|
||||||
]}
|
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -164,11 +145,7 @@ export function CommandCenter() {
|
|||||||
const action = item.action;
|
const action = item.action;
|
||||||
const actionIcon =
|
const actionIcon =
|
||||||
action.icon === "plus" ? (
|
action.icon === "plus" ? (
|
||||||
<Plus
|
<Plus size={16} strokeWidth={2.4} color={theme.colors.foregroundMuted} />
|
||||||
size={16}
|
|
||||||
strokeWidth={2.4}
|
|
||||||
color={theme.colors.foregroundMuted}
|
|
||||||
/>
|
|
||||||
) : action.icon === "settings" ? (
|
) : action.icon === "settings" ? (
|
||||||
<Settings
|
<Settings
|
||||||
size={16}
|
size={16}
|
||||||
|
|||||||
@@ -40,18 +40,8 @@ export function ConnectionStatus({ isConnected }: ConnectionStatusProps) {
|
|||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<View
|
<View style={[styles.dot, isConnected ? styles.dotConnected : styles.dotDisconnected]} />
|
||||||
style={[
|
<Text style={[styles.text, isConnected ? styles.textConnected : styles.textDisconnected]}>
|
||||||
styles.dot,
|
|
||||||
isConnected ? styles.dotConnected : styles.dotDisconnected,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.text,
|
|
||||||
isConnected ? styles.textConnected : styles.textDisconnected,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{isConnected ? "Connected" : "Disconnected"}
|
{isConnected ? "Connected" : "Disconnected"}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -24,9 +24,7 @@ interface DictationControlsProps {
|
|||||||
function formatDuration(seconds: number): string {
|
function formatDuration(seconds: number): string {
|
||||||
const mins = Math.floor(seconds / 60);
|
const mins = Math.floor(seconds / 60);
|
||||||
const secs = seconds % 60;
|
const secs = seconds % 60;
|
||||||
return `${mins.toString().padStart(2, "0")}:${secs
|
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
||||||
.toString()
|
|
||||||
.padStart(2, "0")}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DictationControls({
|
export function DictationControls({
|
||||||
@@ -66,12 +64,7 @@ export function DictationControls({
|
|||||||
return (
|
return (
|
||||||
<View style={styles.activeContainer}>
|
<View style={styles.activeContainer}>
|
||||||
<View style={styles.meterWrapper}>
|
<View style={styles.meterWrapper}>
|
||||||
<VolumeMeter
|
<VolumeMeter volume={volume} isMuted={false} isSpeaking={false} orientation="horizontal" />
|
||||||
volume={volume}
|
|
||||||
isMuted={false}
|
|
||||||
isSpeaking={false}
|
|
||||||
orientation="horizontal"
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
<Text style={[styles.timerText, { color: theme.colors.foreground }]}>
|
<Text style={[styles.timerText, { color: theme.colors.foreground }]}>
|
||||||
{formatDuration(duration)}
|
{formatDuration(duration)}
|
||||||
@@ -152,12 +145,7 @@ export function DictationOverlay({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View style={[overlayStyles.container, { backgroundColor: theme.colors.accent }]}>
|
||||||
style={[
|
|
||||||
overlayStyles.container,
|
|
||||||
{ backgroundColor: theme.colors.accent },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleCancel}
|
onPress={handleCancel}
|
||||||
disabled={actionsDisabled && !isFailed}
|
disabled={actionsDisabled && !isFailed}
|
||||||
@@ -180,12 +168,7 @@ export function DictationOverlay({
|
|||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
color={theme.colors.palette.white}
|
color={theme.colors.palette.white}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text style={[overlayStyles.timerText, { color: theme.colors.palette.white }]}>
|
||||||
style={[
|
|
||||||
overlayStyles.timerText,
|
|
||||||
{ color: theme.colors.palette.white },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{formatDuration(duration)}
|
{formatDuration(duration)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -205,26 +188,16 @@ export function DictationOverlay({
|
|||||||
<View style={overlayStyles.actionButtonsContainer}>
|
<View style={overlayStyles.actionButtonsContainer}>
|
||||||
{actionsDisabled ? (
|
{actionsDisabled ? (
|
||||||
<View style={overlayStyles.loadingContainer}>
|
<View style={overlayStyles.loadingContainer}>
|
||||||
<ActivityIndicator
|
<ActivityIndicator size="small" color={theme.colors.palette.white} />
|
||||||
size="small"
|
|
||||||
color={theme.colors.palette.white}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
) : isFailed ? (
|
) : isFailed ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onRetry}
|
onPress={onRetry}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Retry dictation"
|
accessibilityLabel="Retry dictation"
|
||||||
style={[
|
style={[overlayStyles.actionButton, { backgroundColor: theme.colors.palette.white }]}
|
||||||
overlayStyles.actionButton,
|
|
||||||
{ backgroundColor: theme.colors.palette.white },
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
<RefreshCcw
|
<RefreshCcw size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
|
||||||
size={theme.iconSize.lg}
|
|
||||||
color={theme.colors.accent}
|
|
||||||
strokeWidth={2.5}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -232,10 +205,7 @@ export function DictationOverlay({
|
|||||||
onPress={onAccept}
|
onPress={onAccept}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Insert transcription"
|
accessibilityLabel="Insert transcription"
|
||||||
style={[
|
style={[overlayStyles.actionButton, { backgroundColor: "rgba(255, 255, 255, 0.25)" }]}
|
||||||
overlayStyles.actionButton,
|
|
||||||
{ backgroundColor: "rgba(255, 255, 255, 0.25)" },
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
<Pencil
|
<Pencil
|
||||||
size={theme.iconSize.lg}
|
size={theme.iconSize.lg}
|
||||||
@@ -247,16 +217,9 @@ export function DictationOverlay({
|
|||||||
onPress={onAcceptAndSend}
|
onPress={onAcceptAndSend}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Insert transcription and send"
|
accessibilityLabel="Insert transcription and send"
|
||||||
style={[
|
style={[overlayStyles.actionButton, { backgroundColor: theme.colors.palette.white }]}
|
||||||
overlayStyles.actionButton,
|
|
||||||
{ backgroundColor: theme.colors.palette.white },
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
<ArrowUp
|
<ArrowUp size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
|
||||||
size={theme.iconSize.lg}
|
|
||||||
color={theme.colors.accent}
|
|
||||||
strokeWidth={2.5}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { View, Text, Pressable } from "react-native";
|
import { View, Text, Pressable } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
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";
|
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 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 (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -83,11 +92,7 @@ export function DictationStatusNotice({
|
|||||||
|
|
||||||
{(meta || (actionLabel && onAction)) && (
|
{(meta || (actionLabel && onAction)) && (
|
||||||
<View style={styles.actionsRow}>
|
<View style={styles.actionsRow}>
|
||||||
{meta ? (
|
{meta ? <Text style={[styles.meta, { color: secondaryColor }]}>{meta}</Text> : <View />}
|
||||||
<Text style={[styles.meta, { color: secondaryColor }]}>{meta}</Text>
|
|
||||||
) : (
|
|
||||||
<View />
|
|
||||||
)}
|
|
||||||
{actionLabel && onAction ? (
|
{actionLabel && onAction ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={[
|
style={[
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ export function DiffViewer({
|
|||||||
key={segIdx}
|
key={segIdx}
|
||||||
style={[
|
style={[
|
||||||
line.type === "add" ? styles.addText : styles.removeText,
|
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}
|
{segment.text}
|
||||||
|
|||||||
@@ -41,7 +41,10 @@ export function DownloadToast() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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}>
|
<View style={styles.toast}>
|
||||||
{activeDownload.status === "downloading" ? (
|
{activeDownload.status === "downloading" ? (
|
||||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
||||||
@@ -61,7 +64,7 @@ export function DownloadToast() {
|
|||||||
: "Starting..."
|
: "Starting..."
|
||||||
: activeDownload.status === "complete"
|
: activeDownload.status === "complete"
|
||||||
? "Download complete"
|
? "Download complete"
|
||||||
: activeDownload.message ?? "Download failed"}
|
: (activeDownload.message ?? "Download failed")}
|
||||||
</Text>
|
</Text>
|
||||||
{activeDownload.status === "downloading" && activeDownload.progress && (
|
{activeDownload.status === "downloading" && activeDownload.progress && (
|
||||||
<View style={styles.progressBar}>
|
<View style={styles.progressBar}>
|
||||||
|
|||||||
@@ -5,10 +5,7 @@ import DraggableFlatList, {
|
|||||||
type RenderItemParams,
|
type RenderItemParams,
|
||||||
} from "react-native-draggable-flatlist";
|
} from "react-native-draggable-flatlist";
|
||||||
import { useUnistyles } from "react-native-unistyles";
|
import { useUnistyles } from "react-native-unistyles";
|
||||||
import type {
|
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
|
||||||
DraggableListProps,
|
|
||||||
DraggableRenderItemInfo,
|
|
||||||
} from "./draggable-list.types";
|
|
||||||
|
|
||||||
export type { DraggableListProps, DraggableRenderItemInfo };
|
export type { DraggableListProps, DraggableRenderItemInfo };
|
||||||
|
|
||||||
@@ -53,7 +50,7 @@ export function DraggableList<T>({
|
|||||||
};
|
};
|
||||||
return renderItem(info);
|
return renderItem(info);
|
||||||
},
|
},
|
||||||
[renderItem]
|
[renderItem],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
const handleDragEnd = useCallback(
|
||||||
@@ -61,7 +58,7 @@ export function DraggableList<T>({
|
|||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
onDragEnd(newData);
|
onDragEnd(newData);
|
||||||
},
|
},
|
||||||
[onDragEnd]
|
[onDragEnd],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragBegin = useCallback(() => {
|
const handleDragBegin = useCallback(() => {
|
||||||
@@ -74,12 +71,11 @@ export function DraggableList<T>({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const showRefreshControl = Boolean(onRefresh) && (!isDragging || Boolean(refreshing));
|
const showRefreshControl = Boolean(onRefresh) && (!isDragging || Boolean(refreshing));
|
||||||
const resolvedContainerStyle =
|
const resolvedContainerStyle = containerStyle ?? (scrollEnabled ? { flex: 1 } : undefined);
|
||||||
containerStyle ?? (scrollEnabled ? { flex: 1 } : undefined);
|
|
||||||
const shouldShowRefreshControl = showRefreshControl && !nestable;
|
const shouldShowRefreshControl = showRefreshControl && !nestable;
|
||||||
const ListComponent: typeof DraggableFlatList = (nestable
|
const ListComponent: typeof DraggableFlatList = (
|
||||||
? (NestableDraggableFlatList as any)
|
nestable ? (NestableDraggableFlatList as any) : DraggableFlatList
|
||||||
: DraggableFlatList) as any;
|
) as any;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListComponent
|
<ListComponent
|
||||||
|
|||||||
@@ -19,14 +19,8 @@ import {
|
|||||||
arrayMove,
|
arrayMove,
|
||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import type {
|
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
|
||||||
DraggableListProps,
|
import { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } from "./web-desktop-scrollbar";
|
||||||
DraggableRenderItemInfo,
|
|
||||||
} from "./draggable-list.types";
|
|
||||||
import {
|
|
||||||
WebDesktopScrollbarOverlay,
|
|
||||||
useWebDesktopScrollbarMetrics,
|
|
||||||
} from "./web-desktop-scrollbar";
|
|
||||||
|
|
||||||
export type { DraggableListProps, DraggableRenderItemInfo };
|
export type { DraggableListProps, DraggableRenderItemInfo };
|
||||||
|
|
||||||
@@ -79,7 +73,7 @@ function SortableItem<T>({
|
|||||||
// the "ghost" stretches. Keep the dragged item's size stable by zeroing
|
// the "ghost" stretches. Keep the dragged item's size stable by zeroing
|
||||||
// out the dnd-kit scaling component.
|
// out the dnd-kit scaling component.
|
||||||
const baseTransform = CSS.Transform.toString(
|
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 scaleTransform = isDragging ? "scale(1.02)" : "";
|
||||||
const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
|
const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
|
||||||
@@ -100,9 +94,7 @@ function SortableItem<T>({
|
|||||||
? {
|
? {
|
||||||
attributes: attributes as unknown as Record<string, unknown>,
|
attributes: attributes as unknown as Record<string, unknown>,
|
||||||
listeners: listeners as unknown as Record<string, unknown>,
|
listeners: listeners as unknown as Record<string, unknown>,
|
||||||
setActivatorNodeRef: setActivatorNodeRef as unknown as (
|
setActivatorNodeRef: setActivatorNodeRef as unknown as (node: unknown) => void,
|
||||||
node: unknown
|
|
||||||
) => void,
|
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
@@ -152,7 +144,7 @@ export function DraggableList<T>({
|
|||||||
}),
|
}),
|
||||||
useSensor(KeyboardSensor, {
|
useSensor(KeyboardSensor, {
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragStart = useCallback(
|
const handleDragStart = useCallback(
|
||||||
@@ -161,7 +153,7 @@ export function DraggableList<T>({
|
|||||||
setActiveId(String(event.active.id));
|
setActiveId(String(event.active.id));
|
||||||
onDragBegin?.();
|
onDragBegin?.();
|
||||||
},
|
},
|
||||||
[data, onDragBegin]
|
[data, onDragBegin],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
const handleDragEnd = useCallback(
|
||||||
@@ -172,12 +164,8 @@ export function DraggableList<T>({
|
|||||||
setDragItems(null);
|
setDragItems(null);
|
||||||
|
|
||||||
if (over && active.id !== over.id) {
|
if (over && active.id !== over.id) {
|
||||||
const oldIndex = items.findIndex(
|
const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.id);
|
||||||
(item, i) => keyExtractor(item, i) === active.id
|
const newIndex = items.findIndex((item, i) => keyExtractor(item, i) === over.id);
|
||||||
);
|
|
||||||
const newIndex = items.findIndex(
|
|
||||||
(item, i) => keyExtractor(item, i) === over.id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
|
if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
|
||||||
const newItems = arrayMove(items, 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));
|
const ids = items.map((item, index) => keyExtractor(item, index));
|
||||||
@@ -204,9 +192,7 @@ export function DraggableList<T>({
|
|||||||
testID={testID}
|
testID={testID}
|
||||||
style={style}
|
style={style}
|
||||||
contentContainerStyle={contentContainerStyle}
|
contentContainerStyle={contentContainerStyle}
|
||||||
showsVerticalScrollIndicator={
|
showsVerticalScrollIndicator={showCustomScrollbar ? false : showsVerticalScrollIndicator}
|
||||||
showCustomScrollbar ? false : showsVerticalScrollIndicator
|
|
||||||
}
|
|
||||||
onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined}
|
onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined}
|
||||||
onContentSizeChange={
|
onContentSizeChange={
|
||||||
showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : undefined
|
showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : undefined
|
||||||
|
|||||||
@@ -21,10 +21,7 @@ export function EmptyState({ onCreateAgent, onImportAgent }: EmptyStateProps) {
|
|||||||
<Text style={styles.primaryButtonText}>New agent</Text>
|
<Text style={styles.primaryButtonText}>New agent</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{hasImportCta ? (
|
{hasImportCta ? (
|
||||||
<Pressable
|
<Pressable onPress={onImportAgent} style={[styles.button, styles.secondaryButton]}>
|
||||||
onPress={onImportAgent}
|
|
||||||
style={[styles.button, styles.secondaryButton]}
|
|
||||||
>
|
|
||||||
<Download size={20} color={styles.secondaryButtonText.color} />
|
<Download size={20} color={styles.secondaryButtonText.color} />
|
||||||
<Text style={styles.secondaryButtonText}>Import agent</Text>
|
<Text style={styles.secondaryButtonText}>Import agent</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -2,11 +2,7 @@ import { useCallback, useEffect, useMemo, useRef } from "react";
|
|||||||
import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native";
|
import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useIsFocused } from "@react-navigation/native";
|
import { useIsFocused } from "@react-navigation/native";
|
||||||
import Animated, {
|
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
runOnJS,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { X } from "lucide-react-native";
|
import { X } from "lucide-react-native";
|
||||||
@@ -23,8 +19,7 @@ import { FileExplorerPane } from "./file-explorer-pane";
|
|||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
|
|
||||||
const MIN_CHAT_WIDTH = 400;
|
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 {
|
interface ExplorerSidebarProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -44,8 +39,7 @@ export function ExplorerSidebar({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isScreenFocused = useIsFocused();
|
const isScreenFocused = useIsFocused();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isMobile =
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||||
@@ -68,7 +62,7 @@ export function ExplorerSidebar({
|
|||||||
}
|
}
|
||||||
const maxWidth = Math.max(
|
const maxWidth = Math.max(
|
||||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
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) {
|
if (explorerWidth > maxWidth) {
|
||||||
setExplorerWidth(maxWidth);
|
setExplorerWidth(maxWidth);
|
||||||
@@ -102,7 +96,7 @@ export function ExplorerSidebar({
|
|||||||
});
|
});
|
||||||
closeToAgent();
|
closeToAgent();
|
||||||
},
|
},
|
||||||
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView]
|
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView],
|
||||||
);
|
);
|
||||||
|
|
||||||
const enableSidebarCloseGesture = isMobile && isOpen;
|
const enableSidebarCloseGesture = isMobile && isOpen;
|
||||||
@@ -111,7 +105,7 @@ export function ExplorerSidebar({
|
|||||||
(tab: ExplorerTab) => {
|
(tab: ExplorerTab) => {
|
||||||
setExplorerTabForCheckout({ serverId, cwd: workspaceRoot, isGit, tab });
|
setExplorerTabForCheckout({ serverId, cwd: workspaceRoot, isGit, tab });
|
||||||
},
|
},
|
||||||
[isGit, serverId, setExplorerTabForCheckout, workspaceRoot]
|
[isGit, serverId, setExplorerTabForCheckout, workspaceRoot],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Swipe gesture to close (swipe right on mobile)
|
// Swipe gesture to close (swipe right on mobile)
|
||||||
@@ -170,8 +164,7 @@ export function ExplorerSidebar({
|
|||||||
})
|
})
|
||||||
.onEnd((event) => {
|
.onEnd((event) => {
|
||||||
isGesturing.value = false;
|
isGesturing.value = false;
|
||||||
const shouldClose =
|
const shouldClose = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
||||||
event.translationX > windowWidth / 3 || event.velocityX > 500;
|
|
||||||
runOnJS(logExplorerSidebar)("closeGestureEnd", {
|
runOnJS(logExplorerSidebar)("closeGestureEnd", {
|
||||||
translationX: event.translationX,
|
translationX: event.translationX,
|
||||||
velocityX: event.velocityX,
|
velocityX: event.velocityX,
|
||||||
@@ -200,7 +193,7 @@ export function ExplorerSidebar({
|
|||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
closeTouchStartX,
|
closeTouchStartX,
|
||||||
closeTouchStartY,
|
closeTouchStartY,
|
||||||
]
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Desktop resize gesture (drag left edge)
|
// Desktop resize gesture (drag left edge)
|
||||||
@@ -218,18 +211,15 @@ export function ExplorerSidebar({
|
|||||||
const newWidth = startWidthRef.current - event.translationX;
|
const newWidth = startWidthRef.current - event.translationX;
|
||||||
const maxWidth = Math.max(
|
const maxWidth = Math.max(
|
||||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||||
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH)
|
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
|
||||||
);
|
|
||||||
const clampedWidth = Math.max(
|
|
||||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
|
||||||
Math.min(maxWidth, newWidth)
|
|
||||||
);
|
);
|
||||||
|
const clampedWidth = Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth));
|
||||||
resizeWidth.value = clampedWidth;
|
resizeWidth.value = clampedWidth;
|
||||||
})
|
})
|
||||||
.onEnd(() => {
|
.onEnd(() => {
|
||||||
runOnJS(setExplorerWidth)(resizeWidth.value);
|
runOnJS(setExplorerWidth)(resizeWidth.value);
|
||||||
}),
|
}),
|
||||||
[isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth]
|
[isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth],
|
||||||
);
|
);
|
||||||
|
|
||||||
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
|
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
@@ -299,16 +289,11 @@ export function ExplorerSidebar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}>
|
||||||
style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}
|
|
||||||
>
|
|
||||||
{/* Resize handle - absolutely positioned over left border */}
|
{/* Resize handle - absolutely positioned over left border */}
|
||||||
<GestureDetector gesture={resizeGesture}>
|
<GestureDetector gesture={resizeGesture}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
|
||||||
styles.resizeHandle,
|
|
||||||
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
|
|
||||||
@@ -351,8 +336,7 @@ function SidebarContent({
|
|||||||
onOpenFile,
|
onOpenFile,
|
||||||
}: SidebarContentProps) {
|
}: SidebarContentProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const resolvedTab: ExplorerTab =
|
const resolvedTab: ExplorerTab = !isGit && activeTab === "changes" ? "files" : activeTab;
|
||||||
!isGit && activeTab === "changes" ? "files" : activeTab;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||||
@@ -365,12 +349,7 @@ function SidebarContent({
|
|||||||
style={[styles.tab, resolvedTab === "changes" && styles.tabActive]}
|
style={[styles.tab, resolvedTab === "changes" && styles.tabActive]}
|
||||||
onPress={() => onTabPress("changes")}
|
onPress={() => onTabPress("changes")}
|
||||||
>
|
>
|
||||||
<Text
|
<Text style={[styles.tabText, resolvedTab === "changes" && styles.tabTextActive]}>
|
||||||
style={[
|
|
||||||
styles.tabText,
|
|
||||||
resolvedTab === "changes" && styles.tabTextActive,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
Changes
|
Changes
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -380,12 +359,7 @@ function SidebarContent({
|
|||||||
style={[styles.tab, resolvedTab === "files" && styles.tabActive]}
|
style={[styles.tab, resolvedTab === "files" && styles.tabActive]}
|
||||||
onPress={() => onTabPress("files")}
|
onPress={() => onTabPress("files")}
|
||||||
>
|
>
|
||||||
<Text
|
<Text style={[styles.tabText, resolvedTab === "files" && styles.tabTextActive]}>
|
||||||
style={[
|
|
||||||
styles.tabText,
|
|
||||||
resolvedTab === "files" && styles.tabTextActive,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
Files
|
Files
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { View, Text, Platform } from "react-native";
|
import { View, Text, Platform } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import Animated, {
|
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
|
||||||
useAnimatedStyle,
|
|
||||||
withTiming,
|
|
||||||
useSharedValue,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Upload } from "lucide-react-native";
|
import { Upload } from "lucide-react-native";
|
||||||
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
|
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
|
||||||
@@ -18,11 +14,7 @@ interface FileDropZoneProps {
|
|||||||
|
|
||||||
const IS_WEB = Platform.OS === "web";
|
const IS_WEB = Platform.OS === "web";
|
||||||
|
|
||||||
export function FileDropZone({
|
export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
|
||||||
children,
|
|
||||||
onFilesDropped,
|
|
||||||
disabled = false,
|
|
||||||
}: FileDropZoneProps) {
|
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { isDragging, containerRef } = useFileDropZone({
|
const { isDragging, containerRef } = useFileDropZone({
|
||||||
onFilesDropped,
|
onFilesDropped,
|
||||||
|
|||||||
@@ -38,10 +38,7 @@ import {
|
|||||||
RotateCw,
|
RotateCw,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import type {
|
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
||||||
AgentFileExplorerState,
|
|
||||||
ExplorerEntry,
|
|
||||||
} from "@/stores/session-store";
|
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useDownloadStore } from "@/stores/download-store";
|
import { useDownloadStore } from "@/stores/download-store";
|
||||||
@@ -54,10 +51,7 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||||
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
||||||
import {
|
import { usePanelStore, type SortOption } from "@/stores/panel-store";
|
||||||
usePanelStore,
|
|
||||||
type SortOption,
|
|
||||||
} from "@/stores/panel-store";
|
|
||||||
import { formatTimeAgo } from "@/utils/time";
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
||||||
import {
|
import {
|
||||||
@@ -102,36 +96,32 @@ export function FileExplorerPane({
|
|||||||
onOpenFile,
|
onOpenFile,
|
||||||
}: FileExplorerPaneProps) {
|
}: FileExplorerPaneProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile =
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
|
||||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||||
|
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const daemonProfile = useMemo(
|
const daemonProfile = useMemo(
|
||||||
() => daemons.find((daemon) => daemon.serverId === serverId),
|
() => daemons.find((daemon) => daemon.serverId === serverId),
|
||||||
[daemons, serverId]
|
[daemons, serverId],
|
||||||
);
|
|
||||||
const normalizedWorkspaceRoot = useMemo(
|
|
||||||
() => workspaceRoot.trim(),
|
|
||||||
[workspaceRoot]
|
|
||||||
);
|
);
|
||||||
|
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||||
const workspaceStateKey = useMemo(
|
const workspaceStateKey = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildWorkspaceExplorerStateKey({
|
buildWorkspaceExplorerStateKey({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
workspaceRoot: normalizedWorkspaceRoot,
|
workspaceRoot: normalizedWorkspaceRoot,
|
||||||
}),
|
}),
|
||||||
[normalizedWorkspaceRoot, workspaceId]
|
[normalizedWorkspaceRoot, workspaceId],
|
||||||
);
|
);
|
||||||
const workspaceScopeId = useMemo(
|
const workspaceScopeId = useMemo(
|
||||||
() => workspaceId?.trim() || normalizedWorkspaceRoot,
|
() => workspaceId?.trim() || normalizedWorkspaceRoot,
|
||||||
[normalizedWorkspaceRoot, workspaceId]
|
[normalizedWorkspaceRoot, workspaceId],
|
||||||
);
|
);
|
||||||
const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot);
|
const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot);
|
||||||
const explorerState = useSessionStore((state) =>
|
const explorerState = useSessionStore((state) =>
|
||||||
workspaceStateKey && state.sessions[serverId]
|
workspaceStateKey && state.sessions[serverId]
|
||||||
? state.sessions[serverId]?.fileExplorer.get(workspaceStateKey)
|
? state.sessions[serverId]?.fileExplorer.get(workspaceStateKey)
|
||||||
: undefined
|
: undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -156,9 +146,9 @@ export function FileExplorerPane({
|
|||||||
const isDirectoryLoading = useCallback(
|
const isDirectoryLoading = useCallback(
|
||||||
(path: string) =>
|
(path: string) =>
|
||||||
Boolean(
|
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(["."]));
|
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(
|
const handleOpenFile = useCallback(
|
||||||
@@ -246,7 +236,7 @@ export function FileExplorerPane({
|
|||||||
selectExplorerEntry(entry.path);
|
selectExplorerEntry(entry.path);
|
||||||
onOpenFile?.(entry.path);
|
onOpenFile?.(entry.path);
|
||||||
},
|
},
|
||||||
[hasWorkspaceScope, onOpenFile, selectExplorerEntry]
|
[hasWorkspaceScope, onOpenFile, selectExplorerEntry],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleEntryPress = useCallback(
|
const handleEntryPress = useCallback(
|
||||||
@@ -257,7 +247,7 @@ export function FileExplorerPane({
|
|||||||
}
|
}
|
||||||
handleOpenFile(entry);
|
handleOpenFile(entry);
|
||||||
},
|
},
|
||||||
[handleOpenFile, handleToggleDirectory]
|
[handleOpenFile, handleToggleDirectory],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCopyPath = useCallback(
|
const handleCopyPath = useCallback(
|
||||||
@@ -266,10 +256,10 @@ export function FileExplorerPane({
|
|||||||
buildAbsoluteExplorerPath({
|
buildAbsoluteExplorerPath({
|
||||||
workspaceRoot: normalizedWorkspaceRoot,
|
workspaceRoot: normalizedWorkspaceRoot,
|
||||||
entryPath: path,
|
entryPath: path,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[normalizedWorkspaceRoot]
|
[normalizedWorkspaceRoot],
|
||||||
);
|
);
|
||||||
|
|
||||||
const startDownload = useDownloadStore((state) => state.startDownload);
|
const startDownload = useDownloadStore((state) => state.startDownload);
|
||||||
@@ -288,13 +278,7 @@ export function FileExplorerPane({
|
|||||||
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
|
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[
|
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
|
||||||
daemonProfile,
|
|
||||||
requestFileDownloadToken,
|
|
||||||
serverId,
|
|
||||||
startDownload,
|
|
||||||
workspaceScopeId,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSortCycle = useCallback(() => {
|
const handleSortCycle = useCallback(() => {
|
||||||
@@ -320,7 +304,7 @@ export function FileExplorerPane({
|
|||||||
requestDirectoryListing(path, {
|
requestDirectoryListing(path, {
|
||||||
recordHistory: false,
|
recordHistory: false,
|
||||||
setCurrentPath: false,
|
setCurrentPath: false,
|
||||||
})
|
}),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
return null;
|
return null;
|
||||||
@@ -342,7 +326,7 @@ export function FileExplorerPane({
|
|||||||
easing: Easing.linear,
|
easing: Easing.linear,
|
||||||
}),
|
}),
|
||||||
-1,
|
-1,
|
||||||
false
|
false,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -366,7 +350,7 @@ export function FileExplorerPane({
|
|||||||
if (finished) {
|
if (finished) {
|
||||||
refreshIconRotation.value = 0;
|
refreshIconRotation.value = 0;
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}, [isRefreshFetching, refreshIconRotation]);
|
}, [isRefreshFetching, refreshIconRotation]);
|
||||||
|
|
||||||
@@ -394,10 +378,7 @@ export function FileExplorerPane({
|
|||||||
!directories.has(".") &&
|
!directories.has(".") &&
|
||||||
Boolean(isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === ".");
|
Boolean(isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === ".");
|
||||||
const showBackFromError = Boolean(error && selectedEntryPath);
|
const showBackFromError = Boolean(error && selectedEntryPath);
|
||||||
const errorRecoveryPath = useMemo(
|
const errorRecoveryPath = useMemo(() => getErrorRecoveryPath(explorerState), [explorerState]);
|
||||||
() => getErrorRecoveryPath(explorerState),
|
|
||||||
[explorerState]
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderTreeRow = useCallback(
|
const renderTreeRow = useCallback(
|
||||||
({ item }: ListRenderItemInfo<TreeRow>) => {
|
({ item }: ListRenderItemInfo<TreeRow>) => {
|
||||||
@@ -495,7 +476,7 @@ export function FileExplorerPane({
|
|||||||
selectedEntryPath,
|
selectedEntryPath,
|
||||||
theme.colors,
|
theme.colors,
|
||||||
theme.spacing,
|
theme.spacing,
|
||||||
]
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleBackFromError = useCallback(() => {
|
const handleBackFromError = useCallback(() => {
|
||||||
@@ -515,7 +496,7 @@ export function FileExplorerPane({
|
|||||||
treeScrollbarMetrics.onScroll(event);
|
treeScrollbarMetrics.onScroll(event);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[showDesktopWebScrollbar, treeScrollbarMetrics]
|
[showDesktopWebScrollbar, treeScrollbarMetrics],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTreeListLayout = useCallback(
|
const handleTreeListLayout = useCallback(
|
||||||
@@ -524,7 +505,7 @@ export function FileExplorerPane({
|
|||||||
treeScrollbarMetrics.onLayout(event);
|
treeScrollbarMetrics.onLayout(event);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[showDesktopWebScrollbar, treeScrollbarMetrics]
|
[showDesktopWebScrollbar, treeScrollbarMetrics],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!hasWorkspaceScope) {
|
if (!hasWorkspaceScope) {
|
||||||
@@ -536,9 +517,7 @@ export function FileExplorerPane({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View style={styles.container}>
|
||||||
style={styles.container}
|
|
||||||
>
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<View style={styles.centerState}>
|
<View style={styles.centerState}>
|
||||||
<Text style={styles.errorText}>{error}</Text>
|
<Text style={styles.errorText}>{error}</Text>
|
||||||
@@ -632,16 +611,7 @@ export function FileExplorerPane({
|
|||||||
|
|
||||||
type EntryDisplayKind = "directory" | "image" | "text" | "other";
|
type EntryDisplayKind = "directory" | "image" | "text" | "other";
|
||||||
|
|
||||||
const IMAGE_EXTENSIONS = new Set([
|
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "ico"]);
|
||||||
"png",
|
|
||||||
"jpg",
|
|
||||||
"jpeg",
|
|
||||||
"gif",
|
|
||||||
"bmp",
|
|
||||||
"svg",
|
|
||||||
"webp",
|
|
||||||
"ico",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const TEXT_EXTENSIONS = new Set([
|
const TEXT_EXTENSIONS = new Set([
|
||||||
"txt",
|
"txt",
|
||||||
@@ -684,7 +654,7 @@ const TEXT_EXTENSIONS = new Set([
|
|||||||
|
|
||||||
function renderEntryIcon(
|
function renderEntryIcon(
|
||||||
kind: EntryDisplayKind,
|
kind: EntryDisplayKind,
|
||||||
colors: { foreground: string; primary: string; directoryOpen?: boolean }
|
colors: { foreground: string; primary: string; directoryOpen?: boolean },
|
||||||
) {
|
) {
|
||||||
const color = colors.foreground;
|
const color = colors.foreground;
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
@@ -783,7 +753,7 @@ function buildTreeRows({
|
|||||||
sortOption,
|
sortOption,
|
||||||
path: entry.path,
|
path: entry.path,
|
||||||
depth: depth + 1,
|
depth: depth + 1,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useMemo, useRef } from 'react'
|
import React, { useCallback, useMemo, useRef } from "react";
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Image as RNImage,
|
Image as RNImage,
|
||||||
@@ -10,54 +10,54 @@ import {
|
|||||||
type LayoutChangeEvent,
|
type LayoutChangeEvent,
|
||||||
type NativeScrollEvent,
|
type NativeScrollEvent,
|
||||||
type NativeSyntheticEvent,
|
type NativeSyntheticEvent,
|
||||||
} from 'react-native'
|
} from "react-native";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { Fonts } from '@/constants/theme'
|
import { Fonts } from "@/constants/theme";
|
||||||
import { useSessionStore, type ExplorerFile } from '@/stores/session-store'
|
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
WebDesktopScrollbarOverlay,
|
WebDesktopScrollbarOverlay,
|
||||||
useWebDesktopScrollbarMetrics,
|
useWebDesktopScrollbarMetrics,
|
||||||
} from '@/components/web-desktop-scrollbar'
|
} from "@/components/web-desktop-scrollbar";
|
||||||
import {
|
import {
|
||||||
highlightCode,
|
highlightCode,
|
||||||
darkHighlightColors,
|
darkHighlightColors,
|
||||||
lightHighlightColors,
|
lightHighlightColors,
|
||||||
type HighlightToken,
|
type HighlightToken,
|
||||||
type HighlightStyle,
|
type HighlightStyle,
|
||||||
} from '@getpaseo/highlight'
|
} from "@getpaseo/highlight";
|
||||||
|
|
||||||
interface CodeLineProps {
|
interface CodeLineProps {
|
||||||
tokens: HighlightToken[]
|
tokens: HighlightToken[];
|
||||||
lineNumber: number
|
lineNumber: number;
|
||||||
gutterWidth: number
|
gutterWidth: number;
|
||||||
colorMap: Record<HighlightStyle, string>
|
colorMap: Record<HighlightStyle, string>;
|
||||||
baseColor: string
|
baseColor: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FilePreviewBodyProps {
|
interface FilePreviewBodyProps {
|
||||||
preview: ExplorerFile | null
|
preview: ExplorerFile | null;
|
||||||
isLoading: boolean
|
isLoading: boolean;
|
||||||
showDesktopWebScrollbar: boolean
|
showDesktopWebScrollbar: boolean;
|
||||||
isMobile: boolean
|
isMobile: boolean;
|
||||||
filePath: string
|
filePath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== "string") {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
const trimmed = value.trim()
|
const trimmed = value.trim();
|
||||||
return trimmed.length > 0 ? trimmed : null
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFileSize({ size }: { size: number }): string {
|
function formatFileSize({ size }: { size: number }): string {
|
||||||
if (size < 1024) {
|
if (size < 1024) {
|
||||||
return `${size} B`
|
return `${size} B`;
|
||||||
}
|
}
|
||||||
if (size < 1024 * 1024) {
|
if (size < 1024 * 1024) {
|
||||||
return `${(size / 1024).toFixed(1)} KB`
|
return `${(size / 1024).toFixed(1)} KB`;
|
||||||
}
|
}
|
||||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CodeLine = React.memo(function CodeLine({
|
const CodeLine = React.memo(function CodeLine({
|
||||||
@@ -76,22 +76,22 @@ const CodeLine = React.memo(function CodeLine({
|
|||||||
{tokens.map((token, index) => (
|
{tokens.map((token, index) => (
|
||||||
<Text
|
<Text
|
||||||
key={index}
|
key={index}
|
||||||
style={{ color: token.style ? colorMap[token.style] ?? baseColor : baseColor }}
|
style={{ color: token.style ? (colorMap[token.style] ?? baseColor) : baseColor }}
|
||||||
>
|
>
|
||||||
{token.text}
|
{token.text}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
|
|
||||||
const codeLineStyles = StyleSheet.create((theme) => ({
|
const codeLineStyles = StyleSheet.create((theme) => ({
|
||||||
line: {
|
line: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
},
|
},
|
||||||
gutter: {
|
gutter: {
|
||||||
alignItems: 'flex-end',
|
alignItems: "flex-end",
|
||||||
paddingRight: theme.spacing[3],
|
paddingRight: theme.spacing[3],
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
@@ -107,7 +107,7 @@ const codeLineStyles = StyleSheet.create((theme) => ({
|
|||||||
lineHeight: theme.fontSize.sm * 1.45,
|
lineHeight: theme.fontSize.sm * 1.45,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
}))
|
}));
|
||||||
|
|
||||||
function FilePreviewBody({
|
function FilePreviewBody({
|
||||||
preview,
|
preview,
|
||||||
@@ -116,46 +116,46 @@ function FilePreviewBody({
|
|||||||
isMobile,
|
isMobile,
|
||||||
filePath,
|
filePath,
|
||||||
}: FilePreviewBodyProps) {
|
}: FilePreviewBodyProps) {
|
||||||
const { theme } = useUnistyles()
|
const { theme } = useUnistyles();
|
||||||
const isDark = theme.colors.surface0 === '#18181c'
|
const isDark = theme.colors.surface0 === "#18181c";
|
||||||
const colorMap = isDark ? darkHighlightColors : lightHighlightColors
|
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
|
||||||
const baseColor = isDark ? '#c9d1d9' : '#24292f'
|
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
||||||
|
|
||||||
const enablePreviewDesktopScrollbar = showDesktopWebScrollbar
|
const enablePreviewDesktopScrollbar = showDesktopWebScrollbar;
|
||||||
const previewScrollRef = useRef<RNScrollView>(null)
|
const previewScrollRef = useRef<RNScrollView>(null);
|
||||||
const previewScrollbarMetrics = useWebDesktopScrollbarMetrics()
|
const previewScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
||||||
|
|
||||||
const highlightedLines = useMemo(() => {
|
const highlightedLines = useMemo(() => {
|
||||||
if (!preview || preview.kind !== 'text') {
|
if (!preview || preview.kind !== "text") {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return highlightCode(preview.content ?? '', filePath)
|
return highlightCode(preview.content ?? "", filePath);
|
||||||
}, [preview?.kind, preview?.content, filePath])
|
}, [preview?.kind, preview?.content, filePath]);
|
||||||
|
|
||||||
const gutterWidth = useMemo(() => {
|
const gutterWidth = useMemo(() => {
|
||||||
if (!highlightedLines) return 0
|
if (!highlightedLines) return 0;
|
||||||
const digits = String(highlightedLines.length).length
|
const digits = String(highlightedLines.length).length;
|
||||||
return Math.max(digits * 8 + 12, 28)
|
return Math.max(digits * 8 + 12, 28);
|
||||||
}, [highlightedLines])
|
}, [highlightedLines]);
|
||||||
|
|
||||||
const handlePreviewScroll = useCallback(
|
const handlePreviewScroll = useCallback(
|
||||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
if (enablePreviewDesktopScrollbar) {
|
if (enablePreviewDesktopScrollbar) {
|
||||||
previewScrollbarMetrics.onScroll(event)
|
previewScrollbarMetrics.onScroll(event);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[enablePreviewDesktopScrollbar, previewScrollbarMetrics]
|
[enablePreviewDesktopScrollbar, previewScrollbarMetrics],
|
||||||
)
|
);
|
||||||
|
|
||||||
const handlePreviewLayout = useCallback(
|
const handlePreviewLayout = useCallback(
|
||||||
(event: LayoutChangeEvent) => {
|
(event: LayoutChangeEvent) => {
|
||||||
if (enablePreviewDesktopScrollbar) {
|
if (enablePreviewDesktopScrollbar) {
|
||||||
previewScrollbarMetrics.onLayout(event)
|
previewScrollbarMetrics.onLayout(event);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[enablePreviewDesktopScrollbar, previewScrollbarMetrics]
|
[enablePreviewDesktopScrollbar, previewScrollbarMetrics],
|
||||||
)
|
);
|
||||||
|
|
||||||
if (isLoading && !preview) {
|
if (isLoading && !preview) {
|
||||||
return (
|
return (
|
||||||
@@ -163,7 +163,7 @@ function FilePreviewBody({
|
|||||||
<ActivityIndicator size="small" />
|
<ActivityIndicator size="small" />
|
||||||
<Text style={styles.loadingText}>Loading file…</Text>
|
<Text style={styles.loadingText}>Loading file…</Text>
|
||||||
</View>
|
</View>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!preview) {
|
if (!preview) {
|
||||||
@@ -171,11 +171,11 @@ function FilePreviewBody({
|
|||||||
<View style={styles.centerState}>
|
<View style={styles.centerState}>
|
||||||
<Text style={styles.emptyText}>No preview available</Text>
|
<Text style={styles.emptyText}>No preview available</Text>
|
||||||
</View>
|
</View>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preview.kind === 'text') {
|
if (preview.kind === "text") {
|
||||||
const lines = highlightedLines ?? [[{ text: preview.content ?? '', style: null }]]
|
const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
|
||||||
const codeLines = (
|
const codeLines = (
|
||||||
<View>
|
<View>
|
||||||
{lines.map((tokens, index) => (
|
{lines.map((tokens, index) => (
|
||||||
@@ -189,7 +189,7 @@ function FilePreviewBody({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
)
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.previewScrollContainer}>
|
<View style={styles.previewScrollContainer}>
|
||||||
@@ -221,14 +221,14 @@ function FilePreviewBody({
|
|||||||
enabled={enablePreviewDesktopScrollbar}
|
enabled={enablePreviewDesktopScrollbar}
|
||||||
metrics={previewScrollbarMetrics}
|
metrics={previewScrollbarMetrics}
|
||||||
onScrollToOffset={(nextOffset) => {
|
onScrollToOffset={(nextOffset) => {
|
||||||
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false })
|
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preview.kind === 'image' && preview.content) {
|
if (preview.kind === "image" && preview.content) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.previewScrollContainer}>
|
<View style={styles.previewScrollContainer}>
|
||||||
<RNScrollView
|
<RNScrollView
|
||||||
@@ -245,7 +245,7 @@ function FilePreviewBody({
|
|||||||
>
|
>
|
||||||
<RNImage
|
<RNImage
|
||||||
source={{
|
source={{
|
||||||
uri: `data:${preview.mimeType ?? 'image/png'};base64,${preview.content}`,
|
uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`,
|
||||||
}}
|
}}
|
||||||
style={styles.previewImage}
|
style={styles.previewImage}
|
||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
@@ -255,11 +255,11 @@ function FilePreviewBody({
|
|||||||
enabled={enablePreviewDesktopScrollbar}
|
enabled={enablePreviewDesktopScrollbar}
|
||||||
metrics={previewScrollbarMetrics}
|
metrics={previewScrollbarMetrics}
|
||||||
onScrollToOffset={(nextOffset) => {
|
onScrollToOffset={(nextOffset) => {
|
||||||
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false })
|
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -267,7 +267,7 @@ function FilePreviewBody({
|
|||||||
<Text style={styles.emptyText}>Binary preview unavailable</Text>
|
<Text style={styles.emptyText}>Binary preview unavailable</Text>
|
||||||
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
|
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
|
||||||
</View>
|
</View>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FilePane({
|
export function FilePane({
|
||||||
@@ -275,33 +275,33 @@ export function FilePane({
|
|||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
filePath,
|
filePath,
|
||||||
}: {
|
}: {
|
||||||
serverId: string
|
serverId: string;
|
||||||
workspaceRoot: string
|
workspaceRoot: string;
|
||||||
filePath: 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 showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||||
|
|
||||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null)
|
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot])
|
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||||
const normalizedFilePath = useMemo(() => trimNonEmpty(filePath), [filePath])
|
const normalizedFilePath = useMemo(() => trimNonEmpty(filePath), [filePath]);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['workspaceFile', serverId, normalizedWorkspaceRoot, normalizedFilePath],
|
queryKey: ["workspaceFile", serverId, normalizedWorkspaceRoot, normalizedFilePath],
|
||||||
enabled: Boolean(client && normalizedWorkspaceRoot && normalizedFilePath),
|
enabled: Boolean(client && normalizedWorkspaceRoot && normalizedFilePath),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!client || !normalizedWorkspaceRoot || !normalizedFilePath) {
|
if (!client || !normalizedWorkspaceRoot || !normalizedFilePath) {
|
||||||
return { file: null as ExplorerFile | null, error: 'Host is not connected' }
|
return { file: null as ExplorerFile | null, error: "Host is not connected" };
|
||||||
}
|
}
|
||||||
const payload = await client.exploreFileSystem(
|
const payload = await client.exploreFileSystem(
|
||||||
normalizedWorkspaceRoot,
|
normalizedWorkspaceRoot,
|
||||||
normalizedFilePath,
|
normalizedFilePath,
|
||||||
'file'
|
"file",
|
||||||
)
|
);
|
||||||
return { file: payload.file ?? null, error: payload.error ?? null }
|
return { file: payload.file ?? null, error: payload.error ?? null };
|
||||||
},
|
},
|
||||||
staleTime: 5_000,
|
staleTime: 5_000,
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container} testID="workspace-file-pane">
|
<View style={styles.container} testID="workspace-file-pane">
|
||||||
@@ -319,7 +319,7 @@ export function FilePane({
|
|||||||
filePath={filePath}
|
filePath={filePath}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
@@ -330,8 +330,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
centerState: {
|
centerState: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
padding: theme.spacing[4],
|
padding: theme.spacing[4],
|
||||||
},
|
},
|
||||||
loadingText: {
|
loadingText: {
|
||||||
@@ -342,12 +342,12 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
errorText: {
|
errorText: {
|
||||||
color: theme.colors.destructive,
|
color: theme.colors.destructive,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
textAlign: 'center',
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
emptyText: {
|
emptyText: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
textAlign: 'center',
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
binaryMetaText: {
|
binaryMetaText: {
|
||||||
marginTop: theme.spacing[2],
|
marginTop: theme.spacing[2],
|
||||||
@@ -368,11 +368,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
previewImageScrollContent: {
|
previewImageScrollContent: {
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
padding: theme.spacing[4],
|
padding: theme.spacing[4],
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
previewImage: {
|
previewImage: {
|
||||||
width: '100%',
|
width: "100%",
|
||||||
height: 420,
|
height: 420,
|
||||||
},
|
},
|
||||||
}))
|
}));
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import { buildGitActions, type BuildGitActionsInput } from "./git-actions-policy";
|
import { buildGitActions, type BuildGitActionsInput } from "./git-actions-policy";
|
||||||
|
|
||||||
function createInput(
|
function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitActionsInput {
|
||||||
overrides: Partial<BuildGitActionsInput> = {}
|
|
||||||
): BuildGitActionsInput {
|
|
||||||
return {
|
return {
|
||||||
isGit: true,
|
isGit: true,
|
||||||
githubFeaturesEnabled: true,
|
githubFeaturesEnabled: true,
|
||||||
@@ -68,7 +66,7 @@ describe("git-actions-policy", () => {
|
|||||||
aheadCount: 3,
|
aheadCount: 3,
|
||||||
aheadOfOrigin: 2,
|
aheadOfOrigin: 2,
|
||||||
shipDefault: "pr",
|
shipDefault: "pr",
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(noPrActions.primary).toBeNull();
|
expect(noPrActions.primary).toBeNull();
|
||||||
@@ -117,18 +115,20 @@ describe("git-actions-policy", () => {
|
|||||||
hasRemote: true,
|
hasRemote: true,
|
||||||
hasPullRequest: true,
|
hasPullRequest: true,
|
||||||
pullRequestUrl: "https://example.com/pr/456",
|
pullRequestUrl: "https://example.com/pr/456",
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(actions.primary?.id).toBe("pr");
|
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", () => {
|
it("disables sync on the base branch when already up to date", () => {
|
||||||
const actions = buildGitActions(
|
const actions = buildGitActions(
|
||||||
createInput({
|
createInput({
|
||||||
hasRemote: true,
|
hasRemote: true,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
const syncAction = actions.secondary.find((action) => action.id === "merge-from-base");
|
const syncAction = actions.secondary.find((action) => action.id === "merge-from-base");
|
||||||
|
|
||||||
|
|||||||
@@ -54,12 +54,7 @@ export interface BuildGitActionsInput {
|
|||||||
runtime: Record<GitActionId, GitActionRuntimeState>;
|
runtime: Record<GitActionId, GitActionRuntimeState>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SECONDARY_ACTION_IDS: GitActionId[] = [
|
const SECONDARY_ACTION_IDS: GitActionId[] = ["merge-branch", "pr", "merge-from-base", "push"];
|
||||||
"merge-branch",
|
|
||||||
"pr",
|
|
||||||
"merge-from-base",
|
|
||||||
"push",
|
|
||||||
];
|
|
||||||
|
|
||||||
export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||||
if (!input.isGit) {
|
if (!input.isGit) {
|
||||||
@@ -134,7 +129,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const primaryActionId = getPrimaryActionId(input);
|
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)!);
|
const secondary = SECONDARY_ACTION_IDS.map((id) => allActions.get(id)!);
|
||||||
if (input.isPaseoOwnedWorktree) {
|
if (input.isPaseoOwnedWorktree) {
|
||||||
secondary.push(allActions.get("archive-worktree")!);
|
secondary.push(allActions.get("archive-worktree")!);
|
||||||
@@ -189,10 +184,7 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
|
|||||||
label: "Create PR",
|
label: "Create PR",
|
||||||
pendingLabel: "Creating PR...",
|
pendingLabel: "Creating PR...",
|
||||||
successLabel: "PR Created",
|
successLabel: "PR Created",
|
||||||
disabled:
|
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled || input.aheadCount === 0,
|
||||||
input.runtime.pr.disabled ||
|
|
||||||
!input.githubFeaturesEnabled ||
|
|
||||||
input.aheadCount === 0,
|
|
||||||
status: input.runtime.pr.status,
|
status: input.runtime.pr.status,
|
||||||
description: getCreatePrDescription(input),
|
description: getCreatePrDescription(input),
|
||||||
icon: input.runtime.pr.icon,
|
icon: input.runtime.pr.icon,
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
|
|||||||
) : (
|
) : (
|
||||||
<View style={styles.splitButtonContent}>
|
<View style={styles.splitButtonContent}>
|
||||||
{gitActions.primary.icon}
|
{gitActions.primary.icon}
|
||||||
<Text style={styles.splitButtonText}>{getActionDisplayLabel(gitActions.primary)}</Text>
|
<Text style={styles.splitButtonText}>
|
||||||
|
{getActionDisplayLabel(gitActions.primary)}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,21 +11,14 @@ interface BackHeaderProps {
|
|||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BackHeader({
|
export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
|
||||||
title,
|
|
||||||
rightContent,
|
|
||||||
onBack,
|
|
||||||
}: BackHeaderProps) {
|
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenHeader
|
<ScreenHeader
|
||||||
left={
|
left={
|
||||||
<>
|
<>
|
||||||
<Pressable
|
<Pressable onPress={onBack ?? (() => router.back())} style={styles.backButton}>
|
||||||
onPress={onBack ?? (() => router.back())}
|
|
||||||
style={styles.backButton}
|
|
||||||
>
|
|
||||||
<ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
<ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{title && (
|
{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, { backgroundColor: color }]} />
|
<View style={[styles.mobileMenuLine, { backgroundColor: color }]} />
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[styles.mobileMenuLine, styles.mobileMenuLineShort, { backgroundColor: color }]}
|
||||||
styles.mobileMenuLine,
|
|
||||||
styles.mobileMenuLineShort,
|
|
||||||
{ backgroundColor: color },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -46,17 +42,15 @@ export function SidebarMenuToggle({
|
|||||||
nativeID = "menu-button",
|
nativeID = "menu-button",
|
||||||
}: SidebarMenuToggleProps = {}) {
|
}: SidebarMenuToggleProps = {}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile =
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||||
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
|
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
|
||||||
|
|
||||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||||
const menuIconColor = !isMobile && isOpen
|
const menuIconColor =
|
||||||
? theme.colors.foreground
|
!isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||||
: theme.colors.foregroundMuted;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HeaderToggleButton
|
<HeaderToggleButton
|
||||||
@@ -81,10 +75,7 @@ export function SidebarMenuToggle({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MenuHeader({
|
export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
|
||||||
title,
|
|
||||||
rightContent,
|
|
||||||
}: MenuHeaderProps) {
|
|
||||||
return (
|
return (
|
||||||
<ScreenHeader
|
<ScreenHeader
|
||||||
left={
|
left={
|
||||||
|
|||||||
@@ -22,12 +22,7 @@ interface ScreenHeaderProps {
|
|||||||
* Shared frame for the home/back headers so we only maintain padding, border,
|
* Shared frame for the home/back headers so we only maintain padding, border,
|
||||||
* and safe-area logic in one place.
|
* and safe-area logic in one place.
|
||||||
*/
|
*/
|
||||||
export function ScreenHeader({
|
export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeaderProps) {
|
||||||
left,
|
|
||||||
right,
|
|
||||||
leftStyle,
|
|
||||||
rightStyle,
|
|
||||||
}: ScreenHeaderProps) {
|
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
@@ -37,9 +32,7 @@ export function ScreenHeader({
|
|||||||
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||||
const baseHorizontalPadding = theme.spacing[2];
|
const baseHorizontalPadding = theme.spacing[2];
|
||||||
const collapsedSidebarTrafficLightInset =
|
const collapsedSidebarTrafficLightInset =
|
||||||
!isMobile && !desktopAgentListOpen && getIsDesktopMac()
|
!isMobile && !desktopAgentListOpen && getIsDesktopMac() ? trafficLightPadding.left : 0;
|
||||||
? trafficLightPadding.left
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const dragHandlers = useDesktopDragHandlers();
|
const dragHandlers = useDesktopDragHandlers();
|
||||||
|
|
||||||
|
|||||||
@@ -25,10 +25,34 @@ export function SourceControlPanelIcon({
|
|||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
{/* Plus */}
|
{/* Plus */}
|
||||||
<Line x1={9} y1={9.5} x2={15} y2={9.5} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" />
|
<Line
|
||||||
<Line x1={12} y1={6.5} x2={12} y2={12.5} stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" />
|
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 */}
|
{/* 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>
|
</Svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function KeyboardShortcutsDialog() {
|
|||||||
const isDesktopApp = getIsDesktop();
|
const isDesktopApp = getIsDesktop();
|
||||||
const sections = useMemo(
|
const sections = useMemo(
|
||||||
() => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }),
|
() => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }),
|
||||||
[isDesktopApp, isMac]
|
[isDesktopApp, isMac],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -9,103 +9,108 @@ import {
|
|||||||
type Dispatch,
|
type Dispatch,
|
||||||
type RefObject,
|
type RefObject,
|
||||||
type SetStateAction,
|
type SetStateAction,
|
||||||
} from 'react'
|
} from "react";
|
||||||
import { View, Pressable, Text, Platform } from 'react-native'
|
import { View, Pressable, Text, Platform } from "react-native";
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
interpolate,
|
interpolate,
|
||||||
Extrapolation,
|
Extrapolation,
|
||||||
runOnJS,
|
runOnJS,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
} from 'react-native-reanimated'
|
} from "react-native-reanimated";
|
||||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { MessagesSquare, Plus, Settings } from 'lucide-react-native'
|
import { MessagesSquare, Plus, Settings } from "lucide-react-native";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Shortcut } from '@/components/ui/shortcut'
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import { router, usePathname } from 'expo-router'
|
import { router, usePathname } from "expo-router";
|
||||||
import { usePanelStore } from '@/stores/panel-store'
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { SidebarWorkspaceList } from './sidebar-workspace-list'
|
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||||
import { SidebarAgentListSkeleton } from './sidebar-agent-list-skeleton'
|
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||||
import { useSidebarShortcutModel } from '@/hooks/use-sidebar-shortcut-model'
|
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
|
||||||
import { useSidebarWorkspacesList, type SidebarProjectEntry } from '@/hooks/use-sidebar-workspaces-list'
|
import {
|
||||||
import { useSidebarAnimation } from '@/contexts/sidebar-animation-context'
|
useSidebarWorkspacesList,
|
||||||
import { useDesktopDragHandlers, useTrafficLightPadding } from '@/utils/desktop-window'
|
type SidebarProjectEntry,
|
||||||
import { Combobox } from '@/components/ui/combobox'
|
} from "@/hooks/use-sidebar-workspaces-list";
|
||||||
import { getHostRuntimeStore, useHosts } from '@/runtime/host-runtime'
|
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||||
import { formatConnectionStatus } from '@/utils/daemons'
|
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
|
||||||
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from '@/constants/layout'
|
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 {
|
import {
|
||||||
buildHostSessionsRoute,
|
buildHostSessionsRoute,
|
||||||
buildHostSettingsRoute,
|
buildHostSettingsRoute,
|
||||||
mapPathnameToServer,
|
mapPathnameToServer,
|
||||||
parseServerIdFromPathname,
|
parseServerIdFromPathname,
|
||||||
} from '@/utils/host-routes'
|
} from "@/utils/host-routes";
|
||||||
import { useOpenProjectPicker } from '@/hooks/use-open-project-picker'
|
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||||
|
|
||||||
const DESKTOP_SIDEBAR_WIDTH = 320
|
const DESKTOP_SIDEBAR_WIDTH = 320;
|
||||||
type SidebarShortcutModel = ReturnType<typeof useSidebarShortcutModel>
|
type SidebarShortcutModel = ReturnType<typeof useSidebarShortcutModel>;
|
||||||
type SidebarTheme = ReturnType<typeof useUnistyles>['theme']
|
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
|
||||||
|
|
||||||
interface LeftSidebarProps {
|
interface LeftSidebarProps {
|
||||||
selectedAgentId?: string
|
selectedAgentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HostOption {
|
interface HostOption {
|
||||||
id: string
|
id: string;
|
||||||
label: string
|
label: string;
|
||||||
description: string
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SidebarSharedProps {
|
interface SidebarSharedProps {
|
||||||
theme: SidebarTheme
|
theme: SidebarTheme;
|
||||||
activeServerId: string | null
|
activeServerId: string | null;
|
||||||
activeHostLabel: string
|
activeHostLabel: string;
|
||||||
activeHostStatusColor: string
|
activeHostStatusColor: string;
|
||||||
hostOptions: HostOption[]
|
hostOptions: HostOption[];
|
||||||
hostTriggerRef: RefObject<View | null>
|
hostTriggerRef: RefObject<View | null>;
|
||||||
isHostPickerOpen: boolean
|
isHostPickerOpen: boolean;
|
||||||
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>
|
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
projects: SidebarProjectEntry[]
|
projects: SidebarProjectEntry[];
|
||||||
isInitialLoad: boolean
|
isInitialLoad: boolean;
|
||||||
isRevalidating: boolean
|
isRevalidating: boolean;
|
||||||
isManualRefresh: boolean
|
isManualRefresh: boolean;
|
||||||
collapsedProjectKeys: SidebarShortcutModel['collapsedProjectKeys']
|
collapsedProjectKeys: SidebarShortcutModel["collapsedProjectKeys"];
|
||||||
shortcutIndexByWorkspaceKey: SidebarShortcutModel['shortcutIndexByWorkspaceKey']
|
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
|
||||||
toggleProjectCollapsed: SidebarShortcutModel['toggleProjectCollapsed']
|
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
|
||||||
setProjectCollapsed: SidebarShortcutModel['setProjectCollapsed']
|
setProjectCollapsed: SidebarShortcutModel["setProjectCollapsed"];
|
||||||
handleRefresh: () => void
|
handleRefresh: () => void;
|
||||||
handleHostSelect: (nextServerId: string) => void
|
handleHostSelect: (nextServerId: string) => void;
|
||||||
handleOpenProject: () => void
|
handleOpenProject: () => void;
|
||||||
handleSettings: () => void
|
handleSettings: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MobileSidebarProps extends SidebarSharedProps {
|
interface MobileSidebarProps extends SidebarSharedProps {
|
||||||
insetsTop: number
|
insetsTop: number;
|
||||||
insetsBottom: number
|
insetsBottom: number;
|
||||||
isOpen: boolean
|
isOpen: boolean;
|
||||||
closeToAgent: () => void
|
closeToAgent: () => void;
|
||||||
handleViewMoreNavigate: () => void
|
handleViewMoreNavigate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DesktopSidebarProps extends SidebarSharedProps {
|
interface DesktopSidebarProps extends SidebarSharedProps {
|
||||||
isOpen: boolean
|
isOpen: boolean;
|
||||||
handleViewMore: () => void
|
handleViewMore: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarProps) {
|
export const LeftSidebar = memo(function LeftSidebar({
|
||||||
void _selectedAgentId
|
selectedAgentId: _selectedAgentId,
|
||||||
|
}: LeftSidebarProps) {
|
||||||
|
void _selectedAgentId;
|
||||||
|
|
||||||
const { theme } = useUnistyles()
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets()
|
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 mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen)
|
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||||
const closeToAgent = usePanelStore((state) => state.closeToAgent)
|
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||||
const pathname = usePathname()
|
const pathname = usePathname();
|
||||||
const daemons = useHosts()
|
const daemons = useHosts();
|
||||||
const runtime = getHostRuntimeStore()
|
const runtime = getHostRuntimeStore();
|
||||||
const runtimeConnectionStatusSignature = useSyncExternalStore(
|
const runtimeConnectionStatusSignature = useSyncExternalStore(
|
||||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||||
() =>
|
() =>
|
||||||
@@ -113,128 +118,132 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte
|
|||||||
.map(
|
.map(
|
||||||
(daemon) =>
|
(daemon) =>
|
||||||
`${daemon.serverId}:${
|
`${daemon.serverId}:${
|
||||||
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting'
|
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
|
||||||
}`
|
}`,
|
||||||
)
|
)
|
||||||
.join('|'),
|
.join("|"),
|
||||||
() =>
|
() =>
|
||||||
daemons
|
daemons
|
||||||
.map(
|
.map(
|
||||||
(daemon) =>
|
(daemon) =>
|
||||||
`${daemon.serverId}:${
|
`${daemon.serverId}:${
|
||||||
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting'
|
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
|
||||||
}`
|
}`,
|
||||||
)
|
)
|
||||||
.join('|')
|
.join("|"),
|
||||||
)
|
);
|
||||||
const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname])
|
const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
||||||
const activeDaemon = useMemo(() => {
|
const activeDaemon = useMemo(() => {
|
||||||
if (daemons.length === 0) {
|
if (daemons.length === 0) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
if (activeServerIdFromPath) {
|
if (activeServerIdFromPath) {
|
||||||
const routeMatch = daemons.find((entry) => entry.serverId === activeServerIdFromPath)
|
const routeMatch = daemons.find((entry) => entry.serverId === activeServerIdFromPath);
|
||||||
if (routeMatch) {
|
if (routeMatch) {
|
||||||
return routeMatch
|
return routeMatch;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return daemons[0] ?? null
|
return daemons[0] ?? null;
|
||||||
}, [activeServerIdFromPath, daemons])
|
}, [activeServerIdFromPath, daemons]);
|
||||||
const activeServerId = activeDaemon?.serverId ?? null
|
const activeServerId = activeDaemon?.serverId ?? null;
|
||||||
const activeHostLabel = useMemo(() => {
|
const activeHostLabel = useMemo(() => {
|
||||||
if (!activeDaemon) return 'No host'
|
if (!activeDaemon) return "No host";
|
||||||
const trimmed = activeDaemon.label?.trim()
|
const trimmed = activeDaemon.label?.trim();
|
||||||
return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId
|
return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId;
|
||||||
}, [activeDaemon])
|
}, [activeDaemon]);
|
||||||
const activeHostStatus = activeServerId
|
const activeHostStatus = activeServerId
|
||||||
? (runtime.getSnapshot(activeServerId)?.connectionStatus ?? 'connecting')
|
? (runtime.getSnapshot(activeServerId)?.connectionStatus ?? "connecting")
|
||||||
: 'idle'
|
: "idle";
|
||||||
const activeHostStatusColor =
|
const activeHostStatusColor =
|
||||||
activeHostStatus === 'online'
|
activeHostStatus === "online"
|
||||||
? theme.colors.palette.green[400]
|
? theme.colors.palette.green[400]
|
||||||
: activeHostStatus === 'connecting'
|
: activeHostStatus === "connecting"
|
||||||
? theme.colors.palette.amber[500]
|
? theme.colors.palette.amber[500]
|
||||||
: theme.colors.palette.red[500]
|
: theme.colors.palette.red[500];
|
||||||
const hostOptions = useMemo(
|
const hostOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
daemons.map((daemon) => ({
|
daemons.map((daemon) => ({
|
||||||
id: daemon.serverId,
|
id: daemon.serverId,
|
||||||
label: daemon.label?.trim() || daemon.serverId,
|
label: daemon.label?.trim() || daemon.serverId,
|
||||||
description: formatConnectionStatus(
|
description: formatConnectionStatus(
|
||||||
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting'
|
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting",
|
||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
[daemons, runtime, runtimeConnectionStatusSignature]
|
[daemons, runtime, runtimeConnectionStatusSignature],
|
||||||
)
|
);
|
||||||
const hostTriggerRef = useRef<View | null>(null)
|
const hostTriggerRef = useRef<View | null>(null);
|
||||||
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false)
|
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({
|
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
|
||||||
serverId: activeServerId,
|
serverId: activeServerId,
|
||||||
enabled: isOpen,
|
enabled: isOpen,
|
||||||
})
|
});
|
||||||
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, setProjectCollapsed } =
|
const {
|
||||||
useSidebarShortcutModel(projects)
|
collapsedProjectKeys,
|
||||||
|
shortcutIndexByWorkspaceKey,
|
||||||
|
toggleProjectCollapsed,
|
||||||
|
setProjectCollapsed,
|
||||||
|
} = useSidebarShortcutModel(projects);
|
||||||
|
|
||||||
const [isManualRefresh, setIsManualRefresh] = useState(false)
|
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
setIsManualRefresh(true)
|
setIsManualRefresh(true);
|
||||||
refreshAll()
|
refreshAll();
|
||||||
}, [refreshAll])
|
}, [refreshAll]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isRevalidating && isManualRefresh) {
|
if (!isRevalidating && isManualRefresh) {
|
||||||
setIsManualRefresh(false)
|
setIsManualRefresh(false);
|
||||||
}
|
}
|
||||||
}, [isRevalidating, isManualRefresh])
|
}, [isRevalidating, isManualRefresh]);
|
||||||
|
|
||||||
const openProjectPicker = useOpenProjectPicker(activeServerId)
|
const openProjectPicker = useOpenProjectPicker(activeServerId);
|
||||||
|
|
||||||
const handleOpenProjectMobile = useCallback(() => {
|
const handleOpenProjectMobile = useCallback(() => {
|
||||||
closeToAgent()
|
closeToAgent();
|
||||||
void openProjectPicker()
|
void openProjectPicker();
|
||||||
}, [closeToAgent, openProjectPicker])
|
}, [closeToAgent, openProjectPicker]);
|
||||||
|
|
||||||
const handleOpenProjectDesktop = useCallback(() => {
|
const handleOpenProjectDesktop = useCallback(() => {
|
||||||
void openProjectPicker()
|
void openProjectPicker();
|
||||||
}, [openProjectPicker])
|
}, [openProjectPicker]);
|
||||||
|
|
||||||
const handleSettingsMobile = useCallback(() => {
|
const handleSettingsMobile = useCallback(() => {
|
||||||
if (!activeServerId) {
|
if (!activeServerId) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
closeToAgent()
|
closeToAgent();
|
||||||
router.push(buildHostSettingsRoute(activeServerId) as any)
|
router.push(buildHostSettingsRoute(activeServerId) as any);
|
||||||
}, [activeServerId, closeToAgent])
|
}, [activeServerId, closeToAgent]);
|
||||||
|
|
||||||
const handleSettingsDesktop = useCallback(() => {
|
const handleSettingsDesktop = useCallback(() => {
|
||||||
if (!activeServerId) {
|
if (!activeServerId) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
router.push(buildHostSettingsRoute(activeServerId) as any)
|
router.push(buildHostSettingsRoute(activeServerId) as any);
|
||||||
}, [activeServerId])
|
}, [activeServerId]);
|
||||||
|
|
||||||
const handleViewMoreNavigate = useCallback(() => {
|
const handleViewMoreNavigate = useCallback(() => {
|
||||||
if (!activeServerId) {
|
if (!activeServerId) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
router.push(buildHostSessionsRoute(activeServerId) as any)
|
router.push(buildHostSessionsRoute(activeServerId) as any);
|
||||||
}, [activeServerId])
|
}, [activeServerId]);
|
||||||
|
|
||||||
const handleHostSelect = useCallback(
|
const handleHostSelect = useCallback(
|
||||||
(nextServerId: string) => {
|
(nextServerId: string) => {
|
||||||
if (!nextServerId) {
|
if (!nextServerId) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
const nextPath = mapPathnameToServer(pathname, nextServerId)
|
const nextPath = mapPathnameToServer(pathname, nextServerId);
|
||||||
setIsHostPickerOpen(false)
|
setIsHostPickerOpen(false);
|
||||||
router.push(nextPath as any)
|
router.push(nextPath as any);
|
||||||
},
|
},
|
||||||
[pathname]
|
[pathname],
|
||||||
)
|
);
|
||||||
|
|
||||||
const sharedProps = {
|
const sharedProps = {
|
||||||
theme,
|
theme,
|
||||||
@@ -255,7 +264,7 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte
|
|||||||
setProjectCollapsed,
|
setProjectCollapsed,
|
||||||
handleRefresh,
|
handleRefresh,
|
||||||
handleHostSelect,
|
handleHostSelect,
|
||||||
}
|
};
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
@@ -269,7 +278,7 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte
|
|||||||
handleSettings={handleSettingsMobile}
|
handleSettings={handleSettingsMobile}
|
||||||
handleViewMoreNavigate={handleViewMoreNavigate}
|
handleViewMoreNavigate={handleViewMoreNavigate}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -280,13 +289,13 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte
|
|||||||
handleSettings={handleSettingsDesktop}
|
handleSettings={handleSettingsDesktop}
|
||||||
handleViewMore={handleViewMoreNavigate}
|
handleViewMore={handleViewMoreNavigate}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
|
|
||||||
function SessionsButton({ onPress }: { onPress: () => void }) {
|
function SessionsButton({ onPress }: { onPress: () => void }) {
|
||||||
const { theme } = useUnistyles()
|
const { theme } = useUnistyles();
|
||||||
const pathname = usePathname()
|
const pathname = usePathname();
|
||||||
const isActive = pathname.includes('/sessions')
|
const isActive = pathname.includes("/sessions");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -308,14 +317,17 @@ function SessionsButton({ onPress }: { onPress: () => void }) {
|
|||||||
color={hovered || isActive ? theme.colors.foreground : theme.colors.foregroundMuted}
|
color={hovered || isActive ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={[styles.newAgentButtonText, (hovered || isActive) && styles.newAgentButtonTextHovered]}
|
style={[
|
||||||
|
styles.newAgentButtonText,
|
||||||
|
(hovered || isActive) && styles.newAgentButtonTextHovered,
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
Sessions
|
Sessions
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MobileSidebar({
|
function MobileSidebar({
|
||||||
@@ -353,22 +365,22 @@ function MobileSidebar({
|
|||||||
animateToClose,
|
animateToClose,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
} = useSidebarAnimation()
|
} = useSidebarAnimation();
|
||||||
const closeTouchStartX = useSharedValue(0)
|
const closeTouchStartX = useSharedValue(0);
|
||||||
const closeTouchStartY = useSharedValue(0)
|
const closeTouchStartY = useSharedValue(0);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
closeToAgent()
|
closeToAgent();
|
||||||
}, [closeToAgent])
|
}, [closeToAgent]);
|
||||||
|
|
||||||
const handleViewMore = useCallback(() => {
|
const handleViewMore = useCallback(() => {
|
||||||
if (!activeServerId) {
|
if (!activeServerId) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
translateX.value = -windowWidth
|
translateX.value = -windowWidth;
|
||||||
backdropOpacity.value = 0
|
backdropOpacity.value = 0;
|
||||||
closeToAgent()
|
closeToAgent();
|
||||||
handleViewMoreNavigate()
|
handleViewMoreNavigate();
|
||||||
}, [
|
}, [
|
||||||
activeServerId,
|
activeServerId,
|
||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
@@ -376,7 +388,7 @@ function MobileSidebar({
|
|||||||
handleViewMoreNavigate,
|
handleViewMoreNavigate,
|
||||||
translateX,
|
translateX,
|
||||||
windowWidth,
|
windowWidth,
|
||||||
])
|
]);
|
||||||
|
|
||||||
const closeGesture = useMemo(
|
const closeGesture = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -385,62 +397,62 @@ function MobileSidebar({
|
|||||||
.enabled(isOpen)
|
.enabled(isOpen)
|
||||||
.manualActivation(true)
|
.manualActivation(true)
|
||||||
.onTouchesDown((event) => {
|
.onTouchesDown((event) => {
|
||||||
const touch = event.changedTouches[0]
|
const touch = event.changedTouches[0];
|
||||||
if (!touch) {
|
if (!touch) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
closeTouchStartX.value = touch.absoluteX
|
closeTouchStartX.value = touch.absoluteX;
|
||||||
closeTouchStartY.value = touch.absoluteY
|
closeTouchStartY.value = touch.absoluteY;
|
||||||
})
|
})
|
||||||
.onTouchesMove((event, stateManager) => {
|
.onTouchesMove((event, stateManager) => {
|
||||||
const touch = event.changedTouches[0]
|
const touch = event.changedTouches[0];
|
||||||
if (!touch || event.numberOfTouches !== 1) {
|
if (!touch || event.numberOfTouches !== 1) {
|
||||||
stateManager.fail()
|
stateManager.fail();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const deltaX = touch.absoluteX - closeTouchStartX.value
|
const deltaX = touch.absoluteX - closeTouchStartX.value;
|
||||||
const deltaY = touch.absoluteY - closeTouchStartY.value
|
const deltaY = touch.absoluteY - closeTouchStartY.value;
|
||||||
const absDeltaX = Math.abs(deltaX)
|
const absDeltaX = Math.abs(deltaX);
|
||||||
const absDeltaY = Math.abs(deltaY)
|
const absDeltaY = Math.abs(deltaY);
|
||||||
|
|
||||||
if (deltaX >= 10) {
|
if (deltaX >= 10) {
|
||||||
stateManager.fail()
|
stateManager.fail();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
|
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
|
||||||
stateManager.fail()
|
stateManager.fail();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (deltaX <= -15 && absDeltaX > absDeltaY) {
|
if (deltaX <= -15 && absDeltaX > absDeltaY) {
|
||||||
stateManager.activate()
|
stateManager.activate();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.onStart(() => {
|
.onStart(() => {
|
||||||
isGesturing.value = true
|
isGesturing.value = true;
|
||||||
})
|
})
|
||||||
.onUpdate((event) => {
|
.onUpdate((event) => {
|
||||||
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX))
|
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX));
|
||||||
translateX.value = newTranslateX
|
translateX.value = newTranslateX;
|
||||||
backdropOpacity.value = interpolate(
|
backdropOpacity.value = interpolate(
|
||||||
newTranslateX,
|
newTranslateX,
|
||||||
[-windowWidth, 0],
|
[-windowWidth, 0],
|
||||||
[0, 1],
|
[0, 1],
|
||||||
Extrapolation.CLAMP
|
Extrapolation.CLAMP,
|
||||||
)
|
);
|
||||||
})
|
})
|
||||||
.onEnd((event) => {
|
.onEnd((event) => {
|
||||||
isGesturing.value = false
|
isGesturing.value = false;
|
||||||
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500
|
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
|
||||||
if (shouldClose) {
|
if (shouldClose) {
|
||||||
animateToClose()
|
animateToClose();
|
||||||
runOnJS(handleClose)()
|
runOnJS(handleClose)();
|
||||||
} else {
|
} else {
|
||||||
animateToOpen()
|
animateToOpen();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.onFinalize(() => {
|
.onFinalize(() => {
|
||||||
isGesturing.value = false
|
isGesturing.value = false;
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -454,29 +466,29 @@ function MobileSidebar({
|
|||||||
animateToClose,
|
animateToClose,
|
||||||
animateToOpen,
|
animateToOpen,
|
||||||
handleClose,
|
handleClose,
|
||||||
]
|
],
|
||||||
)
|
);
|
||||||
|
|
||||||
const mobileSidebarInsetStyle = useMemo(
|
const mobileSidebarInsetStyle = useMemo(
|
||||||
() => ({ width: windowWidth, paddingTop: insetsTop, paddingBottom: insetsBottom }),
|
() => ({ width: windowWidth, paddingTop: insetsTop, paddingBottom: insetsBottom }),
|
||||||
[windowWidth, insetsTop, insetsBottom]
|
[windowWidth, insetsTop, insetsBottom],
|
||||||
)
|
);
|
||||||
|
|
||||||
const hostStatusDotStyle = useMemo(
|
const hostStatusDotStyle = useMemo(
|
||||||
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
|
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
|
||||||
[activeHostStatusColor]
|
[activeHostStatusColor],
|
||||||
)
|
);
|
||||||
|
|
||||||
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
|
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
transform: [{ translateX: translateX.value }],
|
transform: [{ translateX: translateX.value }],
|
||||||
}))
|
}));
|
||||||
|
|
||||||
const backdropAnimatedStyle = useAnimatedStyle(() => ({
|
const backdropAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
opacity: backdropOpacity.value,
|
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 (
|
return (
|
||||||
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||||
@@ -554,7 +566,7 @@ function MobileSidebar({
|
|||||||
<TooltipContent side="top" align="center" offset={8}>
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
<View style={styles.tooltipRow}>
|
<View style={styles.tooltipRow}>
|
||||||
<Text style={styles.tooltipText}>Add project</Text>
|
<Text style={styles.tooltipText}>Add project</Text>
|
||||||
<Shortcut keys={['⌘', '⇧', 'O']} />
|
<Shortcut keys={["⌘", "⇧", "O"]} />
|
||||||
</View>
|
</View>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -578,7 +590,7 @@ function MobileSidebar({
|
|||||||
</View>
|
</View>
|
||||||
<Combobox
|
<Combobox
|
||||||
options={hostOptions}
|
options={hostOptions}
|
||||||
value={activeServerId ?? ''}
|
value={activeServerId ?? ""}
|
||||||
onSelect={handleHostSelect}
|
onSelect={handleHostSelect}
|
||||||
searchable={false}
|
searchable={false}
|
||||||
title="Switch host"
|
title="Switch host"
|
||||||
@@ -592,7 +604,7 @@ function MobileSidebar({
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
</View>
|
</View>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DesktopSidebar({
|
function DesktopSidebar({
|
||||||
@@ -619,15 +631,15 @@ function DesktopSidebar({
|
|||||||
isOpen,
|
isOpen,
|
||||||
handleViewMore,
|
handleViewMore,
|
||||||
}: DesktopSidebarProps) {
|
}: DesktopSidebarProps) {
|
||||||
const dragHandlers = useDesktopDragHandlers()
|
const dragHandlers = useDesktopDragHandlers();
|
||||||
const trafficLightPadding = useTrafficLightPadding()
|
const trafficLightPadding = useTrafficLightPadding();
|
||||||
const hostStatusDotStyle = useMemo(
|
const hostStatusDotStyle = useMemo(
|
||||||
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
|
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
|
||||||
[activeHostStatusColor]
|
[activeHostStatusColor],
|
||||||
)
|
);
|
||||||
|
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -697,7 +709,7 @@ function DesktopSidebar({
|
|||||||
<TooltipContent side="top" align="center" offset={8}>
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
<View style={styles.tooltipRow}>
|
<View style={styles.tooltipRow}>
|
||||||
<Text style={styles.tooltipText}>Add project</Text>
|
<Text style={styles.tooltipText}>Add project</Text>
|
||||||
<Shortcut keys={['⌘', '⇧', 'O']} />
|
<Shortcut keys={["⌘", "⇧", "O"]} />
|
||||||
</View>
|
</View>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -721,7 +733,7 @@ function DesktopSidebar({
|
|||||||
</View>
|
</View>
|
||||||
<Combobox
|
<Combobox
|
||||||
options={hostOptions}
|
options={hostOptions}
|
||||||
value={activeServerId ?? ''}
|
value={activeServerId ?? ""}
|
||||||
onSelect={handleHostSelect}
|
onSelect={handleHostSelect}
|
||||||
searchable={false}
|
searchable={false}
|
||||||
title="Switch host"
|
title="Switch host"
|
||||||
@@ -732,29 +744,29 @@ function DesktopSidebar({
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
backdrop: {
|
backdrop: {
|
||||||
...StyleSheet.absoluteFillObject,
|
...StyleSheet.absoluteFillObject,
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
},
|
},
|
||||||
backdropPressable: {
|
backdropPressable: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
mobileSidebar: {
|
mobileSidebar: {
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
backgroundColor: theme.colors.surfaceSidebar,
|
backgroundColor: theme.colors.surfaceSidebar,
|
||||||
overflow: 'hidden',
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
sidebarContent: {
|
sidebarContent: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
overflow: 'hidden',
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
desktopSidebar: {
|
desktopSidebar: {
|
||||||
borderRightWidth: 1,
|
borderRightWidth: 1,
|
||||||
@@ -767,21 +779,21 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
md: HEADER_INNER_HEIGHT,
|
md: HEADER_INNER_HEIGHT,
|
||||||
},
|
},
|
||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: theme.colors.border,
|
borderBottomColor: theme.colors.border,
|
||||||
userSelect: 'none',
|
userSelect: "none",
|
||||||
},
|
},
|
||||||
sidebarHeaderRow: {
|
sidebarHeaderRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'space-between',
|
justifyContent: "space-between",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
newAgentButton: {
|
newAgentButton: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
@@ -802,9 +814,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
},
|
},
|
||||||
hostTrigger: {
|
hostTrigger: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'flex-start',
|
justifyContent: "flex-start",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
@@ -826,9 +838,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
sidebarFooter: {
|
sidebarFooter: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'space-between',
|
justifyContent: "space-between",
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
paddingVertical: theme.spacing[3],
|
paddingVertical: theme.spacing[3],
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
@@ -841,16 +853,16 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
marginRight: theme.spacing[2],
|
marginRight: theme.spacing[2],
|
||||||
},
|
},
|
||||||
footerIconRow: {
|
footerIconRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
footerIconButton: {
|
footerIconButton: {
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
paddingHorizontal: theme.spacing[1],
|
paddingHorizontal: theme.spacing[1],
|
||||||
},
|
},
|
||||||
@@ -876,19 +888,19 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
backgroundColor: theme.colors.surface0,
|
backgroundColor: theme.colors.surface0,
|
||||||
borderWidth: theme.borderWidth[1],
|
borderWidth: theme.borderWidth[1],
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.border,
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
hostPickerCancelText: {
|
hostPickerCancelText: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
tooltipRow: {
|
tooltipRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
tooltipText: {
|
tooltipText: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
color: theme.colors.popoverForeground,
|
color: theme.colors.popoverForeground,
|
||||||
},
|
},
|
||||||
}))
|
}));
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -40,11 +40,7 @@ import {
|
|||||||
TriangleAlertIcon,
|
TriangleAlertIcon,
|
||||||
Scissors,
|
Scissors,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import {
|
import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
|
||||||
StyleSheet,
|
|
||||||
useUnistyles,
|
|
||||||
UnistylesRuntime,
|
|
||||||
} from "react-native-unistyles";
|
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
cancelAnimation,
|
cancelAnimation,
|
||||||
@@ -53,23 +49,14 @@ import Animated, {
|
|||||||
withRepeat,
|
withRepeat,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import Svg, {
|
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
||||||
Defs,
|
|
||||||
LinearGradient as SvgLinearGradient,
|
|
||||||
Rect,
|
|
||||||
Stop,
|
|
||||||
} from "react-native-svg";
|
|
||||||
import { theme } from "@/styles/theme";
|
import { theme } from "@/styles/theme";
|
||||||
import {
|
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||||
createMarkdownStyles,
|
|
||||||
} from "@/styles/markdown-styles";
|
|
||||||
import { Colors, Fonts } from "@/constants/theme";
|
import { Colors, Fonts } from "@/constants/theme";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
|
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
|
||||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||||
import {
|
import { buildToolCallDisplayModel } from "@/utils/tool-call-display";
|
||||||
buildToolCallDisplayModel,
|
|
||||||
} from "@/utils/tool-call-display";
|
|
||||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||||
import {
|
import {
|
||||||
hasMeaningfulToolCallDetail,
|
hasMeaningfulToolCallDetail,
|
||||||
@@ -159,10 +146,7 @@ function ensureWebToolCallShimmerKeyframes() {
|
|||||||
webToolCallShimmerRegistered = true;
|
webToolCallShimmerRegistered = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWheelEventElementTarget(
|
function getWheelEventElementTarget(event: WheelEvent, fallback: HTMLElement): HTMLElement {
|
||||||
event: WheelEvent,
|
|
||||||
fallback: HTMLElement
|
|
||||||
): HTMLElement {
|
|
||||||
const { target } = event;
|
const { target } = event;
|
||||||
if (target instanceof HTMLElement) {
|
if (target instanceof HTMLElement) {
|
||||||
return target;
|
return target;
|
||||||
@@ -176,7 +160,7 @@ function getWheelEventElementTarget(
|
|||||||
function canElementScrollInDirection(
|
function canElementScrollInDirection(
|
||||||
element: HTMLElement,
|
element: HTMLElement,
|
||||||
axis: ScrollAxis,
|
axis: ScrollAxis,
|
||||||
delta: number
|
delta: number,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (delta === 0) {
|
if (delta === 0) {
|
||||||
return false;
|
return false;
|
||||||
@@ -192,7 +176,9 @@ function canElementScrollInDirection(
|
|||||||
|
|
||||||
const scrollPosition = axis === "x" ? element.scrollLeft : element.scrollTop;
|
const scrollPosition = axis === "x" ? element.scrollLeft : element.scrollTop;
|
||||||
const scrollSize =
|
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) {
|
if (scrollSize <= SCROLL_EDGE_EPSILON) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -207,7 +193,7 @@ function canScrollInsideDetailFromTarget(
|
|||||||
detailRoot: HTMLElement,
|
detailRoot: HTMLElement,
|
||||||
startElement: HTMLElement,
|
startElement: HTMLElement,
|
||||||
axis: ScrollAxis,
|
axis: ScrollAxis,
|
||||||
delta: number
|
delta: number,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (delta === 0) {
|
if (delta === 0) {
|
||||||
return false;
|
return false;
|
||||||
@@ -226,14 +212,10 @@ function canScrollInsideDetailFromTarget(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldStopDetailWheelPropagation(
|
function shouldStopDetailWheelPropagation(detailRoot: HTMLElement, event: WheelEvent): boolean {
|
||||||
detailRoot: HTMLElement,
|
|
||||||
event: WheelEvent
|
|
||||||
): boolean {
|
|
||||||
const startElement = getWheelEventElementTarget(event, detailRoot);
|
const startElement = getWheelEventElementTarget(event, detailRoot);
|
||||||
const verticalDelta = event.deltaY;
|
const verticalDelta = event.deltaY;
|
||||||
const horizontalDelta =
|
const horizontalDelta = event.deltaX !== 0 ? event.deltaX : event.shiftKey ? event.deltaY : 0;
|
||||||
event.deltaX !== 0 ? event.deltaX : (event.shiftKey ? event.deltaY : 0);
|
|
||||||
|
|
||||||
const hasVerticalIntent = Math.abs(verticalDelta) > SCROLL_EDGE_EPSILON;
|
const hasVerticalIntent = Math.abs(verticalDelta) > SCROLL_EDGE_EPSILON;
|
||||||
const hasHorizontalIntent = Math.abs(horizontalDelta) > SCROLL_EDGE_EPSILON;
|
const hasHorizontalIntent = Math.abs(horizontalDelta) > SCROLL_EDGE_EPSILON;
|
||||||
@@ -245,12 +227,7 @@ function shouldStopDetailWheelPropagation(
|
|||||||
? canScrollInsideDetailFromTarget(detailRoot, startElement, "y", verticalDelta)
|
? canScrollInsideDetailFromTarget(detailRoot, startElement, "y", verticalDelta)
|
||||||
: false;
|
: false;
|
||||||
const canScrollHorizontally = hasHorizontalIntent
|
const canScrollHorizontally = hasHorizontalIntent
|
||||||
? canScrollInsideDetailFromTarget(
|
? canScrollInsideDetailFromTarget(detailRoot, startElement, "x", horizontalDelta)
|
||||||
detailRoot,
|
|
||||||
startElement,
|
|
||||||
"x",
|
|
||||||
horizontalDelta
|
|
||||||
)
|
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
if (hasVerticalIntent && hasHorizontalIntent) {
|
if (hasVerticalIntent && hasHorizontalIntent) {
|
||||||
@@ -336,11 +313,7 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function UserMessageAttachmentThumbnail({
|
function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAttachment }) {
|
||||||
image,
|
|
||||||
}: {
|
|
||||||
image: UserMessageImageAttachment;
|
|
||||||
}) {
|
|
||||||
const uri = useAttachmentPreviewUrl(image);
|
const uri = useAttachmentPreviewUrl(image);
|
||||||
if (!uri) {
|
if (!uri) {
|
||||||
return <View style={userMessageStylesheet.imageThumbnailPlaceholder} />;
|
return <View style={userMessageStylesheet.imageThumbnailPlaceholder} />;
|
||||||
@@ -358,12 +331,10 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
}: UserMessageProps) {
|
}: UserMessageProps) {
|
||||||
const [messageHovered, setMessageHovered] = useState(false);
|
const [messageHovered, setMessageHovered] = useState(false);
|
||||||
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
|
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
|
||||||
const resolvedDisableOuterSpacing =
|
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||||
useDisableOuterSpacing(disableOuterSpacing);
|
|
||||||
const hasText = message.trim().length > 0;
|
const hasText = message.trim().length > 0;
|
||||||
const hasImages = images.length > 0;
|
const hasImages = images.length > 0;
|
||||||
const showCopyButton =
|
const showCopyButton = hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered);
|
||||||
hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -372,20 +343,14 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
!resolvedDisableOuterSpacing && [
|
!resolvedDisableOuterSpacing && [
|
||||||
isFirstInGroup && { marginTop: theme.spacing[4] },
|
isFirstInGroup && { marginTop: theme.spacing[4] },
|
||||||
isLastInGroup && { marginBottom: theme.spacing[4] },
|
isLastInGroup && { marginBottom: theme.spacing[4] },
|
||||||
!isFirstInGroup || !isLastInGroup
|
!isFirstInGroup || !isLastInGroup ? { marginBottom: theme.spacing[1] } : undefined,
|
||||||
? { marginBottom: theme.spacing[1] }
|
|
||||||
: undefined,
|
|
||||||
],
|
],
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={userMessageStylesheet.content}
|
style={userMessageStylesheet.content}
|
||||||
onHoverIn={
|
onHoverIn={Platform.OS === "web" ? () => setMessageHovered(true) : undefined}
|
||||||
Platform.OS === "web" ? () => setMessageHovered(true) : undefined
|
onHoverOut={Platform.OS === "web" ? () => setMessageHovered(false) : undefined}
|
||||||
}
|
|
||||||
onHoverOut={
|
|
||||||
Platform.OS === "web" ? () => setMessageHovered(false) : undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<View style={userMessageStylesheet.bubble}>
|
<View style={userMessageStylesheet.bubble}>
|
||||||
{hasImages ? (
|
{hasImages ? (
|
||||||
@@ -396,10 +361,7 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{images.map((image, index) => (
|
{images.map((image, index) => (
|
||||||
<View
|
<View key={`${image.id}-${index}`} style={userMessageStylesheet.imagePill}>
|
||||||
key={`${image.id}-${index}`}
|
|
||||||
style={userMessageStylesheet.imagePill}
|
|
||||||
>
|
|
||||||
<UserMessageAttachmentThumbnail image={image} />
|
<UserMessageAttachmentThumbnail image={image} />
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@@ -475,11 +437,7 @@ function MarkdownLink({
|
|||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
if (Platform.OS !== "web") {
|
if (Platform.OS !== "web") {
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text accessibilityRole="link" onPress={() => onPress(href)} style={style}>
|
||||||
accessibilityRole="link"
|
|
||||||
onPress={() => onPress(href)}
|
|
||||||
style={style}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
@@ -492,25 +450,25 @@ function MarkdownLink({
|
|||||||
onHoverIn={() => setHovered(true)}
|
onHoverIn={() => setHovered(true)}
|
||||||
onHoverOut={() => setHovered(false)}
|
onHoverOut={() => setHovered(false)}
|
||||||
>
|
>
|
||||||
<Text style={[style, hovered && { textDecorationLine: "underline" }]}>
|
<Text style={[style, hovered && { textDecorationLine: "underline" }]}>{children}</Text>
|
||||||
{children}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInlineCodeAutoLinkUrl(
|
function getInlineCodeAutoLinkUrl(
|
||||||
markdownParser: ReturnType<typeof MarkdownIt>,
|
markdownParser: ReturnType<typeof MarkdownIt>,
|
||||||
content: string
|
content: string,
|
||||||
): string | null {
|
): string | null {
|
||||||
const trimmed = content.trim();
|
const trimmed = content.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const matches = markdownParser.linkify.match(trimmed) as
|
const matches = markdownParser.linkify.match(trimmed) as Array<{
|
||||||
| Array<{ index: number; lastIndex: number; url: string }>
|
index: number;
|
||||||
| null;
|
lastIndex: number;
|
||||||
|
url: string;
|
||||||
|
}> | null;
|
||||||
if (!matches || matches.length !== 1) {
|
if (!matches || matches.length !== 1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -604,9 +562,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({
|
|||||||
style={[turnCopyButtonStylesheet.container, containerStyle]}
|
style={[turnCopyButtonStylesheet.container, containerStyle]}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={
|
accessibilityLabel={
|
||||||
copied
|
copied ? (copiedAccessibilityLabel ?? "Copied") : (accessibilityLabel ?? "Copy turn")
|
||||||
? (copiedAccessibilityLabel ?? "Copied")
|
|
||||||
: (accessibilityLabel ?? "Copy turn")
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{({ hovered }) => {
|
{({ hovered }) => {
|
||||||
@@ -767,41 +723,38 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { theme, rt } = useUnistyles();
|
const { theme, rt } = useUnistyles();
|
||||||
const resolvedDisableOuterSpacing =
|
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||||
useDisableOuterSpacing(disableOuterSpacing);
|
|
||||||
|
|
||||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [rt.themeName]);
|
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [rt.themeName]);
|
||||||
|
|
||||||
const markdownParser = useMemo(
|
const markdownParser = useMemo(() => {
|
||||||
() => {
|
const parser = MarkdownIt({ typographer: true, linkify: true });
|
||||||
const parser = MarkdownIt({ typographer: true, linkify: true });
|
const defaultValidateLink = parser.validateLink.bind(parser);
|
||||||
const defaultValidateLink = parser.validateLink.bind(parser);
|
parser.validateLink = (url: string) => {
|
||||||
parser.validateLink = (url: string) => {
|
if (url.trim().toLowerCase().startsWith("file://")) {
|
||||||
if (url.trim().toLowerCase().startsWith("file://")) {
|
return true;
|
||||||
return true;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return defaultValidateLink(url);
|
return defaultValidateLink(url);
|
||||||
};
|
};
|
||||||
return parser;
|
return parser;
|
||||||
},
|
}, []);
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleLinkPress = useCallback((url: string) => {
|
const handleLinkPress = useCallback(
|
||||||
const fileTarget = onInlinePathPress
|
(url: string) => {
|
||||||
? parseAssistantFileLink(url, { workspaceRoot })
|
const fileTarget = onInlinePathPress ? parseAssistantFileLink(url, { workspaceRoot }) : null;
|
||||||
: null;
|
if (fileTarget) {
|
||||||
if (fileTarget) {
|
onInlinePathPress?.(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;
|
return false;
|
||||||
}
|
},
|
||||||
|
[onInlinePathPress, workspaceRoot],
|
||||||
void openExternalUrl(url);
|
);
|
||||||
// react-native-markdown-display opens the link itself when this returns true.
|
|
||||||
// We already handled it above, so return false to avoid duplicate opens.
|
|
||||||
return false;
|
|
||||||
}, [onInlinePathPress, workspaceRoot]);
|
|
||||||
|
|
||||||
const markdownRules = useMemo(() => {
|
const markdownRules = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
@@ -810,7 +763,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
_children: ReactNode[],
|
_children: ReactNode[],
|
||||||
_parent: any,
|
_parent: any,
|
||||||
styles: any,
|
styles: any,
|
||||||
inheritedStyles: any = {}
|
inheritedStyles: any = {},
|
||||||
) => (
|
) => (
|
||||||
<Text key={node.key} style={[inheritedStyles, styles.text]}>
|
<Text key={node.key} style={[inheritedStyles, styles.text]}>
|
||||||
{node.content}
|
{node.content}
|
||||||
@@ -821,12 +774,9 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
children: ReactNode[],
|
children: ReactNode[],
|
||||||
_parent: any,
|
_parent: any,
|
||||||
styles: any,
|
styles: any,
|
||||||
inheritedStyles: any = {}
|
inheritedStyles: any = {},
|
||||||
) => (
|
) => (
|
||||||
<Text
|
<Text key={node.key} style={[inheritedStyles, styles.textgroup]}>
|
||||||
key={node.key}
|
|
||||||
style={[inheritedStyles, styles.textgroup]}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
@@ -835,12 +785,9 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
_children: ReactNode[],
|
_children: ReactNode[],
|
||||||
_parent: any,
|
_parent: any,
|
||||||
styles: any,
|
styles: any,
|
||||||
inheritedStyles: any = {}
|
inheritedStyles: any = {},
|
||||||
) => (
|
) => (
|
||||||
<Text
|
<Text key={node.key} style={[inheritedStyles, styles.code_block]}>
|
||||||
key={node.key}
|
|
||||||
style={[inheritedStyles, styles.code_block]}
|
|
||||||
>
|
|
||||||
{node.content}
|
{node.content}
|
||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
@@ -849,7 +796,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
_children: ReactNode[],
|
_children: ReactNode[],
|
||||||
_parent: any,
|
_parent: any,
|
||||||
styles: any,
|
styles: any,
|
||||||
inheritedStyles: any = {}
|
inheritedStyles: any = {},
|
||||||
) => (
|
) => (
|
||||||
<Text key={node.key} style={[inheritedStyles, styles.fence]}>
|
<Text key={node.key} style={[inheritedStyles, styles.fence]}>
|
||||||
{node.content}
|
{node.content}
|
||||||
@@ -860,16 +807,14 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
_children: ReactNode[],
|
_children: ReactNode[],
|
||||||
parent: any,
|
parent: any,
|
||||||
styles: any,
|
styles: any,
|
||||||
inheritedStyles: any = {}
|
inheritedStyles: any = {},
|
||||||
) => {
|
) => {
|
||||||
const content = node.content ?? "";
|
const content = node.content ?? "";
|
||||||
const isLinkedInlineCode =
|
const isLinkedInlineCode =
|
||||||
nodeHasParentType(parent, "link") ||
|
nodeHasParentType(parent, "link") ||
|
||||||
(!Array.isArray(parent) &&
|
(!Array.isArray(parent) && typeof parent?.attributes?.href === "string");
|
||||||
typeof parent?.attributes?.href === "string");
|
const parsed =
|
||||||
const parsed = onInlinePathPress && !isLinkedInlineCode
|
onInlinePathPress && !isLinkedInlineCode ? parseInlinePathToken(content) : null;
|
||||||
? parseInlinePathToken(content)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
return (
|
return (
|
||||||
@@ -877,10 +822,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
key={node.key}
|
key={node.key}
|
||||||
onPress={() => parsed && onInlinePathPress?.(parsed)}
|
onPress={() => parsed && onInlinePathPress?.(parsed)}
|
||||||
selectable={false}
|
selectable={false}
|
||||||
style={[
|
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
|
||||||
assistantMessageStylesheet.pathChip,
|
|
||||||
assistantMessageStylesheet.pathChipText,
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -902,81 +844,42 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text key={node.key} style={[inheritedStyles, styles.code_inline]}>
|
||||||
key={node.key}
|
|
||||||
style={[inheritedStyles, styles.code_inline]}
|
|
||||||
>
|
|
||||||
{content}
|
{content}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
bullet_list: (
|
bullet_list: (node: any, children: ReactNode[], _parent: any, styles: any) => (
|
||||||
node: any,
|
|
||||||
children: ReactNode[],
|
|
||||||
_parent: any,
|
|
||||||
styles: any
|
|
||||||
) => (
|
|
||||||
<View key={node.key} style={styles.bullet_list}>
|
<View key={node.key} style={styles.bullet_list}>
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
ordered_list: (
|
ordered_list: (node: any, children: ReactNode[], _parent: any, styles: any) => (
|
||||||
node: any,
|
|
||||||
children: ReactNode[],
|
|
||||||
_parent: any,
|
|
||||||
styles: any
|
|
||||||
) => (
|
|
||||||
<View key={node.key} style={styles.ordered_list}>
|
<View key={node.key} style={styles.ordered_list}>
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
list_item: (
|
list_item: (node: any, children: ReactNode[], parent: any, styles: any) => {
|
||||||
node: any,
|
|
||||||
children: ReactNode[],
|
|
||||||
parent: any,
|
|
||||||
styles: any
|
|
||||||
) => {
|
|
||||||
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
|
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
|
||||||
const iconStyle = isOrdered
|
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
|
||||||
? styles.ordered_list_icon
|
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
|
||||||
: styles.bullet_list_icon;
|
|
||||||
const contentStyle = isOrdered
|
|
||||||
? styles.ordered_list_content
|
|
||||||
: styles.bullet_list_content;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View key={node.key} style={styles.list_item}>
|
<View key={node.key} style={styles.list_item}>
|
||||||
<Text style={iconStyle}>{marker}</Text>
|
<Text style={iconStyle}>{marker}</Text>
|
||||||
<View
|
<View style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</View>
|
||||||
style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
paragraph: (
|
paragraph: (node: any, children: ReactNode[], parent: any, styles: any) => {
|
||||||
node: any,
|
|
||||||
children: ReactNode[],
|
|
||||||
parent: any,
|
|
||||||
styles: any,
|
|
||||||
) => {
|
|
||||||
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
|
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
|
||||||
return (
|
return (
|
||||||
<View
|
<View key={node.key} style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}>
|
||||||
key={node.key}
|
|
||||||
style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
link: (
|
link: (node: any, children: ReactNode[], _parent: any, styles: any) => (
|
||||||
node: any,
|
|
||||||
children: ReactNode[],
|
|
||||||
_parent: any,
|
|
||||||
styles: any,
|
|
||||||
) => (
|
|
||||||
<MarkdownLink
|
<MarkdownLink
|
||||||
key={node.key}
|
key={node.key}
|
||||||
href={node.attributes?.href ?? ""}
|
href={node.attributes?.href ?? ""}
|
||||||
@@ -985,8 +888,10 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
>
|
>
|
||||||
{Children.map(children, (child) =>
|
{Children.map(children, (child) =>
|
||||||
isValidElement(child)
|
isValidElement(child)
|
||||||
? cloneElement(child, { style: [(child.props as any).style, { color: styles.link.color }] } as any)
|
? cloneElement(child, {
|
||||||
: child
|
style: [(child.props as any).style, { color: styles.link.color }],
|
||||||
|
} as any)
|
||||||
|
: child,
|
||||||
)}
|
)}
|
||||||
</MarkdownLink>
|
</MarkdownLink>
|
||||||
),
|
),
|
||||||
@@ -998,8 +903,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
testID="assistant-message"
|
testID="assistant-message"
|
||||||
style={[
|
style={[
|
||||||
assistantMessageStylesheet.container,
|
assistantMessageStylesheet.container,
|
||||||
!resolvedDisableOuterSpacing &&
|
!resolvedDisableOuterSpacing && assistantMessageStylesheet.containerSpacing,
|
||||||
assistantMessageStylesheet.containerSpacing,
|
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Markdown
|
<Markdown
|
||||||
@@ -1109,8 +1013,7 @@ export const ActivityLog = memo(function ActivityLog({
|
|||||||
onArtifactClick,
|
onArtifactClick,
|
||||||
disableOuterSpacing,
|
disableOuterSpacing,
|
||||||
}: ActivityLogProps) {
|
}: ActivityLogProps) {
|
||||||
const resolvedDisableOuterSpacing =
|
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||||
useDisableOuterSpacing(disableOuterSpacing);
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
const typeConfig = {
|
const typeConfig = {
|
||||||
@@ -1149,9 +1052,7 @@ export const ActivityLog = memo(function ActivityLog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const displayMessage =
|
const displayMessage =
|
||||||
type === "artifact" && artifactType && title
|
type === "artifact" && artifactType && title ? `${artifactType}: ${title}` : message;
|
||||||
? `${artifactType}: ${title}`
|
|
||||||
: message;
|
|
||||||
|
|
||||||
const isInteractive = type === "artifact" || metadata;
|
const isInteractive = type === "artifact" || metadata;
|
||||||
|
|
||||||
@@ -1172,12 +1073,7 @@ export const ActivityLog = memo(function ActivityLog({
|
|||||||
<IconComponent size={16} color={config.color} />
|
<IconComponent size={16} color={config.color} />
|
||||||
</View>
|
</View>
|
||||||
<View style={activityLogStylesheet.textContainer}>
|
<View style={activityLogStylesheet.textContainer}>
|
||||||
<Text
|
<Text style={[activityLogStylesheet.messageText, { color: config.color }]}>
|
||||||
style={[
|
|
||||||
activityLogStylesheet.messageText,
|
|
||||||
{ color: config.color },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{displayMessage}
|
{displayMessage}
|
||||||
</Text>
|
</Text>
|
||||||
{metadata && (
|
{metadata && (
|
||||||
@@ -1402,21 +1298,17 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
testID,
|
testID,
|
||||||
}: ExpandableBadgeProps) {
|
}: ExpandableBadgeProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const resolvedDisableOuterSpacing =
|
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||||
useDisableOuterSpacing(disableOuterSpacing);
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const [isPressed, setIsPressed] = useState(false);
|
const [isPressed, setIsPressed] = useState(false);
|
||||||
const isInteractive = Boolean(onToggle);
|
const isInteractive = Boolean(onToggle);
|
||||||
const hasDetailContent = Boolean(renderDetails);
|
const hasDetailContent = Boolean(renderDetails);
|
||||||
const detailContent =
|
const detailContent = hasDetailContent && isExpanded ? renderDetails?.() : null;
|
||||||
hasDetailContent && isExpanded ? renderDetails?.() : null;
|
|
||||||
const detailWrapperRef = useRef<View | null>(null);
|
const detailWrapperRef = useRef<View | null>(null);
|
||||||
const wheelInvestigationComponentId = `ExpandableBadgeWheel:${
|
const wheelInvestigationComponentId = `ExpandableBadgeWheel:${testID ?? label}`;
|
||||||
testID ?? label
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const nativeGradientIdRef = useRef(
|
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 [labelRowWidth, setLabelRowWidth] = useState(0);
|
||||||
const [labelRowHeight, setLabelRowHeight] = useState(0);
|
const [labelRowHeight, setLabelRowHeight] = useState(0);
|
||||||
@@ -1426,77 +1318,64 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
const [secondaryWidth, setSecondaryWidth] = useState(0);
|
const [secondaryWidth, setSecondaryWidth] = useState(0);
|
||||||
const shimmerTranslateX = useSharedValue(0);
|
const shimmerTranslateX = useSharedValue(0);
|
||||||
|
|
||||||
const totalShimmerChars =
|
const totalShimmerChars = label.trim().length + (secondaryLabel?.trim().length ?? 0);
|
||||||
label.trim().length + (secondaryLabel?.trim().length ?? 0);
|
|
||||||
const shortTextDurationAdjustment = totalShimmerChars <= 12 ? 0.25 : 0;
|
const shortTextDurationAdjustment = totalShimmerChars <= 12 ? 0.25 : 0;
|
||||||
const shimmerDuration = Math.max(
|
const shimmerDuration = Math.max(
|
||||||
1,
|
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(
|
const nativeShimmerPeakWidth = Math.max(
|
||||||
32,
|
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 isWebShimmer = isLoading && Platform.OS === "web";
|
||||||
const shouldMeasureWebShimmer = isWebShimmer;
|
const shouldMeasureWebShimmer = isWebShimmer;
|
||||||
const shouldMeasureNativeShimmer = isLoading && Platform.OS !== "web";
|
const shouldMeasureNativeShimmer = isLoading && Platform.OS !== "web";
|
||||||
const isNativeShimmer =
|
const isNativeShimmer = shouldMeasureNativeShimmer && labelRowWidth > 0 && labelRowHeight > 0;
|
||||||
shouldMeasureNativeShimmer &&
|
|
||||||
labelRowWidth > 0 &&
|
|
||||||
labelRowHeight > 0;
|
|
||||||
const webShimmerSpanStartX = labelOffsetX;
|
const webShimmerSpanStartX = labelOffsetX;
|
||||||
const webShimmerSpanEndX = secondaryLabel
|
const webShimmerSpanEndX = secondaryLabel
|
||||||
? secondaryOffsetX + secondaryWidth
|
? secondaryOffsetX + secondaryWidth
|
||||||
: labelOffsetX + labelWidth;
|
: labelOffsetX + labelWidth;
|
||||||
const webShimmerSpanWidth = Math.max(
|
const webShimmerSpanWidth = Math.max(1, webShimmerSpanEndX - webShimmerSpanStartX);
|
||||||
1,
|
const webShimmerPeakWidth = Math.max(42, Math.min(120, webShimmerSpanWidth * 0.22));
|
||||||
webShimmerSpanEndX - webShimmerSpanStartX
|
|
||||||
);
|
|
||||||
const webShimmerPeakWidth = Math.max(
|
|
||||||
42,
|
|
||||||
Math.min(120, webShimmerSpanWidth * 0.22)
|
|
||||||
);
|
|
||||||
const webShimmerTrackStart = webShimmerSpanStartX - webShimmerPeakWidth;
|
const webShimmerTrackStart = webShimmerSpanStartX - webShimmerPeakWidth;
|
||||||
const webShimmerTrackEnd = webShimmerSpanEndX;
|
const webShimmerTrackEnd = webShimmerSpanEndX;
|
||||||
|
|
||||||
const handleLabelRowLayout = useCallback((event: LayoutChangeEvent) => {
|
const handleLabelRowLayout = useCallback(
|
||||||
if (!shouldMeasureNativeShimmer) {
|
(event: LayoutChangeEvent) => {
|
||||||
return;
|
if (!shouldMeasureNativeShimmer) {
|
||||||
}
|
return;
|
||||||
const { width, height } = event.nativeEvent.layout;
|
}
|
||||||
setLabelRowWidth((previous) =>
|
const { width, height } = event.nativeEvent.layout;
|
||||||
Math.abs(previous - width) > 0.5 ? width : previous
|
setLabelRowWidth((previous) => (Math.abs(previous - width) > 0.5 ? width : previous));
|
||||||
);
|
setLabelRowHeight((previous) => (Math.abs(previous - height) > 0.5 ? height : previous));
|
||||||
setLabelRowHeight((previous) =>
|
},
|
||||||
Math.abs(previous - height) > 0.5 ? height : previous
|
[shouldMeasureNativeShimmer],
|
||||||
);
|
);
|
||||||
}, [shouldMeasureNativeShimmer]);
|
|
||||||
|
|
||||||
const handleLabelLayout = useCallback((event: LayoutChangeEvent) => {
|
const handleLabelLayout = useCallback(
|
||||||
if (!shouldMeasureWebShimmer) {
|
(event: LayoutChangeEvent) => {
|
||||||
return;
|
if (!shouldMeasureWebShimmer) {
|
||||||
}
|
return;
|
||||||
const { x, width } = event.nativeEvent.layout;
|
}
|
||||||
setLabelOffsetX((previous) =>
|
const { x, width } = event.nativeEvent.layout;
|
||||||
Math.abs(previous - x) > 0.5 ? x : previous
|
setLabelOffsetX((previous) => (Math.abs(previous - x) > 0.5 ? x : previous));
|
||||||
);
|
setLabelWidth((previous) => (Math.abs(previous - width) > 0.5 ? width : previous));
|
||||||
setLabelWidth((previous) =>
|
},
|
||||||
Math.abs(previous - width) > 0.5 ? width : previous
|
[shouldMeasureWebShimmer],
|
||||||
);
|
);
|
||||||
}, [shouldMeasureWebShimmer]);
|
|
||||||
|
|
||||||
const handleSecondaryLayout = useCallback((event: LayoutChangeEvent) => {
|
const handleSecondaryLayout = useCallback(
|
||||||
if (!shouldMeasureWebShimmer || !secondaryLabel) {
|
(event: LayoutChangeEvent) => {
|
||||||
return;
|
if (!shouldMeasureWebShimmer || !secondaryLabel) {
|
||||||
}
|
return;
|
||||||
const { x, width } = event.nativeEvent.layout;
|
}
|
||||||
setSecondaryOffsetX((previous) =>
|
const { x, width } = event.nativeEvent.layout;
|
||||||
Math.abs(previous - x) > 0.5 ? x : previous
|
setSecondaryOffsetX((previous) => (Math.abs(previous - x) > 0.5 ? x : previous));
|
||||||
);
|
setSecondaryWidth((previous) => (Math.abs(previous - width) > 0.5 ? width : previous));
|
||||||
setSecondaryWidth((previous) =>
|
},
|
||||||
Math.abs(previous - width) > 0.5 ? width : previous
|
[shouldMeasureWebShimmer, secondaryLabel],
|
||||||
);
|
);
|
||||||
}, [shouldMeasureWebShimmer, secondaryLabel]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isWebShimmer) {
|
if (!isWebShimmer) {
|
||||||
@@ -1520,18 +1399,12 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
easing: Easing.linear,
|
easing: Easing.linear,
|
||||||
}),
|
}),
|
||||||
-1,
|
-1,
|
||||||
false
|
false,
|
||||||
);
|
);
|
||||||
return () => {
|
return () => {
|
||||||
cancelAnimation(shimmerTranslateX);
|
cancelAnimation(shimmerTranslateX);
|
||||||
};
|
};
|
||||||
}, [
|
}, [isNativeShimmer, labelRowWidth, nativeShimmerPeakWidth, shimmerDuration, shimmerTranslateX]);
|
||||||
isNativeShimmer,
|
|
||||||
labelRowWidth,
|
|
||||||
nativeShimmerPeakWidth,
|
|
||||||
shimmerDuration,
|
|
||||||
shimmerTranslateX,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) {
|
if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) {
|
||||||
@@ -1605,23 +1478,21 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
: expandableBadgeStylesheet.containerSpacing),
|
: expandableBadgeStylesheet.containerSpacing),
|
||||||
style,
|
style,
|
||||||
],
|
],
|
||||||
[isLastInSequence, resolvedDisableOuterSpacing, style]
|
[isLastInSequence, resolvedDisableOuterSpacing, style],
|
||||||
);
|
);
|
||||||
|
|
||||||
const pressableStyle = useMemo(
|
const pressableStyle = useMemo(
|
||||||
() => [
|
() => [
|
||||||
expandableBadgeStylesheet.pressable,
|
expandableBadgeStylesheet.pressable,
|
||||||
isPressed && isInteractive
|
isPressed && isInteractive ? expandableBadgeStylesheet.pressablePressed : null,
|
||||||
? expandableBadgeStylesheet.pressablePressed
|
|
||||||
: null,
|
|
||||||
isExpanded && expandableBadgeStylesheet.pressableExpanded,
|
isExpanded && expandableBadgeStylesheet.pressableExpanded,
|
||||||
],
|
],
|
||||||
[isExpanded, isInteractive, isPressed]
|
[isExpanded, isInteractive, isPressed],
|
||||||
);
|
);
|
||||||
|
|
||||||
const accessibilityState = useMemo(
|
const accessibilityState = useMemo(
|
||||||
() => (isInteractive ? { expanded: isExpanded } : undefined),
|
() => (isInteractive ? { expanded: isExpanded } : undefined),
|
||||||
[isExpanded, isInteractive]
|
[isExpanded, isInteractive],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isActive = isHovered || isExpanded;
|
const isActive = isHovered || isExpanded;
|
||||||
@@ -1632,7 +1503,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
isActive && expandableBadgeStylesheet.labelActive,
|
isActive && expandableBadgeStylesheet.labelActive,
|
||||||
isLoading && expandableBadgeStylesheet.labelLoading,
|
isLoading && expandableBadgeStylesheet.labelLoading,
|
||||||
],
|
],
|
||||||
[isActive, isLoading]
|
[isActive, isLoading],
|
||||||
);
|
);
|
||||||
|
|
||||||
const secondaryLabelStyle = useMemo(
|
const secondaryLabelStyle = useMemo(
|
||||||
@@ -1640,7 +1511,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
expandableBadgeStylesheet.secondaryLabel,
|
expandableBadgeStylesheet.secondaryLabel,
|
||||||
isActive && expandableBadgeStylesheet.secondaryLabelActive,
|
isActive && expandableBadgeStylesheet.secondaryLabelActive,
|
||||||
],
|
],
|
||||||
[isActive]
|
[isActive],
|
||||||
);
|
);
|
||||||
|
|
||||||
const shimmerLabelTextStyle = useMemo(
|
const shimmerLabelTextStyle = useMemo(
|
||||||
@@ -1650,7 +1521,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
expandableBadgeStylesheet.shimmerText,
|
expandableBadgeStylesheet.shimmerText,
|
||||||
shimmerLabelStyle,
|
shimmerLabelStyle,
|
||||||
],
|
],
|
||||||
[isLoading, shimmerLabelStyle]
|
[isLoading, shimmerLabelStyle],
|
||||||
);
|
);
|
||||||
|
|
||||||
const shimmerSecondaryTextStyle = useMemo(
|
const shimmerSecondaryTextStyle = useMemo(
|
||||||
@@ -1659,7 +1530,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
expandableBadgeStylesheet.shimmerText,
|
expandableBadgeStylesheet.shimmerText,
|
||||||
shimmerSecondaryStyle,
|
shimmerSecondaryStyle,
|
||||||
],
|
],
|
||||||
[shimmerSecondaryStyle]
|
[shimmerSecondaryStyle],
|
||||||
);
|
);
|
||||||
|
|
||||||
const nativeShimmerTrackStyle = useMemo(
|
const nativeShimmerTrackStyle = useMemo(
|
||||||
@@ -1667,7 +1538,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
expandableBadgeStylesheet.nativeShimmerTrack,
|
expandableBadgeStylesheet.nativeShimmerTrack,
|
||||||
{ width: labelRowWidth, height: labelRowHeight },
|
{ width: labelRowWidth, height: labelRowHeight },
|
||||||
],
|
],
|
||||||
[labelRowHeight, labelRowWidth]
|
[labelRowHeight, labelRowWidth],
|
||||||
);
|
);
|
||||||
|
|
||||||
const nativeShimmerMaskStyle = useMemo(
|
const nativeShimmerMaskStyle = useMemo(
|
||||||
@@ -1675,20 +1546,17 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
expandableBadgeStylesheet.shimmerMaskRow,
|
expandableBadgeStylesheet.shimmerMaskRow,
|
||||||
{ width: labelRowWidth, height: labelRowHeight },
|
{ width: labelRowWidth, height: labelRowHeight },
|
||||||
],
|
],
|
||||||
[labelRowHeight, labelRowWidth]
|
[labelRowHeight, labelRowWidth],
|
||||||
);
|
);
|
||||||
|
|
||||||
const nativeLabelMaskStyle = useMemo(
|
const nativeLabelMaskStyle = useMemo(
|
||||||
() => [expandableBadgeStylesheet.label, { color: "#000000", opacity: 1 }],
|
() => [expandableBadgeStylesheet.label, { color: "#000000", opacity: 1 }],
|
||||||
[]
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const nativeSecondaryMaskStyle = useMemo(
|
const nativeSecondaryMaskStyle = useMemo(
|
||||||
() => [
|
() => [expandableBadgeStylesheet.secondaryLabel, { color: "#000000", opacity: 1 }],
|
||||||
expandableBadgeStylesheet.secondaryLabel,
|
[],
|
||||||
{ color: "#000000", opacity: 1 },
|
|
||||||
],
|
|
||||||
[]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const nativeShimmerPeakCombinedStyle = useMemo(
|
const nativeShimmerPeakCombinedStyle = useMemo(
|
||||||
@@ -1697,7 +1565,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
nativeShimmerPeakStyle,
|
nativeShimmerPeakStyle,
|
||||||
{ width: nativeShimmerPeakWidth, height: labelRowHeight },
|
{ width: nativeShimmerPeakWidth, height: labelRowHeight },
|
||||||
],
|
],
|
||||||
[labelRowHeight, nativeShimmerPeakStyle, nativeShimmerPeakWidth]
|
[labelRowHeight, nativeShimmerPeakStyle, nativeShimmerPeakWidth],
|
||||||
);
|
);
|
||||||
|
|
||||||
const chevronStyle = useMemo(
|
const chevronStyle = useMemo(
|
||||||
@@ -1705,7 +1573,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
expandableBadgeStylesheet.chevron,
|
expandableBadgeStylesheet.chevron,
|
||||||
isExpanded && expandableBadgeStylesheet.chevronExpanded,
|
isExpanded && expandableBadgeStylesheet.chevronExpanded,
|
||||||
],
|
],
|
||||||
[isExpanded]
|
[isExpanded],
|
||||||
);
|
);
|
||||||
|
|
||||||
const IconComponent = icon;
|
const IconComponent = icon;
|
||||||
@@ -1723,10 +1591,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View style={containerStyle} testID={testID}>
|
||||||
style={containerStyle}
|
|
||||||
testID={testID}
|
|
||||||
>
|
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={isInteractive ? onToggle : undefined}
|
onPress={isInteractive ? onToggle : undefined}
|
||||||
onHoverIn={isInteractive ? () => setIsHovered(true) : undefined}
|
onHoverIn={isInteractive ? () => setIsHovered(true) : undefined}
|
||||||
@@ -1770,21 +1635,12 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
<View style={expandableBadgeStylesheet.spacer} />
|
<View style={expandableBadgeStylesheet.spacer} />
|
||||||
)}
|
)}
|
||||||
{isWebShimmer ? (
|
{isWebShimmer ? (
|
||||||
<View
|
<View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none">
|
||||||
style={expandableBadgeStylesheet.shimmerOverlay}
|
<Text style={shimmerLabelTextStyle} numberOfLines={1}>
|
||||||
pointerEvents="none"
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={shimmerLabelTextStyle}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
{secondaryLabel ? (
|
{secondaryLabel ? (
|
||||||
<Text
|
<Text style={shimmerSecondaryTextStyle} numberOfLines={1}>
|
||||||
style={shimmerSecondaryTextStyle}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{secondaryLabel}
|
{secondaryLabel}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
@@ -1793,25 +1649,16 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
{isNativeShimmer ? (
|
{isNativeShimmer ? (
|
||||||
<View
|
<View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none">
|
||||||
style={expandableBadgeStylesheet.shimmerOverlay}
|
|
||||||
pointerEvents="none"
|
|
||||||
>
|
|
||||||
<MaskedView
|
<MaskedView
|
||||||
style={nativeShimmerTrackStyle}
|
style={nativeShimmerTrackStyle}
|
||||||
maskElement={
|
maskElement={
|
||||||
<View style={nativeShimmerMaskStyle}>
|
<View style={nativeShimmerMaskStyle}>
|
||||||
<Text
|
<Text style={nativeLabelMaskStyle} numberOfLines={1}>
|
||||||
style={nativeLabelMaskStyle}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
{secondaryLabel ? (
|
{secondaryLabel ? (
|
||||||
<Text
|
<Text style={nativeSecondaryMaskStyle} numberOfLines={1}>
|
||||||
style={nativeSecondaryMaskStyle}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{secondaryLabel}
|
{secondaryLabel}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
@@ -1820,9 +1667,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View
|
<View style={nativeShimmerTrackStyle}>
|
||||||
style={nativeShimmerTrackStyle}
|
|
||||||
>
|
|
||||||
<Animated.View style={nativeShimmerPeakCombinedStyle}>
|
<Animated.View style={nativeShimmerPeakCombinedStyle}>
|
||||||
<Svg width="100%" height="100%" preserveAspectRatio="none">
|
<Svg width="100%" height="100%" preserveAspectRatio="none">
|
||||||
<Defs>
|
<Defs>
|
||||||
@@ -1853,11 +1698,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
{isInteractive && isHovered ? (
|
{isInteractive && isHovered ? (
|
||||||
<ChevronRight
|
<ChevronRight size={14} color={theme.colors.foreground} style={chevronStyle} />
|
||||||
size={14}
|
|
||||||
color={theme.colors.foreground}
|
|
||||||
style={chevronStyle}
|
|
||||||
/>
|
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -1875,10 +1716,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
);
|
);
|
||||||
}, areExpandableBadgePropsEqual);
|
}, areExpandableBadgePropsEqual);
|
||||||
|
|
||||||
function areExpandableBadgePropsEqual(
|
function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: ExpandableBadgeProps) {
|
||||||
previous: ExpandableBadgeProps,
|
|
||||||
next: ExpandableBadgeProps
|
|
||||||
) {
|
|
||||||
if (previous.label !== next.label) return false;
|
if (previous.label !== next.label) return false;
|
||||||
if (previous.secondaryLabel !== next.secondaryLabel) return false;
|
if (previous.secondaryLabel !== next.secondaryLabel) return false;
|
||||||
if (previous.icon !== next.icon) return false;
|
if (previous.icon !== next.icon) return false;
|
||||||
@@ -1932,9 +1770,7 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
// Check if we're on mobile (use bottom sheet) or desktop (inline expand)
|
// Check if we're on mobile (use bottom sheet) or desktop (inline expand)
|
||||||
const isMobile =
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
UnistylesRuntime.breakpoint === "xs" ||
|
|
||||||
UnistylesRuntime.breakpoint === "sm";
|
|
||||||
|
|
||||||
const effectiveDetail = useMemo<ToolCallDetail | undefined>(() => {
|
const effectiveDetail = useMemo<ToolCallDetail | undefined>(() => {
|
||||||
if (detail) {
|
if (detail) {
|
||||||
@@ -1950,12 +1786,11 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
return undefined;
|
return undefined;
|
||||||
}, [detail, args, result]);
|
}, [detail, args, result]);
|
||||||
|
|
||||||
const displayDetail =
|
const displayDetail = effectiveDetail ?? {
|
||||||
effectiveDetail ?? {
|
type: "unknown",
|
||||||
type: "unknown",
|
input: null,
|
||||||
input: null,
|
output: null,
|
||||||
output: null,
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const displayModel = useMemo(
|
const displayModel = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -1967,7 +1802,7 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
metadata,
|
metadata,
|
||||||
cwd,
|
cwd,
|
||||||
}),
|
}),
|
||||||
[toolName, status, error, displayDetail, metadata, cwd]
|
[toolName, status, error, displayDetail, metadata, cwd],
|
||||||
);
|
);
|
||||||
const displayName = displayModel.displayName;
|
const displayName = displayModel.displayName;
|
||||||
const summary = displayModel.summary;
|
const summary = displayModel.summary;
|
||||||
@@ -1981,9 +1816,7 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
const secondaryLabel = summary;
|
const secondaryLabel = summary;
|
||||||
|
|
||||||
// Check if there's any content to display
|
// Check if there's any content to display
|
||||||
const hasDetails =
|
const hasDetails = Boolean(error) || hasMeaningfulToolCallDetail(effectiveDetail);
|
||||||
Boolean(error) ||
|
|
||||||
hasMeaningfulToolCallDetail(effectiveDetail);
|
|
||||||
const canOpenDetails = hasDetails || isLoadingDetails;
|
const canOpenDetails = hasDetails || isLoadingDetails;
|
||||||
|
|
||||||
const handleToggle = useCallback(() => {
|
const handleToggle = useCallback(() => {
|
||||||
|
|||||||
@@ -18,12 +18,7 @@ export function ModeSelectorModal({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal visible={visible} animationType="fade" transparent={true} onRequestClose={onClose}>
|
||||||
visible={visible}
|
|
||||||
animationType="fade"
|
|
||||||
transparent={true}
|
|
||||||
onRequestClose={onClose}
|
|
||||||
>
|
|
||||||
<Pressable style={styles.modalOverlay} onPress={onClose}>
|
<Pressable style={styles.modalOverlay} onPress={onClose}>
|
||||||
<View style={styles.modeSelectorContent}>
|
<View style={styles.modeSelectorContent}>
|
||||||
{agent?.availableModes?.map((mode) => {
|
{agent?.availableModes?.map((mode) => {
|
||||||
@@ -35,26 +30,13 @@ export function ModeSelectorModal({
|
|||||||
onModeChange(mode.id);
|
onModeChange(mode.id);
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
style={[
|
style={[styles.modeItem, isActive && styles.modeItemActive]}
|
||||||
styles.modeItem,
|
|
||||||
isActive && styles.modeItemActive,
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
<Text
|
<Text style={[styles.modeName, isActive && styles.modeNameActive]}>
|
||||||
style={[
|
|
||||||
styles.modeName,
|
|
||||||
isActive && styles.modeNameActive,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{mode.label}
|
{mode.label}
|
||||||
</Text>
|
</Text>
|
||||||
{mode.description && (
|
{mode.description && (
|
||||||
<Text
|
<Text style={[styles.modeDescription, isActive && styles.modeDescriptionActive]}>
|
||||||
style={[
|
|
||||||
styles.modeDescription,
|
|
||||||
isActive && styles.modeDescriptionActive,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{mode.description}
|
{mode.description}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -81,10 +81,13 @@ export function NameHostModal({ visible, serverId, hostname, onSkip, onSave }: N
|
|||||||
}, [label, onSave, onSkip]);
|
}, [label, onSave, onSkip]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdaptiveModalSheet title="Name this host" visible={visible} onClose={onSkip} testID="name-host-modal">
|
<AdaptiveModalSheet
|
||||||
<Text style={styles.helper}>
|
title="Name this host"
|
||||||
Optional. You can rename this later in Settings.
|
visible={visible}
|
||||||
</Text>
|
onClose={onSkip}
|
||||||
|
testID="name-host-modal"
|
||||||
|
>
|
||||||
|
<Text style={styles.helper}>Optional. You can rename this later in Settings.</Text>
|
||||||
|
|
||||||
<View style={styles.field}>
|
<View style={styles.field}>
|
||||||
<Text style={styles.label}>Label</Text>
|
<Text style={styles.label}>Label</Text>
|
||||||
|
|||||||
@@ -48,15 +48,25 @@ export interface PairLinkModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
targetServerId?: string;
|
targetServerId?: string;
|
||||||
onCancel?: () => void;
|
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 { theme } = useUnistyles();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
||||||
const isMobile =
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
|
||||||
|
|
||||||
const [offerUrl, setOfferUrl] = useState("");
|
const [offerUrl, setOfferUrl] = useState("");
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
@@ -148,10 +158,24 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServe
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [daemons, handleClose, isMobile, isSaving, offerUrl, onSaved, targetServerId, upsertDaemonFromOfferUrl]);
|
}, [
|
||||||
|
daemons,
|
||||||
|
handleClose,
|
||||||
|
isMobile,
|
||||||
|
isSaving,
|
||||||
|
offerUrl,
|
||||||
|
onSaved,
|
||||||
|
targetServerId,
|
||||||
|
upsertDaemonFromOfferUrl,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
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>
|
<Text style={styles.helper}>Paste the pairing link from your server.</Text>
|
||||||
|
|
||||||
<View style={styles.field}>
|
<View style={styles.field}>
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native";
|
||||||
Modal,
|
|
||||||
Pressable,
|
|
||||||
ScrollView,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
View,
|
|
||||||
Platform,
|
|
||||||
} from "react-native";
|
|
||||||
import { Folder } from "lucide-react-native";
|
import { Folder } from "lucide-react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
@@ -37,7 +29,7 @@ export function ProjectPickerModal() {
|
|||||||
const client = useHostRuntimeClient(serverId ?? "");
|
const client = useHostRuntimeClient(serverId ?? "");
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||||
const workspaces = useSessionStore((state) =>
|
const workspaces = useSessionStore((state) =>
|
||||||
serverId ? state.sessions[serverId]?.workspaces : undefined
|
serverId ? state.sessions[serverId]?.workspaces : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
@@ -49,7 +41,7 @@ export function ProjectPickerModal() {
|
|||||||
const recommendedPaths = useMemo(() => {
|
const recommendedPaths = useMemo(() => {
|
||||||
if (!workspaces) return [];
|
if (!workspaces) return [];
|
||||||
return Array.from(workspaces.values()).map(
|
return Array.from(workspaces.values()).map(
|
||||||
(workspace) => workspace.projectRootPath || workspace.id
|
(workspace) => workspace.projectRootPath || workspace.id,
|
||||||
);
|
);
|
||||||
}, [workspaces]);
|
}, [workspaces]);
|
||||||
|
|
||||||
@@ -64,9 +56,7 @@ export function ProjectPickerModal() {
|
|||||||
limit: 30,
|
limit: 30,
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
result.entries?.flatMap((entry) =>
|
result.entries?.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])) ?? []
|
||||||
entry.kind === "directory" ? [entry.path] : []
|
|
||||||
) ?? []
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
enabled: Boolean(client) && isConnected && open,
|
enabled: Boolean(client) && isConnected && open,
|
||||||
@@ -81,7 +71,7 @@ export function ProjectPickerModal() {
|
|||||||
serverPaths: directorySuggestionsQuery.data ?? [],
|
serverPaths: directorySuggestionsQuery.data ?? [],
|
||||||
query,
|
query,
|
||||||
}),
|
}),
|
||||||
[query, directorySuggestionsQuery.data, recommendedPaths]
|
[query, directorySuggestionsQuery.data, recommendedPaths],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
@@ -103,7 +93,7 @@ export function ProjectPickerModal() {
|
|||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[client, openProject, serverId, setOpen]
|
[client, openProject, serverId, setOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmitCustom = useCallback(() => {
|
const handleSubmitCustom = useCallback(() => {
|
||||||
@@ -136,13 +126,7 @@ export function ProjectPickerModal() {
|
|||||||
|
|
||||||
function handler(event: KeyboardEvent) {
|
function handler(event: KeyboardEvent) {
|
||||||
const key = event.key;
|
const key = event.key;
|
||||||
if (
|
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") return;
|
||||||
key !== "ArrowDown" &&
|
|
||||||
key !== "ArrowUp" &&
|
|
||||||
key !== "Enter" &&
|
|
||||||
key !== "Escape"
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (key === "Escape") {
|
if (key === "Escape") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -180,12 +164,7 @@ export function ProjectPickerModal() {
|
|||||||
if (!serverId) return null;
|
if (!serverId) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal visible={open} transparent animationType="fade" onRequestClose={handleClose}>
|
||||||
visible={open}
|
|
||||||
transparent
|
|
||||||
animationType="fade"
|
|
||||||
onRequestClose={handleClose}
|
|
||||||
>
|
|
||||||
<View style={styles.overlay}>
|
<View style={styles.overlay}>
|
||||||
<Pressable style={styles.backdrop} onPress={handleClose} />
|
<Pressable style={styles.backdrop} onPress={handleClose} />
|
||||||
|
|
||||||
@@ -198,9 +177,7 @@ export function ProjectPickerModal() {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View
|
<View style={[styles.header, { borderBottomColor: theme.colors.border }]}>
|
||||||
style={[styles.header, { borderBottomColor: theme.colors.border }]}
|
|
||||||
>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={query}
|
value={query}
|
||||||
@@ -225,21 +202,11 @@ export function ProjectPickerModal() {
|
|||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<Text
|
<Text style={[styles.emptyText, { color: theme.colors.foregroundMuted }]}>
|
||||||
style={[
|
|
||||||
styles.emptyText,
|
|
||||||
{ color: theme.colors.foregroundMuted },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
Opening project...
|
Opening project...
|
||||||
</Text>
|
</Text>
|
||||||
) : options.length === 0 && !query.trim() ? (
|
) : options.length === 0 && !query.trim() ? (
|
||||||
<Text
|
<Text style={[styles.emptyText, { color: theme.colors.foregroundMuted }]}>
|
||||||
style={[
|
|
||||||
styles.emptyText,
|
|
||||||
{ color: theme.colors.foregroundMuted },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
Start typing a path
|
Start typing a path
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
@@ -266,10 +233,7 @@ export function ProjectPickerModal() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[styles.rowText, { color: theme.colors.foreground }]}
|
||||||
styles.rowText,
|
|
||||||
{ color: theme.colors.foreground },
|
|
||||||
]}
|
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{shortenPath(path)}
|
{shortenPath(path)}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Bot } from 'lucide-react-native'
|
import { Bot } from "lucide-react-native";
|
||||||
import { ClaudeIcon } from '@/components/icons/claude-icon'
|
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||||
import { CodexIcon } from '@/components/icons/codex-icon'
|
import { CodexIcon } from "@/components/icons/codex-icon";
|
||||||
|
|
||||||
const PROVIDER_ICONS: Record<string, typeof Bot> = {
|
const PROVIDER_ICONS: Record<string, typeof Bot> = {
|
||||||
claude: ClaudeIcon as unknown as typeof Bot,
|
claude: ClaudeIcon as unknown as typeof Bot,
|
||||||
codex: CodexIcon as unknown as typeof Bot,
|
codex: CodexIcon as unknown as typeof Bot,
|
||||||
}
|
};
|
||||||
|
|
||||||
export function getProviderIcon(provider: string): 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 { useState, useCallback } from "react";
|
||||||
import {
|
import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native";
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
Pressable,
|
|
||||||
ActivityIndicator,
|
|
||||||
Platform,
|
|
||||||
} from "react-native";
|
|
||||||
import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
|
||||||
import { Check, CircleHelp, X } from "lucide-react-native";
|
import { Check, CircleHelp, X } from "lucide-react-native";
|
||||||
import type { PendingPermission } from "@/types/shared";
|
import type { PendingPermission } from "@/types/shared";
|
||||||
@@ -68,52 +61,42 @@ interface QuestionFormCardProps {
|
|||||||
|
|
||||||
const IS_WEB = Platform.OS === "web";
|
const IS_WEB = Platform.OS === "web";
|
||||||
|
|
||||||
export function QuestionFormCard({
|
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
|
||||||
permission,
|
|
||||||
onRespond,
|
|
||||||
isResponding,
|
|
||||||
}: QuestionFormCardProps) {
|
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile =
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
|
||||||
const questions = parseQuestions(permission.request.input);
|
const questions = parseQuestions(permission.request.input);
|
||||||
|
|
||||||
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
|
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
|
||||||
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
|
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
|
||||||
const [respondingAction, setRespondingAction] = useState<
|
const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
|
||||||
"submit" | "dismiss" | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
const toggleOption = useCallback(
|
const toggleOption = useCallback((qIndex: number, optIndex: number, multiSelect: boolean) => {
|
||||||
(qIndex: number, optIndex: number, multiSelect: boolean) => {
|
setSelections((prev) => {
|
||||||
setSelections((prev) => {
|
const current = prev[qIndex] ?? new Set<number>();
|
||||||
const current = prev[qIndex] ?? new Set<number>();
|
const next = new Set(current);
|
||||||
const next = new Set(current);
|
if (multiSelect) {
|
||||||
if (multiSelect) {
|
if (next.has(optIndex)) {
|
||||||
if (next.has(optIndex)) {
|
next.delete(optIndex);
|
||||||
next.delete(optIndex);
|
|
||||||
} else {
|
|
||||||
next.add(optIndex);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (next.has(optIndex)) {
|
next.add(optIndex);
|
||||||
next.clear();
|
|
||||||
} else {
|
|
||||||
next.clear();
|
|
||||||
next.add(optIndex);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return { ...prev, [qIndex]: next };
|
} else {
|
||||||
});
|
if (next.has(optIndex)) {
|
||||||
setOtherTexts((prev) => {
|
next.clear();
|
||||||
if (!prev[qIndex]) return prev;
|
} else {
|
||||||
const next = { ...prev };
|
next.clear();
|
||||||
delete next[qIndex];
|
next.add(optIndex);
|
||||||
return next;
|
}
|
||||||
});
|
}
|
||||||
},
|
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) => {
|
const setOtherText = useCallback((qIndex: number, text: string) => {
|
||||||
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
|
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
|
||||||
@@ -182,9 +165,7 @@ export function QuestionFormCard({
|
|||||||
return (
|
return (
|
||||||
<View key={qIndex} style={styles.questionBlock}>
|
<View key={qIndex} style={styles.questionBlock}>
|
||||||
<View style={styles.questionHeader}>
|
<View style={styles.questionHeader}>
|
||||||
<Text
|
<Text style={[styles.questionText, { color: theme.colors.foreground }]}>
|
||||||
style={[styles.questionText, { color: theme.colors.foreground }]}
|
|
||||||
>
|
|
||||||
{q.question}
|
{q.question}
|
||||||
</Text>
|
</Text>
|
||||||
<CircleHelp size={14} color={theme.colors.foregroundMuted} />
|
<CircleHelp size={14} color={theme.colors.foregroundMuted} />
|
||||||
@@ -202,19 +183,12 @@ export function QuestionFormCard({
|
|||||||
},
|
},
|
||||||
pressed && styles.optionItemPressed,
|
pressed && styles.optionItemPressed,
|
||||||
]}
|
]}
|
||||||
onPress={() =>
|
onPress={() => toggleOption(qIndex, optIndex, q.multiSelect)}
|
||||||
toggleOption(qIndex, optIndex, q.multiSelect)
|
|
||||||
}
|
|
||||||
disabled={isResponding}
|
disabled={isResponding}
|
||||||
>
|
>
|
||||||
<View style={styles.optionItemContent}>
|
<View style={styles.optionItemContent}>
|
||||||
<View style={styles.optionTextBlock}>
|
<View style={styles.optionTextBlock}>
|
||||||
<Text
|
<Text style={[styles.optionLabel, { color: theme.colors.foreground }]}>
|
||||||
style={[
|
|
||||||
styles.optionLabel,
|
|
||||||
{ color: theme.colors.foreground },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</Text>
|
</Text>
|
||||||
{opt.description ? (
|
{opt.description ? (
|
||||||
@@ -230,10 +204,7 @@ export function QuestionFormCard({
|
|||||||
</View>
|
</View>
|
||||||
{isSelected ? (
|
{isSelected ? (
|
||||||
<View style={styles.optionCheckSlot}>
|
<View style={styles.optionCheckSlot}>
|
||||||
<Check
|
<Check size={16} color={theme.colors.foregroundMuted} />
|
||||||
size={16}
|
|
||||||
color={theme.colors.foregroundMuted}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
@@ -245,9 +216,8 @@ export function QuestionFormCard({
|
|||||||
style={[
|
style={[
|
||||||
styles.otherInput,
|
styles.otherInput,
|
||||||
{
|
{
|
||||||
borderColor: otherText.length > 0
|
borderColor:
|
||||||
? theme.colors.borderAccent
|
otherText.length > 0 ? theme.colors.borderAccent : theme.colors.border,
|
||||||
: theme.colors.border,
|
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
@@ -264,19 +234,12 @@ export function QuestionFormCard({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<View
|
<View style={[styles.actionsContainer, !isMobile && styles.actionsContainerDesktop]}>
|
||||||
style={[
|
|
||||||
styles.actionsContainer,
|
|
||||||
!isMobile && styles.actionsContainerDesktop,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Pressable
|
<Pressable
|
||||||
style={({ pressed, hovered = false }) => [
|
style={({ pressed, hovered = false }) => [
|
||||||
styles.actionButton,
|
styles.actionButton,
|
||||||
{
|
{
|
||||||
backgroundColor: hovered
|
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
||||||
? theme.colors.surface2
|
|
||||||
: theme.colors.surface1,
|
|
||||||
borderColor: theme.colors.borderAccent,
|
borderColor: theme.colors.borderAccent,
|
||||||
},
|
},
|
||||||
pressed && styles.optionItemPressed,
|
pressed && styles.optionItemPressed,
|
||||||
@@ -285,19 +248,11 @@ export function QuestionFormCard({
|
|||||||
disabled={isResponding}
|
disabled={isResponding}
|
||||||
>
|
>
|
||||||
{respondingAction === "dismiss" ? (
|
{respondingAction === "dismiss" ? (
|
||||||
<ActivityIndicator
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||||
size="small"
|
|
||||||
color={theme.colors.foregroundMuted}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.actionContent}>
|
<View style={styles.actionContent}>
|
||||||
<X size={14} color={theme.colors.foregroundMuted} />
|
<X size={14} color={theme.colors.foregroundMuted} />
|
||||||
<Text
|
<Text style={[styles.actionText, { color: theme.colors.foregroundMuted }]}>
|
||||||
style={[
|
|
||||||
styles.actionText,
|
|
||||||
{ color: theme.colors.foregroundMuted },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
Dismiss
|
Dismiss
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -311,12 +266,8 @@ export function QuestionFormCard({
|
|||||||
styles.actionButton,
|
styles.actionButton,
|
||||||
{
|
{
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
hovered && !disabled
|
hovered && !disabled ? theme.colors.surface2 : theme.colors.surface1,
|
||||||
? theme.colors.surface2
|
borderColor: disabled ? theme.colors.border : theme.colors.borderAccent,
|
||||||
: theme.colors.surface1,
|
|
||||||
borderColor: disabled
|
|
||||||
? theme.colors.border
|
|
||||||
: theme.colors.borderAccent,
|
|
||||||
opacity: disabled ? 0.5 : 1,
|
opacity: disabled ? 0.5 : 1,
|
||||||
},
|
},
|
||||||
pressed && !disabled ? styles.optionItemPressed : null,
|
pressed && !disabled ? styles.optionItemPressed : null,
|
||||||
@@ -326,27 +277,18 @@ export function QuestionFormCard({
|
|||||||
disabled={!allAnswered || isResponding}
|
disabled={!allAnswered || isResponding}
|
||||||
>
|
>
|
||||||
{respondingAction === "submit" ? (
|
{respondingAction === "submit" ? (
|
||||||
<ActivityIndicator
|
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
||||||
size="small"
|
|
||||||
color={theme.colors.foreground}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.actionContent}>
|
<View style={styles.actionContent}>
|
||||||
<Check
|
<Check
|
||||||
size={14}
|
size={14}
|
||||||
color={
|
color={allAnswered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
allAnswered
|
|
||||||
? theme.colors.foreground
|
|
||||||
: theme.colors.foregroundMuted
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.actionText,
|
styles.actionText,
|
||||||
{
|
{
|
||||||
color: allAnswered
|
color: allAnswered ? theme.colors.foreground : theme.colors.foregroundMuted,
|
||||||
? theme.colors.foreground
|
|
||||||
: theme.colors.foregroundMuted,
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -73,8 +73,7 @@ export function ResizeHandle({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pointerCurrent =
|
const pointerCurrent = direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
|
||||||
direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
|
|
||||||
const deltaRatio =
|
const deltaRatio =
|
||||||
(pointerCurrent - pointerState.pointerStart) / pointerState.containerSize;
|
(pointerCurrent - pointerState.pointerStart) / pointerState.containerSize;
|
||||||
|
|
||||||
@@ -91,7 +90,7 @@ export function ResizeHandle({
|
|||||||
window.addEventListener("pointermove", handlePointerMove);
|
window.addEventListener("pointermove", handlePointerMove);
|
||||||
window.addEventListener("pointerup", handlePointerUp, { once: true });
|
window.addEventListener("pointerup", handlePointerUp, { once: true });
|
||||||
},
|
},
|
||||||
[direction, groupId, index, onResizeSplit, sizes]
|
[direction, groupId, index, onResizeSplit, sizes],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -107,9 +106,7 @@ export function ResizeHandle({
|
|||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
style={[
|
style={[
|
||||||
styles.highlight,
|
styles.highlight,
|
||||||
direction === "horizontal"
|
direction === "horizontal" ? styles.highlightHorizontal : styles.highlightVertical,
|
||||||
? styles.highlightHorizontal
|
|
||||||
: styles.highlightVertical,
|
|
||||||
{ backgroundColor: theme.colors.accent },
|
{ backgroundColor: theme.colors.accent },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,13 +2,7 @@ import { useEffect, useRef } from "react";
|
|||||||
import { Animated, View, type StyleProp, type ViewStyle } from "react-native";
|
import { Animated, View, type StyleProp, type ViewStyle } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
|
||||||
function SkeletonPulse({
|
function SkeletonPulse({ pulse, style }: { pulse: Animated.Value; style: StyleProp<ViewStyle> }) {
|
||||||
pulse,
|
|
||||||
style,
|
|
||||||
}: {
|
|
||||||
pulse: Animated.Value;
|
|
||||||
style: StyleProp<ViewStyle>;
|
|
||||||
}) {
|
|
||||||
const opacity = pulse.interpolate({
|
const opacity = pulse.interpolate({
|
||||||
inputRange: [0, 1],
|
inputRange: [0, 1],
|
||||||
outputRange: [0.4, 0.8],
|
outputRange: [0.4, 0.8],
|
||||||
@@ -33,7 +27,7 @@ export function SidebarAgentListSkeleton() {
|
|||||||
duration: 1000,
|
duration: 1000,
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}),
|
}),
|
||||||
])
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
animation.start();
|
animation.start();
|
||||||
@@ -55,10 +49,7 @@ export function SidebarAgentListSkeleton() {
|
|||||||
|
|
||||||
<View style={styles.rows}>
|
<View style={styles.rows}>
|
||||||
{Array.from({ length: 3 }).map((__, rowIdx) => (
|
{Array.from({ length: 3 }).map((__, rowIdx) => (
|
||||||
<View
|
<View key={`skeleton-row-${sectionIdx}-${rowIdx}`} style={styles.row}>
|
||||||
key={`skeleton-row-${sectionIdx}-${rowIdx}`}
|
|
||||||
style={styles.row}
|
|
||||||
>
|
|
||||||
<SkeletonPulse pulse={pulse} style={styles.rowDot} />
|
<SkeletonPulse pulse={pulse} style={styles.rowDot} />
|
||||||
<SkeletonPulse pulse={pulse} style={styles.rowTitle} />
|
<SkeletonPulse pulse={pulse} style={styles.rowTitle} />
|
||||||
<SkeletonPulse pulse={pulse} style={styles.rowBadge} />
|
<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.
|
// Metro's platform-specific extensions will pick the right one at runtime.
|
||||||
|
|
||||||
export * from "./sortable-inline-list.native";
|
export * from "./sortable-inline-list.native";
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ function SortableItem<T>({
|
|||||||
const baseTransform = externalDndContext
|
const baseTransform = externalDndContext
|
||||||
? undefined
|
? undefined
|
||||||
: CSS.Transform.toString(
|
: 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 scaleTransform = !externalDndContext && isDragging ? "scale(1.01)" : "";
|
||||||
const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
|
const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
|
||||||
@@ -87,9 +87,7 @@ function SortableItem<T>({
|
|||||||
? {
|
? {
|
||||||
attributes: attributes as unknown as Record<string, unknown>,
|
attributes: attributes as unknown as Record<string, unknown>,
|
||||||
listeners: listeners as unknown as Record<string, unknown>,
|
listeners: listeners as unknown as Record<string, unknown>,
|
||||||
setActivatorNodeRef: setActivatorNodeRef as unknown as (
|
setActivatorNodeRef: setActivatorNodeRef as unknown as (node: unknown) => void,
|
||||||
node: unknown
|
|
||||||
) => void,
|
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
@@ -132,7 +130,7 @@ export function SortableInlineList<T>({
|
|||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [dragItems, setDragItems] = useState<T[] | null>(null);
|
const [dragItems, setDragItems] = useState<T[] | null>(null);
|
||||||
const items = externalDndContext ? data : dragItems ?? data;
|
const items = externalDndContext ? data : (dragItems ?? data);
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
@@ -142,7 +140,7 @@ export function SortableInlineList<T>({
|
|||||||
}),
|
}),
|
||||||
useSensor(KeyboardSensor, {
|
useSensor(KeyboardSensor, {
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragStart = useCallback(
|
const handleDragStart = useCallback(
|
||||||
@@ -154,7 +152,7 @@ export function SortableInlineList<T>({
|
|||||||
setActiveId(String(event.active.id));
|
setActiveId(String(event.active.id));
|
||||||
onDragBegin?.();
|
onDragBegin?.();
|
||||||
},
|
},
|
||||||
[data, disabled, onDragBegin]
|
[data, disabled, onDragBegin],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
const handleDragEnd = useCallback(
|
||||||
@@ -169,21 +167,16 @@ export function SortableInlineList<T>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (over && active.id !== over.id) {
|
if (over && active.id !== over.id) {
|
||||||
const oldIndex = items.findIndex(
|
const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.id);
|
||||||
(item, i) => keyExtractor(item, i) === active.id
|
const newIndex = items.findIndex((item, i) => keyExtractor(item, i) === over.id);
|
||||||
);
|
|
||||||
const newIndex = items.findIndex(
|
|
||||||
(item, i) => keyExtractor(item, i) === over.id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
|
if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
|
||||||
const newItems = arrayMove(items, oldIndex, newIndex);
|
const newItems = arrayMove(items, oldIndex, newIndex);
|
||||||
onDragEnd?.(newItems);
|
onDragEnd?.(newItems);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
[disabled, items, keyExtractor, onDragEnd]
|
[disabled, items, keyExtractor, onDragEnd],
|
||||||
);
|
);
|
||||||
|
|
||||||
const ids = items.map((item, index) => keyExtractor(item, index));
|
const ids = items.map((item, index) => keyExtractor(item, index));
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ describe("shouldFocusPaneFromEventTarget", () => {
|
|||||||
it("returns false for links and buttons", () => {
|
it("returns false for links and buttons", () => {
|
||||||
expect(
|
expect(
|
||||||
shouldFocusPaneFromEventTarget({
|
shouldFocusPaneFromEventTarget({
|
||||||
closest: () => ({ tagName: "A" } as Element),
|
closest: () => ({ tagName: "A" }) as Element,
|
||||||
} as unknown as EventTarget)
|
} as unknown as EventTarget),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
expect(
|
expect(
|
||||||
shouldFocusPaneFromEventTarget({
|
shouldFocusPaneFromEventTarget({
|
||||||
closest: () => ({ tagName: "BUTTON" } as Element),
|
closest: () => ({ tagName: "BUTTON" }) as Element,
|
||||||
} as unknown as EventTarget)
|
} as unknown as EventTarget),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ describe("shouldFocusPaneFromEventTarget", () => {
|
|||||||
expect(
|
expect(
|
||||||
shouldFocusPaneFromEventTarget({
|
shouldFocusPaneFromEventTarget({
|
||||||
closest: () => null,
|
closest: () => null,
|
||||||
} as unknown as EventTarget)
|
} as unknown as EventTarget),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(shouldFocusPaneFromEventTarget(null)).toBe(true);
|
expect(shouldFocusPaneFromEventTarget(null)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ describe("computeTabDropPreview", () => {
|
|||||||
targetTabs,
|
targetTabs,
|
||||||
activeRect: { left: 180, width: 40 },
|
activeRect: { left: 180, width: 40 },
|
||||||
overRect: { left: 200, width: 100 },
|
overRect: { left: 200, width: 100 },
|
||||||
})
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
paneId: "target",
|
paneId: "target",
|
||||||
insertionIndex: 2,
|
insertionIndex: 2,
|
||||||
@@ -45,7 +45,7 @@ describe("computeTabDropPreview", () => {
|
|||||||
targetTabs,
|
targetTabs,
|
||||||
activeRect: { left: 280, width: 40 },
|
activeRect: { left: 280, width: 40 },
|
||||||
overRect: { left: 200, width: 100 },
|
overRect: { left: 200, width: 100 },
|
||||||
})
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
paneId: "target",
|
paneId: "target",
|
||||||
insertionIndex: 3,
|
insertionIndex: 3,
|
||||||
@@ -63,7 +63,7 @@ describe("computeTabDropPreview", () => {
|
|||||||
targetTabs,
|
targetTabs,
|
||||||
activeRect: { left: 460, width: 40 },
|
activeRect: { left: 460, width: 40 },
|
||||||
overRect: { left: 400, width: 100 },
|
overRect: { left: 400, width: 100 },
|
||||||
})
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
paneId: "pane",
|
paneId: "pane",
|
||||||
insertionIndex: 3,
|
insertionIndex: 3,
|
||||||
|
|||||||
@@ -22,9 +22,7 @@ interface ComputeTabDropPreviewInput {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function computeTabDropPreview(
|
export function computeTabDropPreview(input: ComputeTabDropPreviewInput): TabDropPreview | null {
|
||||||
input: ComputeTabDropPreviewInput
|
|
||||||
): TabDropPreview | null {
|
|
||||||
const targetIndex = input.targetTabs.findIndex((tab) => tab.tabId === input.overTabId);
|
const targetIndex = input.targetTabs.findIndex((tab) => tab.tabId === input.overTabId);
|
||||||
if (targetIndex < 0 || input.overRect.width <= 0) {
|
if (targetIndex < 0 || input.overRect.width <= 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type ReactNode, type SetStateAction } from "react";
|
import {
|
||||||
|
Fragment,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type Dispatch,
|
||||||
|
type ReactNode,
|
||||||
|
type SetStateAction,
|
||||||
|
} from "react";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
DragOverlay,
|
DragOverlay,
|
||||||
@@ -45,7 +55,12 @@ import {
|
|||||||
WorkspaceTabIcon,
|
WorkspaceTabIcon,
|
||||||
} from "@/screens/workspace/workspace-tab-presentation";
|
} from "@/screens/workspace/workspace-tab-presentation";
|
||||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||||
import { useWorkspaceLayoutStore, type SplitNode, type SplitPane, type WorkspaceLayout } from "@/stores/workspace-layout-store";
|
import {
|
||||||
|
useWorkspaceLayoutStore,
|
||||||
|
type SplitNode,
|
||||||
|
type SplitPane,
|
||||||
|
type WorkspaceLayout,
|
||||||
|
} from "@/stores/workspace-layout-store";
|
||||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
||||||
|
|
||||||
interface SplitContainerProps {
|
interface SplitContainerProps {
|
||||||
@@ -65,7 +80,10 @@ interface SplitContainerProps {
|
|||||||
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||||
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||||
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||||
onSelectNewTabOption: (selection: { optionId: "__new_tab_agent__" | "__new_tab_terminal__"; paneId?: string }) => void;
|
onSelectNewTabOption: (selection: {
|
||||||
|
optionId: "__new_tab_agent__" | "__new_tab_terminal__";
|
||||||
|
paneId?: string;
|
||||||
|
}) => void;
|
||||||
onNewTerminalTab: (input: { paneId?: string }) => void;
|
onNewTerminalTab: (input: { paneId?: string }) => void;
|
||||||
newTabAgentOptionId?: "__new_tab_agent__" | "__new_tab_terminal__";
|
newTabAgentOptionId?: "__new_tab_agent__" | "__new_tab_terminal__";
|
||||||
buildPaneContentModel: (input: {
|
buildPaneContentModel: (input: {
|
||||||
@@ -100,8 +118,7 @@ interface SplitPaneDropData {
|
|||||||
paneId: string;
|
paneId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SplitNodeViewProps
|
interface SplitNodeViewProps extends Omit<SplitContainerProps, "layout"> {
|
||||||
extends Omit<SplitContainerProps, "layout"> {
|
|
||||||
node: SplitNode;
|
node: SplitNode;
|
||||||
uiTabs: WorkspaceTab[];
|
uiTabs: WorkspaceTab[];
|
||||||
focusedPaneId: string;
|
focusedPaneId: string;
|
||||||
@@ -135,14 +152,14 @@ interface SplitPaneViewProps
|
|||||||
const dropCollisionDetection: CollisionDetection = (args) => {
|
const dropCollisionDetection: CollisionDetection = (args) => {
|
||||||
const pointerHits = pointerWithin(args);
|
const pointerHits = pointerWithin(args);
|
||||||
const tabHits = pointerHits.filter(
|
const tabHits = pointerHits.filter(
|
||||||
(entry) => entry.data?.droppableContainer.data.current?.kind === "workspace-tab"
|
(entry) => entry.data?.droppableContainer.data.current?.kind === "workspace-tab",
|
||||||
);
|
);
|
||||||
if (tabHits.length > 0) {
|
if (tabHits.length > 0) {
|
||||||
return tabHits;
|
return tabHits;
|
||||||
}
|
}
|
||||||
|
|
||||||
const paneHits = pointerHits.filter(
|
const paneHits = pointerHits.filter(
|
||||||
(entry) => entry.data?.droppableContainer.data.current?.kind === "split-pane-drop"
|
(entry) => entry.data?.droppableContainer.data.current?.kind === "split-pane-drop",
|
||||||
);
|
);
|
||||||
if (paneHits.length > 0) {
|
if (paneHits.length > 0) {
|
||||||
return paneHits;
|
return paneHits;
|
||||||
@@ -192,7 +209,7 @@ export function SplitContainer({
|
|||||||
}),
|
}),
|
||||||
useSensor(KeyboardSensor, {
|
useSensor(KeyboardSensor, {
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]);
|
const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]);
|
||||||
@@ -215,11 +232,7 @@ export function SplitContainer({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateDropPreview = useCallback(
|
const updateDropPreview = useCallback(
|
||||||
(
|
(event: Pick<DragMoveEvent, "active" | "over"> | Pick<DragOverEvent, "active" | "over">) => {
|
||||||
event:
|
|
||||||
| Pick<DragMoveEvent, "active" | "over">
|
|
||||||
| Pick<DragOverEvent, "active" | "over">
|
|
||||||
) => {
|
|
||||||
const activeData = event.active.data.current as WorkspaceTabDragData | undefined;
|
const activeData = event.active.data.current as WorkspaceTabDragData | undefined;
|
||||||
const overData = event.over?.data.current as
|
const overData = event.over?.data.current as
|
||||||
| WorkspaceTabDragData
|
| WorkspaceTabDragData
|
||||||
@@ -268,7 +281,7 @@ export function SplitContainer({
|
|||||||
left: overRect.left,
|
left: overRect.left,
|
||||||
width: overRect.width,
|
width: overRect.width,
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -305,7 +318,7 @@ export function SplitContainer({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[panesById, uiTabs]
|
[panesById, uiTabs],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
const handleDragEnd = useCallback(
|
||||||
@@ -346,8 +359,15 @@ export function SplitContainer({
|
|||||||
|
|
||||||
if (activeData.paneId === overData.paneId) {
|
if (activeData.paneId === overData.paneId) {
|
||||||
if (sourceIndex !== resolvedTabDropPreview.insertionIndex) {
|
if (sourceIndex !== resolvedTabDropPreview.insertionIndex) {
|
||||||
const nextTabs = arrayMove(sourceTabs, sourceIndex, resolvedTabDropPreview.insertionIndex);
|
const nextTabs = arrayMove(
|
||||||
onReorderTabsInPane(activeData.paneId, nextTabs.map((tab) => tab.tabId));
|
sourceTabs,
|
||||||
|
sourceIndex,
|
||||||
|
resolvedTabDropPreview.insertionIndex,
|
||||||
|
);
|
||||||
|
onReorderTabsInPane(
|
||||||
|
activeData.paneId,
|
||||||
|
nextTabs.map((tab) => tab.tabId),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
setDropPreview(null);
|
setDropPreview(null);
|
||||||
setTabDropPreview(null);
|
setTabDropPreview(null);
|
||||||
@@ -383,7 +403,15 @@ export function SplitContainer({
|
|||||||
setDropPreview(null);
|
setDropPreview(null);
|
||||||
setTabDropPreview(null);
|
setTabDropPreview(null);
|
||||||
},
|
},
|
||||||
[dropPreview, onMoveTabToPane, onReorderTabsInPane, onSplitPane, panesById, tabDropPreview, uiTabs]
|
[
|
||||||
|
dropPreview,
|
||||||
|
onMoveTabToPane,
|
||||||
|
onReorderTabsInPane,
|
||||||
|
onSplitPane,
|
||||||
|
panesById,
|
||||||
|
tabDropPreview,
|
||||||
|
uiTabs,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -492,8 +520,7 @@ function DragOverlayTabChipInner({
|
|||||||
workspaceId={normalizedWorkspaceId}
|
workspaceId={normalizedWorkspaceId}
|
||||||
>
|
>
|
||||||
{(presentation) => {
|
{(presentation) => {
|
||||||
const label =
|
const label = presentation.titleState === "loading" ? "Loading..." : presentation.label;
|
||||||
presentation.titleState === "loading" ? "Loading..." : presentation.label;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -508,10 +535,7 @@ function DragOverlayTabChipInner({
|
|||||||
<WorkspaceTabIcon presentation={presentation} active size={14} />
|
<WorkspaceTabIcon presentation={presentation} active size={14} />
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={[
|
style={[styles.dragOverlayLabel, { color: theme.colors.foreground }]}
|
||||||
styles.dragOverlayLabel,
|
|
||||||
{ color: theme.colors.foreground },
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -593,8 +617,9 @@ function SplitNodeView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const groupSizes =
|
const groupSizes =
|
||||||
useWorkspaceLayoutStore((state) => state.splitSizesByWorkspace[workspaceKey]?.[node.group.id]) ??
|
useWorkspaceLayoutStore(
|
||||||
node.group.sizes;
|
(state) => state.splitSizesByWorkspace[workspaceKey]?.[node.group.id],
|
||||||
|
) ?? node.group.sizes;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -695,12 +720,9 @@ function SplitPaneView({
|
|||||||
pane,
|
pane,
|
||||||
tabs: uiTabs,
|
tabs: uiTabs,
|
||||||
}),
|
}),
|
||||||
[pane, uiTabs]
|
[pane, uiTabs],
|
||||||
);
|
|
||||||
const paneTabs = useMemo(
|
|
||||||
() => paneState.tabs.map((tab) => tab.descriptor),
|
|
||||||
[paneState.tabs]
|
|
||||||
);
|
);
|
||||||
|
const paneTabs = useMemo(() => paneState.tabs.map((tab) => tab.descriptor), [paneState.tabs]);
|
||||||
const activeTabDescriptor = paneState.activeTab?.descriptor ?? null;
|
const activeTabDescriptor = paneState.activeTab?.descriptor ?? null;
|
||||||
const desktopTabRowItems = useMemo<WorkspaceDesktopTabRowItem[]>(
|
const desktopTabRowItems = useMemo<WorkspaceDesktopTabRowItem[]>(
|
||||||
() =>
|
() =>
|
||||||
@@ -710,12 +732,7 @@ function SplitPaneView({
|
|||||||
isCloseHovered: hoveredCloseTabKey === tab.key,
|
isCloseHovered: hoveredCloseTabKey === tab.key,
|
||||||
isClosingTab: closingTabIds.has(tab.tabId),
|
isClosingTab: closingTabIds.has(tab.tabId),
|
||||||
})),
|
})),
|
||||||
[
|
[activeTabDescriptor?.key, closingTabIds, hoveredCloseTabKey, paneTabs],
|
||||||
activeTabDescriptor?.key,
|
|
||||||
closingTabIds,
|
|
||||||
hoveredCloseTabKey,
|
|
||||||
paneTabs,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
const paneContent = useMemo(
|
const paneContent = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -726,11 +743,7 @@ function SplitPaneView({
|
|||||||
tab: activeTabDescriptor,
|
tab: activeTabDescriptor,
|
||||||
})
|
})
|
||||||
: null,
|
: null,
|
||||||
[
|
[activeTabDescriptor, buildPaneContentModel, pane.id],
|
||||||
activeTabDescriptor,
|
|
||||||
buildPaneContentModel,
|
|
||||||
pane.id,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -771,11 +784,7 @@ function SplitPaneView({
|
|||||||
}, [onFocusPane, pane.id]);
|
}, [onFocusPane, pane.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View ref={paneRef} collapsable={false} style={styles.pane}>
|
||||||
ref={paneRef}
|
|
||||||
collapsable={false}
|
|
||||||
style={styles.pane}
|
|
||||||
>
|
|
||||||
<View style={styles.paneTabs}>
|
<View style={styles.paneTabs}>
|
||||||
<WorkspaceDesktopTabsRow
|
<WorkspaceDesktopTabsRow
|
||||||
paneId={pane.id}
|
paneId={pane.id}
|
||||||
@@ -796,13 +805,18 @@ function SplitPaneView({
|
|||||||
onNewTerminalTab={onNewTerminalTab}
|
onNewTerminalTab={onNewTerminalTab}
|
||||||
newTabAgentOptionId={newTabAgentOptionId ?? "__new_tab_agent__"}
|
newTabAgentOptionId={newTabAgentOptionId ?? "__new_tab_agent__"}
|
||||||
onReorderTabs={(nextTabs) => {
|
onReorderTabs={(nextTabs) => {
|
||||||
onReorderTabsInPane(pane.id, nextTabs.map((tab) => tab.tabId));
|
onReorderTabsInPane(
|
||||||
|
pane.id,
|
||||||
|
nextTabs.map((tab) => tab.tabId),
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onSplitRight={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "right" })}
|
onSplitRight={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "right" })}
|
||||||
onSplitDown={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "bottom" })}
|
onSplitDown={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "bottom" })}
|
||||||
externalDndContext
|
externalDndContext
|
||||||
activeDragTabId={activeDragTabId}
|
activeDragTabId={activeDragTabId}
|
||||||
tabDropPreviewIndex={tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null}
|
tabDropPreviewIndex={
|
||||||
|
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -810,7 +824,7 @@ function SplitPaneView({
|
|||||||
{paneContent ? (
|
{paneContent ? (
|
||||||
<WorkspacePaneContent content={paneContent} />
|
<WorkspacePaneContent content={paneContent} />
|
||||||
) : (
|
) : (
|
||||||
renderPaneEmptyState?.() ?? null
|
(renderPaneEmptyState?.() ?? null)
|
||||||
)}
|
)}
|
||||||
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
|
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -23,11 +23,7 @@ export function buildSplitDropZoneId(paneId: string): string {
|
|||||||
return `split-pane-drop:${paneId}`;
|
return `split-pane-drop:${paneId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SplitDropZone({
|
export function SplitDropZone({ paneId, active, preview }: SplitDropZoneProps) {
|
||||||
paneId,
|
|
||||||
active,
|
|
||||||
preview,
|
|
||||||
}: SplitDropZoneProps) {
|
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { setNodeRef } = useDroppable({
|
const { setNodeRef } = useDroppable({
|
||||||
id: buildSplitDropZoneId(paneId),
|
id: buildSplitDropZoneId(paneId),
|
||||||
@@ -66,11 +62,7 @@ export function SplitDropZone({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View ref={setNodeRef as any} style={styles.overlay} pointerEvents="none">
|
||||||
ref={setNodeRef as any}
|
|
||||||
style={styles.overlay}
|
|
||||||
pointerEvents="none"
|
|
||||||
>
|
|
||||||
{previewStyles ? (
|
{previewStyles ? (
|
||||||
<>
|
<>
|
||||||
<View pointerEvents="none" style={previewStyles.overlay} />
|
<View pointerEvents="none" style={previewStyles.overlay} />
|
||||||
@@ -89,10 +81,8 @@ export function resolveSplitDropPosition(input: {
|
|||||||
}): SplitDropZonePosition {
|
}): SplitDropZonePosition {
|
||||||
const centerInsetX = input.width * ((1 - CENTER_RATIO) / 2);
|
const centerInsetX = input.width * ((1 - CENTER_RATIO) / 2);
|
||||||
const centerInsetY = input.height * ((1 - CENTER_RATIO) / 2);
|
const centerInsetY = input.height * ((1 - CENTER_RATIO) / 2);
|
||||||
const insideCenterX =
|
const insideCenterX = input.x >= centerInsetX && input.x <= input.width - centerInsetX;
|
||||||
input.x >= centerInsetX && input.x <= input.width - centerInsetX;
|
const insideCenterY = input.y >= centerInsetY && input.y <= input.height - centerInsetY;
|
||||||
const insideCenterY =
|
|
||||||
input.y >= centerInsetY && input.y <= input.height - centerInsetY;
|
|
||||||
|
|
||||||
if (insideCenterX && insideCenterY) {
|
if (insideCenterX && insideCenterY) {
|
||||||
return "center";
|
return "center";
|
||||||
|
|||||||
@@ -9,11 +9,7 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import { useBottomAnchorController } from "./use-bottom-anchor-controller";
|
import { useBottomAnchorController } from "./use-bottom-anchor-controller";
|
||||||
import type {
|
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./stream-strategy";
|
||||||
StreamRenderInput,
|
|
||||||
StreamStrategy,
|
|
||||||
StreamViewportHandle,
|
|
||||||
} from "./stream-strategy";
|
|
||||||
import {
|
import {
|
||||||
createStreamStrategy,
|
createStreamStrategy,
|
||||||
isNearBottomForStreamRenderStrategy,
|
isNearBottomForStreamRenderStrategy,
|
||||||
@@ -92,7 +88,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
strategy,
|
strategy,
|
||||||
isViewportSettling: isNativeViewportSettling,
|
isViewportSettling: isNativeViewportSettling,
|
||||||
}),
|
}),
|
||||||
[isNativeViewportSettling, strategy]
|
[isNativeViewportSettling, strategy],
|
||||||
);
|
);
|
||||||
|
|
||||||
const scrollToBottom = useCallback(
|
const scrollToBottom = useCallback(
|
||||||
@@ -109,7 +105,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
};
|
};
|
||||||
onNearBottomChange(true);
|
onNearBottomChange(true);
|
||||||
},
|
},
|
||||||
[onNearBottomChange]
|
[onNearBottomChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const bottomAnchorController = useBottomAnchorController({
|
const bottomAnchorController = useBottomAnchorController({
|
||||||
@@ -159,7 +155,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
const subscriptions = keyboardEvents.map((eventName) =>
|
const subscriptions = keyboardEvents.map((eventName) =>
|
||||||
Keyboard.addListener(eventName, () => {
|
Keyboard.addListener(eventName, () => {
|
||||||
markNativeViewportSettling();
|
markNativeViewportSettling();
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
return () => {
|
return () => {
|
||||||
for (const subscription of subscriptions) {
|
for (const subscription of subscriptions) {
|
||||||
@@ -228,7 +224,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[bottomAnchorController, onNearBottomChange, strategy]
|
[bottomAnchorController, onNearBottomChange, strategy],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleListLayout = useCallback(
|
const handleListLayout = useCallback(
|
||||||
@@ -257,7 +253,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
viewportHeight,
|
viewportHeight,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[bottomAnchorController, markNativeViewportSettling]
|
[bottomAnchorController, markNativeViewportSettling],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleContentSizeChange = useCallback(
|
const handleContentSizeChange = useCallback(
|
||||||
@@ -275,7 +271,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
contentHeight: nextContentHeight,
|
contentHeight: nextContentHeight,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[bottomAnchorController]
|
[bottomAnchorController],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
@@ -283,7 +279,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
const rendered = renderers.renderHistoryMountedRow(item, index, historyRows);
|
const rendered = renderers.renderHistoryMountedRow(item, index, historyRows);
|
||||||
return rendered ? <Fragment>{rendered}</Fragment> : null;
|
return rendered ? <Fragment>{rendered}</Fragment> : null;
|
||||||
},
|
},
|
||||||
[historyRows, renderers]
|
[historyRows, renderers],
|
||||||
);
|
);
|
||||||
|
|
||||||
const liveHeaderContent = useMemo(() => {
|
const liveHeaderContent = useMemo(() => {
|
||||||
@@ -336,12 +332,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
|
|
||||||
export function createNativeStreamStrategy(): StreamStrategy {
|
export function createNativeStreamStrategy(): StreamStrategy {
|
||||||
const strategy = createStreamStrategy({
|
const strategy = createStreamStrategy({
|
||||||
render: (renderInput) => (
|
render: (renderInput) => <NativeStreamViewport {...renderInput} strategy={strategy} />,
|
||||||
<NativeStreamViewport
|
|
||||||
{...renderInput}
|
|
||||||
strategy={strategy}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
orderTailReverse: true,
|
orderTailReverse: true,
|
||||||
orderHeadReverse: true,
|
orderHeadReverse: true,
|
||||||
assistantTurnTraversalStep: 1,
|
assistantTurnTraversalStep: 1,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,7 @@
|
|||||||
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
|
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
|
||||||
import type { StyleProp, ViewStyle } from "react-native";
|
import type { StyleProp, ViewStyle } from "react-native";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import type {
|
import type { StreamHistoryBoundary, StreamRenderSegments } from "./agent-stream-render-model";
|
||||||
StreamHistoryBoundary,
|
|
||||||
StreamRenderSegments,
|
|
||||||
} from "./agent-stream-render-model";
|
|
||||||
import type {
|
import type {
|
||||||
BottomAnchorLocalRequest,
|
BottomAnchorLocalRequest,
|
||||||
BottomAnchorRouteRequest,
|
BottomAnchorRouteRequest,
|
||||||
@@ -49,21 +46,9 @@ export type StreamViewportHandle = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type StreamSegmentRenderers = {
|
export type StreamSegmentRenderers = {
|
||||||
renderHistoryVirtualizedRow: (
|
renderHistoryVirtualizedRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode;
|
||||||
item: StreamItem,
|
renderHistoryMountedRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode;
|
||||||
index: number,
|
renderLiveHeadRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode;
|
||||||
items: StreamItem[]
|
|
||||||
) => ReactNode;
|
|
||||||
renderHistoryMountedRow: (
|
|
||||||
item: StreamItem,
|
|
||||||
index: number,
|
|
||||||
items: StreamItem[]
|
|
||||||
) => ReactNode;
|
|
||||||
renderLiveHeadRow: (
|
|
||||||
item: StreamItem,
|
|
||||||
index: number,
|
|
||||||
items: StreamItem[]
|
|
||||||
) => ReactNode;
|
|
||||||
renderLiveAuxiliary: () => ReactNode;
|
renderLiveAuxiliary: () => ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -96,18 +81,16 @@ export interface StreamStrategy {
|
|||||||
getNeighborItem: (
|
getNeighborItem: (
|
||||||
items: StreamItem[],
|
items: StreamItem[],
|
||||||
index: number,
|
index: number,
|
||||||
relation: NeighborRelation
|
relation: NeighborRelation,
|
||||||
) => StreamItem | undefined;
|
) => StreamItem | undefined;
|
||||||
collectAssistantTurnContent: (items: StreamItem[], startIndex: number) => string;
|
collectAssistantTurnContent: (items: StreamItem[], startIndex: number) => string;
|
||||||
isNearBottom: (input: StreamNearBottomInput) => boolean;
|
isNearBottom: (input: StreamNearBottomInput) => boolean;
|
||||||
getBottomOffset: (metrics: StreamViewportMetrics) => number;
|
getBottomOffset: (metrics: StreamViewportMetrics) => number;
|
||||||
getEdgeSlotProps: (
|
getEdgeSlotProps: (
|
||||||
component: ReactElement | ComponentType<any> | null,
|
component: ReactElement | ComponentType<any> | null,
|
||||||
gapSize: number
|
gapSize: number,
|
||||||
) => StreamEdgeSlotProps;
|
) => StreamEdgeSlotProps;
|
||||||
getMaintainVisibleContentPosition: () =>
|
getMaintainVisibleContentPosition: () => MaintainVisibleContentPositionConfig | undefined;
|
||||||
| MaintainVisibleContentPositionConfig
|
|
||||||
| undefined;
|
|
||||||
getBottomAnchorTransportBehavior: () => BottomAnchorTransportBehavior;
|
getBottomAnchorTransportBehavior: () => BottomAnchorTransportBehavior;
|
||||||
getFlatListInverted: () => boolean;
|
getFlatListInverted: () => boolean;
|
||||||
getOverlayScrollbarInverted: () => boolean;
|
getOverlayScrollbarInverted: () => boolean;
|
||||||
@@ -137,15 +120,12 @@ type StreamStrategyConfig = {
|
|||||||
|
|
||||||
const NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES = 4;
|
const NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES = 4;
|
||||||
|
|
||||||
export function createStreamStrategy(
|
export function createStreamStrategy(config: StreamStrategyConfig): StreamStrategy {
|
||||||
config: StreamStrategyConfig
|
|
||||||
): StreamStrategy {
|
|
||||||
return {
|
return {
|
||||||
render: config.render,
|
render: config.render,
|
||||||
orderTail: (streamItems) =>
|
orderTail: (streamItems) =>
|
||||||
config.orderTailReverse ? [...streamItems].reverse() : streamItems,
|
config.orderTailReverse ? [...streamItems].reverse() : streamItems,
|
||||||
orderHead: (streamHead) =>
|
orderHead: (streamHead) => (config.orderHeadReverse ? [...streamHead].reverse() : streamHead),
|
||||||
config.orderHeadReverse ? [...streamHead].reverse() : streamHead,
|
|
||||||
getNeighborIndex: (index, relation) =>
|
getNeighborIndex: (index, relation) =>
|
||||||
relation === "above"
|
relation === "above"
|
||||||
? index + config.assistantTurnTraversalStep
|
? index + config.assistantTurnTraversalStep
|
||||||
@@ -197,15 +177,14 @@ export function createStreamStrategy(
|
|||||||
getOverlayScrollbarInverted: () => config.overlayScrollbarInverted,
|
getOverlayScrollbarInverted: () => config.overlayScrollbarInverted,
|
||||||
shouldDisableParentScrollOnInlineDetailsExpansion: () =>
|
shouldDisableParentScrollOnInlineDetailsExpansion: () =>
|
||||||
config.disableParentScrollOnInlineDetailsExpansion,
|
config.disableParentScrollOnInlineDetailsExpansion,
|
||||||
shouldAnchorBottomOnContentSizeChange: () =>
|
shouldAnchorBottomOnContentSizeChange: () => config.anchorBottomOnContentSizeChange,
|
||||||
config.anchorBottomOnContentSizeChange,
|
|
||||||
shouldAnimateManualScrollToBottom: () => config.animateManualScrollToBottom,
|
shouldAnimateManualScrollToBottom: () => config.animateManualScrollToBottom,
|
||||||
shouldUseVirtualizedList: () => config.useVirtualizedList,
|
shouldUseVirtualizedList: () => config.useVirtualizedList,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveStreamRenderStrategy(
|
export function resolveStreamRenderStrategy(
|
||||||
input: ResolveStreamRenderStrategyInput
|
input: ResolveStreamRenderStrategyInput,
|
||||||
): StreamStrategy {
|
): StreamStrategy {
|
||||||
if (input.platform === "web") {
|
if (input.platform === "web") {
|
||||||
return createWebStreamStrategy({
|
return createWebStreamStrategy({
|
||||||
@@ -226,7 +205,7 @@ export function resolveBottomAnchorTransportBehavior(input: {
|
|||||||
return {
|
return {
|
||||||
verificationDelayFrames: Math.max(
|
verificationDelayFrames: Math.max(
|
||||||
baseBehavior.verificationDelayFrames,
|
baseBehavior.verificationDelayFrames,
|
||||||
NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES
|
NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES,
|
||||||
),
|
),
|
||||||
verificationRetryMode: "recheck",
|
verificationRetryMode: "recheck",
|
||||||
};
|
};
|
||||||
@@ -260,11 +239,7 @@ export function getStreamNeighborItem(params: {
|
|||||||
index: number;
|
index: number;
|
||||||
relation: NeighborRelation;
|
relation: NeighborRelation;
|
||||||
}): StreamItem | undefined {
|
}): StreamItem | undefined {
|
||||||
return params.strategy.getNeighborItem(
|
return params.strategy.getNeighborItem(params.items, params.index, params.relation);
|
||||||
params.items,
|
|
||||||
params.index,
|
|
||||||
params.relation
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function collectAssistantTurnContentForStreamRenderStrategy(params: {
|
export function collectAssistantTurnContentForStreamRenderStrategy(params: {
|
||||||
@@ -272,14 +247,11 @@ export function collectAssistantTurnContentForStreamRenderStrategy(params: {
|
|||||||
items: StreamItem[];
|
items: StreamItem[];
|
||||||
startIndex: number;
|
startIndex: number;
|
||||||
}): string {
|
}): string {
|
||||||
return params.strategy.collectAssistantTurnContent(
|
return params.strategy.collectAssistantTurnContent(params.items, params.startIndex);
|
||||||
params.items,
|
|
||||||
params.startIndex
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isNearBottomForStreamRenderStrategy(
|
export function isNearBottomForStreamRenderStrategy(
|
||||||
params: StreamNearBottomInput & { strategy: StreamStrategy }
|
params: StreamNearBottomInput & { strategy: StreamStrategy },
|
||||||
): boolean {
|
): boolean {
|
||||||
return params.strategy.isNearBottom({
|
return params.strategy.isNearBottom({
|
||||||
offsetY: params.offsetY,
|
offsetY: params.offsetY,
|
||||||
@@ -292,7 +264,7 @@ export function isNearBottomForStreamRenderStrategy(
|
|||||||
export function getBottomOffsetForStreamRenderStrategy(
|
export function getBottomOffsetForStreamRenderStrategy(
|
||||||
params: StreamViewportMetrics & {
|
params: StreamViewportMetrics & {
|
||||||
strategy: StreamStrategy;
|
strategy: StreamStrategy;
|
||||||
}
|
},
|
||||||
): number {
|
): number {
|
||||||
return params.strategy.getBottomOffset({
|
return params.strategy.getBottomOffset({
|
||||||
contentHeight: params.contentHeight,
|
contentHeight: params.contentHeight,
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ function ensureSharedStepLoopStarted(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sharedLoopStarted = true;
|
sharedLoopStarted = true;
|
||||||
const elapsedMs =
|
const elapsedMs = (Date.now() - SYNCED_LOADER_EPOCH_MS) % SYNCED_LOADER_DURATION_MS;
|
||||||
(Date.now() - SYNCED_LOADER_EPOCH_MS) % SYNCED_LOADER_DURATION_MS;
|
|
||||||
sharedStepProgress.value = (elapsedMs / SYNCED_LOADER_DURATION_MS) * DOT_COUNT;
|
sharedStepProgress.value = (elapsedMs / SYNCED_LOADER_DURATION_MS) * DOT_COUNT;
|
||||||
sharedStepProgress.value = withTiming(
|
sharedStepProgress.value = withTiming(
|
||||||
DOT_COUNT,
|
DOT_COUNT,
|
||||||
@@ -47,19 +46,13 @@ function ensureSharedStepLoopStarted(): void {
|
|||||||
easing: Easing.linear,
|
easing: Easing.linear,
|
||||||
}),
|
}),
|
||||||
-1,
|
-1,
|
||||||
false
|
false,
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SyncedLoader({
|
export function SyncedLoader({ size = 10, color }: { size?: number; color: string }) {
|
||||||
size = 10,
|
|
||||||
color,
|
|
||||||
}: {
|
|
||||||
size?: number;
|
|
||||||
color: string;
|
|
||||||
}) {
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ensureSharedStepLoopStarted();
|
ensureSharedStepLoopStarted();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -94,9 +87,7 @@ export function SyncedLoader({
|
|||||||
{Array.from({ length: DOT_COUNT }).map((_, dotIndex) => {
|
{Array.from({ length: DOT_COUNT }).map((_, dotIndex) => {
|
||||||
const rowIndex = Math.floor(dotIndex / GRID_COLUMNS);
|
const rowIndex = Math.floor(dotIndex / GRID_COLUMNS);
|
||||||
const columnIndex = dotIndex % GRID_COLUMNS;
|
const columnIndex = dotIndex % GRID_COLUMNS;
|
||||||
const sequenceIndex = DOT_SEQUENCE.indexOf(
|
const sequenceIndex = DOT_SEQUENCE.indexOf(dotIndex as (typeof DOT_SEQUENCE)[number]);
|
||||||
dotIndex as (typeof DOT_SEQUENCE)[number]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SpinnerDot
|
<SpinnerDot
|
||||||
|
|||||||
@@ -318,10 +318,7 @@ export default function TerminalEmulator({
|
|||||||
initialOutputText,
|
initialOutputText,
|
||||||
theme: mountedThemeRef.current,
|
theme: mountedThemeRef.current,
|
||||||
});
|
});
|
||||||
appliedChunkSequenceRef.current = Math.max(
|
appliedChunkSequenceRef.current = Math.max(0, Math.floor(initialOutputChunkSequence));
|
||||||
0,
|
|
||||||
Math.floor(initialOutputChunkSequence)
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
runtime.unmount();
|
runtime.unmount();
|
||||||
@@ -469,10 +466,7 @@ export default function TerminalEmulator({
|
|||||||
}, [streamKey]);
|
}, [streamKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const maxScrollOffset = Math.max(
|
const maxScrollOffset = Math.max(0, viewportMetrics.contentSize - viewportMetrics.viewportSize);
|
||||||
0,
|
|
||||||
viewportMetrics.contentSize - viewportMetrics.viewportSize
|
|
||||||
);
|
|
||||||
const normalizedOffset = clamp(viewportMetrics.offset, 0, maxScrollOffset);
|
const normalizedOffset = clamp(viewportMetrics.offset, 0, maxScrollOffset);
|
||||||
if (maxScrollOffset <= 0 || viewportMetrics.viewportSize <= 0) {
|
if (maxScrollOffset <= 0 || viewportMetrics.viewportSize <= 0) {
|
||||||
setIsScrollVisible(false);
|
setIsScrollVisible(false);
|
||||||
@@ -504,11 +498,7 @@ export default function TerminalEmulator({
|
|||||||
setIsScrollActive(false);
|
setIsScrollActive(false);
|
||||||
scrollActiveTimeoutRef.current = null;
|
scrollActiveTimeoutRef.current = null;
|
||||||
}, SCROLLBAR_HANDLE_SCROLL_ACTIVE_MS);
|
}, SCROLLBAR_HANDLE_SCROLL_ACTIVE_MS);
|
||||||
}, [
|
}, [viewportMetrics.contentSize, viewportMetrics.offset, viewportMetrics.viewportSize]);
|
||||||
viewportMetrics.contentSize,
|
|
||||||
viewportMetrics.offset,
|
|
||||||
viewportMetrics.viewportSize,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -528,7 +518,7 @@ export default function TerminalEmulator({
|
|||||||
contentSize: viewportMetrics.contentSize,
|
contentSize: viewportMetrics.contentSize,
|
||||||
offset: viewportMetrics.offset,
|
offset: viewportMetrics.offset,
|
||||||
}),
|
}),
|
||||||
[viewportMetrics.contentSize, viewportMetrics.offset, viewportMetrics.viewportSize]
|
[viewportMetrics.contentSize, viewportMetrics.offset, viewportMetrics.viewportSize],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -569,11 +559,7 @@ export default function TerminalEmulator({
|
|||||||
window.removeEventListener("pointerup", stopDragging);
|
window.removeEventListener("pointerup", stopDragging);
|
||||||
window.removeEventListener("pointercancel", stopDragging);
|
window.removeEventListener("pointercancel", stopDragging);
|
||||||
};
|
};
|
||||||
}, [
|
}, [isDraggingScrollbar, scrollbarGeometry.maxHandleOffset, scrollbarGeometry.maxScrollOffset]);
|
||||||
isDraggingScrollbar,
|
|
||||||
scrollbarGeometry.maxHandleOffset,
|
|
||||||
scrollbarGeometry.maxScrollOffset,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleVisible =
|
const handleVisible =
|
||||||
scrollbarGeometry.isVisible && (isDraggingScrollbar || isScrollVisible || isHandleHovered);
|
scrollbarGeometry.isVisible && (isDraggingScrollbar || isScrollVisible || isHandleHovered);
|
||||||
@@ -590,16 +576,13 @@ export default function TerminalEmulator({
|
|||||||
: SCROLLBAR_HANDLE_WIDTH_IDLE;
|
: SCROLLBAR_HANDLE_WIDTH_IDLE;
|
||||||
const thumbRegionOffset = Math.max(
|
const thumbRegionOffset = Math.max(
|
||||||
0,
|
0,
|
||||||
scrollbarGeometry.handleOffset - SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING
|
scrollbarGeometry.handleOffset - SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING,
|
||||||
);
|
);
|
||||||
const thumbRegionHeight = Math.min(
|
const thumbRegionHeight = Math.min(
|
||||||
viewportMetrics.viewportSize - thumbRegionOffset,
|
viewportMetrics.viewportSize - thumbRegionOffset,
|
||||||
scrollbarGeometry.handleSize + SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING * 2
|
scrollbarGeometry.handleSize + SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING * 2,
|
||||||
);
|
|
||||||
const handleInsetTop = Math.max(
|
|
||||||
0,
|
|
||||||
(thumbRegionHeight - scrollbarGeometry.handleSize) / 2
|
|
||||||
);
|
);
|
||||||
|
const handleInsetTop = Math.max(0, (thumbRegionHeight - scrollbarGeometry.handleSize) / 2);
|
||||||
const handleTravelDurationMs =
|
const handleTravelDurationMs =
|
||||||
isDraggingScrollbar || isScrollActive ? 0 : SCROLLBAR_HANDLE_TRAVEL_DURATION_MS;
|
isDraggingScrollbar || isScrollActive ? 0 : SCROLLBAR_HANDLE_TRAVEL_DURATION_MS;
|
||||||
|
|
||||||
@@ -677,7 +660,7 @@ export default function TerminalEmulator({
|
|||||||
dragStartOffsetRef.current = clamp(
|
dragStartOffsetRef.current = clamp(
|
||||||
viewportMetrics.offset,
|
viewportMetrics.offset,
|
||||||
0,
|
0,
|
||||||
scrollbarGeometry.maxScrollOffset
|
scrollbarGeometry.maxScrollOffset,
|
||||||
);
|
);
|
||||||
dragStartClientYRef.current = event.clientY;
|
dragStartClientYRef.current = event.clientY;
|
||||||
setIsDraggingScrollbar(true);
|
setIsDraggingScrollbar(true);
|
||||||
@@ -703,8 +686,7 @@ export default function TerminalEmulator({
|
|||||||
opacity: handleOpacity,
|
opacity: handleOpacity,
|
||||||
transitionProperty: "opacity, width, background-color",
|
transitionProperty: "opacity, width, background-color",
|
||||||
transitionDuration: `${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms, ${SCROLLBAR_HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms`,
|
transitionDuration: `${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms, ${SCROLLBAR_HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms`,
|
||||||
transitionTimingFunction:
|
transitionTimingFunction: "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out",
|
||||||
"ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out",
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,21 +1,10 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
|
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
|
||||||
import {
|
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
|
||||||
ActivityIndicator,
|
|
||||||
Pressable,
|
|
||||||
ScrollView,
|
|
||||||
Text,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { Plus, X } from "lucide-react-native";
|
import { Plus, X } from "lucide-react-native";
|
||||||
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
|
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
|
||||||
import Svg, {
|
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
||||||
Defs,
|
|
||||||
LinearGradient as SvgLinearGradient,
|
|
||||||
Rect,
|
|
||||||
Stop,
|
|
||||||
} from "react-native-svg";
|
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import type { ListTerminalsResponse } from "@server/shared/messages";
|
import type { ListTerminalsResponse } from "@server/shared/messages";
|
||||||
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
|
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
|
||||||
@@ -28,9 +17,7 @@ import {
|
|||||||
} from "@/utils/terminal-keys";
|
} from "@/utils/terminal-keys";
|
||||||
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
import {
|
import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
|
||||||
getWorkspaceTerminalSession,
|
|
||||||
} from "@/terminal/runtime/workspace-terminal-session";
|
|
||||||
import {
|
import {
|
||||||
TerminalStreamController,
|
TerminalStreamController,
|
||||||
type TerminalStreamControllerStatus,
|
type TerminalStreamControllerStatus,
|
||||||
@@ -109,13 +96,7 @@ function TerminalCloseGradient({ color, gradientId }: { color: string; gradientI
|
|||||||
<View style={styles.terminalTabCloseGradient} pointerEvents="none">
|
<View style={styles.terminalTabCloseGradient} pointerEvents="none">
|
||||||
<Svg width="100%" height="100%" preserveAspectRatio="none">
|
<Svg width="100%" height="100%" preserveAspectRatio="none">
|
||||||
<Defs>
|
<Defs>
|
||||||
<SvgLinearGradient
|
<SvgLinearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
id={gradientId}
|
|
||||||
x1="0%"
|
|
||||||
y1="0%"
|
|
||||||
x2="100%"
|
|
||||||
y2="0%"
|
|
||||||
>
|
|
||||||
<Stop offset="0%" stopColor={color} stopOpacity={0} />
|
<Stop offset="0%" stopColor={color} stopOpacity={0} />
|
||||||
<Stop offset="10%" stopColor={color} stopOpacity={1} />
|
<Stop offset="10%" stopColor={color} stopOpacity={1} />
|
||||||
<Stop offset="100%" stopColor={color} stopOpacity={1} />
|
<Stop offset="100%" stopColor={color} stopOpacity={1} />
|
||||||
@@ -138,8 +119,7 @@ export function TerminalPane({
|
|||||||
const isScreenFocused = useIsFocused();
|
const isScreenFocused = useIsFocused();
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]);
|
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]);
|
||||||
const isMobile =
|
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const openAgentList = usePanelStore((state) => state.openAgentList);
|
const openAgentList = usePanelStore((state) => state.openAgentList);
|
||||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
||||||
@@ -163,21 +143,18 @@ export function TerminalPane({
|
|||||||
scopeKey,
|
scopeKey,
|
||||||
maxOutputChars: MAX_OUTPUT_CHARS,
|
maxOutputChars: MAX_OUTPUT_CHARS,
|
||||||
}),
|
}),
|
||||||
[scopeKey]
|
[scopeKey],
|
||||||
);
|
);
|
||||||
const outputSession = workspaceTerminalSession.outputSession;
|
const outputSession = workspaceTerminalSession.outputSession;
|
||||||
const subscribeOutputSession = useCallback(
|
const subscribeOutputSession = useCallback(
|
||||||
(listener: () => void) => outputSession.subscribe(listener),
|
(listener: () => void) => outputSession.subscribe(listener),
|
||||||
[outputSession]
|
[outputSession],
|
||||||
);
|
|
||||||
const getOutputSessionState = useCallback(
|
|
||||||
() => outputSession.getState(),
|
|
||||||
[outputSession]
|
|
||||||
);
|
);
|
||||||
|
const getOutputSessionState = useCallback(() => outputSession.getState(), [outputSession]);
|
||||||
const outputState = useSyncExternalStore(
|
const outputState = useSyncExternalStore(
|
||||||
subscribeOutputSession,
|
subscribeOutputSession,
|
||||||
getOutputSessionState,
|
getOutputSessionState,
|
||||||
getOutputSessionState
|
getOutputSessionState,
|
||||||
);
|
);
|
||||||
const selectedOutputState = useMemo(() => {
|
const selectedOutputState = useMemo(() => {
|
||||||
if (outputState.selectedTerminalId === selectedTerminalId) {
|
if (outputState.selectedTerminalId === selectedTerminalId) {
|
||||||
@@ -204,9 +181,7 @@ export function TerminalPane({
|
|||||||
const [focusRequestToken, setFocusRequestToken] = useState(0);
|
const [focusRequestToken, setFocusRequestToken] = useState(0);
|
||||||
const [resizeRequestToken, setResizeRequestToken] = useState(0);
|
const [resizeRequestToken, setResizeRequestToken] = useState(0);
|
||||||
const [hoveredTerminalId, setHoveredTerminalId] = useState<string | null>(null);
|
const [hoveredTerminalId, setHoveredTerminalId] = useState<string | null>(null);
|
||||||
const [hoveredCloseTerminalId, setHoveredCloseTerminalId] = useState<string | null>(
|
const [hoveredCloseTerminalId, setHoveredCloseTerminalId] = useState<string | null>(null);
|
||||||
null
|
|
||||||
);
|
|
||||||
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
|
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
|
||||||
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
|
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
|
||||||
@@ -217,7 +192,7 @@ export function TerminalPane({
|
|||||||
(next: string | null) => {
|
(next: string | null) => {
|
||||||
onSelectedTerminalIdChange?.(next);
|
onSelectedTerminalIdChange?.(next);
|
||||||
},
|
},
|
||||||
[onSelectedTerminalIdChange]
|
[onSelectedTerminalIdChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -237,7 +212,7 @@ export function TerminalPane({
|
|||||||
clearHoverOutTimeout();
|
clearHoverOutTimeout();
|
||||||
setHoveredTerminalId(terminalId);
|
setHoveredTerminalId(terminalId);
|
||||||
},
|
},
|
||||||
[clearHoverOutTimeout]
|
[clearHoverOutTimeout],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTerminalTabHoverOut = useCallback(
|
const handleTerminalTabHoverOut = useCallback(
|
||||||
@@ -245,12 +220,10 @@ export function TerminalPane({
|
|||||||
clearHoverOutTimeout();
|
clearHoverOutTimeout();
|
||||||
hoverOutTimeoutRef.current = setTimeout(() => {
|
hoverOutTimeoutRef.current = setTimeout(() => {
|
||||||
setHoveredTerminalId((current) => (current === terminalId ? null : current));
|
setHoveredTerminalId((current) => (current === terminalId ? null : current));
|
||||||
setHoveredCloseTerminalId((current) =>
|
setHoveredCloseTerminalId((current) => (current === terminalId ? null : current));
|
||||||
current === terminalId ? null : current
|
|
||||||
);
|
|
||||||
}, 50);
|
}, 50);
|
||||||
},
|
},
|
||||||
[clearHoverOutTimeout]
|
[clearHoverOutTimeout],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTerminalCloseHoverIn = useCallback(
|
const handleTerminalCloseHoverIn = useCallback(
|
||||||
@@ -259,7 +232,7 @@ export function TerminalPane({
|
|||||||
setHoveredTerminalId(terminalId);
|
setHoveredTerminalId(terminalId);
|
||||||
setHoveredCloseTerminalId(terminalId);
|
setHoveredCloseTerminalId(terminalId);
|
||||||
},
|
},
|
||||||
[clearHoverOutTimeout]
|
[clearHoverOutTimeout],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTerminalCloseHoverOut = useCallback((terminalId: string) => {
|
const handleTerminalCloseHoverOut = useCallback((terminalId: string) => {
|
||||||
@@ -308,7 +281,7 @@ export function TerminalPane({
|
|||||||
keyboardRefitTimeoutsRef.current = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
|
keyboardRefitTimeoutsRef.current = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
requestTerminalReflow();
|
requestTerminalReflow();
|
||||||
}, delayMs)
|
}, delayMs),
|
||||||
);
|
);
|
||||||
}, [clearKeyboardRefitTimeouts, requestTerminalReflow]);
|
}, [clearKeyboardRefitTimeouts, requestTerminalReflow]);
|
||||||
|
|
||||||
@@ -324,7 +297,7 @@ export function TerminalPane({
|
|||||||
}
|
}
|
||||||
runOnJS(pulseKeyboardRefits)();
|
runOnJS(pulseKeyboardRefits)();
|
||||||
},
|
},
|
||||||
[pulseKeyboardRefits]
|
[pulseKeyboardRefits],
|
||||||
);
|
);
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
@@ -337,7 +310,7 @@ export function TerminalPane({
|
|||||||
const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
|
const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
requestTerminalReflow();
|
requestTerminalReflow();
|
||||||
}, delayMs)
|
}, delayMs),
|
||||||
);
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -345,7 +318,7 @@ export function TerminalPane({
|
|||||||
clearTimeout(handle);
|
clearTimeout(handle);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [requestTerminalReflow, selectedTerminalId])
|
}, [requestTerminalReflow, selectedTerminalId]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const terminalsQuery = useQuery({
|
const terminalsQuery = useQuery({
|
||||||
@@ -386,12 +359,7 @@ export function TerminalPane({
|
|||||||
}, [client, isConnected]);
|
}, [client, isConnected]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (!manageTerminalDirectorySubscription || !client || !isConnected || !cwd.startsWith("/")) {
|
||||||
!manageTerminalDirectorySubscription ||
|
|
||||||
!client ||
|
|
||||||
!isConnected ||
|
|
||||||
!cwd.startsWith("/")
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,21 +462,18 @@ export function TerminalPane({
|
|||||||
streamControllerRef.current?.pruneResumeOffsets({ terminalIds });
|
streamControllerRef.current?.pruneResumeOffsets({ terminalIds });
|
||||||
}, [outputSession, terminals]);
|
}, [outputSession, terminals]);
|
||||||
|
|
||||||
const handleStreamControllerStatus = useCallback(
|
const handleStreamControllerStatus = useCallback((status: TerminalStreamControllerStatus) => {
|
||||||
(status: TerminalStreamControllerStatus) => {
|
setIsAttaching(status.isAttaching);
|
||||||
setIsAttaching(status.isAttaching);
|
setStreamError(status.error);
|
||||||
setStreamError(status.error);
|
if (status.terminalId && typeof status.streamId === "number") {
|
||||||
if (status.terminalId && typeof status.streamId === "number") {
|
setActiveStream({
|
||||||
setActiveStream({
|
terminalId: status.terminalId,
|
||||||
terminalId: status.terminalId,
|
streamId: status.streamId,
|
||||||
streamId: status.streamId,
|
});
|
||||||
});
|
return;
|
||||||
return;
|
}
|
||||||
}
|
setActiveStream(null);
|
||||||
setActiveStream(null);
|
}, []);
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
streamControllerRef.current?.dispose();
|
streamControllerRef.current?.dispose();
|
||||||
@@ -566,16 +531,14 @@ export function TerminalPane({
|
|||||||
}, [isScreenFocused, outputSession, selectedTerminalId]);
|
}, [isScreenFocused, outputSession, selectedTerminalId]);
|
||||||
|
|
||||||
const activeStreamId =
|
const activeStreamId =
|
||||||
activeStream && activeStream.terminalId === selectedTerminalId
|
activeStream && activeStream.terminalId === selectedTerminalId ? activeStream.streamId : null;
|
||||||
? activeStream.streamId
|
|
||||||
: null;
|
|
||||||
const getCurrentActiveStreamId = useCallback(() => {
|
const getCurrentActiveStreamId = useCallback(() => {
|
||||||
return streamControllerRef.current?.getActiveStreamId() ?? null;
|
return streamControllerRef.current?.getActiveStreamId() ?? null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const selectedTerminal = useMemo(
|
const selectedTerminal = useMemo(
|
||||||
() => terminals.find((terminal) => terminal.id === selectedTerminalId) ?? null,
|
() => terminals.find((terminal) => terminal.id === selectedTerminalId) ?? null,
|
||||||
[terminals, selectedTerminalId]
|
[terminals, selectedTerminalId],
|
||||||
);
|
);
|
||||||
const handleCreateTerminal = useCallback(() => {
|
const handleCreateTerminal = useCallback(() => {
|
||||||
createTerminalMutation.mutate();
|
createTerminalMutation.mutate();
|
||||||
@@ -618,7 +581,7 @@ export function TerminalPane({
|
|||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[client]
|
[client],
|
||||||
);
|
);
|
||||||
|
|
||||||
const flushPendingTerminalInput = useCallback(() => {
|
const flushPendingTerminalInput = useCallback(() => {
|
||||||
@@ -650,10 +613,7 @@ export function TerminalPane({
|
|||||||
|
|
||||||
const handleCloseTerminal = useCallback(
|
const handleCloseTerminal = useCallback(
|
||||||
async (terminalId: string) => {
|
async (terminalId: string) => {
|
||||||
if (
|
if (killTerminalMutation.isPending && killTerminalMutation.variables === terminalId) {
|
||||||
killTerminalMutation.isPending &&
|
|
||||||
killTerminalMutation.variables === terminalId
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -671,7 +631,7 @@ export function TerminalPane({
|
|||||||
|
|
||||||
killTerminalMutation.mutate(terminalId);
|
killTerminalMutation.mutate(terminalId);
|
||||||
},
|
},
|
||||||
[killTerminalMutation]
|
[killTerminalMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearPendingModifiers = useCallback(() => {
|
const clearPendingModifiers = useCallback(() => {
|
||||||
@@ -679,15 +639,13 @@ export function TerminalPane({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const sendTerminalKey = useCallback(
|
const sendTerminalKey = useCallback(
|
||||||
(
|
(input: {
|
||||||
input: {
|
key: string;
|
||||||
key: string;
|
ctrl: boolean;
|
||||||
ctrl: boolean;
|
shift: boolean;
|
||||||
shift: boolean;
|
alt: boolean;
|
||||||
alt: boolean;
|
meta?: boolean;
|
||||||
meta?: boolean;
|
}): boolean => {
|
||||||
}
|
|
||||||
): boolean => {
|
|
||||||
if (!client || !selectedTerminalIdRef.current) {
|
if (!client || !selectedTerminalIdRef.current) {
|
||||||
enqueuePendingTerminalInput({
|
enqueuePendingTerminalInput({
|
||||||
type: "key",
|
type: "key",
|
||||||
@@ -718,12 +676,7 @@ export function TerminalPane({
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[
|
[client, dispatchTerminalInputEntry, enqueuePendingTerminalInput, getCurrentActiveStreamId],
|
||||||
client,
|
|
||||||
dispatchTerminalInputEntry,
|
|
||||||
enqueuePendingTerminalInput,
|
|
||||||
getCurrentActiveStreamId,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTerminalData = useCallback(
|
const handleTerminalData = useCallback(
|
||||||
@@ -782,7 +735,7 @@ export function TerminalPane({
|
|||||||
modifiers.shift,
|
modifiers.shift,
|
||||||
sendTerminalKey,
|
sendTerminalKey,
|
||||||
enqueuePendingTerminalInput,
|
enqueuePendingTerminalInput,
|
||||||
]
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTerminalResize = useCallback(
|
const handleTerminalResize = useCallback(
|
||||||
@@ -794,11 +747,7 @@ export function TerminalPane({
|
|||||||
const normalizedRows = Math.floor(rows);
|
const normalizedRows = Math.floor(rows);
|
||||||
const normalizedCols = Math.floor(cols);
|
const normalizedCols = Math.floor(cols);
|
||||||
const previous = lastReportedSizeRef.current;
|
const previous = lastReportedSizeRef.current;
|
||||||
if (
|
if (previous && previous.rows === normalizedRows && previous.cols === normalizedCols) {
|
||||||
previous &&
|
|
||||||
previous.rows === normalizedRows &&
|
|
||||||
previous.cols === normalizedCols
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols };
|
lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols };
|
||||||
@@ -808,36 +757,33 @@ export function TerminalPane({
|
|||||||
cols: normalizedCols,
|
cols: normalizedCols,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[client, selectedTerminalId]
|
[client, selectedTerminalId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTerminalKey = useCallback(
|
const handleTerminalKey = useCallback(
|
||||||
async (input: {
|
async (input: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean }) => {
|
||||||
key: string;
|
|
||||||
ctrl: boolean;
|
|
||||||
shift: boolean;
|
|
||||||
alt: boolean;
|
|
||||||
meta: boolean;
|
|
||||||
}) => {
|
|
||||||
sendTerminalKey(input);
|
sendTerminalKey(input);
|
||||||
},
|
},
|
||||||
[sendTerminalKey]
|
[sendTerminalKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePendingModifiersConsumed = useCallback(() => {
|
const handlePendingModifiersConsumed = useCallback(() => {
|
||||||
clearPendingModifiers();
|
clearPendingModifiers();
|
||||||
}, [clearPendingModifiers]);
|
}, [clearPendingModifiers]);
|
||||||
|
|
||||||
const handleOutputChunkConsumed = useCallback((sequence: number) => {
|
const handleOutputChunkConsumed = useCallback(
|
||||||
outputSession.consume({ sequence });
|
(sequence: number) => {
|
||||||
}, [outputSession]);
|
outputSession.consume({ sequence });
|
||||||
|
},
|
||||||
|
[outputSession],
|
||||||
|
);
|
||||||
|
|
||||||
const toggleModifier = useCallback(
|
const toggleModifier = useCallback(
|
||||||
(modifier: keyof ModifierState) => {
|
(modifier: keyof ModifierState) => {
|
||||||
setModifiers((current) => ({ ...current, [modifier]: !current[modifier] }));
|
setModifiers((current) => ({ ...current, [modifier]: !current[modifier] }));
|
||||||
requestTerminalFocus();
|
requestTerminalFocus();
|
||||||
},
|
},
|
||||||
[requestTerminalFocus]
|
[requestTerminalFocus],
|
||||||
);
|
);
|
||||||
|
|
||||||
const sendVirtualKey = useCallback(
|
const sendVirtualKey = useCallback(
|
||||||
@@ -859,7 +805,7 @@ export function TerminalPane({
|
|||||||
modifiers.shift,
|
modifiers.shift,
|
||||||
requestTerminalFocus,
|
requestTerminalFocus,
|
||||||
sendTerminalKey,
|
sendTerminalKey,
|
||||||
]
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!client || !isConnected) {
|
if (!client || !isConnected) {
|
||||||
@@ -870,17 +816,12 @@ export function TerminalPane({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryError =
|
const queryError = terminalsQuery.error instanceof Error ? terminalsQuery.error.message : null;
|
||||||
terminalsQuery.error instanceof Error ? terminalsQuery.error.message : null;
|
|
||||||
const isCreating = createTerminalMutation.isPending;
|
const isCreating = createTerminalMutation.isPending;
|
||||||
const createError =
|
const createError =
|
||||||
createTerminalMutation.error instanceof Error
|
createTerminalMutation.error instanceof Error ? createTerminalMutation.error.message : null;
|
||||||
? createTerminalMutation.error.message
|
|
||||||
: null;
|
|
||||||
const closeError =
|
const closeError =
|
||||||
killTerminalMutation.error instanceof Error
|
killTerminalMutation.error instanceof Error ? killTerminalMutation.error.message : null;
|
||||||
? killTerminalMutation.error.message
|
|
||||||
: null;
|
|
||||||
const combinedError = streamError ?? closeError ?? createError ?? queryError;
|
const combinedError = streamError ?? closeError ?? createError ?? queryError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -898,13 +839,11 @@ export function TerminalPane({
|
|||||||
const isTabHovered = hoveredTerminalId === terminal.id;
|
const isTabHovered = hoveredTerminalId === terminal.id;
|
||||||
const isCloseHovered = hoveredCloseTerminalId === terminal.id;
|
const isCloseHovered = hoveredCloseTerminalId === terminal.id;
|
||||||
const isClosingTerminal =
|
const isClosingTerminal =
|
||||||
killTerminalMutation.isPending &&
|
killTerminalMutation.isPending && killTerminalMutation.variables === terminal.id;
|
||||||
killTerminalMutation.variables === terminal.id;
|
const shouldShowCloseButton = isTabHovered || isCloseHovered || isClosingTerminal;
|
||||||
const shouldShowCloseButton =
|
|
||||||
isTabHovered || isCloseHovered || isClosingTerminal;
|
|
||||||
const gradientId = `terminal-close-gradient-${terminal.id.replace(
|
const gradientId = `terminal-close-gradient-${terminal.id.replace(
|
||||||
/[^a-zA-Z0-9_-]/g,
|
/[^a-zA-Z0-9_-]/g,
|
||||||
"-"
|
"-",
|
||||||
)}`;
|
)}`;
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -946,9 +885,7 @@ export function TerminalPane({
|
|||||||
>
|
>
|
||||||
{({ hovered = false, pressed = false }) => {
|
{({ hovered = false, pressed = false }) => {
|
||||||
const iconColor =
|
const iconColor =
|
||||||
hovered || pressed
|
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||||
? theme.colors.foreground
|
|
||||||
: theme.colors.foregroundMuted;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TerminalCloseGradient
|
<TerminalCloseGradient
|
||||||
@@ -1044,11 +981,7 @@ export function TerminalPane({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isAttaching && isScreenFocused ? (
|
{isAttaching && isScreenFocused ? (
|
||||||
<View
|
<View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading">
|
||||||
style={styles.attachOverlay}
|
|
||||||
pointerEvents="none"
|
|
||||||
testID="terminal-attach-loading"
|
|
||||||
>
|
|
||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1077,7 +1010,12 @@ export function TerminalPane({
|
|||||||
(hovered || pressed) && styles.keyButtonHovered,
|
(hovered || pressed) && styles.keyButtonHovered,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={[styles.keyButtonText, modifiers[modifier] && styles.keyButtonTextActive]}>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.keyButtonText,
|
||||||
|
modifiers[modifier] && styles.keyButtonTextActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
{MODIFIER_LABELS[modifier]}
|
{MODIFIER_LABELS[modifier]}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user