Refactor git actions and update React to 19.1.4

This commit is contained in:
Mohamed Boudra
2026-03-10 15:49:33 +07:00
parent 2b17aa1a1d
commit 845cf68d38
25 changed files with 1660 additions and 620 deletions
+98 -40
View File
@@ -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
View File
@@ -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"
+9
View File
@@ -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("./") ||
+3 -3
View File
@@ -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",
+8 -18
View File
@@ -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],
},
}));
+18 -253
View File
@@ -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>
);
}
+2 -2
View File
@@ -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>
</> </>
)} )}
+203 -17
View File
@@ -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,
+445
View File
@@ -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,
+2 -2
View File
@@ -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"
}, },
+124 -40
View File
@@ -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>
) )