chore: land multi-daemon rollout

This commit is contained in:
Mohamed Boudra
2025-11-26 08:47:49 +00:00
parent 4ab79b1a50
commit 59967aad87
18 changed files with 758 additions and 259 deletions

View File

@@ -1 +1 @@
nodejs 22.20.0
nodejs 25.2.1

52
package-lock.json generated
View File

@@ -146,6 +146,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1527,6 +1528,7 @@
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
@@ -5067,7 +5069,6 @@
"integrity": "sha512-6rqTdFt67AAAzln3NOKsXRmv5ZzPkgbfaebKBqUbts7vK1GZudqnrun5a8d3M/h955cam9RHZ6Jb4Y1XhnmFPg==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"ajv": "^6.12.6",
"content-type": "^1.0.5",
@@ -5092,7 +5093,6 @@
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
@@ -5107,7 +5107,6 @@
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
@@ -5129,7 +5128,6 @@
"integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"safe-buffer": "5.2.1"
},
@@ -5143,7 +5141,6 @@
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=6.6.0"
}
@@ -5154,7 +5151,6 @@
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.0",
@@ -5198,7 +5194,6 @@
"integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
@@ -5217,7 +5212,6 @@
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -5228,7 +5222,6 @@
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
@@ -5242,7 +5235,6 @@
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -5253,7 +5245,6 @@
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=18"
},
@@ -5267,7 +5258,6 @@
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"mime-db": "^1.54.0"
},
@@ -5281,7 +5271,6 @@
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -5292,7 +5281,6 @@
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"license": "BSD-3-Clause",
"optional": true,
"peer": true,
"dependencies": {
"side-channel": "^1.1.0"
},
@@ -5309,7 +5297,6 @@
"integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"debug": "^4.3.5",
"encodeurl": "^2.0.0",
@@ -5333,7 +5320,6 @@
"integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
@@ -5350,7 +5336,6 @@
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
@@ -6520,6 +6505,7 @@
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.18.tgz",
"integrity": "sha512-DZgd6860dxcq3YX7UzIXeBr6m3UgXvo9acxp5jiJyIZXdR00Br9JwVkO7e0bUeTA2d3Z8dsmtAR84Y86NnH64Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-navigation/core": "^7.12.4",
"escape-string-regexp": "^4.0.0",
@@ -7209,6 +7195,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.23.tgz",
"integrity": "sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -7243,6 +7230,7 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -7364,6 +7352,7 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
@@ -8052,6 +8041,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -8112,6 +8102,7 @@
"resolved": "https://registry.npmjs.org/ai/-/ai-5.0.78.tgz",
"integrity": "sha512-ec77fmQwJGLduswMrW4AAUGSOiu8dZaIwMmWHHGKsrMUFFS6ugfkTyx0srtuKYHNRRLRC2dT7cPirnUl98VnxA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@ai-sdk/gateway": "2.0.1",
"@ai-sdk/provider": "2.0.0",
@@ -9000,6 +8991,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -10484,6 +10476,7 @@
"integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
@@ -11048,6 +11041,7 @@
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -11244,6 +11238,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -11614,6 +11609,7 @@
"resolved": "https://registry.npmjs.org/expo/-/expo-54.0.20.tgz",
"integrity": "sha512-mWHky+H63W60P5Oo+VbtqzF2sLvdaoSSwG57H9rlq1DrgIla++QJZuwJkXXo55lYPymVmkVhwG6FjWYKKylwpw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.20.0",
"@expo/cli": "54.0.13",
@@ -11786,6 +11782,7 @@
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.10.tgz",
"integrity": "sha512-Rhtv+X974k0Cahmvx6p7ER5+pNhBC0XbP1lRviL2J1Xl4sT2FBaIuIxF/0I0CbhOsySf0ksqc5caFweAy9Ewiw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@expo/config": "~12.0.10",
"@expo/env": "~2.0.7"
@@ -11866,6 +11863,7 @@
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.9.tgz",
"integrity": "sha512-xCoQbR/36qqB6tew/LQ6GWICpaBmHLhg/Loix5Rku/0ZtNaXMJv08M9o1AcrdiGTn/Xf/BnLu6DgS45cWQEHZg==",
"license": "MIT",
"peer": true,
"dependencies": {
"fontfaceobserver": "^2.1.0"
},
@@ -11943,6 +11941,7 @@
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.8.tgz",
"integrity": "sha512-MyeMcbFDKhXh4sDD1EHwd0uxFQNAc6VCrwBkNvvvufUsTYFq3glTA9Y8a+x78CPpjNqwNAamu74yIaIz7IEJyg==",
"license": "MIT",
"peer": true,
"dependencies": {
"expo-constants": "~18.0.8",
"invariant": "^2.2.4"
@@ -13248,6 +13247,7 @@
"integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"engines": {
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
}
@@ -14771,6 +14771,7 @@
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
"integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==",
"license": "MPL-2.0",
"peer": true,
"dependencies": {
"detect-libc": "^2.0.3"
},
@@ -17236,6 +17237,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -17276,6 +17278,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -17312,6 +17315,7 @@
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.5.tgz",
"integrity": "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/create-cache-key-function": "^29.7.0",
"@react-native/assets-registry": "0.81.5",
@@ -17401,6 +17405,7 @@
"resolved": "https://registry.npmjs.org/react-native-edge-to-edge/-/react-native-edge-to-edge-1.7.0.tgz",
"integrity": "sha512-ERegbsq28yoMndn/Uq49i4h6aAhMvTEjOfkFh50yX9H/dMjjCr/Tix/es/9JcPRvC+q7VzCMWfxWDUb6Jrq1OQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "*",
"react-native": "*"
@@ -17420,6 +17425,7 @@
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz",
"integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@egjs/hammerjs": "^2.0.17",
"hoist-non-react-statics": "^3.3.0",
@@ -17476,6 +17482,7 @@
"integrity": "sha512-+/uVS7FQwOiKYZQERMIvBRv5/X3CVHrFG6Nr/kIhVfVxGeUimHnBz7cgA97lJKIn7AKDRWL+UjLedW8pGOt0dg==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "*",
"react-native": "*"
@@ -17502,6 +17509,7 @@
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.3.tgz",
"integrity": "sha512-GP8wsi1u3nqvC1fMab/m8gfFwFyldawElCcUSBJQgfrXeLmsPPUOpDw44lbLeCpcwUuLa05WTVePdTEwCLTUZg==",
"license": "MIT",
"peer": true,
"dependencies": {
"react-native-is-edge-to-edge": "^1.2.1",
"semver": "7.7.2"
@@ -17530,6 +17538,7 @@
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.1.tgz",
"integrity": "sha512-/wJE58HLEAkATzhhX1xSr+fostLsK8Q97EfpfMDKo8jlOc1QKESSX/FQrhk7HhQH/2uSaox4Y86sNaI02kteiA==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "*",
"react-native": "*"
@@ -17540,6 +17549,7 @@
"resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.16.0.tgz",
"integrity": "sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"react-freeze": "^1.0.0",
"react-native-is-edge-to-edge": "^1.2.1",
@@ -17555,6 +17565,7 @@
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.14.0.tgz",
"integrity": "sha512-B3gYc7WztcOT4N54AtUutbe0Nuqqh/nkresY0fAXzUHYLsWuIu/yGiCCD3DKfAs6GLv5LFtWTu7N333Q+e3bkg==",
"license": "MIT",
"peer": true,
"dependencies": {
"css-select": "^5.1.0",
"css-tree": "^1.1.3",
@@ -17597,6 +17608,7 @@
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
"integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.6",
"@react-native/normalize-colors": "^0.74.1",
@@ -17629,6 +17641,7 @@
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.5.1.tgz",
"integrity": "sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/plugin-transform-arrow-functions": "^7.0.0-0",
"@babel/plugin-transform-class-properties": "^7.0.0-0",
@@ -17744,6 +17757,7 @@
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
"integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -19782,7 +19796,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
@@ -19941,6 +19954,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -20297,6 +20311,7 @@
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -21013,6 +21028,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -101,16 +101,16 @@ export default function LegacyAgentRedirectScreen() {
</View>
);
} else if (matches.length === 0) {
body = (
<View style={styles.centerState}>
<Text style={styles.title}>Agent not found</Text>
<Text style={styles.subtitle}>
We couldn't find "{agentId}" on any connected daemon. Make sure the daemon is online
or reopen the agent from the Home screen after it reconnects.
</Text>
<Pressable style={styles.primaryButton} onPress={handleGoHome}>
<Text style={styles.primaryButtonText}>Go Home</Text>
</Pressable>
body = (
<View style={styles.centerState}>
<Text style={styles.title}>Agent not found</Text>
<Text style={styles.subtitle}>
We could not find {agentId} on any connected daemon. Make sure the daemon is online
or reopen the agent from the Home screen after it reconnects.
</Text>
<Pressable style={styles.primaryButton} onPress={handleGoHome}>
<Text style={styles.primaryButtonText}>Go Home</Text>
</Pressable>
</View>
);
} else if (matches.length > 1) {

View File

@@ -23,7 +23,7 @@ import { useFooterControls } from "@/contexts/footer-controls-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
import { formatConnectionStatus } from "@/utils/daemons";
import { useDaemonSession, DaemonSessionUnavailableError } from "@/hooks/use-daemon-session";
import { useDaemonSession } from "@/hooks/use-daemon-session";
import { useDaemonRequest } from "@/hooks/use-daemon-request";
import type { SessionOutboundMessage } from "@server/server/messages";
@@ -78,20 +78,15 @@ function extractAgentModel(agent?: Agent | null): string | null {
}
export default function AgentScreen() {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const router = useRouter();
const { serverId, agentId } = useLocalSearchParams<{ serverId: string; agentId: string }>();
const resolvedAgentId = typeof agentId === "string" ? agentId : undefined;
const resolvedServerId = typeof serverId === "string" ? serverId : undefined;
const handleBackToHome = useCallback(() => {
router.replace("/");
}, [router]);
const { connectionStates, activeDaemonId, setActiveDaemonId } = useDaemonConnections();
let session: SessionContextValue | null = null;
const hasConnectionEntry = resolvedServerId ? connectionStates.has(resolvedServerId) : false;
useEffect(() => {
if (!resolvedServerId) {
if (!resolvedServerId || !hasConnectionEntry) {
return;
}
@@ -99,26 +94,26 @@ export default function AgentScreen() {
return;
}
setActiveDaemonId(resolvedServerId);
}, [resolvedServerId, activeDaemonId, setActiveDaemonId]);
setActiveDaemonId(resolvedServerId, { source: "agent_route" });
}, [resolvedServerId, hasConnectionEntry, activeDaemonId, setActiveDaemonId]);
try {
session = useDaemonSession(resolvedServerId, { suppressUnavailableAlert: true });
} catch (error) {
if (error instanceof DaemonSessionUnavailableError) {
session = null;
} else {
throw error;
}
}
const session = useDaemonSession(resolvedServerId, {
suppressUnavailableAlert: true,
allowUnavailable: true,
});
const connectionServerId = resolvedServerId ?? null;
const connection = connectionServerId ? connectionStates.get(connectionServerId) : null;
const serverLabel = connection?.daemon.label ?? connectionServerId ?? "Selected daemon";
const connectionStatus = connection?.status ?? "idle";
const isUnknownDaemon = Boolean(connectionServerId && !connection);
const connectionStatus = connection?.status ?? (isUnknownDaemon ? "offline" : "idle");
const connectionStatusLabel = formatConnectionStatus(connectionStatus);
const lastConnectionError = connection?.lastError ?? null;
const handleBackToHome = useCallback(() => {
router.replace("/");
}, [router]);
if (!session) {
return (
<AgentSessionUnavailableState
@@ -127,21 +122,34 @@ export default function AgentScreen() {
connectionStatus={connectionStatus}
connectionStatusLabel={connectionStatusLabel}
lastError={lastConnectionError}
isUnknownDaemon={isUnknownDaemon}
/>
);
}
const {
agents,
agentStreamState,
initializingAgents,
pendingPermissions,
initializeAgent,
refreshAgent,
setFocusedAgentId,
ws,
} = session;
const routeServerId = resolvedServerId ?? session.serverId;
return (
<AgentScreenContent
session={session}
agentId={resolvedAgentId}
routeServerId={routeServerId}
onBack={handleBackToHome}
/>
);
}
type AgentScreenContentProps = {
session: SessionContextValue;
agentId?: string;
routeServerId: string;
onBack: () => void;
};
function AgentScreenContent({ session, agentId, routeServerId, onBack }: AgentScreenContentProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const router = useRouter();
const { registerFooterControls, unregisterFooterControls } = useFooterControls();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const [menuVisible, setMenuVisible] = useState(false);
@@ -153,7 +161,18 @@ export default function AgentScreen() {
const [createAgentInitialValues, setCreateAgentInitialValues] =
useState<CreateAgentInitialValues | undefined>();
// Keyboard animation
const {
agents,
agentStreamState,
initializingAgents,
pendingPermissions,
initializeAgent,
refreshAgent,
setFocusedAgentId,
ws,
} = session;
const resolvedAgentId = agentId;
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
const bottomInset = useSharedValue(insets.bottom);
@@ -303,11 +322,11 @@ export default function AgentScreen() {
// Position menu below button using the raw coordinates from measureInWindow
const buttonBottom = y + height;
const top = buttonBottom + verticalOffset;
// If menu would go off screen, clamp to visible area
const bottomEdge = top + menuContentHeight;
const maxBottom = windowHeight - horizontalMargin;
const clampedTop = bottomEdge > maxBottom
const clampedTop = bottomEdge > maxBottom
? Math.max(verticalOffset, maxBottom - menuContentHeight)
: top;
@@ -435,7 +454,7 @@ export default function AgentScreen() {
return (
<>
<View style={styles.container}>
<BackHeader onBack={handleBackToHome} />
<BackHeader onBack={onBack} />
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Agent not found</Text>
</View>
@@ -452,7 +471,7 @@ export default function AgentScreen() {
{/* Header */}
<BackHeader
title={agent.title || "Agent"}
onBack={handleBackToHome}
onBack={onBack}
rightContent={
<View ref={menuButtonRef} collapsable={false}>
<Pressable onPress={handleOpenMenu} style={styles.menuButton}>
@@ -607,13 +626,31 @@ function AgentSessionUnavailableState({
connectionStatus,
connectionStatusLabel,
lastError,
isUnknownDaemon = false,
}: {
onBack: () => void;
serverLabel: string;
connectionStatus: ConnectionStatus;
connectionStatusLabel: string;
lastError: string | null;
isUnknownDaemon?: boolean;
}) {
if (isUnknownDaemon) {
return (
<View style={styles.container}>
<BackHeader title="Agent" onBack={onBack} />
<View style={styles.centerState}>
<Text style={styles.errorText}>
Cannot open this agent because {serverLabel} is not configured on this device.
</Text>
<Text style={styles.statusText}>
Add the daemon in Settings or open an agent on a configured server to continue.
</Text>
</View>
</View>
);
}
const isConnecting = connectionStatus === "connecting";
return (
@@ -624,12 +661,12 @@ function AgentSessionUnavailableState({
<>
<ActivityIndicator size="large" />
<Text style={styles.loadingText}>Connecting to {serverLabel}...</Text>
<Text style={styles.statusText}>We'll show this agent once the daemon is online.</Text>
<Text style={styles.statusText}>We will show this agent once the daemon is online.</Text>
</>
) : (
<>
<Text style={styles.errorText}>
Can't open this agent while {serverLabel} is {connectionStatusLabel.toLowerCase()}.
Cannot open this agent while {serverLabel} is {connectionStatusLabel.toLowerCase()}.
</Text>
<Text style={styles.statusText}>
Connect this daemon or switch to another one to continue.

View File

@@ -33,7 +33,7 @@ import type { ExplorerEntry, SessionContextValue } from "@/contexts/session-cont
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { formatConnectionStatus } from "@/utils/daemons";
import { useDaemonSession, DaemonSessionUnavailableError } from "@/hooks/use-daemon-session";
import { useDaemonSession } from "@/hooks/use-daemon-session";
export default function FileExplorerScreen() {
const {
@@ -49,18 +49,10 @@ export default function FileExplorerScreen() {
}>();
const resolvedServerId = typeof serverId === "string" ? serverId : undefined;
const { connectionStates } = useDaemonConnections();
let session: SessionContextValue | null = null;
try {
session = useDaemonSession(resolvedServerId, { suppressUnavailableAlert: true });
} catch (error) {
if (error instanceof DaemonSessionUnavailableError) {
session = null;
} else {
throw error;
}
}
const session = useDaemonSession(resolvedServerId, {
suppressUnavailableAlert: true,
allowUnavailable: true,
});
const connectionServerId = resolvedServerId ?? null;
const connection = connectionServerId ? connectionStates.get(connectionServerId) : null;
@@ -478,25 +470,6 @@ function FileExplorerContent({
);
}, [activePath, showListLoadingBanner, viewMode]);
if (!agent) {
return (
<View style={styles.container}>
<BackHeader
title="Files"
onBack={handleBackNavigation}
rightContent={
<Pressable style={styles.closeButton} onPress={handleCloseExplorer}>
<X size={18} color={theme.colors.foreground} />
</Pressable>
}
/>
<View style={styles.centerState}>
<Text style={styles.errorText}>Agent not found</Text>
</View>
</View>
);
}
const handleViewableItemsChanged = useCallback(
({ viewableItems }: { viewableItems: Array<ViewToken> }) => {
if (!agentId || viewMode !== "grid") {
@@ -549,6 +522,25 @@ function FileExplorerContent({
setThumbnailLoadingMap({});
}, [activePath, viewMode]);
if (!agent) {
return (
<View style={styles.container}>
<BackHeader
title="Files"
onBack={handleBackNavigation}
rightContent={
<Pressable style={styles.closeButton} onPress={handleCloseExplorer}>
<X size={18} color={theme.colors.foreground} />
</Pressable>
}
/>
<View style={styles.centerState}>
<Text style={styles.errorText}>Agent not found</Text>
</View>
</View>
);
}
return (
<View style={styles.container}>
<BackHeader
@@ -696,12 +688,12 @@ function FileExplorerSessionUnavailable({
<>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Connecting to {serverLabel}...</Text>
<Text style={styles.statusText}>We'll load files once this daemon is online.</Text>
<Text style={styles.statusText}>We will load files once this daemon is online.</Text>
</>
) : (
<>
<Text style={styles.errorText}>
Can't open files while {serverLabel} is {connectionStatusLabel.toLowerCase()}.
Cannot open files while {serverLabel} is {connectionStatusLabel.toLowerCase()}.
</Text>
<Text style={styles.statusText}>Connect this daemon and try again.</Text>
{lastError ? <Text style={styles.errorDetails}>{lastError}</Text> : null}

View File

@@ -7,7 +7,7 @@ import type { SessionContextValue } from "@/contexts/session-context";
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { formatConnectionStatus } from "@/utils/daemons";
import { useDaemonSession, DaemonSessionUnavailableError } from "@/hooks/use-daemon-session";
import { useDaemonSession } from "@/hooks/use-daemon-session";
interface ParsedDiffFile {
path: string;
@@ -58,18 +58,10 @@ export default function GitDiffScreen() {
const { agentId, serverId } = useLocalSearchParams<{ agentId: string; serverId?: string }>();
const resolvedServerId = typeof serverId === "string" ? serverId : undefined;
const { connectionStates } = useDaemonConnections();
let session: SessionContextValue | null = null;
try {
session = useDaemonSession(resolvedServerId, { suppressUnavailableAlert: true });
} catch (error) {
if (error instanceof DaemonSessionUnavailableError) {
session = null;
} else {
throw error;
}
}
const session = useDaemonSession(resolvedServerId, {
suppressUnavailableAlert: true,
allowUnavailable: true,
});
const connectionServerId = resolvedServerId ?? null;
const connection = connectionServerId ? connectionStates.get(connectionServerId) : null;
@@ -230,12 +222,12 @@ function SessionUnavailableState({
<>
<ActivityIndicator size="large" />
<Text style={styles.loadingText}>Connecting to {serverLabel}...</Text>
<Text style={styles.statusText}>We'll show changes once this session is online.</Text>
<Text style={styles.statusText}>We will show changes once this session is online.</Text>
</>
) : (
<>
<Text style={styles.errorText}>
Can't load changes while {serverLabel} is {connectionStatusLabel.toLowerCase()}.
Cannot load changes while {serverLabel} is {connectionStatusLabel.toLowerCase()}.
</Text>
<Text style={styles.statusText}>Connect this daemon or switch to another one to continue.</Text>
{lastError ? <Text style={styles.errorDetails}>{lastError}</Text> : null}

View File

@@ -476,10 +476,10 @@ export default function SettingsScreen() {
};
if (daemonForm.id) {
await updateDaemon(daemonForm.id, payload);
setActiveDaemonId(daemonForm.id);
setActiveDaemonId(daemonForm.id, { source: "settings_save_daemon" });
} else {
const created = await addDaemon(payload);
setActiveDaemonId(created.id);
setActiveDaemonId(created.id, { source: "settings_save_daemon" });
}
handleCloseDaemonForm();
} catch (error) {
@@ -524,7 +524,7 @@ export default function SettingsScreen() {
const handleSetActiveDaemon = useCallback(
(profile: DaemonProfile) => {
setActiveDaemonId(profile.id);
setActiveDaemonId(profile.id, { source: "settings_switch" });
},
[setActiveDaemonId]
);
@@ -545,15 +545,15 @@ export default function SettingsScreen() {
return;
}
updateDaemonTestState(profile.id, { status: "testing" });
updateConnectionStatus(profile.id, "connecting");
updateConnectionStatus(profile.id, { status: "connecting" });
try {
await testServerConnection(url, 4000);
updateDaemonTestState(profile.id, { status: "success", message: "Reachable" });
updateConnectionStatus(profile.id, "online", { lastOnlineAt: new Date().toISOString() });
updateConnectionStatus(profile.id, { status: "online", lastOnlineAt: new Date().toISOString() });
} catch (error) {
const message = error instanceof Error ? error.message : "Connection failed";
updateDaemonTestState(profile.id, { status: "error", message });
updateConnectionStatus(profile.id, "offline", { lastError: message });
updateConnectionStatus(profile.id, { status: "offline", lastError: message });
}
},
[testServerConnection, updateConnectionStatus, updateDaemonTestState]

View File

@@ -8,7 +8,7 @@ import { getAgentStatusColor, getAgentStatusLabel } from "@/utils/agent-status";
import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { type AggregatedAgentGroup } from "@/hooks/use-aggregated-agents";
import { useDaemonSession, DaemonSessionUnavailableError } from "@/hooks/use-daemon-session";
import { useDaemonSession } from "@/hooks/use-daemon-session";
interface AgentListProps {
agentGroups: AggregatedAgentGroup[];
@@ -19,29 +19,20 @@ export function AgentList({ agentGroups }: AgentListProps) {
const { setActiveDaemonId } = useDaemonConnections();
const [actionAgent, setActionAgent] = useState<Agent | null>(null);
const [actionAgentServerId, setActionAgentServerId] = useState<string | null>(null);
let actionSession = null;
let actionSessionUnavailable = false;
try {
actionSession = useDaemonSession(actionAgentServerId ?? undefined, { suppressUnavailableAlert: true });
} catch (error) {
if (error instanceof DaemonSessionUnavailableError) {
actionSessionUnavailable = true;
actionSession = null;
} else {
throw error;
}
}
const actionSession = useDaemonSession(actionAgentServerId ?? undefined, {
suppressUnavailableAlert: true,
allowUnavailable: true,
});
const deleteAgent = actionSession?.deleteAgent;
const isActionSheetVisible = actionAgent !== null;
const isActionDaemonUnavailable = Boolean(actionAgentServerId && actionSessionUnavailable);
const isActionDaemonUnavailable = Boolean(actionAgentServerId && !actionSession);
const handleAgentPress = useCallback((serverId: string, agentId: string) => {
if (isActionSheetVisible) {
return;
}
setActiveDaemonId(serverId);
setActiveDaemonId(serverId, { source: "agent_row_press" });
router.push({
pathname: "/agent/[serverId]/[agentId]",
params: {

View File

@@ -40,9 +40,8 @@ export function AgentSidebar({
const insets = useSafeAreaInsets();
const savedTranslateX = useSharedValue(0);
const [isVisible, setIsVisible] = useState(isOpen);
// Use edgeSwipeTranslateX directly since it's always provided
const translateX = edgeSwipeTranslateX || useSharedValue(-300);
const fallbackTranslateX = useSharedValue(-300);
const translateX = edgeSwipeTranslateX ?? fallbackTranslateX;
useEffect(() => {
if (isOpen) {

View File

@@ -49,7 +49,7 @@ import {
AGENT_PROVIDER_DEFINITIONS,
type AgentProviderDefinition,
} from "@server/server/agent/provider-manifest";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useDaemonConnections, type ConnectionStatus } from "@/contexts/daemon-connections-context";
import type {
AgentProvider,
AgentMode,
@@ -61,8 +61,9 @@ import type {
import { useDaemonRequest } from "@/hooks/use-daemon-request";
import type { WSInboundMessage, SessionOutboundMessage } from "@server/server/messages";
import { formatConnectionStatus } from "@/utils/daemons";
import { useSession } from "@/contexts/session-context";
import { useSessionForServer } from "@/hooks/use-session-directory";
import { trackAnalyticsEvent } from "@/utils/analytics";
import { useSession, type SessionContextValue } from "@/contexts/session-context";
import { useSessionDirectory, useSessionForServer } from "@/hooks/use-session-directory";
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
export type CreateAgentInitialValues = {
@@ -230,6 +231,7 @@ function AgentFlowModal({
const selectedSession = useSessionForServer(selectedServerId);
const session =
selectedServerId === null ? activeSession : selectedSession ?? null;
const sessionDirectory = useSessionDirectory();
useEffect(() => {
if (!selectedServerId) {
@@ -307,21 +309,29 @@ function AgentFlowModal({
const isWsConnected = ws?.isConnected ?? false;
const router = useRouter();
const activeServerId = session?.serverId ?? null;
const selectedDaemonConnection = selectedServerId
? connectionStates.get(selectedServerId)
const selectedDaemonId = selectedServerId ?? activeServerId;
const selectedDaemonConnection = selectedDaemonId
? connectionStates.get(selectedDaemonId)
: null;
const selectedDaemonStatus: ConnectionStatus =
selectedDaemonConnection?.status ??
(ws?.isConnected
? "online"
: ws?.isConnecting
? "connecting"
: ws?.lastError
? "error"
: "offline");
const selectedDaemonLabel =
selectedDaemonConnection?.daemon.label ??
selectedDaemonConnection?.daemon.wsUrl ??
selectedServerId ??
selectedDaemonId ??
"Selected daemon";
const selectedDaemonStatusLabel = selectedDaemonConnection
? formatConnectionStatus(selectedDaemonConnection.status)
: "offline";
const selectedDaemonIsUnavailable = Boolean(
selectedServerId &&
(!session || selectedDaemonConnection?.status !== "online")
);
const selectedDaemonStatusLabel = formatConnectionStatus(selectedDaemonStatus);
const selectedDaemonIsUnavailable =
!session || selectedDaemonStatus !== "online" || !ws?.isConnected;
const isBackgroundDaemonTarget =
Boolean(selectedDaemonId && activeDaemonId && selectedDaemonId !== activeDaemonId);
const selectedDaemonLastError = selectedDaemonConnection?.lastError?.trim();
const daemonAvailabilityError = selectedDaemonIsUnavailable
? `${selectedDaemonLabel} is ${selectedDaemonStatusLabel}. Connect to it before creating or importing agents.${
@@ -390,6 +400,12 @@ function AgentFlowModal({
const shouldListenForDictation =
isCreateFlow && (isVisible || hasPendingDictation);
const providerModelRequestTimersRef = useRef<
Map<string, ReturnType<typeof setTimeout>>
>(new Map());
const idleProviderPrefetchHandleRef = useRef<ReturnType<
typeof InteractionManager.runAfterInteractions
> | null>(null);
const pendingNavigationAgentIdRef = useRef<string | null>(null);
const openDropdownSheet = useCallback((key: DropdownKey) => {
setOpenDropdown(key);
@@ -397,6 +413,46 @@ function AgentFlowModal({
const closeDropdown = useCallback(() => {
setOpenDropdown(null);
}, []);
const clearQueuedProviderModelRequest = useCallback((serverId: string | null) => {
if (!serverId) {
return;
}
const timer = providerModelRequestTimersRef.current.get(serverId);
if (timer) {
clearTimeout(timer);
providerModelRequestTimersRef.current.delete(serverId);
}
}, []);
const queueProviderModelFetch = useCallback(
(
serverId: string | null,
targetSession: SessionContextValue | null,
options?: { cwd?: string; delayMs?: number }
) => {
if (!serverId || !targetSession?.requestProviderModels) {
clearQueuedProviderModelRequest(serverId);
return;
}
const currentState = targetSession.providerModels?.get(selectedProvider);
if (currentState?.models?.length || currentState?.isLoading) {
clearQueuedProviderModelRequest(serverId);
return;
}
const delayMs = options?.delayMs ?? 0;
const trigger = () => {
providerModelRequestTimersRef.current.delete(serverId);
targetSession.requestProviderModels(selectedProvider, options?.cwd ? { cwd: options.cwd } : undefined);
};
clearQueuedProviderModelRequest(serverId);
if (delayMs > 0) {
providerModelRequestTimersRef.current.set(serverId, setTimeout(trigger, delayMs));
} else {
trigger();
}
},
[clearQueuedProviderModelRequest, selectedProvider]
);
const setProviderFromUser = useCallback((provider: AgentProvider) => {
userEditedPreferencesRef.current.provider = true;
@@ -586,18 +642,28 @@ function AgentFlowModal({
const modelError = modelState?.error ?? null;
useEffect(() => {
if (!isVisible || !providerModels || !requestProviderModels || !isTargetDaemonReady) {
const targetServerId = selectedServerId ?? session?.serverId ?? null;
if (!isVisible || !isTargetDaemonReady || !targetServerId || !session) {
clearQueuedProviderModelRequest(targetServerId);
return;
}
const trimmed = workingDir.trim();
const currentState = providerModels.get(selectedProvider);
if (currentState?.models?.length || currentState?.isLoading) {
return;
}
requestProviderModels(selectedProvider, {
queueProviderModelFetch(targetServerId, session, {
cwd: trimmed.length > 0 ? trimmed : undefined,
delayMs: 180,
});
}, [isVisible, providerModels, requestProviderModels, selectedProvider, workingDir, isTargetDaemonReady]);
return () => {
clearQueuedProviderModelRequest(targetServerId);
};
}, [
clearQueuedProviderModelRequest,
isTargetDaemonReady,
isVisible,
queueProviderModelFetch,
selectedServerId,
session,
workingDir,
]);
const setPromptHeight = useCallback((nextHeight: number) => {
const bounded = Math.min(
PROMPT_MAX_HEIGHT,
@@ -790,6 +856,51 @@ function AgentFlowModal({
focusPromptInput();
}, [focusPromptInput, isCreateFlow, isVisible, shouldAutoFocusPrompt]);
const logOfflineDaemonAction = useCallback(
(action: "create" | "resume" | "dictation" | "import_list", reason?: string | null) => {
trackAnalyticsEvent({
type: "offline_daemon_action_attempt",
action,
daemonId: selectedDaemonId,
activeDaemonId,
status: selectedDaemonStatus ?? null,
isBackground: isBackgroundDaemonTarget,
reason: reason ?? daemonAvailabilityError,
});
},
[
activeDaemonId,
daemonAvailabilityError,
isBackgroundDaemonTarget,
selectedDaemonId,
selectedDaemonStatus,
]
);
const logBackgroundAgentAction = useCallback(
(
action: "create" | "resume",
payload: { cwd?: string; provider?: string; modeId?: string; model?: string; baseBranch?: string }
) => {
if (!isBackgroundDaemonTarget || !selectedDaemonId) {
return;
}
trackAnalyticsEvent({
type: "background_agent_action",
action,
daemonId: selectedDaemonId,
activeDaemonId,
isBackground: true,
cwd: payload.cwd,
provider: payload.provider,
modeId: payload.modeId,
model: payload.model,
baseBranch: payload.baseBranch,
});
},
[activeDaemonId, isBackgroundDaemonTarget, selectedDaemonId]
);
const resetFormState = useCallback(() => {
setInitialPrompt("");
promptBaselineHeightRef.current = null;
@@ -820,7 +931,14 @@ function AgentFlowModal({
}, [cancelRepoInfo, resetRepoInfo, setPromptHeight]);
const handleDictationStart = useCallback(async () => {
if (!isCreateFlow || isLoading || !isWsConnected || isDictating || isDictationProcessing) {
if (!isCreateFlow || isLoading || isDictating || isDictationProcessing) {
return;
}
if (!isTargetDaemonReady || !isWsConnected) {
logOfflineDaemonAction(
"dictation",
daemonAvailabilityError ?? "WebSocket disconnected"
);
return;
}
try {
@@ -834,7 +952,18 @@ function AgentFlowModal({
} catch (error) {
console.error("[CreateAgentModal] Failed to start dictation:", error);
}
}, [dictationRecorder, isCreateFlow, isDictating, isDictationProcessing, isLoading, isWsConnected, shouldShowAudioDebug]);
}, [
daemonAvailabilityError,
dictationRecorder,
isCreateFlow,
isDictating,
isDictationProcessing,
isLoading,
isTargetDaemonReady,
isWsConnected,
logOfflineDaemonAction,
shouldShowAudioDebug,
]);
const handleDictationCancel = useCallback(async () => {
if (!isDictating) {
@@ -854,7 +983,8 @@ function AgentFlowModal({
if (!isDictating || isDictationProcessing) {
return;
}
if (!sendAgentAudio) {
if (!sendAgentAudio || !isTargetDaemonReady) {
logOfflineDaemonAction("dictation");
setErrorMessage(
daemonAvailabilityError ??
"Connect to the selected daemon before using dictation."
@@ -887,6 +1017,8 @@ function AgentFlowModal({
dictationRecorder,
isDictating,
isDictationProcessing,
isTargetDaemonReady,
logOfflineDaemonAction,
sendAgentAudio,
]);
@@ -898,7 +1030,7 @@ function AgentFlowModal({
pendingNavigationAgentIdRef.current = null;
if (activeDaemonId !== activeServerId) {
setActiveDaemonId(activeServerId);
setActiveDaemonId(activeServerId, { source: "agent_create_resume_nav" });
}
InteractionManager.runAfterInteractions(() => {
router.push({
@@ -926,6 +1058,7 @@ function AgentFlowModal({
(provider?: AgentProvider) => {
if (!isTargetDaemonReady || !ws || !ws.isConnected) {
setIsImportLoading(false);
logOfflineDaemonAction("import_list");
setImportError(
daemonAvailabilityError ??
"Connect to the selected daemon to load import candidates."
@@ -953,7 +1086,7 @@ function AgentFlowModal({
setImportError("Unable to load agents to import. Please try again.");
}
},
[daemonAvailabilityError, isTargetDaemonReady, ws]
[daemonAvailabilityError, isTargetDaemonReady, logOfflineDaemonAction, ws]
);
useEffect(() => {
@@ -1040,6 +1173,16 @@ function AgentFlowModal({
};
}, [dictationRecorder]);
useEffect(() => {
return () => {
providerModelRequestTimersRef.current.forEach((timer) => {
clearTimeout(timer);
});
providerModelRequestTimersRef.current.clear();
idleProviderPrefetchHandleRef.current?.cancel?.();
};
}, []);
const footerAnimatedStyle = useAnimatedStyle(() => {
"worklet";
const absoluteHeight = Math.abs(keyboardHeight.value);
@@ -1129,6 +1272,44 @@ function AgentFlowModal({
shouldSyncBaseBranchRef.current = true;
}, [isCreateFlow, isVisible, workingDir]);
useEffect(() => {
const activeServerIds = new Set(daemonEntries.map(({ daemon }) => daemon.id));
providerModelRequestTimersRef.current.forEach((timer, serverId) => {
if (!activeServerIds.has(serverId)) {
clearTimeout(timer);
providerModelRequestTimersRef.current.delete(serverId);
}
});
idleProviderPrefetchHandleRef.current?.cancel?.();
idleProviderPrefetchHandleRef.current = InteractionManager.runAfterInteractions(() => {
daemonEntries.forEach(({ daemon, status }) => {
const serverId = daemon.id;
const isSelected = serverId === selectedServerId;
const isReady = status === "online" || status === "idle";
if (isSelected || !isReady) {
clearQueuedProviderModelRequest(serverId);
return;
}
const daemonSession = sessionDirectory.get(serverId) ?? null;
if (!daemonSession?.ws?.isConnected) {
return;
}
queueProviderModelFetch(serverId, daemonSession, { delayMs: 320 });
});
});
return () => {
idleProviderPrefetchHandleRef.current?.cancel?.();
};
}, [
clearQueuedProviderModelRequest,
daemonEntries,
queueProviderModelFetch,
selectedProvider,
selectedServerId,
sessionDirectory,
]);
const trimmedWorkingDir = workingDir.trim();
const shouldInspectRepo = isCreateFlow && isVisible && trimmedWorkingDir.length > 0;
const repoAvailabilityError = shouldInspectRepo && (!isTargetDaemonReady || !isWsConnected)
@@ -1208,6 +1389,7 @@ function AgentFlowModal({
}
if (!createAgent || !isTargetDaemonReady) {
logOfflineDaemonAction("create");
setErrorMessage(
daemonAvailabilityError ??
"Connect to the selected daemon before creating an agent."
@@ -1257,10 +1439,18 @@ function AgentFlowModal({
createWorktree: true,
worktreeSlug: (worktreeSlug || branchName).trim(),
}
: {}),
: {}),
}
: undefined;
logBackgroundAgentAction("create", {
cwd: trimmedPath,
provider: selectedProvider,
modeId,
model: trimmedModel,
baseBranch: trimmedBaseBranch || undefined,
});
try {
createAgent({
config,
@@ -1285,6 +1475,8 @@ function AgentFlowModal({
repoInfo,
selectedMode,
modeOptions,
logBackgroundAgentAction,
logOfflineDaemonAction,
selectedProvider,
isLoading,
validateWorktreeName,
@@ -1300,12 +1492,17 @@ function AgentFlowModal({
return;
}
if (!resumeAgent || !isTargetDaemonReady) {
logOfflineDaemonAction("resume");
setImportError(
daemonAvailabilityError ??
"Connect to the selected daemon before importing an agent."
);
return;
}
logBackgroundAgentAction("resume", {
cwd: candidate.cwd,
provider: candidate.provider,
});
setErrorMessage("");
const requestId = generateMessageId();
pendingRequestIdRef.current = requestId;
@@ -1318,6 +1515,8 @@ function AgentFlowModal({
[
daemonAvailabilityError,
isLoading,
logBackgroundAgentAction,
logOfflineDaemonAction,
isTargetDaemonReady,
resumeAgent,
setImportError,
@@ -1955,7 +2154,7 @@ function AgentFlowModal({
<View style={styles.resumeEmptyState}>
<Text style={styles.resumeEmptyTitle}>No agents to import</Text>
<Text style={styles.resumeEmptySubtitle}>
We'll load the latest Claude and Codex sessions from your
We will load the latest Claude and Codex sessions from your
local history so you can import them.
</Text>
<Pressable
@@ -2422,7 +2621,7 @@ function WorkingDirectoryDropdown({
/>
{!hasRecentPaths && !showCustomOption ? (
<Text style={styles.helperText}>
We'll remember the directories you use most often.
We will remember the directories you use most often.
</Text>
) : null}
{showCustomOption ? (

View File

@@ -1,33 +1,67 @@
import { useMemo } from "react";
import { useEffect, useMemo, useRef } from "react";
import { SessionProvider } from "@/contexts/session-context";
import { useDaemonRegistry, type DaemonProfile } from "@/contexts/daemon-registry-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
export function MultiDaemonSessionHost() {
const { daemons } = useDaemonRegistry();
const { activeDaemonId } = useDaemonConnections();
const { activeDaemonId, sessionAccessorRoles } = useDaemonConnections();
const autoConnectStatesRef = useRef<Map<string, boolean>>(new Map());
const primaryDaemonIds = useMemo(() => {
const ids = new Set<string>();
sessionAccessorRoles.forEach((roles, daemonId) => {
if (roles.has("primary")) {
ids.add(daemonId);
}
});
return ids;
}, [sessionAccessorRoles]);
useEffect(() => {
const trackedStates = autoConnectStatesRef.current;
const activeIds = new Set<string>();
for (const daemon of daemons) {
activeIds.add(daemon.id);
const previousAutoConnect = trackedStates.get(daemon.id);
if (daemon.autoConnect === false && previousAutoConnect !== false) {
console.info("[DaemonConnection]", {
event: "auto_connect_skip",
daemonId: daemon.id,
label: daemon.label,
reason: "autoConnect disabled",
timestamp: new Date().toISOString(),
});
}
trackedStates.set(daemon.id, daemon.autoConnect);
}
for (const daemonId of Array.from(trackedStates.keys())) {
if (!activeIds.has(daemonId)) {
trackedStates.delete(daemonId);
}
}
}, [daemons]);
const backgroundDaemons = useMemo<DaemonProfile[]>(() => {
const shouldConnect = new Map<string, DaemonProfile>();
for (const daemon of daemons) {
if (daemon.autoConnect) {
shouldConnect.set(daemon.id, daemon);
if (!daemon.autoConnect) {
continue;
}
}
if (activeDaemonId) {
const activeDaemon = daemons.find((daemon) => daemon.id === activeDaemonId);
if (activeDaemon) {
shouldConnect.set(activeDaemon.id, activeDaemon);
if (daemon.id === activeDaemonId) {
continue;
}
// The active daemon already wraps the UI tree via ProvidersWrapper,
// so we skip rendering it here to avoid duplicate websocket connections.
shouldConnect.delete(activeDaemonId);
if (primaryDaemonIds.has(daemon.id)) {
continue;
}
shouldConnect.set(daemon.id, daemon);
}
return Array.from(shouldConnect.values());
}, [daemons, activeDaemonId]);
}, [daemons, activeDaemonId, primaryDaemonIds]);
if (backgroundDaemons.length === 0) {
return null;
@@ -36,7 +70,12 @@ export function MultiDaemonSessionHost() {
return (
<>
{backgroundDaemons.map((daemon) => (
<SessionProvider key={daemon.id} serverUrl={daemon.wsUrl} serverId={daemon.id}>
<SessionProvider
key={daemon.id}
serverUrl={daemon.wsUrl}
serverId={daemon.id}
role="background"
>
{null}
</SessionProvider>
))}

View File

@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useDaemonRegistry, type DaemonProfile } from "./daemon-registry-context";
import { trackAnalyticsEvent } from "@/utils/analytics";
import type { SessionContextValue } from "./session-context";
export interface SessionDirectoryEntry {
@@ -10,29 +11,44 @@ export interface SessionDirectoryEntry {
subscribe: (listener: () => void) => () => void;
}
export type ConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
export type SessionAccessorRole = "primary" | "background";
type SessionAccessorRegistry = Map<string, Map<SessionAccessorRole, SessionDirectoryEntry>>;
export interface DaemonConnectionRecord {
export type ConnectionState =
| { status: "idle"; lastError: null; lastOnlineAt: string | null }
| { status: "connecting"; lastError: null; lastOnlineAt: string | null }
| { status: "online"; lastError: null; lastOnlineAt: string }
| { status: "offline"; lastError: string | null; lastOnlineAt: string | null }
| { status: "error"; lastError: string; lastOnlineAt: string | null };
export type ConnectionStatus = ConnectionState["status"];
type ConnectionStateUpdate =
| { status: "idle" }
| { status: "connecting"; lastOnlineAt?: string | null }
| { status: "online"; lastOnlineAt: string }
| { status: "offline"; lastError?: string | null; lastOnlineAt?: string | null }
| { status: "error"; lastError: string; lastOnlineAt?: string | null };
export type DaemonConnectionRecord = {
daemon: DaemonProfile;
status: ConnectionStatus;
lastOnlineAt?: string;
lastError?: string | null;
}
} & ConnectionState;
interface DaemonConnectionsContextValue {
activeDaemonId: string | null;
activeDaemon: DaemonProfile | null;
connectionStates: Map<string, DaemonConnectionRecord>;
isLoading: boolean;
setActiveDaemonId: (daemonId: string) => void;
updateConnectionStatus: (
daemonId: string,
status: ConnectionStatus,
extras?: { lastError?: string | null; lastOnlineAt?: string }
) => void;
setActiveDaemonId: (daemonId: string, options?: SetActiveDaemonOptions) => void;
updateConnectionStatus: (daemonId: string, update: ConnectionStateUpdate) => void;
sessionAccessors: Map<string, SessionDirectoryEntry>;
registerSessionAccessor: (daemonId: string, entry: SessionDirectoryEntry) => void;
unregisterSessionAccessor: (daemonId: string) => void;
sessionAccessorRoles: Map<string, Set<SessionAccessorRole>>;
registerSessionAccessor: (
daemonId: string,
entry: SessionDirectoryEntry,
role?: SessionAccessorRole
) => void;
unregisterSessionAccessor: (daemonId: string, role?: SessionAccessorRole) => void;
subscribeToSessionDirectory: (listener: () => void) => () => void;
notifySessionDirectoryChange: () => void;
}
@@ -42,6 +58,70 @@ const DaemonConnectionsContext = createContext<DaemonConnectionsContextValue | n
const ACTIVE_DAEMON_STORAGE_KEY = "@paseo:active-daemon-id";
const ACTIVE_DAEMON_QUERY_KEY = ["active-daemon-id"];
export type SetActiveDaemonOptions = {
source?: string;
};
function createDefaultConnectionState(): ConnectionState {
return {
status: "idle",
lastError: null,
lastOnlineAt: null,
};
}
function resolveNextConnectionState(
existing: ConnectionState,
update: ConnectionStateUpdate
): ConnectionState {
switch (update.status) {
case "idle":
return { status: "idle", lastError: null, lastOnlineAt: existing.lastOnlineAt };
case "connecting":
return {
status: "connecting",
lastError: null,
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
};
case "online":
return { status: "online", lastError: null, lastOnlineAt: update.lastOnlineAt };
case "offline":
return {
status: "offline",
lastError: update.lastError ?? null,
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
};
case "error":
return {
status: "error",
lastError: update.lastError,
lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt,
};
}
}
function logConnectionLifecycle(daemon: DaemonProfile, previous: ConnectionState, next: ConnectionState) {
const logPayload = {
event: "daemon_connection_state",
daemonId: daemon.id,
label: daemon.label,
from: previous.status,
to: next.status,
lastError: next.lastError ?? null,
lastOnlineAt: next.lastOnlineAt ?? null,
timestamp: new Date().toISOString(),
};
const logger =
next.status === "error"
? console.error
: next.status === "offline"
? console.warn
: console.info;
logger("[DaemonConnection]", logPayload);
}
export function useDaemonConnections(): DaemonConnectionsContextValue {
const ctx = useContext(DaemonConnectionsContext);
if (!ctx) {
@@ -54,7 +134,7 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode })
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
const [activeDaemonId, setActiveDaemonIdState] = useState<string | null>(null);
const [connectionStates, setConnectionStates] = useState<Map<string, DaemonConnectionRecord>>(new Map());
const [sessionAccessors, setSessionAccessors] = useState<Map<string, SessionDirectoryEntry>>(new Map());
const [sessionAccessorRegistry, setSessionAccessorRegistry] = useState<SessionAccessorRegistry>(new Map());
const queryClient = useQueryClient();
const activeDaemonPreference = useQuery({
queryKey: ACTIVE_DAEMON_QUERY_KEY,
@@ -79,9 +159,7 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode })
const existing = prev.get(daemon.id);
next.set(daemon.id, {
daemon,
status: existing?.status ?? "idle",
lastOnlineAt: existing?.lastOnlineAt,
lastError: existing?.lastError,
...(existing ?? createDefaultConnectionState()),
});
}
return next;
@@ -89,14 +167,20 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode })
}, [daemons]);
useEffect(() => {
setSessionAccessors((prev) => {
const next = new Map(prev);
for (const key of Array.from(next.keys())) {
if (!daemons.some((daemon) => daemon.id === key)) {
next.delete(key);
setSessionAccessorRegistry((prev) => {
const validDaemonIds = new Set(daemons.map((daemon) => daemon.id));
let changed = false;
const next: SessionAccessorRegistry = new Map();
for (const [daemonId, roleMap] of prev.entries()) {
if (!validDaemonIds.has(daemonId)) {
changed = true;
continue;
}
next.set(daemonId, roleMap);
}
return next;
return changed ? next : prev;
});
}, [daemons]);
@@ -115,8 +199,18 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode })
}, [queryClient]);
const setActiveDaemonId = useCallback(
(daemonId: string) => {
setActiveDaemonIdState(daemonId);
(daemonId: string, options?: SetActiveDaemonOptions) => {
setActiveDaemonIdState((previous) => {
if (previous !== daemonId) {
trackAnalyticsEvent({
type: "daemon_active_changed",
daemonId,
previousDaemonId: previous,
source: options?.source,
});
}
return daemonId;
});
void persistActiveDaemonId(daemonId);
},
[persistActiveDaemonId]
@@ -138,7 +232,7 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode })
}
const fallback = daemons.find((daemon) => daemon.isDefault) ?? daemons[0];
setActiveDaemonId(fallback.id);
setActiveDaemonId(fallback.id, { source: "auto_fallback" });
}, [daemons, activeDaemonId, activeDaemonPreference.isPending, persistActiveDaemonId, setActiveDaemonId]);
const activeDaemon = useMemo(() => {
@@ -149,45 +243,65 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode })
}, [activeDaemonId, daemons]);
const updateConnectionStatus = useCallback(
(
daemonId: string,
status: ConnectionStatus,
extras?: { lastError?: string | null; lastOnlineAt?: string }
) => {
(daemonId: string, update: ConnectionStateUpdate) => {
setConnectionStates((prev) => {
const next = new Map(prev);
const existing = next.get(daemonId);
const existing = prev.get(daemonId);
if (!existing) {
return prev;
}
const hasExplicitLastError = Boolean(extras && Object.prototype.hasOwnProperty.call(extras, "lastError"));
next.set(daemonId, {
...existing,
status,
lastError: hasExplicitLastError ? (extras!.lastError ?? null) : existing.lastError ?? null,
lastOnlineAt: extras?.lastOnlineAt ?? existing.lastOnlineAt,
});
const nextState = resolveNextConnectionState(existing, update);
const hasChanged =
existing.status !== nextState.status ||
existing.lastError !== nextState.lastError ||
existing.lastOnlineAt !== nextState.lastOnlineAt;
if (hasChanged) {
logConnectionLifecycle(existing.daemon, existing, nextState);
}
const next = new Map(prev);
next.set(daemonId, { daemon: existing.daemon, ...nextState });
return next;
});
},
[]
);
const registerSessionAccessor = useCallback((daemonId: string, entry: SessionDirectoryEntry) => {
setSessionAccessors((prev) => {
const next = new Map(prev);
next.set(daemonId, entry);
return next;
});
}, []);
const registerSessionAccessor = useCallback(
(daemonId: string, entry: SessionDirectoryEntry, role: SessionAccessorRole = "primary") => {
setSessionAccessorRegistry((prev) => {
const next = new Map(prev);
const existing = new Map(next.get(daemonId) ?? []);
existing.set(role, entry);
next.set(daemonId, existing);
return next;
});
},
[]
);
const unregisterSessionAccessor = useCallback((daemonId: string) => {
setSessionAccessors((prev) => {
const next = new Map(prev);
next.delete(daemonId);
return next;
});
}, []);
const unregisterSessionAccessor = useCallback(
(daemonId: string, role: SessionAccessorRole = "primary") => {
setSessionAccessorRegistry((prev) => {
const existing = prev.get(daemonId);
if (!existing) {
return prev;
}
const nextRoleMap = new Map(existing);
nextRoleMap.delete(role);
const next = new Map(prev);
if (nextRoleMap.size === 0) {
next.delete(daemonId);
} else {
next.set(daemonId, nextRoleMap);
}
return next;
});
},
[]
);
const subscribeToSessionDirectory = useCallback((listener: () => void) => {
sessionDirectoryListenersRef.current.add(listener);
@@ -207,6 +321,34 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode })
}
}, []);
const { sessionAccessors, sessionAccessorRoles } = useMemo(() => {
const flattened = new Map<string, SessionDirectoryEntry>();
const roleSets = new Map<string, Set<SessionAccessorRole>>();
sessionAccessorRegistry.forEach((roleMap, daemonId) => {
if (roleMap.size === 0) {
return;
}
const roles = new Set<SessionAccessorRole>();
roleMap.forEach((_entry, role) => roles.add(role));
roleSets.set(daemonId, roles);
const primaryEntry = roleMap.get("primary");
if (primaryEntry) {
flattened.set(daemonId, primaryEntry);
return;
}
const backgroundEntry = roleMap.get("background");
if (backgroundEntry) {
flattened.set(daemonId, backgroundEntry);
}
});
return { sessionAccessors: flattened, sessionAccessorRoles: roleSets };
}, [sessionAccessorRegistry]);
const value: DaemonConnectionsContextValue = {
activeDaemonId,
activeDaemon,
@@ -215,6 +357,7 @@ export function DaemonConnectionsProvider({ children }: { children: ReactNode })
setActiveDaemonId,
updateConnectionStatus,
sessionAccessors,
sessionAccessorRoles,
registerSessionAccessor,
unregisterSessionAccessor,
subscribeToSessionDirectory,

View File

@@ -29,7 +29,7 @@ import type {
} from "@server/server/agent/agent-sdk-types";
import { ScrollView } from "react-native";
import * as FileSystem from 'expo-file-system';
import { useDaemonConnections } from "./daemon-connections-context";
import { useDaemonConnections, type SessionAccessorRole } from "./daemon-connections-context";
const derivePendingPermissionKey = (agentId: string, request: AgentPermissionRequest) => {
const fallbackId =
@@ -388,9 +388,11 @@ interface SessionProviderProps {
children: ReactNode;
serverUrl: string;
serverId: string;
role?: SessionAccessorRole;
}
export function SessionProvider({ children, serverUrl, serverId }: SessionProviderProps) {
export function SessionProvider({ children, serverUrl, serverId, role }: SessionProviderProps) {
const sessionRole = role ?? "primary";
const ws = useWebSocket(serverUrl);
const wsIsConnected = ws.isConnected;
const {
@@ -402,29 +404,29 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
useEffect(() => {
if (ws.isConnected) {
updateConnectionStatus(serverId, "online", {
updateConnectionStatus(serverId, {
status: "online",
lastOnlineAt: new Date().toISOString(),
lastError: null,
});
return;
}
if (ws.isConnecting) {
updateConnectionStatus(serverId, "connecting");
updateConnectionStatus(serverId, { status: "connecting" });
return;
}
if (ws.lastError) {
updateConnectionStatus(serverId, "error", { lastError: ws.lastError });
updateConnectionStatus(serverId, { status: "error", lastError: ws.lastError });
return;
}
updateConnectionStatus(serverId, "offline");
updateConnectionStatus(serverId, { status: "offline" });
}, [serverId, updateConnectionStatus, ws.isConnected, ws.isConnecting, ws.lastError]);
useEffect(() => {
return () => {
updateConnectionStatus(serverId, "offline", { lastError: null });
updateConnectionStatus(serverId, { status: "offline", lastError: null });
};
}, [serverId, updateConnectionStatus]);
@@ -1794,11 +1796,11 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
);
useEffect(() => {
registerSessionAccessor(serverId, sessionDirectoryEntry);
registerSessionAccessor(serverId, sessionDirectoryEntry, sessionRole);
return () => {
unregisterSessionAccessor(serverId);
unregisterSessionAccessor(serverId, sessionRole);
};
}, [serverId, registerSessionAccessor, unregisterSessionAccessor, sessionDirectoryEntry]);
}, [serverId, sessionRole, registerSessionAccessor, unregisterSessionAccessor, sessionDirectoryEntry]);
return (
<SessionContext.Provider value={value}>

View File

@@ -28,15 +28,24 @@ export function getSessionForServer(
type UseDaemonSessionOptions = {
suppressUnavailableAlert?: boolean;
allowUnavailable?: boolean;
};
export function useDaemonSession(
serverId?: string,
options?: UseDaemonSessionOptions & { allowUnavailable?: false }
): SessionContextValue;
export function useDaemonSession(
serverId: string | undefined,
options: UseDaemonSessionOptions & { allowUnavailable: true }
): SessionContextValue | null;
export function useDaemonSession(serverId?: string, options?: UseDaemonSessionOptions) {
const activeSession = useSession();
const sessionDirectory = useSessionDirectory();
const { connectionStates } = useDaemonConnections();
const alertedDaemonsRef = useRef<Set<string>>(new Set());
const loggedDaemonsRef = useRef<Set<string>>(new Set());
const { suppressUnavailableAlert = false } = options ?? {};
const { suppressUnavailableAlert = false, allowUnavailable = false } = options ?? {};
const targetServerId = serverId ?? activeSession.serverId;
const isActiveSession = targetServerId === activeSession.serverId;
@@ -64,6 +73,10 @@ export function useDaemonSession(serverId?: string, options?: UseDaemonSessionOp
loggedDaemonsRef.current.add(targetServerId);
console.warn(`[useDaemonSession] Session unavailable for daemon "${label}" (${status}).`);
}
if (allowUnavailable) {
return null;
}
}
throw error;
}

View File

@@ -0,0 +1,47 @@
type BackgroundAgentAction = "create" | "resume";
type OfflineAction =
| "create"
| "resume"
| "dictation"
| "import_list";
type AnalyticsEvent =
| {
type: "daemon_active_changed";
daemonId: string;
previousDaemonId: string | null;
source?: string;
}
| {
type: "background_agent_action";
action: BackgroundAgentAction;
daemonId: string;
activeDaemonId: string | null;
isBackground: boolean;
cwd?: string;
provider?: string;
modeId?: string;
model?: string;
baseBranch?: string;
}
| {
type: "offline_daemon_action_attempt";
action: OfflineAction;
daemonId: string | null;
activeDaemonId: string | null;
status: string | null;
isBackground: boolean;
reason?: string | null;
};
export function trackAnalyticsEvent(event: AnalyticsEvent) {
try {
const payload = {
...event,
timestamp: new Date().toISOString(),
};
console.info("[Analytics]", payload);
} catch (error) {
console.error("[Analytics] Failed to emit event", error);
}
}

View File

@@ -1,4 +1,5 @@
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
import { assertUnreachable } from "./exhaustive";
export function formatConnectionStatus(status: ConnectionStatus): string {
switch (status) {
@@ -10,8 +11,10 @@ export function formatConnectionStatus(status: ConnectionStatus): string {
return "Offline";
case "error":
return "Error";
default:
case "idle":
return "Idle";
default:
return assertUnreachable(status);
}
}
@@ -25,7 +28,11 @@ export function getConnectionStatusTone(status: ConnectionStatus): ConnectionSta
return "warning";
case "error":
return "error";
default:
case "offline":
return "warning";
case "idle":
return "muted";
default:
return assertUnreachable(status);
}
}

View File

@@ -0,0 +1,3 @@
export function assertUnreachable(value: never): never {
throw new Error(`Unhandled case: ${String(value)}`);
}

33
plan.md
View File

@@ -57,6 +57,8 @@
- [x] Restore the Import Agent flow (modal entry, mutation wiring, navigation) without undoing the multi-daemon routing work from previous steps.
- After reworking Home/Header and the modals, the import trigger disappeared and existing deep links no longer reach a functioning flow. Bring the Import CTA back (Home, footer, agent screen), ensure it accepts a daemon id, and verify the import mutation routes to the selected daemon without regressing the new server-aware navigation.
- Rewired the import buttons across Home (header + empty state with deep-link auto open), the global footer, and the agent action menu so every trigger passes the correct daemon id into `ImportAgentModal`, and ran `npm run typecheck --workspace=@paseo/app`.
- [x] Guard Create/Resume/Dictation actions when the active daemon is offline and no explicit daemon is selected.
- The offline gate only ran for an explicitly chosen `serverId`, so opening the modal on an offline active daemon still fired create/resume/dictation websocket sends; `packages/app/src/components/create-agent-modal.tsx` now derives availability from the effective daemon id and requires an online websocket before enabling those flows.
### 4. Connection State & Persistence
- [x] Stop the Settings daemon list from firing “Daemon unavailable” alerts when background daemons are offline by reading session snapshots via `useSessionForServer` and only performing restart/test flows when a session is actually mounted.
@@ -75,18 +77,35 @@
### 5. Performance & UX Polish
- [x] Ensure agent image attachments preserve MIME metadata and are base64 encoded before hitting the daemon.
- `packages/app/src/contexts/session-context.tsx:1198` now accepts `{ uri, mimeType }` attachments and reads them via `expo-file-system`, and `packages/app/src/components/agent-input-area.tsx:140` forwards the stored metadata so queued sends no longer drop screenshots (`npm run typecheck` passes).
- [ ] Profile the Create Agent modal—debounce expensive effects (e.g., provider model fetches) per server and prefetch metadata when daemons are idle to eliminate the visible lag when switching targets.
- [ ] Audit websocket usage so background `SessionProvider`s never duplicate connections for the active daemon (one live connection per daemon id).
- [ ] Enforce “impossible states are impossible” across UI/data models (strict typing, discriminated unions, exhaustive switches) so complex flows remain clean without relying on Expo E2E tests.
- [x] Profile the Create Agent modal—debounce expensive effects (e.g., provider model fetches) per server and prefetch metadata when daemons are idle to eliminate the visible lag when switching targets.
- Added per-server debounce + cleanup around provider model requests, scheduled idle-time prefetch for online daemons via the session directory, and verified with `npm run typecheck --workspace=@paseo/app`.
- [x] Audit websocket usage so background `SessionProvider`s never duplicate connections for the active daemon (one live connection per daemon id).
- Tracked session accessors by role, had background hosts register as `background`, filtered out daemons that already have a primary session before spinning up background connections, and ran `npm run typecheck --workspace=@paseo/app`.
- [x] Enforce “impossible states are impossible” across UI/data models (strict typing, discriminated unions, exhaustive switches) so complex flows remain clean without relying on Expo E2E tests.
- Tightened daemon connection state to a discriminated union with exhaustiveness checks, added an `assertUnreachable` helper for status switches, and re-ran `npm run typecheck --workspace=@paseo/app`.
### 6. Observability & Tooling
- [ ] Add structured logging for daemon connection lifecycle (connect, error, auto-connect skip) so we can diagnose “multi daemon” issues from device logs.
- [ ] Emit analytics when users create/resume agents on background daemons, attempt actions while those daemons are offline, or switch default daemons—helps prioritize reconnection UX.
- [ ] Document the architecture in `docs/multi-daemon.md` (registry, sessions, routing rules) so future contributors understand how to extend it.
- [ ] Land the accumulated multi-daemon changes in source control with a clean commit (linted, type-checked, plan updated).
- [x] Add structured logging for daemon connection lifecycle (connect, error, auto-connect skip) so we can diagnose “multi daemon” issues from device logs.
- Added connection state transition logs with daemon ids/labels plus auto-connect skip logs for disabled daemons, and ran `npm run typecheck --workspace=@paseo/app`.
- [x] Emit analytics when users create/resume agents on background daemons, attempt actions while those daemons are offline, or switch default daemons—helps prioritize reconnection UX.
- Added a centralized analytics helper, instrumented active-daemon switches plus background create/resume flows (including offline/blocked actions like dictation and import refresh), and ran `npm run typecheck --workspace=@paseo/app`.
- [x] Document the architecture in `docs/multi-daemon.md` (registry, sessions, routing rules) so future contributors understand how to extend it.
- Added a multi-daemon architecture guide detailing the registry, connection/session hosts, session directory access, routing rules, and extension guidelines (docs-only change; no tests needed).
- [x] Land the accumulated multi-daemon changes in source control with a clean commit (linted, type-checked, plan updated).
- Fixed lint violations around guarded daemon screens, added an opt-in nullable `useDaemonSession`, reran `npm run lint --workspace=@paseo/app` (warnings only) and `npm run typecheck --workspace=@paseo/app`, and prepared the tree for a clean commit.
### Review
- [x] 2025-11-26 Reviewer sanity check for the recent multi-daemon rollout work.
- Confirmed the new `useDaemonSession` hook, guarded Agent/Git Diff/File Explorer screens, and server-aware Create/Import flows align with the documented fixes; no regressions or missing follow-ups spotted, so no additional tasks were opened.
- [x] 2025-11-26 Reviewer follow-up on session isolation across daemons.
- Found that the active `SessionProvider` kept its React state when switching daemons, so stale agents/permissions could leak between server contexts; fixed by keying the provider in `_layout.tsx` so the tree remounts on each daemon change.
- [x] 2025-11-26 Reviewer pass on provider model prefetch/perf work in CreateAgentModal.
- Idle provider-model prefetch timers could still fire after a daemon was removed; cleared stale timers before scheduling background fetches so requests don't target deleted entries (`packages/app/src/components/create-agent-modal.tsx:1198`) and re-ran `npm run typecheck --workspace=@paseo/app`.
- [x] 2025-11-26 Reviewer pass on connection-state/SessionProvider role refactor.
- Checked the new discriminated `ConnectionState`, role-aware session accessor registry, and `MultiDaemonSessionHost` filters to avoid duplicate websocket sessions; reran `npm run typecheck --workspace=@paseo/app` and didn't spot regressions or new follow-ups to open.
- [x] 2025-11-26 Reviewer pass on agent route daemon selection.
- Deep links with unregistered daemon ids repeatedly forced `setActiveDaemonId`, remounting the root `SessionProvider` and flashing the missing-daemon screen; `/agent/[serverId]/[agentId]` now ignores unknown daemons and shows a clear unavailable state instead.
- [x] 2025-11-27 Reviewer pass on connection status UX.
- Offline daemons were rendered with a muted tone via `getConnectionStatusTone`, hiding them in the connection banners; mapped offline to `warning` so settings/home surface disconnected daemons in amber (`packages/app/src/utils/daemons.ts`).
- [x] 2025-11-27 Reviewer fix for provider model fetching on the active daemon.
- `CreateAgentModal` skipped provider model requests whenever no daemon was explicitly selected (active daemon fallback), so the model list never refreshed after changing the working directory. The effect now targets the effective daemon id instead of requiring `selectedServerId`, and `npm run typecheck --workspace=@paseo/app` passes (`packages/app/src/components/create-agent-modal.tsx`).