Refactor git actions and update React to 19.1.4
This commit is contained in:
Generated
+98
-40
@@ -7731,6 +7731,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7744,6 +7745,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7757,6 +7759,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7770,6 +7773,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7783,6 +7787,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7796,6 +7801,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7809,6 +7815,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7822,6 +7829,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7835,6 +7843,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7848,6 +7857,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7861,6 +7871,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7874,6 +7885,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7887,6 +7899,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7900,6 +7913,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7913,6 +7927,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7926,6 +7941,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7939,6 +7955,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7952,6 +7969,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7965,6 +7983,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7978,6 +7997,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7991,6 +8011,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -8004,6 +8025,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -8017,6 +8039,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -8030,6 +8053,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -8043,6 +8067,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -9486,6 +9511,7 @@
|
|||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -9495,7 +9521,7 @@
|
|||||||
"version": "19.2.3",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
@@ -12509,6 +12535,7 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/data-view-buffer": {
|
"node_modules/data-view-buffer": {
|
||||||
@@ -17213,7 +17240,7 @@
|
|||||||
"version": "16.8.1",
|
"version": "16.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz",
|
||||||
"integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==",
|
"integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
|
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
|
||||||
@@ -18627,7 +18654,7 @@
|
|||||||
"version": "2.6.1",
|
"version": "2.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||||
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
@@ -22771,15 +22798,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react": {
|
|
||||||
"version": "19.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
|
||||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-devtools-core": {
|
"node_modules/react-devtools-core": {
|
||||||
"version": "6.1.5",
|
"version": "6.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.5.tgz",
|
||||||
@@ -22811,18 +22829,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-dom": {
|
|
||||||
"version": "19.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
|
||||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"scheduler": "^0.26.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^19.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-fast-compare": {
|
"node_modules/react-fast-compare": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
|
||||||
@@ -23051,17 +23057,6 @@
|
|||||||
"react-native": ">=0.50.4"
|
"react-native": ">=0.50.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-native-nitro-modules": {
|
|
||||||
"version": "0.30.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.30.2.tgz",
|
|
||||||
"integrity": "sha512-+/uVS7FQwOiKYZQERMIvBRv5/X3CVHrFG6Nr/kIhVfVxGeUimHnBz7cgA97lJKIn7AKDRWL+UjLedW8pGOt0dg==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "*",
|
|
||||||
"react-native": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-native-permissions": {
|
"node_modules/react-native-permissions": {
|
||||||
"version": "5.5.1",
|
"version": "5.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-permissions/-/react-native-permissions-5.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-permissions/-/react-native-permissions-5.5.1.tgz",
|
||||||
@@ -23372,6 +23367,7 @@
|
|||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
|
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -23938,6 +23934,7 @@
|
|||||||
"version": "4.59.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
@@ -26625,6 +26622,7 @@
|
|||||||
"version": "7.3.1",
|
"version": "7.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
@@ -26742,6 +26740,7 @@
|
|||||||
"version": "8.5.8",
|
"version": "8.5.8",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||||
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -27525,6 +27524,7 @@
|
|||||||
"version": "4.3.6",
|
"version": "4.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
@@ -27654,8 +27654,8 @@
|
|||||||
"lezer-elixir": "^1.1.2",
|
"lezer-elixir": "^1.1.2",
|
||||||
"lucide-react-native": "^0.546.0",
|
"lucide-react-native": "^0.546.0",
|
||||||
"mnemonic-id": "^3.2.7",
|
"mnemonic-id": "^3.2.7",
|
||||||
"react": "19.1.0",
|
"react": "19.1.4",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.4",
|
||||||
"react-native": "^0.81.5",
|
"react-native": "^0.81.5",
|
||||||
"react-native-css": "^3.0.1",
|
"react-native-css": "^3.0.1",
|
||||||
"react-native-draggable-flatlist": "^4.0.3",
|
"react-native-draggable-flatlist": "^4.0.3",
|
||||||
@@ -27663,7 +27663,7 @@
|
|||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-keyboard-controller": "^1.19.2",
|
"react-native-keyboard-controller": "^1.19.2",
|
||||||
"react-native-markdown-display": "^7.0.2",
|
"react-native-markdown-display": "^7.0.2",
|
||||||
"react-native-nitro-modules": "^0.30.0",
|
"react-native-nitro-modules": "0.33.8",
|
||||||
"react-native-permissions": "^5.4.2",
|
"react-native-permissions": "^5.4.2",
|
||||||
"react-native-popover-view": "^6.1.0",
|
"react-native-popover-view": "^6.1.0",
|
||||||
"react-native-reanimated": "~4.1.1",
|
"react-native-reanimated": "~4.1.1",
|
||||||
@@ -27699,6 +27699,37 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/app/node_modules/react": {
|
||||||
|
"version": "19.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/react/-/react-19.1.4.tgz",
|
||||||
|
"integrity": "sha512-DHINL3PAmPUiK1uszfbKiXqfE03eszdt5BpVSuEAHb5nfmNPwnsy7g39h2t8aXFc/Bv99GH81s+j8dobtD+jOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packages/app/node_modules/react-dom": {
|
||||||
|
"version": "19.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.4.tgz",
|
||||||
|
"integrity": "sha512-s2868ab/xo2SI6H4106A7aFI8Mrqa4xC6HZT/pBzYyQ3cBLqa88hu47xYD8xf+uECleN698Awn7RCWlkTiKnqQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"scheduler": "^0.26.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.1.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packages/app/node_modules/react-native-nitro-modules": {
|
||||||
|
"version": "0.33.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.33.8.tgz",
|
||||||
|
"integrity": "sha512-1BffckbrPpxg6Een1Dj5EvijC9ctxEiEHvCBRiJWEnKKa5c24/JDgjfJFm1GyuaGtH5jlGVUbPzCcbIwiwEtuw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/app/node_modules/zod": {
|
"packages/app/node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -28140,8 +28171,8 @@
|
|||||||
"@tanstack/react-router": "^1.120.3",
|
"@tanstack/react-router": "^1.120.3",
|
||||||
"@tanstack/react-start": "^1.120.3",
|
"@tanstack/react-start": "^1.120.3",
|
||||||
"framer-motion": "^12.35.2",
|
"framer-motion": "^12.35.2",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.4",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"wrangler": "^4.59.1"
|
"wrangler": "^4.59.1"
|
||||||
},
|
},
|
||||||
@@ -28164,6 +28195,33 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"packages/website/node_modules/react": {
|
||||||
|
"version": "19.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
|
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packages/website/node_modules/react-dom": {
|
||||||
|
"version": "19.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
|
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"scheduler": "^0.27.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packages/website/node_modules/scheduler": {
|
||||||
|
"version": "0.27.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
|
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||||
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -73,7 +73,9 @@
|
|||||||
"author": "moboudra",
|
"author": "moboudra",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"lightningcss": "1.30.1"
|
"lightningcss": "1.30.1",
|
||||||
|
"react": "19.1.4",
|
||||||
|
"react-dom": "19.1.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11"
|
"@anthropic-ai/claude-agent-sdk": "^0.2.11"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const fs = require("fs");
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
const projectRoot = __dirname;
|
const projectRoot = __dirname;
|
||||||
|
const appNodeModulesRoot = path.resolve(projectRoot, "node_modules");
|
||||||
const serverSrcRoot = path.resolve(projectRoot, "../server/src");
|
const serverSrcRoot = path.resolve(projectRoot, "../server/src");
|
||||||
const relaySrcRoot = path.resolve(projectRoot, "../relay/src");
|
const relaySrcRoot = path.resolve(projectRoot, "../relay/src");
|
||||||
const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
|
const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
|
||||||
@@ -14,6 +15,14 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
|
|||||||
const config = getDefaultConfig(projectRoot);
|
const config = getDefaultConfig(projectRoot);
|
||||||
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
|
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
|
||||||
|
|
||||||
|
config.resolver.extraNodeModules = {
|
||||||
|
...(config.resolver.extraNodeModules ?? {}),
|
||||||
|
react: path.join(appNodeModulesRoot, "react"),
|
||||||
|
"react-dom": path.join(appNodeModulesRoot, "react-dom"),
|
||||||
|
"react/jsx-runtime": path.join(appNodeModulesRoot, "react/jsx-runtime"),
|
||||||
|
"react/jsx-dev-runtime": path.join(appNodeModulesRoot, "react/jsx-dev-runtime"),
|
||||||
|
};
|
||||||
|
|
||||||
function isLocalModuleImport(moduleName) {
|
function isLocalModuleImport(moduleName) {
|
||||||
return (
|
return (
|
||||||
moduleName.startsWith("./") ||
|
moduleName.startsWith("./") ||
|
||||||
|
|||||||
@@ -86,8 +86,8 @@
|
|||||||
"lezer-elixir": "^1.1.2",
|
"lezer-elixir": "^1.1.2",
|
||||||
"lucide-react-native": "^0.546.0",
|
"lucide-react-native": "^0.546.0",
|
||||||
"mnemonic-id": "^3.2.7",
|
"mnemonic-id": "^3.2.7",
|
||||||
"react": "19.1.0",
|
"react": "19.1.4",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.4",
|
||||||
"react-native": "^0.81.5",
|
"react-native": "^0.81.5",
|
||||||
"react-native-css": "^3.0.1",
|
"react-native-css": "^3.0.1",
|
||||||
"react-native-draggable-flatlist": "^4.0.3",
|
"react-native-draggable-flatlist": "^4.0.3",
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-keyboard-controller": "^1.19.2",
|
"react-native-keyboard-controller": "^1.19.2",
|
||||||
"react-native-markdown-display": "^7.0.2",
|
"react-native-markdown-display": "^7.0.2",
|
||||||
"react-native-nitro-modules": "^0.30.0",
|
"react-native-nitro-modules": "0.33.8",
|
||||||
"react-native-permissions": "^5.4.2",
|
"react-native-permissions": "^5.4.2",
|
||||||
"react-native-popover-view": "^6.1.0",
|
"react-native-popover-view": "^6.1.0",
|
||||||
"react-native-reanimated": "~4.1.1",
|
"react-native-reanimated": "~4.1.1",
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { ActivityIndicator, View } from "react-native";
|
|
||||||
import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
|
import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
|
||||||
import { useUnistyles } from "react-native-unistyles";
|
|
||||||
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
|
||||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||||
|
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||||
|
import { WelcomeScreen } from "@/components/welcome-screen";
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||||
const { theme } = useUnistyles();
|
const { daemons, isLoading: registryLoading, isReconciling } = useDaemonRegistry();
|
||||||
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
|
|
||||||
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
|
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
|
||||||
const requestedServerId = useMemo(() => {
|
const requestedServerId = useMemo(() => {
|
||||||
return typeof params.serverId === "string" ? params.serverId.trim() : "";
|
return typeof params.serverId === "string" ? params.serverId.trim() : "";
|
||||||
@@ -53,22 +51,14 @@ export default function Index() {
|
|||||||
}, [pathname, preferencesLoading, registryLoading, router, targetServerId]);
|
}, [pathname, preferencesLoading, registryLoading, router, targetServerId]);
|
||||||
|
|
||||||
if (registryLoading || preferencesLoading) {
|
if (registryLoading || preferencesLoading) {
|
||||||
return (
|
return <StartupSplashScreen />;
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
backgroundColor: theme.colors.surface0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!targetServerId) {
|
if (!targetServerId) {
|
||||||
return <DraftAgentScreen />;
|
if (isReconciling) {
|
||||||
|
return <StartupSplashScreen />;
|
||||||
|
}
|
||||||
|
return <WelcomeScreen />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -412,6 +412,7 @@ function SidebarContent({
|
|||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
cwd={workspaceRoot}
|
cwd={workspaceRoot}
|
||||||
|
hideHeaderRow={!isMobile}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{resolvedTab === "files" && (
|
{resolvedTab === "files" && (
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { View, Text, ActivityIndicator, Pressable } from "react-native";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { ChevronDown, MoreVertical } from "lucide-react-native";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import type { GitAction, GitActions } from "@/hooks/use-git-actions";
|
||||||
|
|
||||||
|
interface GitActionsSplitButtonProps {
|
||||||
|
gitActions: GitActions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
|
const getActionDisplayLabel = useCallback((action: GitAction): string => {
|
||||||
|
if (action.status === "pending") return action.pendingLabel;
|
||||||
|
if (action.status === "success") return action.successLabel;
|
||||||
|
return action.label;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.row}>
|
||||||
|
{gitActions.primary ? (
|
||||||
|
<View style={styles.splitButton}>
|
||||||
|
<Pressable
|
||||||
|
testID="changes-primary-cta"
|
||||||
|
style={({ hovered, pressed }) => [
|
||||||
|
styles.splitButtonPrimary,
|
||||||
|
(hovered || pressed) && styles.splitButtonPrimaryHovered,
|
||||||
|
gitActions.primary!.disabled && styles.splitButtonPrimaryDisabled,
|
||||||
|
]}
|
||||||
|
onPress={gitActions.primary.handler}
|
||||||
|
disabled={gitActions.primary.disabled}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={gitActions.primary.label}
|
||||||
|
>
|
||||||
|
{gitActions.primary.status === "pending" ? (
|
||||||
|
<ActivityIndicator
|
||||||
|
size="small"
|
||||||
|
color={theme.colors.foreground}
|
||||||
|
style={styles.splitButtonSpinnerOnly}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={styles.splitButtonContent}>
|
||||||
|
{gitActions.primary.icon}
|
||||||
|
<Text style={styles.splitButtonText}>{getActionDisplayLabel(gitActions.primary)}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
{gitActions.secondary.length > 0 ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
testID="changes-primary-cta-caret"
|
||||||
|
style={({ hovered, pressed, open }) => [
|
||||||
|
styles.splitButtonCaret,
|
||||||
|
(hovered || pressed || open) && styles.splitButtonCaretHovered,
|
||||||
|
]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="More options"
|
||||||
|
>
|
||||||
|
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" testID="changes-primary-cta-menu">
|
||||||
|
{gitActions.secondary.map((action, index) => {
|
||||||
|
const needsSeparator = action.id === "merge-from-base" || action.id === "push";
|
||||||
|
return (
|
||||||
|
<View key={action.id}>
|
||||||
|
{needsSeparator && index > 0 ? <DropdownMenuSeparator /> : null}
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`changes-menu-${action.id}`}
|
||||||
|
leading={action.icon}
|
||||||
|
disabled={action.disabled}
|
||||||
|
status={action.status}
|
||||||
|
pendingLabel={action.pendingLabel}
|
||||||
|
successLabel={action.successLabel}
|
||||||
|
closeOnSelect={action.status === "idle" && action.id === "view-pr"}
|
||||||
|
description={action.description}
|
||||||
|
onSelect={action.handler}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{gitActions.menu.length > 0 ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
testID="changes-overflow-menu"
|
||||||
|
hitSlop={8}
|
||||||
|
style={[styles.iconButton, styles.overflowMenuButton]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="More actions"
|
||||||
|
>
|
||||||
|
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" width={220} testID="changes-overflow-content">
|
||||||
|
{gitActions.menu.map((action) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={action.id}
|
||||||
|
testID={`changes-menu-${action.id}`}
|
||||||
|
leading={action.icon}
|
||||||
|
disabled={action.disabled}
|
||||||
|
status={action.status}
|
||||||
|
pendingLabel={action.pendingLabel}
|
||||||
|
successLabel={action.successLabel}
|
||||||
|
closeOnSelect={false}
|
||||||
|
onSelect={action.handler}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
splitButton: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "stretch",
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.borderAccent,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
splitButtonPrimary: {
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
paddingVertical: theme.spacing[1],
|
||||||
|
justifyContent: "center",
|
||||||
|
position: "relative",
|
||||||
|
},
|
||||||
|
splitButtonPrimaryHovered: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
splitButtonPrimaryDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
splitButtonText: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
lineHeight: theme.fontSize.sm * 1.5,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
},
|
||||||
|
splitButtonContent: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
splitButtonSpinnerOnly: {
|
||||||
|
transform: [{ scale: 0.8 }],
|
||||||
|
},
|
||||||
|
splitButtonCaret: {
|
||||||
|
width: 28,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderLeftWidth: theme.borderWidth[1],
|
||||||
|
borderLeftColor: theme.colors.borderAccent,
|
||||||
|
},
|
||||||
|
splitButtonCaretHovered: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
iconButton: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
},
|
||||||
|
overflowMenuButton: {
|
||||||
|
marginRight: -theme.spacing[2],
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
GitMerge,
|
GitMerge,
|
||||||
ListChevronsDownUp,
|
ListChevronsDownUp,
|
||||||
ListChevronsUpDown,
|
ListChevronsUpDown,
|
||||||
MoreVertical,
|
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
@@ -47,7 +46,6 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
type ActionStatus,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||||
import {
|
import {
|
||||||
@@ -58,36 +56,11 @@ import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent
|
|||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { shouldShowMergeFromBaseAction } from "./git-action-visibility";
|
import { shouldShowMergeFromBaseAction } from "./git-action-visibility";
|
||||||
|
|
||||||
// =============================================================================
|
import { type GitActionId, type GitAction, type GitActions } from "@/hooks/use-git-actions";
|
||||||
// Git Actions Data Structure
|
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
type GitActionId =
|
// Re-export types from shared hook
|
||||||
| "commit"
|
export type { GitActionId, GitAction, GitActions } from "@/hooks/use-git-actions";
|
||||||
| "push"
|
|
||||||
| "view-pr"
|
|
||||||
| "create-pr"
|
|
||||||
| "merge-branch"
|
|
||||||
| "merge-from-base"
|
|
||||||
| "archive-worktree";
|
|
||||||
|
|
||||||
interface GitAction {
|
|
||||||
id: GitActionId;
|
|
||||||
label: string;
|
|
||||||
pendingLabel: string;
|
|
||||||
successLabel: string;
|
|
||||||
disabled: boolean;
|
|
||||||
status: ActionStatus;
|
|
||||||
description?: string;
|
|
||||||
icon?: ReactElement;
|
|
||||||
handler: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GitActions {
|
|
||||||
primary: GitAction | null;
|
|
||||||
secondary: GitAction[];
|
|
||||||
menu: GitAction[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function openURLInNewTab(url: string): void {
|
function openURLInNewTab(url: string): void {
|
||||||
void openExternalUrl(url);
|
void openExternalUrl(url);
|
||||||
@@ -466,13 +439,14 @@ interface GitDiffPaneProps {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId?: string | null;
|
workspaceId?: string | null;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
hideHeaderRow?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DiffFlatItem =
|
type DiffFlatItem =
|
||||||
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean }
|
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean }
|
||||||
| { type: "body"; file: ParsedDiffFile; fileIndex: number };
|
| { type: "body"; file: ParsedDiffFile; fileIndex: number };
|
||||||
|
|
||||||
export function GitDiffPane({ serverId, workspaceId, cwd }: GitDiffPaneProps) {
|
export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile =
|
const isMobile =
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
@@ -1219,119 +1193,22 @@ export function GitDiffPane({ serverId, workspaceId, cwd }: GitDiffPaneProps) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Helper to get display label based on status
|
// Helper to get display label based on status
|
||||||
const getActionDisplayLabel = useCallback((action: GitAction): string => {
|
|
||||||
if (action.status === "pending") return action.pendingLabel;
|
|
||||||
if (action.status === "success") return action.successLabel;
|
|
||||||
return action.label;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={styles.header} testID="changes-header">
|
{!hideHeaderRow ? (
|
||||||
<View style={styles.headerLeft}>
|
<View style={styles.header} testID="changes-header">
|
||||||
<GitBranch size={16} color={theme.colors.foregroundMuted} />
|
<View style={styles.headerLeft}>
|
||||||
<Text style={styles.branchLabel} testID="changes-branch" numberOfLines={1}>
|
<GitBranch size={16} color={theme.colors.foregroundMuted} />
|
||||||
{branchLabel}
|
<Text style={styles.branchLabel} testID="changes-branch" numberOfLines={1}>
|
||||||
</Text>
|
{branchLabel}
|
||||||
</View>
|
</Text>
|
||||||
{isGit ? (
|
|
||||||
<View style={styles.headerRight}>
|
|
||||||
{gitActions.primary ? (
|
|
||||||
<View style={styles.splitButton}>
|
|
||||||
<Pressable
|
|
||||||
testID="changes-primary-cta"
|
|
||||||
style={[
|
|
||||||
styles.splitButtonPrimary,
|
|
||||||
gitActions.primary.disabled && styles.splitButtonPrimaryDisabled,
|
|
||||||
]}
|
|
||||||
onPress={gitActions.primary.handler}
|
|
||||||
disabled={gitActions.primary.disabled}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={gitActions.primary.label}
|
|
||||||
>
|
|
||||||
{gitActions.primary.status === "pending" ? (
|
|
||||||
<ActivityIndicator
|
|
||||||
size="small"
|
|
||||||
color={theme.colors.foreground}
|
|
||||||
style={styles.splitButtonSpinnerOnly}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<View style={styles.splitButtonContent}>
|
|
||||||
{gitActions.primary.icon}
|
|
||||||
<Text style={styles.splitButtonText}>{getActionDisplayLabel(gitActions.primary)}</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
{gitActions.secondary.length > 0 ? (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
testID="changes-primary-cta-caret"
|
|
||||||
style={styles.splitButtonCaret}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="More options"
|
|
||||||
>
|
|
||||||
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" testID="changes-primary-cta-menu">
|
|
||||||
{gitActions.secondary.map((action, index) => {
|
|
||||||
const needsSeparator = action.id === "merge-from-base" || action.id === "push";
|
|
||||||
return (
|
|
||||||
<View key={action.id}>
|
|
||||||
{needsSeparator && index > 0 ? <DropdownMenuSeparator /> : null}
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID={`changes-menu-${action.id}`}
|
|
||||||
leading={action.icon}
|
|
||||||
disabled={action.disabled}
|
|
||||||
status={action.status}
|
|
||||||
pendingLabel={action.pendingLabel}
|
|
||||||
successLabel={action.successLabel}
|
|
||||||
closeOnSelect={action.status === "idle" && action.id === "view-pr"}
|
|
||||||
description={action.description}
|
|
||||||
onSelect={action.handler}
|
|
||||||
>
|
|
||||||
{action.label}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
{gitActions.menu.length > 0 ? (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
testID="changes-overflow-menu"
|
|
||||||
hitSlop={8}
|
|
||||||
style={[styles.iconButton, styles.overflowMenuButton]}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="More actions"
|
|
||||||
>
|
|
||||||
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" width={220} testID="changes-overflow-content">
|
|
||||||
{gitActions.menu.map((action) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={action.id}
|
|
||||||
testID={`changes-menu-${action.id}`}
|
|
||||||
leading={action.icon}
|
|
||||||
disabled={action.disabled}
|
|
||||||
status={action.status}
|
|
||||||
pendingLabel={action.pendingLabel}
|
|
||||||
successLabel={action.successLabel}
|
|
||||||
closeOnSelect={false}
|
|
||||||
onSelect={action.handler}
|
|
||||||
>
|
|
||||||
{action.label}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
{isGit ? (
|
||||||
</View>
|
<GitActionsSplitButton gitActions={gitActions} />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{isGit ? (
|
{isGit ? (
|
||||||
<View style={styles.diffStatusContainer}>
|
<View style={styles.diffStatusContainer}>
|
||||||
@@ -1438,12 +1315,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
headerRight: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[1],
|
|
||||||
flexShrink: 0,
|
|
||||||
},
|
|
||||||
branchLabel: {
|
branchLabel: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
@@ -1496,112 +1367,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
borderRadius: theme.borderRadius.base,
|
borderRadius: theme.borderRadius.base,
|
||||||
},
|
},
|
||||||
splitButton: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "stretch",
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
borderWidth: theme.borderWidth[1],
|
|
||||||
borderColor: theme.colors.borderAccent,
|
|
||||||
overflow: "hidden",
|
|
||||||
},
|
|
||||||
splitButtonPrimary: {
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
paddingVertical: theme.spacing[1],
|
|
||||||
justifyContent: "center",
|
|
||||||
position: "relative",
|
|
||||||
},
|
|
||||||
splitButtonPrimaryDisabled: {
|
|
||||||
opacity: 0.6,
|
|
||||||
},
|
|
||||||
splitButtonText: {
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
lineHeight: theme.fontSize.sm * 1.5,
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontWeight: theme.fontWeight.medium,
|
|
||||||
},
|
|
||||||
splitButtonContent: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
splitButtonSpinnerOnly: {
|
|
||||||
transform: [{ scale: 0.8 }],
|
|
||||||
},
|
|
||||||
splitButtonCaret: {
|
|
||||||
width: 36,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
borderLeftWidth: theme.borderWidth[1],
|
|
||||||
borderLeftColor: theme.colors.borderAccent,
|
|
||||||
},
|
|
||||||
iconButton: {
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
},
|
|
||||||
overflowMenuButton: {
|
|
||||||
marginRight: -theme.spacing[2],
|
|
||||||
},
|
|
||||||
menuOverlay: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
menuBackdrop: {
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
},
|
|
||||||
dropdownMenu: {
|
|
||||||
backgroundColor: theme.colors.surface0,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: theme.colors.borderAccent,
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
overflow: "hidden",
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOffset: { width: 0, height: 4 },
|
|
||||||
shadowOpacity: 0.2,
|
|
||||||
shadowRadius: 8,
|
|
||||||
elevation: 8,
|
|
||||||
},
|
|
||||||
menuItem: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
paddingVertical: theme.spacing[2],
|
|
||||||
},
|
|
||||||
menuItemSelected: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
menuItemDisabled: {
|
|
||||||
opacity: 0.5,
|
|
||||||
},
|
|
||||||
menuItemText: {
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontWeight: theme.fontWeight.medium,
|
|
||||||
},
|
|
||||||
menuHintText: {
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
paddingBottom: theme.spacing[2],
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
menuItemDestructive: {
|
|
||||||
backgroundColor: "rgba(248, 81, 73, 0.08)",
|
|
||||||
},
|
|
||||||
menuItemTextDestructive: {
|
|
||||||
color: theme.colors.destructive,
|
|
||||||
},
|
|
||||||
menuDivider: {
|
|
||||||
height: 1,
|
|
||||||
backgroundColor: theme.colors.border,
|
|
||||||
},
|
|
||||||
actionErrorText: {
|
actionErrorText: {
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
paddingBottom: theme.spacing[1],
|
paddingBottom: theme.spacing[1],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PropsWithChildren, ReactElement } from "react";
|
import type { ReactElement, ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
Platform,
|
Platform,
|
||||||
Text,
|
Text,
|
||||||
@@ -12,6 +12,21 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
|
|
||||||
|
interface HeaderToggleButtonState {
|
||||||
|
hovered: boolean;
|
||||||
|
pressed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HeaderToggleButtonProps extends Omit<PressableProps, "style" | "onPress" | "children"> {
|
||||||
|
onPress: NonNullable<PressableProps["onPress"]>;
|
||||||
|
tooltipLabel: string;
|
||||||
|
tooltipKeys: ShortcutKey[];
|
||||||
|
tooltipSide: "left" | "right" | "top" | "bottom";
|
||||||
|
tooltipDelayDuration?: number;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
children: ReactNode | ((state: HeaderToggleButtonState) => ReactNode);
|
||||||
|
}
|
||||||
|
|
||||||
export function HeaderToggleButton({
|
export function HeaderToggleButton({
|
||||||
onPress,
|
onPress,
|
||||||
tooltipLabel,
|
tooltipLabel,
|
||||||
@@ -22,16 +37,7 @@ export function HeaderToggleButton({
|
|||||||
disabled,
|
disabled,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: PropsWithChildren<
|
}: HeaderToggleButtonProps): ReactElement {
|
||||||
Omit<PressableProps, "style" | "onPress"> & {
|
|
||||||
onPress: NonNullable<PressableProps["onPress"]>;
|
|
||||||
tooltipLabel: string;
|
|
||||||
tooltipKeys: ShortcutKey[];
|
|
||||||
tooltipSide: "left" | "right" | "top" | "bottom";
|
|
||||||
tooltipDelayDuration?: number;
|
|
||||||
style?: StyleProp<ViewStyle>;
|
|
||||||
}
|
|
||||||
>): ReactElement {
|
|
||||||
const tooltipTestID =
|
const tooltipTestID =
|
||||||
typeof props.testID === "string" && props.testID.length > 0
|
typeof props.testID === "string" && props.testID.length > 0
|
||||||
? `${props.testID}-tooltip`
|
? `${props.testID}-tooltip`
|
||||||
@@ -53,7 +59,10 @@ export function HeaderToggleButton({
|
|||||||
}}
|
}}
|
||||||
style={[styles.button, style]}
|
style={[styles.button, style]}
|
||||||
>
|
>
|
||||||
{children}
|
{typeof children === "function"
|
||||||
|
? (state: { pressed: boolean; hovered?: boolean }) =>
|
||||||
|
children({ hovered: Boolean(state.hovered), pressed: state.pressed })
|
||||||
|
: children}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent testID={tooltipTestID} side={tooltipSide} align="center" offset={8}>
|
<TooltipContent testID={tooltipTestID} side={tooltipSide} align="center" offset={8}>
|
||||||
<View style={styles.tooltipRow}>
|
<View style={styles.tooltipRow}>
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import Svg, { Rect, Line } from "react-native-svg";
|
||||||
|
|
||||||
|
interface SourceControlPanelIconProps {
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SourceControlPanelIcon({
|
||||||
|
size = 16,
|
||||||
|
color = "currentColor",
|
||||||
|
}: SourceControlPanelIconProps) {
|
||||||
|
return (
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||||
|
<Rect
|
||||||
|
x={3}
|
||||||
|
y={3}
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
rx={2}
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
{/* Plus */}
|
||||||
|
<Line x1={9} y1={9.5} x2={15} y2={9.5} stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||||
|
<Line x1={12} y1={6.5} x2={12} y2={12.5} stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||||
|
{/* Minus */}
|
||||||
|
<Line x1={9} y1={16} x2={15} y2={16} stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -339,7 +339,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
|||||||
hovered && styles.newAgentButtonTextHovered,
|
hovered && styles.newAgentButtonTextHovered,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
Open project
|
Add project
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -463,7 +463,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
|||||||
<Text
|
<Text
|
||||||
style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}
|
style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}
|
||||||
>
|
>
|
||||||
Open project
|
Add project
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export interface MessageInputRef {
|
|||||||
const MIN_INPUT_HEIGHT = 30
|
const MIN_INPUT_HEIGHT = 30
|
||||||
const MAX_INPUT_HEIGHT = 160
|
const MAX_INPUT_HEIGHT = 160
|
||||||
const IS_WEB = Platform.OS === 'web'
|
const IS_WEB = Platform.OS === 'web'
|
||||||
|
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__)
|
||||||
|
|
||||||
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
|
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
|
||||||
TextInputKeyPressEventData & {
|
TextInputKeyPressEventData & {
|
||||||
@@ -112,12 +113,68 @@ type WebTextInputKeyPressEvent = NativeSyntheticEvent<
|
|||||||
|
|
||||||
type TextAreaHandle = {
|
type TextAreaHandle = {
|
||||||
scrollHeight?: number
|
scrollHeight?: number
|
||||||
|
clientHeight?: number
|
||||||
|
offsetHeight?: number
|
||||||
|
scrollTop?: number
|
||||||
|
selectionStart?: number | null
|
||||||
|
selectionEnd?: number | null
|
||||||
style?: {
|
style?: {
|
||||||
height?: string
|
height?: string
|
||||||
overflowY?: string
|
overflowY?: string
|
||||||
} & Record<string, unknown>
|
} & Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function logWebStickyBottom(
|
||||||
|
event: string,
|
||||||
|
details: Record<string, unknown>
|
||||||
|
): void {
|
||||||
|
if (!IS_DEV || !IS_WEB) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log('[WebStickyBottom]', event, details)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDebugNow(): number | null {
|
||||||
|
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
|
||||||
|
return Number(performance.now().toFixed(3))
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getElementDescriptor(element: HTMLElement | null): string | null {
|
||||||
|
if (!element) return null
|
||||||
|
const tag = element.tagName?.toLowerCase() ?? 'unknown'
|
||||||
|
const id = element.id ? `#${element.id}` : ''
|
||||||
|
const testId = element.getAttribute?.('data-testid')
|
||||||
|
const label = element.getAttribute?.('aria-label')
|
||||||
|
const suffix = testId
|
||||||
|
? `[data-testid="${testId}"]`
|
||||||
|
: label
|
||||||
|
? `[aria-label="${label}"]`
|
||||||
|
: ''
|
||||||
|
return `${tag}${id}${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScrollableAncestorChain(element: HTMLElement | null): string[] {
|
||||||
|
if (!element || typeof window === 'undefined') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const results: string[] = []
|
||||||
|
let current = element.parentElement
|
||||||
|
while (current) {
|
||||||
|
const style = window.getComputedStyle(current)
|
||||||
|
const overflowY = style.overflowY
|
||||||
|
const canScroll =
|
||||||
|
(overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') &&
|
||||||
|
current.scrollHeight > current.clientHeight
|
||||||
|
if (canScroll) {
|
||||||
|
results.push(getElementDescriptor(current) ?? current.tagName.toLowerCase())
|
||||||
|
}
|
||||||
|
current = current.parentElement
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
function ImageAttachmentThumbnail({ image }: { image: ImageAttachment }) {
|
function ImageAttachmentThumbnail({ image }: { image: ImageAttachment }) {
|
||||||
const uri = useAttachmentPreviewUrl(image)
|
const uri = useAttachmentPreviewUrl(image)
|
||||||
if (!uri) {
|
if (!uri) {
|
||||||
@@ -164,6 +221,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const voice = useVoiceOptional()
|
const voice = useVoiceOptional()
|
||||||
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT)
|
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT)
|
||||||
|
const rootRef = useRef<View | null>(null)
|
||||||
|
const inputWrapperRef = useRef<View | null>(null)
|
||||||
const textInputRef = useRef<TextInput | (TextInput & { getNativeRef?: () => unknown }) | null>(
|
const textInputRef = useRef<TextInput | (TextInput & { getNativeRef?: () => unknown }) | null>(
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
@@ -509,6 +568,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
return null
|
return null
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const getWebElement = useCallback((target: 'root' | 'wrapper'): HTMLElement | null => {
|
||||||
|
const ref = target === 'root' ? rootRef.current : inputWrapperRef.current
|
||||||
|
if (!ref) return null
|
||||||
|
return ref instanceof HTMLElement ? ref : ((ref as unknown as { getBoundingClientRect?: () => DOMRect }).getBoundingClientRect ? (ref as unknown as HTMLElement) : null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!IS_WEB || !onAddImages) {
|
if (!IS_WEB || !onAddImages) {
|
||||||
return
|
return
|
||||||
@@ -562,36 +627,125 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
onAddImages,
|
onAddImages,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!IS_WEB || typeof ResizeObserver === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const textarea = getWebTextArea()
|
||||||
|
const root = getWebElement('root')
|
||||||
|
const wrapper = getWebElement('wrapper')
|
||||||
|
const observed = [
|
||||||
|
{ name: 'composer_root', element: root },
|
||||||
|
{ name: 'composer_wrapper', element: wrapper },
|
||||||
|
{ name: 'composer_textarea', element: textarea as unknown as HTMLElement | null },
|
||||||
|
].filter((entry): entry is { name: string; element: HTMLElement } => entry.element instanceof HTMLElement)
|
||||||
|
|
||||||
|
if (observed.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const target = entry.target as HTMLElement
|
||||||
|
const match = observed.find((item) => item.element === target)
|
||||||
|
if (!match) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const textareaNode = getWebTextArea()
|
||||||
|
logWebStickyBottom('composer_element_resized', {
|
||||||
|
target: match.name,
|
||||||
|
width: target.clientWidth,
|
||||||
|
height: target.clientHeight,
|
||||||
|
offsetHeight: target.offsetHeight,
|
||||||
|
scrollHeight: target.scrollHeight,
|
||||||
|
textareaClientHeight: textareaNode?.clientHeight ?? null,
|
||||||
|
textareaOffsetHeight: textareaNode?.offsetHeight ?? null,
|
||||||
|
textareaScrollHeight: textareaNode?.scrollHeight ?? null,
|
||||||
|
textareaScrollTop: (textareaNode as unknown as HTMLTextAreaElement | null)?.scrollTop ?? null,
|
||||||
|
valueLength: valueRef.current.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const entry of observed) {
|
||||||
|
observer.observe(entry.element)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
|
}, [getWebElement, getWebTextArea])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!IS_WEB) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const textarea = getWebTextArea() as (HTMLTextAreaElement & TextAreaHandle) | null
|
||||||
|
if (!textarea || typeof textarea.addEventListener !== 'function') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
const textareaElement = textarea as unknown as HTMLElement
|
||||||
|
const chatScroller =
|
||||||
|
typeof document !== 'undefined'
|
||||||
|
? (document.querySelector('[data-testid="agent-chat-scroll"]') as HTMLElement | null)
|
||||||
|
: null
|
||||||
|
logWebStickyBottom('composer_textarea_scrolled', {
|
||||||
|
now: getDebugNow(),
|
||||||
|
scrollTop: textarea.scrollTop,
|
||||||
|
clientHeight: textarea.clientHeight ?? null,
|
||||||
|
scrollHeight: textarea.scrollHeight ?? null,
|
||||||
|
selectionStart: textarea.selectionStart ?? null,
|
||||||
|
selectionEnd: textarea.selectionEnd ?? null,
|
||||||
|
textareaDescriptor: getElementDescriptor(textareaElement),
|
||||||
|
chatScrollerDescriptor: getElementDescriptor(chatScroller),
|
||||||
|
chatScrollerContainsTextarea: Boolean(chatScroller && textareaElement && chatScroller.contains(textareaElement)),
|
||||||
|
textareaScrollableAncestors: getScrollableAncestorChain(textareaElement),
|
||||||
|
valueLength: valueRef.current.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.addEventListener('scroll', handleScroll, { passive: true })
|
||||||
|
return () => {
|
||||||
|
textarea.removeEventListener('scroll', handleScroll)
|
||||||
|
}
|
||||||
|
}, [getWebTextArea])
|
||||||
|
|
||||||
function measureWebInputHeight(source: string): boolean {
|
function measureWebInputHeight(source: string): boolean {
|
||||||
if (!IS_WEB) return false
|
if (!IS_WEB) return false
|
||||||
const textarea = getWebTextArea()
|
const textarea = getWebTextArea()
|
||||||
if (!textarea || typeof textarea.scrollHeight !== 'number') return false
|
if (!textarea || typeof textarea.scrollHeight !== 'number') return false
|
||||||
|
|
||||||
const prevHeight = textarea.style?.height
|
|
||||||
const prevOverflow = textarea.style?.overflowY
|
|
||||||
if (textarea.style) {
|
|
||||||
textarea.style.height = 'auto'
|
|
||||||
textarea.style.overflowY = 'hidden'
|
|
||||||
}
|
|
||||||
|
|
||||||
const scrollHeight = textarea.scrollHeight ?? 0
|
const scrollHeight = textarea.scrollHeight ?? 0
|
||||||
if (textarea.style) {
|
|
||||||
textarea.style.height = prevHeight ?? ''
|
|
||||||
textarea.style.overflowY = prevOverflow ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
if (baselineInputHeightRef.current === null && scrollHeight > 0) {
|
if (baselineInputHeightRef.current === null && scrollHeight > 0) {
|
||||||
baselineInputHeightRef.current = scrollHeight
|
baselineInputHeightRef.current = scrollHeight
|
||||||
|
logWebStickyBottom('composer_baseline_measured', {
|
||||||
|
source,
|
||||||
|
baseline: scrollHeight,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseline = baselineInputHeightRef.current ?? MIN_INPUT_HEIGHT
|
const baseline = baselineInputHeightRef.current ?? MIN_INPUT_HEIGHT
|
||||||
const rawTarget = scrollHeight > 0 ? scrollHeight : baseline
|
const rawTarget = scrollHeight > 0 ? scrollHeight : baseline
|
||||||
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, rawTarget))
|
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, rawTarget))
|
||||||
|
|
||||||
if (Math.abs(inputHeightRef.current - bounded) >= 1) {
|
const previousHeight = inputHeightRef.current
|
||||||
|
if (Math.abs(previousHeight - bounded) >= 1) {
|
||||||
inputHeightRef.current = bounded
|
inputHeightRef.current = bounded
|
||||||
setInputHeight(bounded)
|
setInputHeight(bounded)
|
||||||
onHeightChange?.(bounded)
|
onHeightChange?.(bounded)
|
||||||
|
logWebStickyBottom('composer_height_changed', {
|
||||||
|
source,
|
||||||
|
previousHeight,
|
||||||
|
nextHeight: bounded,
|
||||||
|
scrollHeight,
|
||||||
|
clientHeight: textarea.clientHeight ?? null,
|
||||||
|
offsetHeight: textarea.offsetHeight ?? null,
|
||||||
|
baseline,
|
||||||
|
rawTarget,
|
||||||
|
})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -600,25 +754,51 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
function setBoundedInputHeight(nextHeight: number) {
|
function setBoundedInputHeight(nextHeight: number) {
|
||||||
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, nextHeight))
|
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, nextHeight))
|
||||||
if (Math.abs(inputHeightRef.current - bounded) < 1) return
|
if (Math.abs(inputHeightRef.current - bounded) < 1) return
|
||||||
|
const previousHeight = inputHeightRef.current
|
||||||
inputHeightRef.current = bounded
|
inputHeightRef.current = bounded
|
||||||
setInputHeight(bounded)
|
setInputHeight(bounded)
|
||||||
onHeightChange?.(bounded)
|
onHeightChange?.(bounded)
|
||||||
|
logWebStickyBottom('composer_height_changed_native', {
|
||||||
|
previousHeight,
|
||||||
|
nextHeight: bounded,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleContentSizeChange(
|
function handleContentSizeChange(
|
||||||
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>
|
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>
|
||||||
) {
|
) {
|
||||||
|
const contentHeight = event.nativeEvent.contentSize.height
|
||||||
if (IS_WEB) {
|
if (IS_WEB) {
|
||||||
measureWebInputHeight('contentSizeChange')
|
logWebStickyBottom('composer_content_size_change', {
|
||||||
|
reportedHeight: contentHeight,
|
||||||
|
})
|
||||||
|
if (baselineInputHeightRef.current === null && contentHeight > 0) {
|
||||||
|
baselineInputHeightRef.current = contentHeight
|
||||||
|
logWebStickyBottom('composer_baseline_measured', {
|
||||||
|
source: 'contentSizeChange',
|
||||||
|
baseline: contentHeight,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setBoundedInputHeight(contentHeight)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const contentHeight = event.nativeEvent.contentSize.height
|
|
||||||
setBoundedInputHeight(contentHeight)
|
setBoundedInputHeight(contentHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) {
|
function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) {
|
||||||
const start = event.nativeEvent.selection?.start ?? 0
|
const start = event.nativeEvent.selection?.start ?? 0
|
||||||
const end = event.nativeEvent.selection?.end ?? start
|
const end = event.nativeEvent.selection?.end ?? start
|
||||||
|
if (IS_WEB) {
|
||||||
|
const textarea = getWebTextArea()
|
||||||
|
logWebStickyBottom('composer_selection_changed', {
|
||||||
|
now: getDebugNow(),
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
textareaScrollTop: textarea?.scrollTop ?? null,
|
||||||
|
textareaClientHeight: textarea?.clientHeight ?? null,
|
||||||
|
textareaScrollHeight: textarea?.scrollHeight ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
onSelectionChangeCallback?.({ start, end })
|
onSelectionChangeCallback?.({ start, end })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -674,14 +854,20 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
(nextValue: string) => {
|
(nextValue: string) => {
|
||||||
markScrollInvestigationEvent(investigationComponentId, 'inputChange')
|
markScrollInvestigationEvent(investigationComponentId, 'inputChange')
|
||||||
onChangeText(nextValue)
|
onChangeText(nextValue)
|
||||||
|
if (IS_WEB) {
|
||||||
|
logWebStickyBottom('composer_text_changed', {
|
||||||
|
valueLength: nextValue.length,
|
||||||
|
lineCount: nextValue.split('\n').length,
|
||||||
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[investigationComponentId, onChangeText]
|
[investigationComponentId, onChangeText]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container} testID="message-input-root">
|
<View ref={rootRef} style={styles.container} testID="message-input-root">
|
||||||
{/* Regular input */}
|
{/* Regular input */}
|
||||||
<Animated.View style={[styles.inputWrapper, inputAnimatedStyle]}>
|
<Animated.View ref={inputWrapperRef} style={[styles.inputWrapper, inputAnimatedStyle]}>
|
||||||
{/* Image preview pills */}
|
{/* Image preview pills */}
|
||||||
{hasImages && (
|
{hasImages && (
|
||||||
<View style={styles.imagePreviewContainer} testID="message-input-image-preview">
|
<View style={styles.imagePreviewContainer} testID="message-input-image-preview">
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export function ProjectPickerModal() {
|
|||||||
|
|
||||||
// Keyboard navigation
|
// Keyboard navigation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open || Platform.OS !== "web") return;
|
||||||
|
|
||||||
function handler(event: KeyboardEvent) {
|
function handler(event: KeyboardEvent) {
|
||||||
const key = event.key;
|
const key = event.key;
|
||||||
@@ -200,7 +200,6 @@ export function ProjectPickerModal() {
|
|||||||
return () => window.removeEventListener("keydown", handler, true);
|
return () => window.removeEventListener("keydown", handler, true);
|
||||||
}, [activeIndex, handleSelectPath, handleSubmitCustom, open, options, query, setOpen]);
|
}, [activeIndex, handleSelectPath, handleSubmitCustom, open, options, query, setOpen]);
|
||||||
|
|
||||||
if (Platform.OS !== "web") return null;
|
|
||||||
if (!serverId) return null;
|
if (!serverId) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -665,11 +665,6 @@ function WorkspaceRowInner({
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.workspaceRowRight}>
|
<View style={styles.workspaceRowRight}>
|
||||||
{showShortcutBadge && shortcutNumber !== null ? (
|
|
||||||
<View style={styles.shortcutBadge}>
|
|
||||||
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
{onArchive && (isHovered || isMobile) ? (
|
{onArchive && (isHovered || isMobile) ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
@@ -716,6 +711,11 @@ function WorkspaceRowInner({
|
|||||||
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
|
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
{showShortcutBadge && shortcutNumber !== null ? (
|
||||||
|
<View style={styles.shortcutBadge}>
|
||||||
|
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -1772,7 +1772,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
shortcutBadgeText: {
|
shortcutBadgeText: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: '600',
|
fontWeight: theme.fontWeight.normal,
|
||||||
lineHeight: 14,
|
lineHeight: 14,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const USER_SCROLL_DELTA_EPSILON = 1
|
|||||||
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64
|
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64
|
||||||
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1
|
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1
|
||||||
const WEB_STREAM_SCROLLBAR_STYLE_ID = 'web-stream-viewport-scrollbar-style'
|
const WEB_STREAM_SCROLLBAR_STYLE_ID = 'web-stream-viewport-scrollbar-style'
|
||||||
|
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__)
|
||||||
const WEB_STREAM_SCROLLBAR_STYLE = `
|
const WEB_STREAM_SCROLLBAR_STYLE = `
|
||||||
#agent-chat-scroll-web-dom-scroll,
|
#agent-chat-scroll-web-dom-scroll,
|
||||||
#agent-chat-scroll-web-dom-virtualized {
|
#agent-chat-scroll-web-dom-virtualized {
|
||||||
@@ -39,6 +40,20 @@ const WEB_STREAM_SCROLLBAR_STYLE = `
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
function logWebStickyBottom(event: string, details: Record<string, unknown>): void {
|
||||||
|
if (!IS_DEV) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log('[WebStickyBottom]', event, details)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDebugNow(): number | null {
|
||||||
|
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
|
||||||
|
return Number(performance.now().toFixed(3))
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function isScrollContainerNearBottom(
|
function isScrollContainerNearBottom(
|
||||||
scrollContainer: Pick<HTMLElement, 'scrollTop' | 'clientHeight' | 'scrollHeight'>,
|
scrollContainer: Pick<HTMLElement, 'scrollTop' | 'clientHeight' | 'scrollHeight'>,
|
||||||
thresholdPx = AUTO_SCROLL_BOTTOM_THRESHOLD_PX
|
thresholdPx = AUTO_SCROLL_BOTTOM_THRESHOLD_PX
|
||||||
@@ -119,6 +134,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
}
|
}
|
||||||
const followOutputRef = useRef(followOutput)
|
const followOutputRef = useRef(followOutput)
|
||||||
const lastKnownScrollTopRef = useRef(0)
|
const lastKnownScrollTopRef = useRef(0)
|
||||||
|
const lastLoggedMetricsRef = useRef<{
|
||||||
|
scrollTop: number
|
||||||
|
clientWidth: number
|
||||||
|
clientHeight: number
|
||||||
|
scrollWidth: number
|
||||||
|
scrollHeight: number
|
||||||
|
} | null>(null)
|
||||||
const pendingUserScrollUpIntentRef = useRef(false)
|
const pendingUserScrollUpIntentRef = useRef(false)
|
||||||
const isPointerScrollActiveRef = useRef(false)
|
const isPointerScrollActiveRef = useRef(false)
|
||||||
const lastTouchClientYRef = useRef<number | null>(null)
|
const lastTouchClientYRef = useRef<number | null>(null)
|
||||||
@@ -156,6 +178,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
const viewportHeight = instance.scrollRect?.height ?? 0
|
const viewportHeight = instance.scrollRect?.height ?? 0
|
||||||
const scrollOffset = instance.scrollOffset ?? 0
|
const scrollOffset = instance.scrollOffset ?? 0
|
||||||
const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight)
|
const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight)
|
||||||
|
logWebStickyBottom('virtualizer_item_size_change', {
|
||||||
|
agentId: props.agentId,
|
||||||
|
delta: _delta,
|
||||||
|
itemIndex: _item.index,
|
||||||
|
itemStart: _item.start,
|
||||||
|
itemSize: _item.size,
|
||||||
|
viewportHeight,
|
||||||
|
scrollOffset,
|
||||||
|
totalSize: instance.getTotalSize(),
|
||||||
|
remainingDistance,
|
||||||
|
})
|
||||||
return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX
|
return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
@@ -187,11 +220,21 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
if (isScrollContainerOverscrolledPastBottom(scrollContainer)) {
|
if (isScrollContainerOverscrolledPastBottom(scrollContainer)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
logWebStickyBottom('viewport_scroll_to_bottom', {
|
||||||
|
agentId: props.agentId,
|
||||||
|
behavior,
|
||||||
|
followOutput: followOutputRef.current,
|
||||||
|
scrollTop: scrollContainer.scrollTop,
|
||||||
|
clientWidth: scrollContainer.clientWidth,
|
||||||
|
clientHeight: scrollContainer.clientHeight,
|
||||||
|
scrollWidth: scrollContainer.scrollWidth,
|
||||||
|
scrollHeight: scrollContainer.scrollHeight,
|
||||||
|
})
|
||||||
scrollElementToBottom(scrollContainer, behavior)
|
scrollElementToBottom(scrollContainer, behavior)
|
||||||
lastKnownScrollTopRef.current = scrollContainer.scrollTop
|
lastKnownScrollTopRef.current = scrollContainer.scrollTop
|
||||||
syncNearBottom(scrollContainer, onNearBottomChange)
|
syncNearBottom(scrollContainer, onNearBottomChange)
|
||||||
},
|
},
|
||||||
[onNearBottomChange]
|
[onNearBottomChange, props.agentId]
|
||||||
)
|
)
|
||||||
|
|
||||||
const scheduleStickToBottom = useCallback(
|
const scheduleStickToBottom = useCallback(
|
||||||
@@ -203,6 +246,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
if (pendingAutoScrollFrameRef.current !== null) {
|
if (pendingAutoScrollFrameRef.current !== null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
logWebStickyBottom('viewport_schedule_stick_to_bottom', {
|
||||||
|
agentId: props.agentId,
|
||||||
|
followOutput: followOutputRef.current,
|
||||||
|
scrollTop: scrollContainer?.scrollTop ?? null,
|
||||||
|
clientWidth: scrollContainer?.clientWidth ?? null,
|
||||||
|
clientHeight: scrollContainer?.clientHeight ?? null,
|
||||||
|
scrollWidth: scrollContainer?.scrollWidth ?? null,
|
||||||
|
scrollHeight: scrollContainer?.scrollHeight ?? null,
|
||||||
|
})
|
||||||
pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
|
pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||||
pendingAutoScrollFrameRef.current = null
|
pendingAutoScrollFrameRef.current = null
|
||||||
if (!followOutputRef.current) {
|
if (!followOutputRef.current) {
|
||||||
@@ -211,7 +263,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
scrollMessagesToBottom('auto')
|
scrollMessagesToBottom('auto')
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[scrollMessagesToBottom]
|
[props.agentId, scrollMessagesToBottom]
|
||||||
)
|
)
|
||||||
|
|
||||||
const forceStickToBottom = useCallback(() => {
|
const forceStickToBottom = useCallback(() => {
|
||||||
@@ -254,7 +306,31 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
},
|
},
|
||||||
} as never)
|
} as never)
|
||||||
syncNearBottom(scrollContainer, onNearBottomChange)
|
syncNearBottom(scrollContainer, onNearBottomChange)
|
||||||
}, [onNearBottomChange, streamScrollbarMetrics])
|
const currentMetrics = {
|
||||||
|
scrollTop: scrollContainer.scrollTop,
|
||||||
|
clientWidth: scrollContainer.clientWidth,
|
||||||
|
clientHeight: scrollContainer.clientHeight,
|
||||||
|
scrollWidth: scrollContainer.scrollWidth,
|
||||||
|
scrollHeight: scrollContainer.scrollHeight,
|
||||||
|
}
|
||||||
|
const previousMetrics = lastLoggedMetricsRef.current
|
||||||
|
const shouldLog =
|
||||||
|
!previousMetrics ||
|
||||||
|
previousMetrics.scrollTop !== currentMetrics.scrollTop ||
|
||||||
|
previousMetrics.clientWidth !== currentMetrics.clientWidth ||
|
||||||
|
previousMetrics.clientHeight !== currentMetrics.clientHeight ||
|
||||||
|
previousMetrics.scrollWidth !== currentMetrics.scrollWidth ||
|
||||||
|
previousMetrics.scrollHeight !== currentMetrics.scrollHeight
|
||||||
|
if (shouldLog) {
|
||||||
|
lastLoggedMetricsRef.current = currentMetrics
|
||||||
|
logWebStickyBottom('viewport_metrics_updated', {
|
||||||
|
agentId: props.agentId,
|
||||||
|
followOutput: followOutputRef.current,
|
||||||
|
distanceFromBottom: getScrollContainerDistanceFromBottom(scrollContainer),
|
||||||
|
...currentMetrics,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [onNearBottomChange, props.agentId, streamScrollbarMetrics])
|
||||||
|
|
||||||
const handleDomScroll = useCallback(() => {
|
const handleDomScroll = useCallback(() => {
|
||||||
const scrollContainer = scrollContainerRef.current
|
const scrollContainer = scrollContainerRef.current
|
||||||
@@ -283,6 +359,19 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
lastKnownScrollTopRef.current = currentScrollTop
|
lastKnownScrollTopRef.current = currentScrollTop
|
||||||
|
logWebStickyBottom('viewport_dom_scroll', {
|
||||||
|
agentId: props.agentId,
|
||||||
|
now: getDebugNow(),
|
||||||
|
scrollTop: currentScrollTop,
|
||||||
|
clientHeight: scrollContainer.clientHeight,
|
||||||
|
scrollHeight: scrollContainer.scrollHeight,
|
||||||
|
activeElementTag:
|
||||||
|
typeof document !== 'undefined' ? document.activeElement?.tagName?.toLowerCase() ?? null : null,
|
||||||
|
activeElementRole:
|
||||||
|
typeof document !== 'undefined'
|
||||||
|
? document.activeElement?.getAttribute?.('aria-label') ?? null
|
||||||
|
: null,
|
||||||
|
})
|
||||||
updateScrollMetrics()
|
updateScrollMetrics()
|
||||||
}, [cancelPendingStickToBottom, updateScrollMetrics])
|
}, [cancelPendingStickToBottom, updateScrollMetrics])
|
||||||
|
|
||||||
@@ -348,6 +437,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
|
|
||||||
updateScrollMetrics()
|
updateScrollMetrics()
|
||||||
const observer = new ResizeObserver(() => {
|
const observer = new ResizeObserver(() => {
|
||||||
|
logWebStickyBottom('viewport_resize_observed', {
|
||||||
|
agentId: props.agentId,
|
||||||
|
followOutput: followOutputRef.current,
|
||||||
|
scrollTop: scrollContainer.scrollTop,
|
||||||
|
clientWidth: scrollContainer.clientWidth,
|
||||||
|
clientHeight: scrollContainer.clientHeight,
|
||||||
|
scrollWidth: scrollContainer.scrollWidth,
|
||||||
|
scrollHeight: scrollContainer.scrollHeight,
|
||||||
|
})
|
||||||
updateScrollMetrics()
|
updateScrollMetrics()
|
||||||
if (!followOutputRef.current) {
|
if (!followOutputRef.current) {
|
||||||
return
|
return
|
||||||
@@ -361,7 +459,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
return () => {
|
return () => {
|
||||||
observer.disconnect()
|
observer.disconnect()
|
||||||
}
|
}
|
||||||
}, [scheduleStickToBottom, updateScrollMetrics])
|
}, [props.agentId, scheduleStickToBottom, updateScrollMetrics])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scrollContainer = scrollContainerRef.current
|
const scrollContainer = scrollContainerRef.current
|
||||||
@@ -369,6 +467,59 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const originalScrollTo = scrollContainer.scrollTo.bind(scrollContainer)
|
||||||
|
const scrollTopDescriptor =
|
||||||
|
Object.getOwnPropertyDescriptor(Object.getPrototypeOf(scrollContainer), 'scrollTop') ??
|
||||||
|
Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop')
|
||||||
|
scrollContainer.scrollTo = ((...args: Parameters<HTMLElement['scrollTo']>) => {
|
||||||
|
const firstArg = args[0] as ScrollToOptions | number | undefined
|
||||||
|
const target =
|
||||||
|
typeof firstArg === 'object' && firstArg !== null
|
||||||
|
? {
|
||||||
|
top: firstArg.top ?? null,
|
||||||
|
left: firstArg.left ?? null,
|
||||||
|
behavior: firstArg.behavior ?? null,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
top: typeof args[1] === 'number' ? args[1] : null,
|
||||||
|
left: typeof firstArg === 'number' ? firstArg : null,
|
||||||
|
behavior: null,
|
||||||
|
}
|
||||||
|
logWebStickyBottom('viewport_scroll_to_called', {
|
||||||
|
agentId: props.agentId,
|
||||||
|
now: getDebugNow(),
|
||||||
|
currentScrollTop: scrollContainer.scrollTop,
|
||||||
|
target,
|
||||||
|
stack:
|
||||||
|
typeof Error !== 'undefined'
|
||||||
|
? new Error().stack?.split('\n').slice(1, 6).join('\n') ?? null
|
||||||
|
: null,
|
||||||
|
})
|
||||||
|
return originalScrollTo(...args)
|
||||||
|
}) as typeof scrollContainer.scrollTo
|
||||||
|
if (scrollTopDescriptor?.get && scrollTopDescriptor?.set) {
|
||||||
|
Object.defineProperty(scrollContainer, 'scrollTop', {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: scrollTopDescriptor.enumerable ?? false,
|
||||||
|
get() {
|
||||||
|
return scrollTopDescriptor.get?.call(scrollContainer)
|
||||||
|
},
|
||||||
|
set(value: number) {
|
||||||
|
logWebStickyBottom('viewport_scroll_top_set', {
|
||||||
|
agentId: props.agentId,
|
||||||
|
now: getDebugNow(),
|
||||||
|
currentScrollTop: scrollTopDescriptor.get?.call(scrollContainer) ?? null,
|
||||||
|
nextScrollTop: value,
|
||||||
|
stack:
|
||||||
|
typeof Error !== 'undefined'
|
||||||
|
? new Error().stack?.split('\n').slice(1, 6).join('\n') ?? null
|
||||||
|
: null,
|
||||||
|
})
|
||||||
|
return scrollTopDescriptor.set?.call(scrollContainer, value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const handleWheel = (event: WheelEvent) => {
|
const handleWheel = (event: WheelEvent) => {
|
||||||
if (event.deltaY < 0) {
|
if (event.deltaY < 0) {
|
||||||
pendingUserScrollUpIntentRef.current = true
|
pendingUserScrollUpIntentRef.current = true
|
||||||
@@ -403,6 +554,25 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
const handleTouchEnd = () => {
|
const handleTouchEnd = () => {
|
||||||
lastTouchClientYRef.current = null
|
lastTouchClientYRef.current = null
|
||||||
}
|
}
|
||||||
|
const handleSelectionChange = () => {
|
||||||
|
const activeElement =
|
||||||
|
typeof document !== 'undefined' ? (document.activeElement as HTMLTextAreaElement | null) : null
|
||||||
|
logWebStickyBottom('document_selection_changed', {
|
||||||
|
agentId: props.agentId,
|
||||||
|
now: getDebugNow(),
|
||||||
|
activeElementTag: activeElement?.tagName?.toLowerCase() ?? null,
|
||||||
|
activeElementRole: activeElement?.getAttribute?.('aria-label') ?? null,
|
||||||
|
selectionStart:
|
||||||
|
activeElement && typeof activeElement.selectionStart === 'number'
|
||||||
|
? activeElement.selectionStart
|
||||||
|
: null,
|
||||||
|
selectionEnd:
|
||||||
|
activeElement && typeof activeElement.selectionEnd === 'number'
|
||||||
|
? activeElement.selectionEnd
|
||||||
|
: null,
|
||||||
|
scrollTop: scrollContainer.scrollTop,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
scrollContainer.addEventListener('scroll', handleDomScroll, { passive: true })
|
scrollContainer.addEventListener('scroll', handleDomScroll, { passive: true })
|
||||||
scrollContainer.addEventListener('wheel', handleWheel, { passive: true })
|
scrollContainer.addEventListener('wheel', handleWheel, { passive: true })
|
||||||
@@ -413,6 +583,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
scrollContainer.addEventListener('touchmove', handleTouchMove, { passive: true })
|
scrollContainer.addEventListener('touchmove', handleTouchMove, { passive: true })
|
||||||
scrollContainer.addEventListener('touchend', handleTouchEnd, { passive: true })
|
scrollContainer.addEventListener('touchend', handleTouchEnd, { passive: true })
|
||||||
scrollContainer.addEventListener('touchcancel', handleTouchEnd, { passive: true })
|
scrollContainer.addEventListener('touchcancel', handleTouchEnd, { passive: true })
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.addEventListener('selectionchange', handleSelectionChange, { passive: true })
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
scrollContainer.removeEventListener('scroll', handleDomScroll)
|
scrollContainer.removeEventListener('scroll', handleDomScroll)
|
||||||
@@ -424,8 +597,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
scrollContainer.removeEventListener('touchmove', handleTouchMove)
|
scrollContainer.removeEventListener('touchmove', handleTouchMove)
|
||||||
scrollContainer.removeEventListener('touchend', handleTouchEnd)
|
scrollContainer.removeEventListener('touchend', handleTouchEnd)
|
||||||
scrollContainer.removeEventListener('touchcancel', handleTouchEnd)
|
scrollContainer.removeEventListener('touchcancel', handleTouchEnd)
|
||||||
|
scrollContainer.scrollTo = originalScrollTo
|
||||||
|
if (scrollTopDescriptor) {
|
||||||
|
Reflect.deleteProperty(scrollContainer, 'scrollTop')
|
||||||
|
}
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.removeEventListener('selectionchange', handleSelectionChange)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [cancelPendingStickToBottom, handleDomScroll])
|
}, [cancelPendingStickToBottom, handleDomScroll, props.agentId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handle: StreamViewportHandle = {
|
const handle: StreamViewportHandle = {
|
||||||
@@ -438,6 +618,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
if (!followOutputRef.current) {
|
if (!followOutputRef.current) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const scrollContainer = scrollContainerRef.current
|
||||||
|
logWebStickyBottom('viewport_prepare_for_change', {
|
||||||
|
agentId: props.agentId,
|
||||||
|
followOutput: followOutputRef.current,
|
||||||
|
scrollTop: scrollContainer?.scrollTop ?? null,
|
||||||
|
clientWidth: scrollContainer?.clientWidth ?? null,
|
||||||
|
clientHeight: scrollContainer?.clientHeight ?? null,
|
||||||
|
scrollWidth: scrollContainer?.scrollWidth ?? null,
|
||||||
|
scrollHeight: scrollContainer?.scrollHeight ?? null,
|
||||||
|
})
|
||||||
scheduleStickToBottom()
|
scheduleStickToBottom()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -448,7 +638,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
}
|
}
|
||||||
cancelPendingStickToBottom()
|
cancelPendingStickToBottom()
|
||||||
}
|
}
|
||||||
}, [cancelPendingStickToBottom, forceStickToBottom, scheduleStickToBottom, viewportRef])
|
}, [cancelPendingStickToBottom, forceStickToBottom, props.agentId, scheduleStickToBottom, viewportRef])
|
||||||
|
|
||||||
const contentContainerStyle = useMemo(
|
const contentContainerStyle = useMemo(
|
||||||
(): CSSProperties => ({
|
(): CSSProperties => ({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useRef } from 'react'
|
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
@@ -99,6 +99,7 @@ export type DesktopStartupReconciliationInput = {
|
|||||||
interface DaemonRegistryContextValue {
|
interface DaemonRegistryContextValue {
|
||||||
daemons: HostProfile[]
|
daemons: HostProfile[]
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
|
isReconciling: boolean
|
||||||
error: unknown | null
|
error: unknown | null
|
||||||
upsertDirectConnection: (input: {
|
upsertDirectConnection: (input: {
|
||||||
serverId: string
|
serverId: string
|
||||||
@@ -565,6 +566,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const desktopStartupReconciledRef = useRef(false)
|
const desktopStartupReconciledRef = useRef(false)
|
||||||
const localhostBootstrapAttemptedRef = useRef(false)
|
const localhostBootstrapAttemptedRef = useRef(false)
|
||||||
|
const [isReconciling, setIsReconciling] = useState(true)
|
||||||
const { settings, isLoading: settingsLoading } = useAppSettings()
|
const { settings, isLoading: settingsLoading } = useAppSettings()
|
||||||
const {
|
const {
|
||||||
data: daemons = [],
|
data: daemons = [],
|
||||||
@@ -761,7 +763,11 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void reconcileDesktopStartup()
|
void reconcileDesktopStartup().finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsReconciling(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
@@ -826,7 +832,11 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void bootstrapLocalhost()
|
void bootstrapLocalhost().finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsReconciling(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
@@ -895,6 +905,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
|||||||
const value: DaemonRegistryContextValue = {
|
const value: DaemonRegistryContextValue = {
|
||||||
daemons,
|
daemons,
|
||||||
isLoading: isPending,
|
isLoading: isPending,
|
||||||
|
isReconciling,
|
||||||
error: error ?? null,
|
error: error ?? null,
|
||||||
upsertDirectConnection,
|
upsertDirectConnection,
|
||||||
upsertRelayConnection,
|
upsertRelayConnection,
|
||||||
|
|||||||
@@ -0,0 +1,445 @@
|
|||||||
|
import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
|
||||||
|
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
||||||
|
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
|
||||||
|
import { shouldShowMergeFromBaseAction } from "@/components/git-action-visibility";
|
||||||
|
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
|
||||||
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
|
import type { ActionStatus } from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
|
export type GitActionId =
|
||||||
|
| "commit"
|
||||||
|
| "push"
|
||||||
|
| "view-pr"
|
||||||
|
| "create-pr"
|
||||||
|
| "merge-branch"
|
||||||
|
| "merge-from-base"
|
||||||
|
| "archive-worktree";
|
||||||
|
|
||||||
|
export interface GitAction {
|
||||||
|
id: GitActionId;
|
||||||
|
label: string;
|
||||||
|
pendingLabel: string;
|
||||||
|
successLabel: string;
|
||||||
|
disabled: boolean;
|
||||||
|
status: ActionStatus;
|
||||||
|
description?: string;
|
||||||
|
icon?: ReactElement;
|
||||||
|
handler: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitActions {
|
||||||
|
primary: GitAction | null;
|
||||||
|
secondary: GitAction[];
|
||||||
|
menu: GitAction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function openURLInNewTab(url: string): void {
|
||||||
|
void openExternalUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseGitActionsInput {
|
||||||
|
serverId: string;
|
||||||
|
cwd: string;
|
||||||
|
icons: {
|
||||||
|
commit: ReactElement;
|
||||||
|
push: ReactElement;
|
||||||
|
viewPr: ReactElement;
|
||||||
|
createPr: ReactElement;
|
||||||
|
merge: ReactElement;
|
||||||
|
mergeFromBase: ReactElement;
|
||||||
|
archive: ReactElement;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseGitActionsResult {
|
||||||
|
gitActions: GitActions;
|
||||||
|
branchLabel: string;
|
||||||
|
actionError: string | null;
|
||||||
|
isGit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult {
|
||||||
|
const router = useRouter();
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
||||||
|
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
||||||
|
|
||||||
|
const { status, isLoading: isStatusLoading } =
|
||||||
|
useCheckoutStatusQuery({ serverId, cwd });
|
||||||
|
const gitStatus = status && status.isGit ? status : null;
|
||||||
|
const isGit = Boolean(gitStatus);
|
||||||
|
const notGit = status !== null && !status.isGit && !status.error;
|
||||||
|
const baseRef = gitStatus?.baseRef ?? undefined;
|
||||||
|
|
||||||
|
const hasUncommittedChanges = Boolean(gitStatus?.isDirty);
|
||||||
|
|
||||||
|
const {
|
||||||
|
status: prStatus,
|
||||||
|
githubFeaturesEnabled,
|
||||||
|
} = useCheckoutPrStatusQuery({
|
||||||
|
serverId,
|
||||||
|
cwd,
|
||||||
|
enabled: isGit,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ship default persistence
|
||||||
|
const shipDefaultStorageKey = useMemo(() => {
|
||||||
|
if (!gitStatus?.repoRoot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `@paseo:changes-ship-default:${gitStatus.repoRoot}`;
|
||||||
|
}, [gitStatus?.repoRoot]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shipDefaultStorageKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let isActive = true;
|
||||||
|
AsyncStorage.getItem(shipDefaultStorageKey)
|
||||||
|
.then((value) => {
|
||||||
|
if (!isActive) return;
|
||||||
|
if (value === "pr" || value === "merge") {
|
||||||
|
setShipDefault(value);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
isActive = false;
|
||||||
|
};
|
||||||
|
}, [shipDefaultStorageKey]);
|
||||||
|
|
||||||
|
const persistShipDefault = useCallback(
|
||||||
|
async (next: "merge" | "pr") => {
|
||||||
|
setShipDefault(next);
|
||||||
|
if (!shipDefaultStorageKey) return;
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(shipDefaultStorageKey, next);
|
||||||
|
} catch {
|
||||||
|
// Ignore persistence failures; default will reset to "merge".
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[shipDefaultStorageKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPostShipArchiveSuggested(false);
|
||||||
|
}, [cwd]);
|
||||||
|
|
||||||
|
// Store selectors
|
||||||
|
const commitStatus = useCheckoutGitActionsStore((state) =>
|
||||||
|
state.getStatus({ serverId, cwd, actionId: "commit" })
|
||||||
|
);
|
||||||
|
const pushStatus = useCheckoutGitActionsStore((state) =>
|
||||||
|
state.getStatus({ serverId, cwd, actionId: "push" })
|
||||||
|
);
|
||||||
|
const prCreateStatus = useCheckoutGitActionsStore((state) =>
|
||||||
|
state.getStatus({ serverId, cwd, actionId: "create-pr" })
|
||||||
|
);
|
||||||
|
const mergeStatus = useCheckoutGitActionsStore((state) =>
|
||||||
|
state.getStatus({ serverId, cwd, actionId: "merge-branch" })
|
||||||
|
);
|
||||||
|
const mergeFromBaseStatus = useCheckoutGitActionsStore((state) =>
|
||||||
|
state.getStatus({ serverId, cwd, actionId: "merge-from-base" })
|
||||||
|
);
|
||||||
|
const archiveStatus = useCheckoutGitActionsStore((state) =>
|
||||||
|
state.getStatus({ serverId, cwd, actionId: "archive-worktree" })
|
||||||
|
);
|
||||||
|
|
||||||
|
const runCommit = useCheckoutGitActionsStore((state) => state.commit);
|
||||||
|
const runPush = useCheckoutGitActionsStore((state) => state.push);
|
||||||
|
const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr);
|
||||||
|
const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch);
|
||||||
|
const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase);
|
||||||
|
const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleCommit = useCallback(() => {
|
||||||
|
setActionError(null);
|
||||||
|
void runCommit({ serverId, cwd }).catch((err) => {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to commit";
|
||||||
|
setActionError(message);
|
||||||
|
});
|
||||||
|
}, [runCommit, serverId, cwd]);
|
||||||
|
|
||||||
|
const handlePush = useCallback(() => {
|
||||||
|
setActionError(null);
|
||||||
|
void runPush({ serverId, cwd }).catch((err) => {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to push";
|
||||||
|
setActionError(message);
|
||||||
|
});
|
||||||
|
}, [runPush, serverId, cwd]);
|
||||||
|
|
||||||
|
const handleCreatePr = useCallback(() => {
|
||||||
|
void persistShipDefault("pr");
|
||||||
|
setActionError(null);
|
||||||
|
void runCreatePr({ serverId, cwd }).catch((err) => {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to create PR";
|
||||||
|
setActionError(message);
|
||||||
|
});
|
||||||
|
}, [persistShipDefault, runCreatePr, serverId, cwd]);
|
||||||
|
|
||||||
|
const handleMergeBranch = useCallback(() => {
|
||||||
|
if (!baseRef) {
|
||||||
|
setActionError("Base ref unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void persistShipDefault("merge");
|
||||||
|
setActionError(null);
|
||||||
|
void runMergeBranch({ serverId, cwd, baseRef })
|
||||||
|
.then(() => {
|
||||||
|
setPostShipArchiveSuggested(true);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to merge";
|
||||||
|
setActionError(message);
|
||||||
|
});
|
||||||
|
}, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]);
|
||||||
|
|
||||||
|
const handleMergeFromBase = useCallback(() => {
|
||||||
|
if (!baseRef) {
|
||||||
|
setActionError("Base ref unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActionError(null);
|
||||||
|
void runMergeFromBase({ serverId, cwd, baseRef }).catch((err) => {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to merge from base";
|
||||||
|
setActionError(message);
|
||||||
|
});
|
||||||
|
}, [baseRef, runMergeFromBase, serverId, cwd]);
|
||||||
|
|
||||||
|
const handleArchiveWorktree = useCallback(() => {
|
||||||
|
const worktreePath = status?.cwd;
|
||||||
|
if (!worktreePath) {
|
||||||
|
setActionError("Worktree path unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActionError(null);
|
||||||
|
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
|
||||||
|
void runArchiveWorktree({ serverId, cwd, worktreePath })
|
||||||
|
.then(() => {
|
||||||
|
router.replace(buildNewAgentRoute(serverId, targetWorkingDir) as any);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to archive worktree";
|
||||||
|
setActionError(message);
|
||||||
|
});
|
||||||
|
}, [runArchiveWorktree, router, serverId, cwd, status]);
|
||||||
|
|
||||||
|
// Derived state
|
||||||
|
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
|
||||||
|
const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0;
|
||||||
|
const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0;
|
||||||
|
const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0;
|
||||||
|
const baseRefLabel = useMemo(() => {
|
||||||
|
if (!baseRef) return "base";
|
||||||
|
const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim();
|
||||||
|
return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed;
|
||||||
|
}, [baseRef]);
|
||||||
|
const hasPullRequest = Boolean(prStatus?.url);
|
||||||
|
const hasRemote = gitStatus?.hasRemote ?? false;
|
||||||
|
const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false;
|
||||||
|
const isMergedPullRequest = Boolean(prStatus?.isMerged);
|
||||||
|
const currentBranch = gitStatus?.currentBranch;
|
||||||
|
const isOnBaseBranch = currentBranch === baseRefLabel;
|
||||||
|
const shouldPromoteArchive =
|
||||||
|
isPaseoOwnedWorktree &&
|
||||||
|
!hasUncommittedChanges &&
|
||||||
|
(postShipArchiveSuggested || isMergedPullRequest);
|
||||||
|
|
||||||
|
const commitDisabled = actionsDisabled || commitStatus === "pending";
|
||||||
|
const prDisabled = actionsDisabled || prCreateStatus === "pending";
|
||||||
|
const mergeDisabled =
|
||||||
|
actionsDisabled || mergeStatus === "pending" || hasUncommittedChanges || !baseRef;
|
||||||
|
const mergeFromBaseDisabled =
|
||||||
|
actionsDisabled ||
|
||||||
|
mergeFromBaseStatus === "pending" ||
|
||||||
|
hasUncommittedChanges ||
|
||||||
|
!baseRef ||
|
||||||
|
(isOnBaseBranch && !hasRemote);
|
||||||
|
const pushDisabled =
|
||||||
|
actionsDisabled || pushStatus === "pending" || !(gitStatus?.hasRemote ?? false);
|
||||||
|
const archiveDisabled =
|
||||||
|
actionsDisabled ||
|
||||||
|
archiveStatus === "pending" ||
|
||||||
|
!gitStatus?.isPaseoOwnedWorktree;
|
||||||
|
|
||||||
|
const branchLabel =
|
||||||
|
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"
|
||||||
|
? gitStatus.currentBranch
|
||||||
|
: notGit
|
||||||
|
? "Not a git repository"
|
||||||
|
: "Unknown";
|
||||||
|
|
||||||
|
// Build actions
|
||||||
|
const gitActions: GitActions = useMemo(() => {
|
||||||
|
if (!isGit) {
|
||||||
|
return { primary: null, secondary: [], menu: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const allActions = new Map<GitActionId, GitAction>();
|
||||||
|
|
||||||
|
allActions.set("commit", {
|
||||||
|
id: "commit",
|
||||||
|
label: "Commit",
|
||||||
|
pendingLabel: "Committing...",
|
||||||
|
successLabel: "Committed",
|
||||||
|
disabled: commitDisabled,
|
||||||
|
status: commitStatus,
|
||||||
|
icon: icons.commit,
|
||||||
|
handler: handleCommit,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasRemote) {
|
||||||
|
allActions.set("push", {
|
||||||
|
id: "push",
|
||||||
|
label: "Push",
|
||||||
|
pendingLabel: "Pushing...",
|
||||||
|
successLabel: "Pushed",
|
||||||
|
disabled: pushDisabled,
|
||||||
|
status: pushStatus,
|
||||||
|
description: !hasRemote ? "No remote configured" : undefined,
|
||||||
|
icon: icons.push,
|
||||||
|
handler: handlePush,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (githubFeaturesEnabled && hasPullRequest && prStatus?.url) {
|
||||||
|
const prUrl = prStatus.url;
|
||||||
|
allActions.set("view-pr", {
|
||||||
|
id: "view-pr",
|
||||||
|
label: "View PR",
|
||||||
|
pendingLabel: "View PR",
|
||||||
|
successLabel: "View PR",
|
||||||
|
disabled: false,
|
||||||
|
status: "idle",
|
||||||
|
icon: icons.viewPr,
|
||||||
|
handler: () => openURLInNewTab(prUrl),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (githubFeaturesEnabled && aheadCount > 0 && !hasPullRequest) {
|
||||||
|
allActions.set("create-pr", {
|
||||||
|
id: "create-pr",
|
||||||
|
label: "Create PR",
|
||||||
|
pendingLabel: "Creating PR...",
|
||||||
|
successLabel: "PR Created",
|
||||||
|
disabled: prDisabled,
|
||||||
|
status: prCreateStatus,
|
||||||
|
icon: icons.createPr,
|
||||||
|
handler: handleCreatePr,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aheadCount > 0) {
|
||||||
|
allActions.set("merge-branch", {
|
||||||
|
id: "merge-branch",
|
||||||
|
label: `Merge into ${baseRefLabel}`,
|
||||||
|
pendingLabel: "Merging...",
|
||||||
|
successLabel: "Merged",
|
||||||
|
disabled: mergeDisabled,
|
||||||
|
status: mergeStatus,
|
||||||
|
description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
|
||||||
|
icon: icons.merge,
|
||||||
|
handler: handleMergeBranch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
shouldShowMergeFromBaseAction({
|
||||||
|
isOnBaseBranch,
|
||||||
|
hasRemote,
|
||||||
|
aheadOfOrigin,
|
||||||
|
behindOfOrigin,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
allActions.set("merge-from-base", {
|
||||||
|
id: "merge-from-base",
|
||||||
|
label: isOnBaseBranch ? "Sync" : `Update from ${baseRefLabel}`,
|
||||||
|
pendingLabel: "Updating...",
|
||||||
|
successLabel: "Updated",
|
||||||
|
disabled: mergeFromBaseDisabled,
|
||||||
|
status: mergeFromBaseStatus,
|
||||||
|
description:
|
||||||
|
hasUncommittedChanges
|
||||||
|
? "Requires clean working tree"
|
||||||
|
: isOnBaseBranch && !hasRemote
|
||||||
|
? "No remote configured"
|
||||||
|
: undefined,
|
||||||
|
icon: icons.mergeFromBase,
|
||||||
|
handler: handleMergeFromBase,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPaseoOwnedWorktree) {
|
||||||
|
allActions.set("archive-worktree", {
|
||||||
|
id: "archive-worktree",
|
||||||
|
label: "Archive worktree",
|
||||||
|
pendingLabel: "Archiving...",
|
||||||
|
successLabel: "Archived",
|
||||||
|
disabled: archiveDisabled,
|
||||||
|
status: archiveStatus,
|
||||||
|
icon: icons.archive,
|
||||||
|
handler: handleArchiveWorktree,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select primary action (priority rules)
|
||||||
|
let primaryActionId: GitActionId | null = null;
|
||||||
|
|
||||||
|
if (shouldPromoteArchive && allActions.has("archive-worktree")) {
|
||||||
|
primaryActionId = "archive-worktree";
|
||||||
|
} else if (hasUncommittedChanges) {
|
||||||
|
primaryActionId = "commit";
|
||||||
|
} else if (aheadOfOrigin > 0 && allActions.has("push")) {
|
||||||
|
primaryActionId = "push";
|
||||||
|
} else if (hasPullRequest) {
|
||||||
|
primaryActionId = "view-pr";
|
||||||
|
} else if (isOnBaseBranch && allActions.has("merge-from-base")) {
|
||||||
|
primaryActionId = "merge-from-base";
|
||||||
|
} else if (aheadCount > 0) {
|
||||||
|
const preferred: GitActionId = shipDefault === "merge" ? "merge-branch" : "create-pr";
|
||||||
|
const fallback: GitActionId = shipDefault === "merge" ? "create-pr" : "merge-branch";
|
||||||
|
|
||||||
|
const preferredAction = allActions.get(preferred);
|
||||||
|
const fallbackAction = allActions.get(fallback);
|
||||||
|
|
||||||
|
if (preferredAction && !preferredAction.disabled) {
|
||||||
|
primaryActionId = preferred;
|
||||||
|
} else if (fallbackAction && !fallbackAction.disabled) {
|
||||||
|
primaryActionId = fallback;
|
||||||
|
} else if (preferredAction) {
|
||||||
|
primaryActionId = preferred;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null;
|
||||||
|
|
||||||
|
const secondaryIds: GitActionId[] = [
|
||||||
|
"merge-branch",
|
||||||
|
"create-pr",
|
||||||
|
"view-pr",
|
||||||
|
"merge-from-base",
|
||||||
|
"push",
|
||||||
|
"archive-worktree",
|
||||||
|
];
|
||||||
|
const secondary = secondaryIds
|
||||||
|
.filter(id => id !== primaryActionId && allActions.has(id))
|
||||||
|
.map(id => allActions.get(id)!);
|
||||||
|
|
||||||
|
const menu: GitAction[] = [];
|
||||||
|
|
||||||
|
return { primary, secondary, menu };
|
||||||
|
}, [
|
||||||
|
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled,
|
||||||
|
hasUncommittedChanges, aheadOfOrigin, behindOfOrigin, shipDefault, baseRefLabel, shouldPromoteArchive,
|
||||||
|
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
|
||||||
|
commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus,
|
||||||
|
handleCommit, handlePush, handleCreatePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree,
|
||||||
|
icons, baseRef,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { gitActions, branchLabel, actionError, isGit };
|
||||||
|
}
|
||||||
@@ -74,6 +74,13 @@ function logAgentExplorer(event: string, details: Record<string, unknown>): void
|
|||||||
console.log(`[AgentExplorer] ${event}`, details);
|
console.log(`[AgentExplorer] ${event}`, details);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function logWebStickyBottom(event: string, details: Record<string, unknown>): void {
|
||||||
|
if (!IS_DEV || Platform.OS !== "web") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("[WebStickyBottom]", event, details);
|
||||||
|
}
|
||||||
|
|
||||||
export function AgentReadyScreen({
|
export function AgentReadyScreen({
|
||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
@@ -946,12 +953,19 @@ function AgentScreenContent({
|
|||||||
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
||||||
onAttentionPromptSend={attentionController.clearOnPromptSend}
|
onAttentionPromptSend={attentionController.clearOnPromptSend}
|
||||||
onAddImages={handleAddImagesCallback}
|
onAddImages={handleAddImagesCallback}
|
||||||
onComposerHeightChange={() =>
|
onComposerHeightChange={(height) => {
|
||||||
streamViewRef.current?.prepareForViewportChange()
|
logWebStickyBottom("screen_composer_height_change", {
|
||||||
}
|
agentId: resolvedAgentId,
|
||||||
onMessageSent={() =>
|
height,
|
||||||
streamViewRef.current?.scrollToBottom("message-sent")
|
});
|
||||||
}
|
streamViewRef.current?.prepareForViewportChange();
|
||||||
|
}}
|
||||||
|
onMessageSent={() => {
|
||||||
|
logWebStickyBottom("screen_message_sent_scroll_to_bottom", {
|
||||||
|
agentId: resolvedAgentId,
|
||||||
|
});
|
||||||
|
streamViewRef.current?.scrollToBottom("message-sent");
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,34 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
import { View, Text, Pressable } from "react-native";
|
import { View, Text, Pressable } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { FolderOpen } from "lucide-react-native";
|
import { FolderOpen } from "lucide-react-native";
|
||||||
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||||
|
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
|
|
||||||
export function OpenProjectScreen({ serverId: _serverId }: { serverId: string }) {
|
export function OpenProjectScreen({ serverId: _serverId }: { serverId: string }) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
const setProjectPickerOpen = useKeyboardShortcutsStore(
|
const setProjectPickerOpen = useKeyboardShortcutsStore(
|
||||||
(s) => s.setProjectPickerOpen
|
(s) => s.setProjectPickerOpen
|
||||||
);
|
);
|
||||||
|
const openAgentList = usePanelStore((s) => s.openAgentList);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isMobile =
|
||||||
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
|
if (!isMobile) {
|
||||||
|
openAgentList();
|
||||||
|
}
|
||||||
|
}, [openAgentList]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
|
<View style={[styles.menuToggle, { paddingTop: insets.top }]}>
|
||||||
|
<SidebarMenuToggle />
|
||||||
|
</View>
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
<PaseoLogo size={56} />
|
<PaseoLogo size={56} />
|
||||||
<Text style={styles.heading}>What shall we build today?</Text>
|
<Text style={styles.heading}>What shall we build today?</Text>
|
||||||
@@ -24,7 +41,7 @@ export function OpenProjectScreen({ serverId: _serverId }: { serverId: string })
|
|||||||
testID="open-project-submit"
|
testID="open-project-submit"
|
||||||
>
|
>
|
||||||
<FolderOpen size={16} color={theme.colors.foregroundMuted} />
|
<FolderOpen size={16} color={theme.colors.foregroundMuted} />
|
||||||
<Text style={styles.openButtonText}>Open a project</Text>
|
<Text style={styles.openButtonText}>Add a project</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -36,6 +53,12 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: theme.colors.surface0,
|
backgroundColor: theme.colors.surface0,
|
||||||
},
|
},
|
||||||
|
menuToggle: {
|
||||||
|
position: "absolute",
|
||||||
|
top: theme.spacing[3],
|
||||||
|
left: theme.spacing[3],
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
content: {
|
content: {
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Image, Text, View } from "react-native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: theme.colors.surface0,
|
||||||
|
},
|
||||||
|
logo: {
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
marginBottom: theme.spacing[6],
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function StartupSplashScreen() {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Image
|
||||||
|
source={require("../../assets/images/icon.png")}
|
||||||
|
style={styles.logo}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
<Text style={styles.status}>Starting up…</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from "react";
|
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from "react";
|
||||||
import { ActivityIndicator, Pressable, ScrollView, Text, View, type LayoutChangeEvent } from "react-native";
|
import { ActivityIndicator, Pressable, ScrollView, Text, View, type LayoutChangeEvent } from "react-native";
|
||||||
import { Plus, SquareTerminal, X } from "lucide-react-native";
|
import { Plus, X } from "lucide-react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { SortableInlineList } from "@/components/sortable-inline-list";
|
import { SortableInlineList } from "@/components/sortable-inline-list";
|
||||||
import {
|
import {
|
||||||
@@ -22,7 +22,7 @@ import type { Agent } from "@/stores/session-store";
|
|||||||
|
|
||||||
const DROPDOWN_WIDTH = 220;
|
const DROPDOWN_WIDTH = 220;
|
||||||
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
|
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
|
||||||
type NewTabOptionId = "__new_tab_agent__" | "__new_tab_terminal__";
|
type NewTabOptionId = "__new_tab_agent__";
|
||||||
|
|
||||||
type WorkspaceDesktopTabsRowProps = {
|
type WorkspaceDesktopTabsRowProps = {
|
||||||
tabs: WorkspaceTabDescriptor[];
|
tabs: WorkspaceTabDescriptor[];
|
||||||
@@ -44,10 +44,6 @@ type WorkspaceDesktopTabsRowProps = {
|
|||||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||||
onSelectNewTabOption: (optionId: NewTabOptionId) => void;
|
onSelectNewTabOption: (optionId: NewTabOptionId) => void;
|
||||||
newTabAgentOptionId: NewTabOptionId;
|
newTabAgentOptionId: NewTabOptionId;
|
||||||
newTabTerminalOptionId: NewTabOptionId;
|
|
||||||
createTerminalPending: boolean;
|
|
||||||
isNewTerminalHovered: boolean;
|
|
||||||
setIsNewTerminalHovered: Dispatch<SetStateAction<boolean>>;
|
|
||||||
onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void;
|
onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -71,10 +67,6 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
onCloseOtherTabs,
|
onCloseOtherTabs,
|
||||||
onSelectNewTabOption,
|
onSelectNewTabOption,
|
||||||
newTabAgentOptionId,
|
newTabAgentOptionId,
|
||||||
newTabTerminalOptionId,
|
|
||||||
createTerminalPending,
|
|
||||||
isNewTerminalHovered,
|
|
||||||
setIsNewTerminalHovered,
|
|
||||||
onReorderTabs,
|
onReorderTabs,
|
||||||
}: WorkspaceDesktopTabsRowProps) {
|
}: WorkspaceDesktopTabsRowProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
@@ -378,36 +370,6 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
<Text style={styles.newTabTooltipText}>New agent tab</Text>
|
<Text style={styles.newTabTooltipText}>New agent tab</Text>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
|
||||||
<TooltipTrigger
|
|
||||||
testID="workspace-new-terminal-tab"
|
|
||||||
onPress={() => onSelectNewTabOption(newTabTerminalOptionId)}
|
|
||||||
onHoverIn={() => setIsNewTerminalHovered(true)}
|
|
||||||
onHoverOut={() => setIsNewTerminalHovered(false)}
|
|
||||||
disabled={createTerminalPending}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="New terminal tab"
|
|
||||||
style={({ hovered, pressed }) => [
|
|
||||||
styles.newTabActionButton,
|
|
||||||
createTerminalPending && styles.newTabActionButtonDisabled,
|
|
||||||
(hovered || pressed) && styles.newTabActionButtonHovered,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{createTerminalPending ? (
|
|
||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
|
||||||
) : (
|
|
||||||
<View style={styles.terminalPlusIcon}>
|
|
||||||
<SquareTerminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
||||||
<View style={[styles.terminalPlusBadge, isNewTerminalHovered && styles.terminalPlusBadgeHovered]}>
|
|
||||||
<Plus size={10} color={theme.colors.foregroundMuted} />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" align="end" offset={8}>
|
|
||||||
<Text style={styles.newTabTooltipText}>New terminal tab</Text>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -522,31 +484,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
newTabActionButtonHovered: {
|
newTabActionButtonHovered: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
newTabActionButtonDisabled: {
|
|
||||||
opacity: 0.5,
|
|
||||||
},
|
|
||||||
terminalPlusIcon: {
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
terminalPlusBadge: {
|
|
||||||
position: "absolute",
|
|
||||||
right: -7,
|
|
||||||
bottom: -7,
|
|
||||||
width: 12,
|
|
||||||
height: 12,
|
|
||||||
borderRadius: 6,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: theme.colors.surface0,
|
|
||||||
backgroundColor: theme.colors.surface0,
|
|
||||||
},
|
|
||||||
terminalPlusBadgeHovered: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
newTabTooltipText: {
|
newTabTooltipText: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useUnistyles } from "react-native-unistyles";
|
||||||
|
import {
|
||||||
|
Archive,
|
||||||
|
GitCommitHorizontal,
|
||||||
|
GitMerge,
|
||||||
|
RefreshCcw,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react-native";
|
||||||
|
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||||
|
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
|
||||||
|
import { useGitActions } from "@/hooks/use-git-actions";
|
||||||
|
|
||||||
|
interface WorkspaceGitActionsProps {
|
||||||
|
serverId: string;
|
||||||
|
cwd: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkspaceGitActions({ serverId, cwd }: WorkspaceGitActionsProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
|
const icons = useMemo(() => ({
|
||||||
|
commit: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
|
||||||
|
push: <Upload size={16} color={theme.colors.foregroundMuted} />,
|
||||||
|
viewPr: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
|
||||||
|
createPr: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
|
||||||
|
merge: <GitMerge size={16} color={theme.colors.foregroundMuted} />,
|
||||||
|
mergeFromBase: <RefreshCcw size={16} color={theme.colors.foregroundMuted} />,
|
||||||
|
archive: <Archive size={16} color={theme.colors.foregroundMuted} />,
|
||||||
|
}), [theme.colors.foregroundMuted]);
|
||||||
|
|
||||||
|
const { gitActions, isGit } = useGitActions({ serverId, cwd, icons });
|
||||||
|
|
||||||
|
if (!isGit) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <GitActionsSplitButton gitActions={gitActions} />;
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Folder,
|
Folder,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
MoreVertical,
|
Ellipsis,
|
||||||
PanelRight,
|
PanelRight,
|
||||||
Plus,
|
Plus,
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
@@ -40,6 +40,8 @@ import {
|
|||||||
import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
||||||
import { FilePane } from "@/components/file-pane";
|
import { FilePane } from "@/components/file-pane";
|
||||||
import { TerminalPane } from "@/components/terminal-pane";
|
import { TerminalPane } from "@/components/terminal-pane";
|
||||||
|
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
|
||||||
|
import { WorkspaceGitActions } from "@/screens/workspace/workspace-git-actions";
|
||||||
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||||
@@ -98,7 +100,6 @@ import {
|
|||||||
|
|
||||||
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
||||||
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
|
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
|
||||||
const NEW_TAB_TERMINAL_OPTION_ID = "__new_tab_terminal__";
|
|
||||||
const EMPTY_UI_TABS: ReturnType<typeof useWorkspaceTabsStore.getState>["uiTabsByWorkspace"][string] = [];
|
const EMPTY_UI_TABS: ReturnType<typeof useWorkspaceTabsStore.getState>["uiTabsByWorkspace"][string] = [];
|
||||||
const EMPTY_TAB_ORDER: string[] = [];
|
const EMPTY_TAB_ORDER: string[] = [];
|
||||||
|
|
||||||
@@ -751,7 +752,6 @@ function WorkspaceScreenContent({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [isTabSwitcherOpen, setIsTabSwitcherOpen] = useState(false);
|
const [isTabSwitcherOpen, setIsTabSwitcherOpen] = useState(false);
|
||||||
const [isNewTerminalHovered, setIsNewTerminalHovered] = useState(false);
|
|
||||||
const [hoveredTabKey, setHoveredTabKey] = useState<string | null>(null);
|
const [hoveredTabKey, setHoveredTabKey] = useState<string | null>(null);
|
||||||
const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState<string | null>(
|
const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState<string | null>(
|
||||||
null
|
null
|
||||||
@@ -828,16 +828,12 @@ function WorkspaceScreenContent({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectNewTabOption = useCallback(
|
const handleSelectNewTabOption = useCallback(
|
||||||
(key: typeof NEW_TAB_AGENT_OPTION_ID | typeof NEW_TAB_TERMINAL_OPTION_ID) => {
|
(key: typeof NEW_TAB_AGENT_OPTION_ID) => {
|
||||||
if (key === NEW_TAB_AGENT_OPTION_ID) {
|
if (key === NEW_TAB_AGENT_OPTION_ID) {
|
||||||
handleCreateDraftTab();
|
handleCreateDraftTab();
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key === NEW_TAB_TERMINAL_OPTION_ID) {
|
|
||||||
handleCreateTerminal();
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleCreateDraftTab, handleCreateTerminal]
|
[handleCreateDraftTab]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCloseTerminalTab = useCallback(
|
const handleCloseTerminalTab = useCallback(
|
||||||
@@ -1349,87 +1345,134 @@ function WorkspaceScreenContent({
|
|||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
testID="workspace-header-menu-trigger"
|
||||||
|
style={styles.headerActionButton}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Workspace actions"
|
||||||
|
>
|
||||||
|
{({ hovered, open }) => (
|
||||||
|
<Ellipsis
|
||||||
|
size={theme.iconSize.md}
|
||||||
|
color={hovered || open ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="start"
|
||||||
|
width={220}
|
||||||
|
testID="workspace-header-menu"
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID="workspace-header-new-terminal"
|
||||||
|
leading={
|
||||||
|
<SquareTerminal
|
||||||
|
size={16}
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
disabled={createTerminalMutation.isPending}
|
||||||
|
onSelect={handleCreateTerminal}
|
||||||
|
>
|
||||||
|
New terminal
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID="workspace-header-copy-path"
|
||||||
|
leading={
|
||||||
|
<Copy
|
||||||
|
size={16}
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
disabled={!normalizedWorkspaceId.startsWith("/")}
|
||||||
|
onSelect={handleCopyWorkspacePath}
|
||||||
|
>
|
||||||
|
Copy workspace path
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
right={
|
right={
|
||||||
<View style={styles.headerRight}>
|
<View style={styles.headerRight}>
|
||||||
<DropdownMenu>
|
{!isMobile && isGitCheckout ? (
|
||||||
<DropdownMenuTrigger
|
<>
|
||||||
testID="workspace-header-menu-trigger"
|
<WorkspaceGitActions
|
||||||
style={styles.headerActionButton}
|
serverId={normalizedServerId}
|
||||||
accessibilityRole="button"
|
cwd={normalizedWorkspaceId}
|
||||||
accessibilityLabel="Workspace actions"
|
|
||||||
>
|
|
||||||
<MoreVertical
|
|
||||||
size={theme.iconSize.md}
|
|
||||||
color={theme.colors.foregroundMuted}
|
|
||||||
/>
|
/>
|
||||||
</DropdownMenuTrigger>
|
<Pressable
|
||||||
<DropdownMenuContent
|
testID="workspace-explorer-toggle"
|
||||||
align="end"
|
onPress={handleToggleExplorer}
|
||||||
width={220}
|
accessibilityRole="button"
|
||||||
testID="workspace-header-menu"
|
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
|
||||||
>
|
accessibilityState={{ expanded: isExplorerOpen }}
|
||||||
<DropdownMenuItem
|
style={({ hovered, pressed }) => [
|
||||||
testID="workspace-header-copy-path"
|
styles.sourceControlButton,
|
||||||
leading={
|
workspaceDescriptor?.diffStat && styles.sourceControlButtonWithStats,
|
||||||
<Copy
|
(hovered || pressed || isExplorerOpen) && styles.sourceControlButtonHovered,
|
||||||
size={16}
|
]}
|
||||||
color={theme.colors.foregroundMuted}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
disabled={!normalizedWorkspaceId.startsWith("/")}
|
|
||||||
onSelect={handleCopyWorkspacePath}
|
|
||||||
>
|
>
|
||||||
Copy workspace path
|
{({ hovered, pressed }) => {
|
||||||
</DropdownMenuItem>
|
const active = isExplorerOpen || hovered || pressed;
|
||||||
</DropdownMenuContent>
|
const iconColor = active ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||||
</DropdownMenu>
|
return (
|
||||||
<HeaderToggleButton
|
<>
|
||||||
testID="workspace-explorer-toggle"
|
<SourceControlPanelIcon size={theme.iconSize.md} color={iconColor} />
|
||||||
onPress={handleToggleExplorer}
|
{workspaceDescriptor?.diffStat ? (
|
||||||
tooltipLabel="Toggle explorer"
|
<View style={styles.diffStatRow}>
|
||||||
tooltipKeys={["mod", "E"]}
|
<Text style={styles.diffStatAdditions}>+{workspaceDescriptor.diffStat.additions}</Text>
|
||||||
tooltipSide="left"
|
<Text style={styles.diffStatDeletions}>-{workspaceDescriptor.diffStat.deletions}</Text>
|
||||||
style={styles.headerActionButton}
|
</View>
|
||||||
accessible
|
) : null}
|
||||||
accessibilityRole="button"
|
</>
|
||||||
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
|
);
|
||||||
accessibilityState={{ expanded: isExplorerOpen }}
|
}}
|
||||||
>
|
</Pressable>
|
||||||
{isMobile ? (
|
</>
|
||||||
isGitCheckout ? (
|
) : null}
|
||||||
<GitBranch
|
{!isMobile && !isGitCheckout ? (
|
||||||
size={theme.iconSize.lg}
|
<HeaderToggleButton
|
||||||
color={
|
testID="workspace-explorer-toggle"
|
||||||
isExplorerOpen
|
onPress={handleToggleExplorer}
|
||||||
? theme.colors.foreground
|
tooltipLabel="Toggle explorer"
|
||||||
: theme.colors.foregroundMuted
|
tooltipKeys={["mod", "E"]}
|
||||||
}
|
tooltipSide="left"
|
||||||
/>
|
style={styles.headerActionButton}
|
||||||
) : (
|
accessible
|
||||||
<Folder
|
accessibilityRole="button"
|
||||||
size={theme.iconSize.lg}
|
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
|
||||||
color={
|
accessibilityState={{ expanded: isExplorerOpen }}
|
||||||
isExplorerOpen
|
>
|
||||||
? theme.colors.foreground
|
{({ hovered }) => {
|
||||||
: theme.colors.foregroundMuted
|
const color = isExplorerOpen || hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||||
}
|
return <PanelRight size={theme.iconSize.md} color={color} />;
|
||||||
/>
|
}}
|
||||||
)
|
</HeaderToggleButton>
|
||||||
) : (
|
) : null}
|
||||||
<PanelRight
|
{isMobile ? (
|
||||||
size={theme.iconSize.md}
|
<HeaderToggleButton
|
||||||
color={
|
testID="workspace-explorer-toggle"
|
||||||
isExplorerOpen
|
onPress={handleToggleExplorer}
|
||||||
? theme.colors.foreground
|
tooltipLabel="Toggle explorer"
|
||||||
: theme.colors.foregroundMuted
|
tooltipKeys={["mod", "E"]}
|
||||||
}
|
tooltipSide="left"
|
||||||
/>
|
style={styles.headerActionButton}
|
||||||
)}
|
accessible
|
||||||
</HeaderToggleButton>
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
|
||||||
|
accessibilityState={{ expanded: isExplorerOpen }}
|
||||||
|
>
|
||||||
|
{({ hovered }) => {
|
||||||
|
const color = isExplorerOpen || hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||||
|
return isGitCheckout
|
||||||
|
? <GitBranch size={theme.iconSize.lg} color={color} />
|
||||||
|
: <Folder size={theme.iconSize.lg} color={color} />;
|
||||||
|
}}
|
||||||
|
</HeaderToggleButton>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -1486,36 +1529,6 @@ function WorkspaceScreenContent({
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
|
||||||
<TooltipTrigger
|
|
||||||
testID="workspace-new-terminal-tab"
|
|
||||||
onPress={() => handleSelectNewTabOption(NEW_TAB_TERMINAL_OPTION_ID)}
|
|
||||||
onHoverIn={() => setIsNewTerminalHovered(true)}
|
|
||||||
onHoverOut={() => setIsNewTerminalHovered(false)}
|
|
||||||
disabled={createTerminalMutation.isPending}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="New terminal tab"
|
|
||||||
style={({ hovered, pressed }) => [
|
|
||||||
styles.newTabActionButton,
|
|
||||||
createTerminalMutation.isPending && styles.newTabActionButtonDisabled,
|
|
||||||
(hovered || pressed) && styles.newTabActionButtonHovered,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{createTerminalMutation.isPending ? (
|
|
||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
|
||||||
) : (
|
|
||||||
<View style={styles.terminalPlusIcon}>
|
|
||||||
<SquareTerminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
||||||
<View style={[styles.terminalPlusBadge, isNewTerminalHovered && styles.terminalPlusBadgeHovered]}>
|
|
||||||
<Plus size={10} color={theme.colors.foregroundMuted} />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" align="end" offset={8}>
|
|
||||||
<Text style={styles.newTabTooltipText}>New terminal tab</Text>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Combobox
|
<Combobox
|
||||||
@@ -1568,10 +1581,6 @@ function WorkspaceScreenContent({
|
|||||||
onCloseOtherTabs={handleCloseOtherTabs}
|
onCloseOtherTabs={handleCloseOtherTabs}
|
||||||
onSelectNewTabOption={handleSelectNewTabOption}
|
onSelectNewTabOption={handleSelectNewTabOption}
|
||||||
newTabAgentOptionId={NEW_TAB_AGENT_OPTION_ID}
|
newTabAgentOptionId={NEW_TAB_AGENT_OPTION_ID}
|
||||||
newTabTerminalOptionId={NEW_TAB_TERMINAL_OPTION_ID}
|
|
||||||
createTerminalPending={createTerminalMutation.isPending}
|
|
||||||
isNewTerminalHovered={isNewTerminalHovered}
|
|
||||||
setIsNewTerminalHovered={setIsNewTerminalHovered}
|
|
||||||
onReorderTabs={handleReorderTabs}
|
onReorderTabs={handleReorderTabs}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1654,13 +1663,49 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
headerRight: {
|
headerRight: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[1],
|
gap: {
|
||||||
|
xs: theme.spacing[1],
|
||||||
|
md: theme.spacing[2],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
headerActionButton: {
|
headerActionButton: {
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
},
|
},
|
||||||
|
sourceControlButton: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
paddingHorizontal: theme.spacing[1],
|
||||||
|
paddingVertical: theme.spacing[1],
|
||||||
|
minHeight: Math.ceil(theme.fontSize.sm * 1.5) + theme.spacing[1] * 2,
|
||||||
|
minWidth: Math.ceil(theme.fontSize.sm * 1.5) + theme.spacing[1] * 2,
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
},
|
||||||
|
sourceControlButtonWithStats: {
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
},
|
||||||
|
sourceControlButtonHovered: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
diffStatRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
diffStatAdditions: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
color: theme.colors.palette.green[400],
|
||||||
|
},
|
||||||
|
diffStatDeletions: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
color: theme.colors.palette.red[500],
|
||||||
|
},
|
||||||
newTabActions: {
|
newTabActions: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -1679,34 +1724,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
newTabActionButtonHovered: {
|
newTabActionButtonHovered: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
newTabActionButtonDisabled: {
|
|
||||||
opacity: 0.6,
|
|
||||||
},
|
|
||||||
newTabTooltipText: {
|
newTabTooltipText: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
color: theme.colors.popoverForeground,
|
color: theme.colors.popoverForeground,
|
||||||
},
|
},
|
||||||
terminalPlusIcon: {
|
|
||||||
position: "relative",
|
|
||||||
width: theme.iconSize.sm,
|
|
||||||
height: theme.iconSize.sm,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
terminalPlusBadge: {
|
|
||||||
position: "absolute",
|
|
||||||
right: -5,
|
|
||||||
bottom: -5,
|
|
||||||
width: 12,
|
|
||||||
height: 12,
|
|
||||||
borderRadius: 6,
|
|
||||||
backgroundColor: theme.colors.surface1,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
terminalPlusBadgeHovered: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
mobileTabsRow: {
|
mobileTabsRow: {
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: theme.colors.border,
|
borderBottomColor: theme.colors.border,
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
"@tanstack/react-router": "^1.120.3",
|
"@tanstack/react-router": "^1.120.3",
|
||||||
"@tanstack/react-start": "^1.120.3",
|
"@tanstack/react-start": "^1.120.3",
|
||||||
"framer-motion": "^12.35.2",
|
"framer-motion": "^12.35.2",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.4",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"wrangler": "^4.59.1"
|
"wrangler": "^4.59.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -109,9 +109,11 @@ function Home() {
|
|||||||
{/* Content section */}
|
{/* Content section */}
|
||||||
<div className="bg-black">
|
<div className="bg-black">
|
||||||
<main className="p-6 md:p-20 md:pt-8 max-w-5xl mx-auto">
|
<main className="p-6 md:p-20 md:pt-8 max-w-5xl mx-auto">
|
||||||
<Features />
|
<div className="space-y-24">
|
||||||
<CLISection />
|
<Features />
|
||||||
<FAQ />
|
<CLISection />
|
||||||
|
<FAQ />
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<footer className="p-6 md:p-20 md:pt-0 max-w-5xl mx-auto">
|
<footer className="p-6 md:p-20 md:pt-0 max-w-5xl mx-auto">
|
||||||
<div className="border-t border-white/10 pt-6">
|
<div className="border-t border-white/10 pt-6">
|
||||||
@@ -231,17 +233,55 @@ function Differentiator({ title, description }: { title: string; description: st
|
|||||||
|
|
||||||
function Features() {
|
function Features() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-10">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
|
{/* Primary differentiators - 2x2 */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-8">
|
||||||
{[
|
{[
|
||||||
{ title: 'Self-hosted', description: 'The daemon runs on your laptop, home server, or VPS. Allowing you to take full advantage of your dev environment.' },
|
{
|
||||||
{ title: 'Multi-provider', description: 'Works with existing agent harnesses like Claude Code, Codex, and OpenCode from one interface.' },
|
title: 'Self-hosted',
|
||||||
{ title: 'Multi-host', description: 'Connect to multiple daemons and see all your agents in one place.' },
|
description: 'Agents run on your machine with your full dev environment. Use your tools, your configs and your skills.',
|
||||||
{ title: 'First-class voice', description: 'Real-time voice conversations and dictation. Talk to your agent, hear responses, and orchestrate work hands-free.' },
|
icon: (
|
||||||
{ title: 'Optional relay', description: 'Use the hosted end-to-end encrypted relay for remote access, or connect directly over your network.' },
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
{ title: 'Cross-device', description: 'Jump seamlessly between iOS, Android, desktop, web, and CLI.' },
|
<rect x="2" y="2" width="20" height="8" rx="2" ry="2" />
|
||||||
{ title: 'Git integration', description: 'Manage agents in isolated worktrees. Review diffs and ship directly from the app.' },
|
<rect x="2" y="14" width="20" height="8" rx="2" ry="2" />
|
||||||
{ title: 'Open source', description: 'Free and open source. Run it yourself, fork it, contribute.' },
|
<line x1="6" y1="6" x2="6.01" y2="6" />
|
||||||
|
<line x1="6" y1="18" x2="6.01" y2="18" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Multi-provider',
|
||||||
|
description: 'Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.',
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M16 3h5v5" />
|
||||||
|
<path d="M8 3H3v5" />
|
||||||
|
<path d="M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3" />
|
||||||
|
<path d="m15 9 6-6" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Voice control',
|
||||||
|
description: 'Dictate tasks or talk through problems in voice mode. Hands-free when you need it.',
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
|
||||||
|
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||||
|
<line x1="12" x2="12" y1="19" y2="22" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Cross-device',
|
||||||
|
description: 'iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.',
|
||||||
|
icon: (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
|
||||||
|
<line x1="12" y1="18" x2="12.01" y2="18" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
].map((feature, i) => (
|
].map((feature, i) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={feature.title}
|
key={feature.title}
|
||||||
@@ -249,13 +289,31 @@ function Features() {
|
|||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true, margin: '-40px' }}
|
viewport={{ once: true, margin: '-40px' }}
|
||||||
transition={{ duration: 0.4, delay: i * 0.06, ease: 'easeOut' }}
|
transition={{ duration: 0.4, delay: i * 0.06, ease: 'easeOut' }}
|
||||||
className="space-y-1"
|
className="space-y-2"
|
||||||
>
|
>
|
||||||
<p className="font-medium text-base">{feature.title}</p>
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-sm text-white/60">{feature.description}</p>
|
<span className="text-white/40">{feature.icon}</span>
|
||||||
|
<p className="font-medium text-lg">{feature.title}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-white/60 leading-relaxed">{feature.description}</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Also */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
whileInView={{ opacity: 1 }}
|
||||||
|
viewport={{ once: true, margin: '-40px' }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.25, ease: 'easeOut' }}
|
||||||
|
className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-white/40"
|
||||||
|
>
|
||||||
|
<span>Multi-host</span>
|
||||||
|
<span>Built-in terminal</span>
|
||||||
|
<span>Git worktrees</span>
|
||||||
|
<span>E2E encrypted relay</span>
|
||||||
|
<span>Open source</span>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -277,7 +335,7 @@ function GetStarted() {
|
|||||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-white hover:bg-white/10 transition-colors"
|
className="inline-flex items-center justify-center gap-2 rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-white hover:bg-white/10 transition-colors"
|
||||||
>
|
>
|
||||||
<GlobeIcon className="h-4 w-4" />
|
<GlobeIcon className="h-4 w-4" />
|
||||||
Launch Web App
|
Web App
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="https://apps.apple.com/app/paseo-pocket-engineer/id6758887924"
|
href="https://apps.apple.com/app/paseo-pocket-engineer/id6758887924"
|
||||||
@@ -836,23 +894,12 @@ const cliExamples: CLIExample[] = [
|
|||||||
title: 'Launch and monitor',
|
title: 'Launch and monitor',
|
||||||
description: 'Give an agent a task and watch it work. The --worktree flag spins up an isolated git branch so you can run multiple agents on the same repo without conflicts.',
|
description: 'Give an agent a task and watch it work. The --worktree flag spins up an isolated git branch so you can run multiple agents on the same repo without conflicts.',
|
||||||
code:
|
code:
|
||||||
`paseo run "implement user authentication"
|
`paseo run --provider claude/opus-4.6 "implement user authentication"
|
||||||
paseo run --worktree feature-x "implement feature X"
|
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
|
||||||
|
|
||||||
paseo ls # list running agents
|
paseo ls # list running agents
|
||||||
paseo attach abc123 # stream live output
|
paseo attach abc123 # stream live output
|
||||||
paseo send abc123 "also add tests" # follow-up task`,
|
paseo send abc123 "also add tests" # follow-up task`,
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Multi-provider',
|
|
||||||
description: 'Same commands, any agent. Pick a provider and model, or mix them in the same workflow.',
|
|
||||||
code:
|
|
||||||
`paseo run --provider claude/opus-4.6 "refactor the auth module"
|
|
||||||
paseo run --provider codex/gpt-5.4 "write integration tests"
|
|
||||||
|
|
||||||
# Use different models for different jobs
|
|
||||||
paseo run --detach --provider codex/gpt-5.4 "implement the feature" --name worker
|
|
||||||
paseo run --provider claude/opus-4.6 "review the worker branch for issues"`,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Orchestration',
|
title: 'Orchestration',
|
||||||
@@ -923,7 +970,7 @@ function CLISection() {
|
|||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true, margin: '-60px' }}
|
viewport={{ once: true, margin: '-60px' }}
|
||||||
transition={{ duration: 0.5, ease: 'easeOut' }}
|
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||||
className="pt-16 space-y-6"
|
className="space-y-6"
|
||||||
>
|
>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h2 className="text-2xl font-medium">CLI</h2>
|
<h2 className="text-2xl font-medium">CLI</h2>
|
||||||
@@ -973,22 +1020,59 @@ function FAQ() {
|
|||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true, margin: '-60px' }}
|
viewport={{ once: true, margin: '-60px' }}
|
||||||
transition={{ duration: 0.5, ease: 'easeOut' }}
|
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||||
className="pt-16 space-y-6"
|
className="space-y-6"
|
||||||
>
|
>
|
||||||
<h2 className="text-2xl font-medium">FAQ</h2>
|
<h2 className="text-2xl font-medium">FAQ</h2>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<FAQItem question="Is this free?">
|
<FAQItem question="Is this free?">
|
||||||
Paseo is free and open source. It wraps CLI tools like Claude Code and Codex, which you'll
|
Yes. Paseo is free and open source. You need Claude Code, Codex, or OpenCode installed
|
||||||
need to have installed and configured with your own credentials. Voice is local-first by
|
with your own credentials. Voice is local-first by default and can optionally use OpenAI
|
||||||
default and can optionally use OpenAI speech providers if you configure them.
|
speech providers if you configure them.
|
||||||
</FAQItem>
|
</FAQItem>
|
||||||
<FAQItem question="Does my code leave my machine?">
|
<FAQItem question="Does my code leave my machine?">
|
||||||
Paseo itself doesn't send your code anywhere. Agents run locally and communicate with
|
Paseo doesn't send your code anywhere. Agents run locally and talk to their own APIs
|
||||||
their own APIs as they normally would. We provide an optional end-to-end encrypted relay
|
as they normally would. For remote access, you can use the optional{' '}
|
||||||
for remote access, but you can also connect directly over your local network or use your
|
<a href="/docs/security" className="underline hover:text-white/80">end-to-end encrypted relay</a>,
|
||||||
own tunnel.
|
connect directly over your local network, or use your own tunnel.
|
||||||
|
</FAQItem>
|
||||||
|
<FAQItem question="What agents does it support?">
|
||||||
|
Claude Code, Codex, and OpenCode. Each agent runs as its own process using its own CLI.
|
||||||
|
Paseo doesn't modify or wrap their behavior.
|
||||||
|
</FAQItem>
|
||||||
|
<FAQItem question="Do I need the desktop app?">
|
||||||
|
No. You can run the daemon headless with{' '}
|
||||||
|
<code className="font-mono text-white/50">npm install -g @getpaseo/cli && paseo</code>{' '}
|
||||||
|
and use the CLI, web app, or mobile app to connect. The desktop app just bundles the
|
||||||
|
daemon with a UI.
|
||||||
|
</FAQItem>
|
||||||
|
<FAQItem question="How does voice work?">
|
||||||
|
Voice runs locally on your device by default. You talk, the app transcribes and sends it
|
||||||
|
to your agent as text. Optionally, you can configure OpenAI speech providers for
|
||||||
|
higher-quality transcription and text-to-speech.
|
||||||
|
See the <a href="/docs/voice" className="underline hover:text-white/80">voice docs</a>.
|
||||||
|
</FAQItem>
|
||||||
|
<FAQItem question="Can I connect from outside my network?">
|
||||||
|
Yes. You can use the hosted relay (end-to-end encrypted, Paseo can't read your traffic),
|
||||||
|
set up your own tunnel (Tailscale, Cloudflare Tunnel, etc.), or expose the daemon port directly.
|
||||||
|
See <a href="/docs/configuration" className="underline hover:text-white/80">configuration</a>.
|
||||||
|
</FAQItem>
|
||||||
|
<FAQItem question="Do I need git or GitHub?">
|
||||||
|
No. Paseo works in any directory. Worktrees are optional and only relevant if you use git.
|
||||||
|
You can run agents anywhere you'd normally work.
|
||||||
|
</FAQItem>
|
||||||
|
<FAQItem question="Can I get banned for using Paseo?">
|
||||||
|
<p>We can't make promises on behalf of providers.</p>
|
||||||
|
<p>That said, Paseo launches the official first-party CLIs (Claude Code, Codex, OpenCode) as
|
||||||
|
subprocesses. It doesn't extract tokens or call inference APIs directly. From the provider's
|
||||||
|
perspective, usage through Paseo is indistinguishable from running the CLI yourself.</p>
|
||||||
|
<p>I've been using Paseo with all providers for months without issue.</p>
|
||||||
|
</FAQItem>
|
||||||
|
<FAQItem question="How do worktrees work?">
|
||||||
|
When you launch an agent with the worktree option (from the app, desktop, or CLI), Paseo
|
||||||
|
creates a git worktree and runs the agent inside it. The agent works on an isolated branch
|
||||||
|
without touching your main working directory. See the{' '}
|
||||||
|
<a href="/docs/worktrees" className="underline hover:text-white/80">worktrees docs</a>.
|
||||||
</FAQItem>
|
</FAQItem>
|
||||||
<FAQItem question="What agents does it support?">Claude Code, Codex, and OpenCode.</FAQItem>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user