Compare commits
71 Commits
v0.2.7-dev
...
v0.2.8-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91ab2d5e2c | ||
|
|
72773546f5 | ||
|
|
2f58e8a1a9 | ||
|
|
d0cab51eca | ||
|
|
6f04d23b09 | ||
|
|
3e72b83393 | ||
|
|
87da8ee9f8 | ||
|
|
ec5c5c8c0f | ||
|
|
b9394fb467 | ||
|
|
de432106e5 | ||
|
|
1fbf51b7ae | ||
|
|
864d665049 | ||
|
|
c4a9c032a3 | ||
|
|
3373e23a41 | ||
|
|
b0650a283e | ||
|
|
52149f5543 | ||
|
|
2e5a904034 | ||
|
|
f1ad1400a7 | ||
|
|
bbd28404ff | ||
|
|
04f6e362b9 | ||
|
|
0b9cce6f86 | ||
|
|
d68cb6b1b8 | ||
|
|
e345dc1262 | ||
|
|
2b27790a81 | ||
|
|
2514fa94b4 | ||
|
|
522910ff64 | ||
|
|
971abe24d7 | ||
|
|
49143bd049 | ||
|
|
df52ed3035 | ||
|
|
617aac8fd8 | ||
|
|
6e82ecc97e | ||
|
|
636a19fc50 | ||
|
|
97f78bb337 | ||
|
|
0ca39d2fb0 | ||
|
|
aad1337111 | ||
|
|
6d7bc813ed | ||
|
|
1a0dd21540 | ||
|
|
7cf9c35375 | ||
|
|
f1c32253af | ||
|
|
4a8d13e2cd | ||
|
|
b0fd63ead5 | ||
|
|
94cb741c7f | ||
|
|
976430d61c | ||
|
|
8a8555d591 | ||
|
|
57c1605242 | ||
|
|
cfbd0bdffa | ||
|
|
58efb8bc3e | ||
|
|
b35bfe63c0 | ||
|
|
d7b5f53d59 | ||
|
|
168b782006 | ||
|
|
9e0fbd185d | ||
|
|
11be314f63 | ||
|
|
36ee301ef2 | ||
|
|
d6dd06b7d1 | ||
|
|
6a16dd8f98 | ||
|
|
78338f33c1 | ||
|
|
8c72d279df | ||
|
|
a9500276c8 | ||
|
|
f9ec757c64 | ||
|
|
f4c9385661 | ||
|
|
6ba50cadd2 | ||
|
|
8d5169cb39 | ||
|
|
fe8b4a9acd | ||
|
|
831e59cd77 | ||
|
|
7fde8afcf0 | ||
|
|
d07c2ec4a9 | ||
|
|
4306147990 | ||
|
|
c614da3e3c | ||
|
|
73b59d8266 | ||
|
|
a2d8ea0dfd | ||
|
|
52ee196103 |
12
README.md
12
README.md
@@ -58,6 +58,18 @@ This command starts the server and opens the web client in your default browser.
|
||||
- **[OpenCode CLI](https://opencode.ai)**: Must be installed and available in your `PATH`.
|
||||
- **Node.js 18+**: Required if running the CLI server or building from source.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### macOS says the app is damaged
|
||||
If macOS reports that "CodeNomad.app is damaged and can't be opened," Gatekeeper flagged the download because the app is not yet notarized. You can clear the quarantine flag after moving CodeNomad into `/Applications`:
|
||||
|
||||
```bash
|
||||
xattr -l /Applications/CodeNomad.app
|
||||
xattr -dr com.apple.quarantine /Applications/CodeNomad.app
|
||||
```
|
||||
|
||||
After removing the quarantine attribute, launch the app normally. On Intel Macs you may also need to approve CodeNomad from **System Settings → Privacy & Security** the first time you run it.
|
||||
|
||||
## Architecture & Development
|
||||
|
||||
CodeNomad is a monorepo split into specialized packages. If you want to contribute or build from source, check out the individual package documentation:
|
||||
|
||||
@@ -35,7 +35,7 @@ CodeNomad is a cross-platform desktop application built with Electron that provi
|
||||
│ │ │ UI Components │ │ │
|
||||
│ │ │ - InstanceTabs │ │ │
|
||||
│ │ │ - SessionTabs │ │ │
|
||||
│ │ │ - MessageStreamV2 │ │ │
|
||||
│ │ │ - MessageSection │ │ │
|
||||
│ │ │ - PromptInput │ │ │
|
||||
│ │ └────────────────────────────────────────────┘ │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
|
||||
228
package-lock.json
generated
228
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.2.7",
|
||||
"version": "0.2.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.2.7",
|
||||
"version": "0.2.8",
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
"google-auth-library": "^10.5.0"
|
||||
@@ -1276,9 +1276,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/sdk": {
|
||||
"version": "1.0.68",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.0.68.tgz",
|
||||
"integrity": "sha512-QdpLZw2L/nHdPFGCz8z4du2RvlALgZTFgNeKUM+kJuZTtOWC5t425ELGg5xKIpynD0kj83Euvfn6l6uHs99g3w=="
|
||||
"version": "1.0.133",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.0.133.tgz",
|
||||
"integrity": "sha512-kM+VJJ09SU51aruQ78DSy+6CjNc4wMytvGBrZ1IIJ8etUIdGA59wrnIOSxBVs4u/Gb9pjjgsF8sWp59UdLWP9w=="
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
@@ -2898,6 +2898,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase-css": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
@@ -3393,6 +3402,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
@@ -3536,6 +3554,12 @@
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dir-compare": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-3.3.0.tgz",
|
||||
@@ -4440,6 +4464,19 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
@@ -4660,7 +4697,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
@@ -5626,6 +5662,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
@@ -6254,6 +6302,42 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
@@ -6273,6 +6357,15 @@
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
@@ -6701,6 +6794,98 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -6868,7 +7053,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -6883,6 +7067,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -7238,6 +7428,12 @@
|
||||
"seroval": "^1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
@@ -8436,6 +8632,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
@@ -8613,7 +8815,7 @@
|
||||
},
|
||||
"packages/electron-app": {
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.2.7",
|
||||
"version": "0.2.8",
|
||||
"dependencies": {
|
||||
"@codenomad/ui": "file:../ui",
|
||||
"@neuralnomads/codenomad": "file:../server"
|
||||
@@ -8641,7 +8843,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.2.7",
|
||||
"version": "0.2.8",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
@@ -8680,22 +8882,24 @@
|
||||
},
|
||||
"packages/tauri-app": {
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.2.7",
|
||||
"version": "0.2.8",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
}
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.2.7",
|
||||
"version": "0.2.8",
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode-ai/sdk": "1.0.68",
|
||||
"@opencode-ai/sdk": "^1.0.133",
|
||||
"@solidjs/router": "^0.13.0",
|
||||
"debug": "^4.4.3",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
"lucide-solid": "^0.300.0",
|
||||
"marked": "^12.0.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"shiki": "^3.13.0",
|
||||
"solid-js": "^1.8.0",
|
||||
"solid-toast": "^0.5.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.2.7",
|
||||
"version": "0.2.8",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"workspaces": {
|
||||
|
||||
@@ -53,12 +53,19 @@ export default defineConfig({
|
||||
port: 3000,
|
||||
},
|
||||
build: {
|
||||
minify: false,
|
||||
cssMinify: false,
|
||||
sourcemap: true,
|
||||
outDir: resolve(__dirname, "dist/renderer"),
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: uiRendererEntry,
|
||||
loading: uiRendererLoadingEntry,
|
||||
},
|
||||
output: {
|
||||
compact: false,
|
||||
minifyInternalExports: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -34,6 +34,12 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
||||
|
||||
ipcMain.handle("cli:getStatus", async () => cliManager.getStatus())
|
||||
|
||||
ipcMain.handle("cli:restart", async () => {
|
||||
const devMode = process.env.NODE_ENV === "development"
|
||||
await cliManager.stop()
|
||||
return cliManager.start({ dev: devMode })
|
||||
})
|
||||
|
||||
ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise<DialogOpenResult> => {
|
||||
const properties: OpenDialogOptions["properties"] =
|
||||
request.mode === "directory" ? ["openDirectory", "createDirectory"] : ["openFile"]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { app, BrowserView, BrowserWindow, nativeImage, session } from "electron"
|
||||
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
|
||||
import { existsSync } from "fs"
|
||||
import { dirname, join } from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
@@ -89,6 +89,56 @@ function loadLoadingScreen(window: BrowserWindow) {
|
||||
})
|
||||
}
|
||||
|
||||
function getAllowedRendererOrigins(): string[] {
|
||||
const origins = new Set<string>()
|
||||
const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL]
|
||||
for (const candidate of rendererCandidates) {
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
origins.add(new URL(candidate).origin)
|
||||
} catch (error) {
|
||||
console.warn("[cli] failed to parse origin for", candidate, error)
|
||||
}
|
||||
}
|
||||
return Array.from(origins)
|
||||
}
|
||||
|
||||
function shouldOpenExternally(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return true
|
||||
}
|
||||
const allowedOrigins = getAllowedRendererOrigins()
|
||||
return !allowedOrigins.includes(parsed.origin)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function setupNavigationGuards(window: BrowserWindow) {
|
||||
const handleExternal = (url: string) => {
|
||||
shell.openExternal(url).catch((error) => console.error("[cli] failed to open external URL", url, error))
|
||||
}
|
||||
|
||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (shouldOpenExternally(url)) {
|
||||
handleExternal(url)
|
||||
return { action: "deny" }
|
||||
}
|
||||
return { action: "allow" }
|
||||
})
|
||||
|
||||
window.webContents.on("will-navigate", (event, url) => {
|
||||
if (shouldOpenExternally(url)) {
|
||||
event.preventDefault()
|
||||
handleExternal(url)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let cachedPreloadPath: string | null = null
|
||||
function getPreloadPath() {
|
||||
if (cachedPreloadPath && existsSync(cachedPreloadPath)) {
|
||||
@@ -153,6 +203,8 @@ function createWindow() {
|
||||
},
|
||||
})
|
||||
|
||||
setupNavigationGuards(mainWindow)
|
||||
|
||||
if (isMac) {
|
||||
mainWindow.webContents.session.setSpellCheckerEnabled(false)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { spawn, type ChildProcess } from "child_process"
|
||||
import { app } from "electron"
|
||||
import { createRequire } from "module"
|
||||
import { EventEmitter } from "events"
|
||||
import { existsSync } from "fs"
|
||||
import { existsSync, readFileSync } from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
||||
|
||||
@@ -10,6 +11,7 @@ const nodeRequire = createRequire(import.meta.url)
|
||||
|
||||
|
||||
type CliState = "starting" | "ready" | "error" | "stopped"
|
||||
type ListeningMode = "local" | "all"
|
||||
|
||||
export interface CliStatus {
|
||||
state: CliState
|
||||
@@ -34,6 +36,36 @@ interface CliEntryResolution {
|
||||
runnerPath?: string
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
||||
|
||||
function resolveConfigPath(configPath?: string): string {
|
||||
const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH
|
||||
if (target.startsWith("~/")) {
|
||||
return path.join(os.homedir(), target.slice(2))
|
||||
}
|
||||
return path.resolve(target)
|
||||
}
|
||||
|
||||
function resolveHostForMode(mode: ListeningMode): string {
|
||||
return mode === "local" ? "127.0.0.1" : "0.0.0.0"
|
||||
}
|
||||
|
||||
function readListeningModeFromConfig(): ListeningMode {
|
||||
try {
|
||||
const configPath = resolveConfigPath(process.env.CLI_CONFIG)
|
||||
if (!existsSync(configPath)) return "local"
|
||||
const content = readFileSync(configPath, "utf-8")
|
||||
const parsed = JSON.parse(content)
|
||||
const mode = parsed?.preferences?.listeningMode
|
||||
if (mode === "local" || mode === "all") {
|
||||
return mode
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[cli] failed to read listening mode from config", error)
|
||||
}
|
||||
return "local"
|
||||
}
|
||||
|
||||
export declare interface CliProcessManager {
|
||||
on(event: "status", listener: (status: CliStatus) => void): this
|
||||
on(event: "ready", listener: (status: CliStatus) => void): this
|
||||
@@ -58,10 +90,12 @@ export class CliProcessManager extends EventEmitter {
|
||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||
|
||||
const cliEntry = this.resolveCliEntry(options)
|
||||
const args = this.buildCliArgs(options)
|
||||
const listeningMode = this.resolveListeningMode()
|
||||
const host = resolveHostForMode(listeningMode)
|
||||
const args = this.buildCliArgs(options, host)
|
||||
|
||||
console.info(
|
||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry}`,
|
||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
||||
)
|
||||
|
||||
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||
@@ -116,7 +150,7 @@ export class CliProcessManager extends EventEmitter {
|
||||
const timeout = setTimeout(() => {
|
||||
this.handleTimeout()
|
||||
reject(new Error("CLI startup timeout"))
|
||||
}, 15000)
|
||||
}, 60000)
|
||||
|
||||
this.once("ready", (status) => {
|
||||
clearTimeout(timeout)
|
||||
@@ -158,6 +192,10 @@ export class CliProcessManager extends EventEmitter {
|
||||
return { ...this.status }
|
||||
}
|
||||
|
||||
private resolveListeningMode(): ListeningMode {
|
||||
return readListeningModeFromConfig()
|
||||
}
|
||||
|
||||
private handleTimeout() {
|
||||
if (this.child) {
|
||||
this.child.kill("SIGKILL")
|
||||
@@ -232,8 +270,8 @@ export class CliProcessManager extends EventEmitter {
|
||||
this.emit("status", this.status)
|
||||
}
|
||||
|
||||
private buildCliArgs(options: StartOptions): string[] {
|
||||
const args = ["serve", "--host", "127.0.0.1", "--port", "0"]
|
||||
private buildCliArgs(options: StartOptions, host: string): string[] {
|
||||
const args = ["serve", "--host", host, "--port", "0"]
|
||||
|
||||
if (options.dev) {
|
||||
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
|
||||
|
||||
@@ -10,6 +10,7 @@ const electronAPI = {
|
||||
return () => ipcRenderer.removeAllListeners("cli:error")
|
||||
},
|
||||
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
||||
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
||||
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.2.7",
|
||||
"version": "0.2.8",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
|
||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.2.7",
|
||||
"version": "0.2.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.2.7",
|
||||
"version": "0.2.8",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"commander": "^12.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.2.7",
|
||||
"version": "0.2.8",
|
||||
"description": "CodeNomad Server",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
|
||||
@@ -167,6 +167,7 @@ export type WorkspaceEventType =
|
||||
| "instance.dataChanged"
|
||||
| "instance.event"
|
||||
| "instance.eventStatus"
|
||||
| "app.releaseAvailable"
|
||||
|
||||
export type WorkspaceEventPayload =
|
||||
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
|
||||
@@ -179,16 +180,43 @@ export type WorkspaceEventPayload =
|
||||
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
||||
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
|
||||
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
|
||||
| { type: "app.releaseAvailable"; release: LatestReleaseInfo }
|
||||
|
||||
export interface NetworkAddress {
|
||||
ip: string
|
||||
family: "ipv4" | "ipv6"
|
||||
scope: "external" | "internal" | "loopback"
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface LatestReleaseInfo {
|
||||
version: string
|
||||
tag: string
|
||||
url: string
|
||||
channel: "stable" | "dev"
|
||||
publishedAt?: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface ServerMeta {
|
||||
/** Base URL clients should target for REST calls (useful for Electron embedding). */
|
||||
httpBaseUrl: string
|
||||
/** SSE endpoint advertised to clients (`/api/events` by default). */
|
||||
eventsUrl: string
|
||||
/** Host the server is bound to (e.g., 127.0.0.1 or 0.0.0.0). */
|
||||
host: string
|
||||
/** Listening mode derived from host binding. */
|
||||
listeningMode: "local" | "all"
|
||||
/** Actual port in use after binding. */
|
||||
port: number
|
||||
/** Display label for the host (e.g., hostname or friendly name). */
|
||||
hostLabel: string
|
||||
/** Absolute path of the filesystem root exposed to clients. */
|
||||
workspaceRoot: string
|
||||
/** Reachable addresses for this server, external first. */
|
||||
addresses: NetworkAddress[]
|
||||
/** Optional metadata about the most recent public release. */
|
||||
latestRelease?: LatestReleaseInfo
|
||||
}
|
||||
|
||||
export type {
|
||||
|
||||
@@ -19,6 +19,7 @@ const PreferencesSchema = z.object({
|
||||
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
showUsageMetrics: z.boolean().default(true),
|
||||
autoCleanupBlankSessions: z.boolean().default(true),
|
||||
listeningMode: z.enum(["local", "all"]).default("local"),
|
||||
})
|
||||
|
||||
const RecentFolderSchema = z.object({
|
||||
|
||||
@@ -52,9 +52,10 @@ export class ConfigStore {
|
||||
this.cache = next
|
||||
this.loaded = true
|
||||
this.persist()
|
||||
const published = Boolean(this.eventBus)
|
||||
this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
|
||||
this.logger.info("Config updated")
|
||||
this.logger.debug({ config: this.cache }, "Config payload")
|
||||
this.logger.debug({ broadcast: published }, "Config SSE event emitted")
|
||||
this.logger.trace({ config: this.cache }, "Config payload")
|
||||
}
|
||||
|
||||
private persist() {
|
||||
|
||||
@@ -9,7 +9,10 @@ export class EventBus extends EventEmitter {
|
||||
|
||||
publish(event: WorkspaceEventPayload): boolean {
|
||||
if (event.type !== "instance.event" && event.type !== "instance.eventStatus") {
|
||||
this.logger?.debug({ event }, "Publishing workspace event")
|
||||
this.logger?.debug({ type: event.type }, "Publishing workspace event")
|
||||
if (this.logger?.isLevelEnabled("trace")) {
|
||||
this.logger.trace({ event }, "Workspace event payload")
|
||||
}
|
||||
}
|
||||
return super.emit(event.type, event)
|
||||
}
|
||||
@@ -26,6 +29,7 @@ export class EventBus extends EventEmitter {
|
||||
this.on("instance.dataChanged", handler)
|
||||
this.on("instance.event", handler)
|
||||
this.on("instance.eventStatus", handler)
|
||||
this.on("app.releaseAvailable", handler)
|
||||
return () => {
|
||||
this.off("workspace.created", handler)
|
||||
this.off("workspace.started", handler)
|
||||
@@ -37,6 +41,7 @@ export class EventBus extends EventEmitter {
|
||||
this.off("instance.dataChanged", handler)
|
||||
this.off("instance.event", handler)
|
||||
this.off("instance.eventStatus", handler)
|
||||
this.off("app.releaseAvailable", handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { InstanceStore } from "./storage/instance-store"
|
||||
import { InstanceEventBridge } from "./workspaces/instance-events"
|
||||
import { createLogger } from "./logger"
|
||||
import { launchInBrowser } from "./launcher"
|
||||
import { startReleaseMonitor } from "./releases/release-monitor"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
@@ -141,10 +142,27 @@ async function main() {
|
||||
const serverMeta: ServerMeta = {
|
||||
httpBaseUrl: `http://${options.host}:${options.port}`,
|
||||
eventsUrl: `/api/events`,
|
||||
host: options.host,
|
||||
listeningMode: options.host === "0.0.0.0" ? "all" : "local",
|
||||
port: options.port,
|
||||
hostLabel: options.host,
|
||||
workspaceRoot: options.rootDir,
|
||||
addresses: [],
|
||||
}
|
||||
|
||||
const releaseMonitor = startReleaseMonitor({
|
||||
currentVersion: packageJson.version,
|
||||
logger: logger.child({ component: "release-monitor" }),
|
||||
onUpdate: (release) => {
|
||||
if (release) {
|
||||
serverMeta.latestRelease = release
|
||||
eventBus.publish({ type: "app.releaseAvailable", release })
|
||||
} else {
|
||||
delete serverMeta.latestRelease
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const server = createHttpServer({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
@@ -192,6 +210,8 @@ async function main() {
|
||||
logger.error({ err: error }, "Workspace manager shutdown failed")
|
||||
}
|
||||
|
||||
releaseMonitor.stop()
|
||||
|
||||
logger.info("Exiting process")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
141
packages/server/src/releases/release-monitor.ts
Normal file
141
packages/server/src/releases/release-monitor.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { fetch } from "undici"
|
||||
import type { LatestReleaseInfo } from "../api-types"
|
||||
import type { Logger } from "../logger"
|
||||
|
||||
const RELEASES_API_URL = "https://api.github.com/repos/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||
interface ReleaseMonitorOptions {
|
||||
currentVersion: string
|
||||
logger: Logger
|
||||
onUpdate: (release: LatestReleaseInfo | null) => void
|
||||
}
|
||||
|
||||
interface GithubReleaseResponse {
|
||||
tag_name?: string
|
||||
name?: string
|
||||
html_url?: string
|
||||
body?: string
|
||||
published_at?: string
|
||||
created_at?: string
|
||||
prerelease?: boolean
|
||||
}
|
||||
|
||||
interface NormalizedVersion {
|
||||
major: number
|
||||
minor: number
|
||||
patch: number
|
||||
prerelease: string | null
|
||||
}
|
||||
|
||||
export interface ReleaseMonitor {
|
||||
stop(): void
|
||||
}
|
||||
|
||||
export function startReleaseMonitor(options: ReleaseMonitorOptions): ReleaseMonitor {
|
||||
let stopped = false
|
||||
|
||||
const refreshRelease = async () => {
|
||||
if (stopped) return
|
||||
try {
|
||||
const release = await fetchLatestRelease(options)
|
||||
options.onUpdate(release)
|
||||
} catch (error) {
|
||||
options.logger.warn({ err: error }, "Failed to refresh release information")
|
||||
}
|
||||
}
|
||||
|
||||
void refreshRelease()
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<LatestReleaseInfo | null> {
|
||||
const response = await fetch(RELEASES_API_URL, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": "CodeNomad-CLI",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Release API responded with ${response.status}`)
|
||||
}
|
||||
|
||||
const json = (await response.json()) as GithubReleaseResponse
|
||||
const tagFromServer = json.tag_name || json.name
|
||||
if (!tagFromServer) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedVersion = stripTagPrefix(tagFromServer)
|
||||
if (!normalizedVersion) {
|
||||
return null
|
||||
}
|
||||
|
||||
const current = parseVersion(options.currentVersion)
|
||||
const remote = parseVersion(normalizedVersion)
|
||||
|
||||
if (compareVersions(remote, current) <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
version: normalizedVersion,
|
||||
tag: tagFromServer,
|
||||
url: json.html_url ?? `https://github.com/NeuralNomadsAI/CodeNomad/releases/tag/${encodeURIComponent(tagFromServer)}`,
|
||||
channel: json.prerelease || normalizedVersion.includes("-") ? "dev" : "stable",
|
||||
publishedAt: json.published_at ?? json.created_at,
|
||||
notes: json.body,
|
||||
}
|
||||
}
|
||||
|
||||
function stripTagPrefix(tag: string | undefined): string | null {
|
||||
if (!tag) return null
|
||||
const trimmed = tag.trim()
|
||||
if (!trimmed) return null
|
||||
return trimmed.replace(/^v/i, "")
|
||||
}
|
||||
|
||||
function parseVersion(value: string): NormalizedVersion {
|
||||
const normalized = stripTagPrefix(value) ?? "0.0.0"
|
||||
const [core, prerelease = null] = normalized.split("-", 2)
|
||||
const [major = 0, minor = 0, patch = 0] = core.split(".").map((segment) => {
|
||||
const parsed = Number.parseInt(segment, 10)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
})
|
||||
return {
|
||||
major,
|
||||
minor,
|
||||
patch,
|
||||
prerelease,
|
||||
}
|
||||
}
|
||||
|
||||
function compareVersions(a: NormalizedVersion, b: NormalizedVersion): number {
|
||||
if (a.major !== b.major) {
|
||||
return a.major > b.major ? 1 : -1
|
||||
}
|
||||
if (a.minor !== b.minor) {
|
||||
return a.minor > b.minor ? 1 : -1
|
||||
}
|
||||
if (a.patch !== b.patch) {
|
||||
return a.patch > b.patch ? 1 : -1
|
||||
}
|
||||
|
||||
const aPre = a.prerelease && a.prerelease.length > 0 ? a.prerelease : null
|
||||
const bPre = b.prerelease && b.prerelease.length > 0 ? b.prerelease : null
|
||||
|
||||
if (aPre === bPre) {
|
||||
return 0
|
||||
}
|
||||
if (!aPre) {
|
||||
return 1
|
||||
}
|
||||
if (!bPre) {
|
||||
return -1
|
||||
}
|
||||
return aPre.localeCompare(bPre)
|
||||
}
|
||||
@@ -42,9 +42,13 @@ interface HttpServerStartResult {
|
||||
displayHost: string
|
||||
}
|
||||
|
||||
const DEFAULT_HTTP_PORT = 9898
|
||||
|
||||
export function createHttpServer(deps: HttpServerDeps) {
|
||||
const app = Fastify({ logger: false })
|
||||
const proxyLogger = deps.logger.child({ component: "proxy" })
|
||||
const apiLogger = deps.logger.child({ component: "http" })
|
||||
const sseLogger = deps.logger.child({ component: "sse" })
|
||||
|
||||
const sseClients = new Set<() => void>()
|
||||
const registerSseClient = (cleanup: () => void) => {
|
||||
@@ -58,6 +62,29 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
sseClients.clear()
|
||||
}
|
||||
|
||||
app.addHook("onRequest", (request, _reply, done) => {
|
||||
;(request as FastifyRequest & { __logMeta?: { start: bigint } }).__logMeta = {
|
||||
start: process.hrtime.bigint(),
|
||||
}
|
||||
done()
|
||||
})
|
||||
|
||||
app.addHook("onResponse", (request, reply, done) => {
|
||||
const meta = (request as FastifyRequest & { __logMeta?: { start: bigint } }).__logMeta
|
||||
const durationMs = meta ? Number((process.hrtime.bigint() - meta.start) / BigInt(1_000_000)) : undefined
|
||||
const base = {
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
status: reply.statusCode,
|
||||
durationMs,
|
||||
}
|
||||
apiLogger.debug(base, "HTTP request completed")
|
||||
if (apiLogger.isLevelEnabled("trace")) {
|
||||
apiLogger.trace({ ...base, params: request.params, query: request.query, body: request.body }, "HTTP request payload")
|
||||
}
|
||||
done()
|
||||
})
|
||||
|
||||
app.register(cors, {
|
||||
origin: true,
|
||||
credentials: true,
|
||||
@@ -77,7 +104,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
|
||||
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
||||
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
||||
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient })
|
||||
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
|
||||
registerStorageRoutes(app, {
|
||||
instanceStore: deps.instanceStore,
|
||||
eventBus: deps.eventBus,
|
||||
@@ -95,16 +122,40 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
return {
|
||||
instance: app,
|
||||
start: async (): Promise<HttpServerStartResult> => {
|
||||
const addressInfo = await app.listen({ port: deps.port, host: deps.host })
|
||||
const attemptListen = async (requestedPort: number) => {
|
||||
const addressInfo = await app.listen({ port: requestedPort, host: deps.host })
|
||||
return { addressInfo, requestedPort }
|
||||
}
|
||||
|
||||
let actualPort = deps.port
|
||||
const autoPortRequested = deps.port === 0
|
||||
const primaryPort = autoPortRequested ? DEFAULT_HTTP_PORT : deps.port
|
||||
|
||||
if (typeof addressInfo === "string") {
|
||||
const shouldRetryWithEphemeral = (error: unknown) => {
|
||||
if (!autoPortRequested) return false
|
||||
const err = error as NodeJS.ErrnoException | undefined
|
||||
return Boolean(err && err.code === "EADDRINUSE")
|
||||
}
|
||||
|
||||
let listenResult
|
||||
|
||||
try {
|
||||
listenResult = await attemptListen(primaryPort)
|
||||
} catch (error) {
|
||||
if (!shouldRetryWithEphemeral(error)) {
|
||||
throw error
|
||||
}
|
||||
deps.logger.warn({ err: error, port: primaryPort }, "Preferred port unavailable, retrying on ephemeral port")
|
||||
listenResult = await attemptListen(0)
|
||||
}
|
||||
|
||||
let actualPort = listenResult.requestedPort
|
||||
|
||||
if (typeof listenResult.addressInfo === "string") {
|
||||
try {
|
||||
const parsed = new URL(addressInfo)
|
||||
actualPort = Number(parsed.port) || deps.port
|
||||
const parsed = new URL(listenResult.addressInfo)
|
||||
actualPort = Number(parsed.port) || listenResult.requestedPort
|
||||
} catch {
|
||||
actualPort = deps.port
|
||||
actualPort = listenResult.requestedPort
|
||||
}
|
||||
} else {
|
||||
const address = app.server.address()
|
||||
@@ -117,6 +168,9 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
const serverUrl = `http://${displayHost}:${actualPort}`
|
||||
|
||||
deps.serverMeta.httpBaseUrl = serverUrl
|
||||
deps.serverMeta.host = deps.host
|
||||
deps.serverMeta.port = actualPort
|
||||
deps.serverMeta.listeningMode = deps.host === "0.0.0.0" ? "all" : "local"
|
||||
deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening")
|
||||
console.log(`CodeNomad Server is ready at ${serverUrl}`)
|
||||
|
||||
@@ -196,6 +250,11 @@ async function proxyWorkspaceRequest(args: {
|
||||
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
||||
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
|
||||
|
||||
logger.debug({ workspaceId, method: request.method, targetUrl }, "Proxying request to instance")
|
||||
if (logger.isLevelEnabled("trace")) {
|
||||
logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload")
|
||||
}
|
||||
|
||||
return reply.from(targetUrl, {
|
||||
onError: (proxyReply, { error }) => {
|
||||
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { EventBus } from "../../events/bus"
|
||||
import { WorkspaceEventPayload } from "../../api-types"
|
||||
import { Logger } from "../../logger"
|
||||
|
||||
interface RouteDeps {
|
||||
eventBus: EventBus
|
||||
registerClient: (cleanup: () => void) => () => void
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
let nextClientId = 0
|
||||
|
||||
export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/events", (request, reply) => {
|
||||
const clientId = ++nextClientId
|
||||
deps.logger.debug({ clientId }, "SSE client connected")
|
||||
|
||||
const origin = request.headers.origin ?? "*"
|
||||
reply.raw.setHeader("Access-Control-Allow-Origin", origin)
|
||||
reply.raw.setHeader("Access-Control-Allow-Credentials", "true")
|
||||
@@ -19,6 +26,10 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
reply.hijack()
|
||||
|
||||
const send = (event: WorkspaceEventPayload) => {
|
||||
deps.logger.debug({ clientId, type: event.type }, "SSE event dispatched")
|
||||
if (deps.logger.isLevelEnabled("trace")) {
|
||||
deps.logger.trace({ clientId, event }, "SSE event payload")
|
||||
}
|
||||
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`)
|
||||
}
|
||||
|
||||
@@ -34,6 +45,7 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
clearInterval(heartbeat)
|
||||
unsubscribe()
|
||||
reply.raw.end?.()
|
||||
deps.logger.debug({ clientId }, "SSE client disconnected")
|
||||
}
|
||||
|
||||
const unregister = deps.registerClient(close)
|
||||
|
||||
@@ -1,10 +1,104 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { ServerMeta } from "../../api-types"
|
||||
import os from "os"
|
||||
import { NetworkAddress, ServerMeta } from "../../api-types"
|
||||
|
||||
interface RouteDeps {
|
||||
serverMeta: ServerMeta
|
||||
}
|
||||
|
||||
export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/meta", async () => deps.serverMeta)
|
||||
app.get("/api/meta", async () => buildMetaResponse(deps.serverMeta))
|
||||
}
|
||||
|
||||
function buildMetaResponse(meta: ServerMeta): ServerMeta {
|
||||
const port = resolvePort(meta)
|
||||
const addresses = port > 0 ? resolveAddresses(port, meta.host) : []
|
||||
|
||||
return {
|
||||
...meta,
|
||||
port,
|
||||
listeningMode: meta.host === "0.0.0.0" ? "all" : "local",
|
||||
addresses,
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePort(meta: ServerMeta): number {
|
||||
if (Number.isInteger(meta.port) && meta.port > 0) {
|
||||
return meta.port
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(meta.httpBaseUrl)
|
||||
const port = Number(parsed.port)
|
||||
return Number.isInteger(port) && port > 0 ? port : 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAddresses(port: number, host: string): NetworkAddress[] {
|
||||
const interfaces = os.networkInterfaces()
|
||||
const seen = new Set<string>()
|
||||
const results: NetworkAddress[] = []
|
||||
|
||||
const addAddress = (ip: string, scope: NetworkAddress["scope"]) => {
|
||||
if (!ip || ip === "0.0.0.0") return
|
||||
const key = `ipv4-${ip}`
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
results.push({ ip, family: "ipv4", scope, url: `http://${ip}:${port}` })
|
||||
}
|
||||
|
||||
const normalizeFamily = (value: string | number) => {
|
||||
if (typeof value === "string") {
|
||||
const lowered = value.toLowerCase()
|
||||
if (lowered === "ipv4") {
|
||||
return "ipv4" as const
|
||||
}
|
||||
}
|
||||
if (value === 4) return "ipv4" as const
|
||||
return null
|
||||
}
|
||||
|
||||
if (host === "0.0.0.0") {
|
||||
// Enumerate system interfaces (IPv4 only)
|
||||
for (const entries of Object.values(interfaces)) {
|
||||
if (!entries) continue
|
||||
for (const entry of entries) {
|
||||
const family = normalizeFamily(entry.family)
|
||||
if (!family) continue
|
||||
if (!entry.address || entry.address === "0.0.0.0") continue
|
||||
const scope: NetworkAddress["scope"] = entry.internal ? "loopback" : "external"
|
||||
addAddress(entry.address, scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always include loopback address
|
||||
addAddress("127.0.0.1", "loopback")
|
||||
|
||||
// Include explicitly configured host if it was IPv4
|
||||
if (isIPv4Address(host) && host !== "0.0.0.0") {
|
||||
const isLoopback = host.startsWith("127.")
|
||||
addAddress(host, isLoopback ? "loopback" : "external")
|
||||
}
|
||||
|
||||
const scopeWeight: Record<NetworkAddress["scope"], number> = { external: 0, internal: 1, loopback: 2 }
|
||||
|
||||
return results.sort((a, b) => {
|
||||
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
|
||||
if (scopeDelta !== 0) return scopeDelta
|
||||
return a.ip.localeCompare(b.ip)
|
||||
})
|
||||
}
|
||||
|
||||
function isIPv4Address(value: string | undefined): value is string {
|
||||
if (!value) return false
|
||||
const parts = value.split(".")
|
||||
if (parts.length !== 4) return false
|
||||
return parts.every((part) => {
|
||||
if (part.length === 0 || part.length > 3) return false
|
||||
if (!/^[0-9]+$/.test(part)) return false
|
||||
const num = Number(part)
|
||||
return Number.isInteger(num) && num >= 0 && num <= 255
|
||||
})
|
||||
}
|
||||
|
||||
@@ -159,6 +159,10 @@ export class InstanceEventBridge {
|
||||
|
||||
try {
|
||||
const event = JSON.parse(payload) as InstanceStreamEvent
|
||||
this.options.logger.debug({ workspaceId, eventType: event.type }, "Instance SSE event received")
|
||||
if (this.options.logger.isLevelEnabled("trace")) {
|
||||
this.options.logger.trace({ workspaceId, event }, "Instance SSE event payload")
|
||||
}
|
||||
this.options.eventBus.publish({ type: "instance.event", instanceId: workspaceId, event })
|
||||
} catch (error) {
|
||||
this.options.logger.warn({ workspaceId, chunk: payload, err: error }, "Failed to parse instance SSE payload")
|
||||
@@ -166,6 +170,7 @@ export class InstanceEventBridge {
|
||||
}
|
||||
|
||||
private publishStatus(instanceId: string, status: InstanceStreamStatus, reason?: string) {
|
||||
this.options.logger.debug({ instanceId, status, reason }, "Instance SSE status updated")
|
||||
this.options.eventBus.publish({ type: "instance.eventStatus", instanceId, status, reason })
|
||||
}
|
||||
|
||||
|
||||
318
packages/tauri-app/Cargo.lock
generated
318
packages/tauri-app/Cargo.lock
generated
@@ -80,6 +80,79 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-executor"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8"
|
||||
dependencies = [
|
||||
"async-task",
|
||||
"concurrent-queue",
|
||||
"fastrand",
|
||||
"futures-lite",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-io"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"parking",
|
||||
"polling",
|
||||
"rustix 1.1.2",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-lock"
|
||||
version = "3.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-process"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-signal",
|
||||
"async-task",
|
||||
"blocking",
|
||||
"cfg-if",
|
||||
"event-listener",
|
||||
"futures-lite",
|
||||
"rustix 1.1.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-recursion"
|
||||
version = "1.1.1"
|
||||
@@ -91,6 +164,30 @@ dependencies = [
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-signal"
|
||||
version = "0.2.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c"
|
||||
dependencies = [
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"atomic-waker",
|
||||
"cfg-if",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"rustix 1.1.2",
|
||||
"signal-hook-registry",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-task"
|
||||
version = "4.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
@@ -191,6 +288,19 @@ dependencies = [
|
||||
"objc2 0.6.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blocking"
|
||||
version = "1.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-task",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "8.0.2"
|
||||
@@ -372,6 +482,7 @@ name = "codenomad-tauri"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dirs 5.0.1",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
@@ -381,6 +492,7 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-opener",
|
||||
"thiserror 1.0.69",
|
||||
"which",
|
||||
]
|
||||
@@ -608,13 +720,34 @@ dependencies = [
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "5.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
||||
dependencies = [
|
||||
"dirs-sys 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
"dirs-sys 0.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.4.6",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -625,7 +758,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -1335,6 +1468,12 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
@@ -1637,6 +1776,25 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-wsl"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||
dependencies = [
|
||||
"is-docker",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.15"
|
||||
@@ -2294,6 +2452,18 @@ version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"is-wsl",
|
||||
"libc",
|
||||
"pathdiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -2364,6 +2534,12 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -2516,6 +2692,17 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "piper"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"fastrand",
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.32"
|
||||
@@ -2548,6 +2735,20 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"hermit-abi",
|
||||
"pin-project-lite",
|
||||
"rustix 1.1.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.4"
|
||||
@@ -2804,6 +3005,17 @@ dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||
dependencies = [
|
||||
"getrandom 0.2.16",
|
||||
"libredox",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.2"
|
||||
@@ -3530,7 +3742,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"embed_plist",
|
||||
"getrandom 0.3.4",
|
||||
@@ -3580,7 +3792,7 @@ checksum = "87d6f8cafe6a75514ce5333f115b7b1866e8e68d9672bf4ca89fc0f35697ea9d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cargo_toml",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"glob",
|
||||
"heck 0.5.0",
|
||||
"json-patch",
|
||||
@@ -3692,6 +3904,28 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c26b72571d25dee25667940027114e60f569fc3974f8cefbe50c2cbc5fd65e3b"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"glob",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation 0.3.2",
|
||||
"open",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.17",
|
||||
"url",
|
||||
"windows",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.9.1"
|
||||
@@ -4106,7 +4340,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3d5572781bee8e3f994d7467084e1b1fd7a93ce66bd480f8156ba89dee55a2b"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"libappindicator",
|
||||
"muda",
|
||||
"objc2 0.6.3",
|
||||
@@ -4750,6 +4984,15 @@ dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||
dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
@@ -4792,6 +5035,21 @@ dependencies = [
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.48.5",
|
||||
"windows_aarch64_msvc 0.48.5",
|
||||
"windows_i686_gnu 0.48.5",
|
||||
"windows_i686_msvc 0.48.5",
|
||||
"windows_x86_64_gnu 0.48.5",
|
||||
"windows_x86_64_gnullvm 0.48.5",
|
||||
"windows_x86_64_msvc 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
@@ -4849,6 +5107,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -4867,6 +5131,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -4885,6 +5155,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -4915,6 +5191,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -4933,6 +5215,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -4951,6 +5239,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -4969,6 +5263,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -5031,7 +5331,7 @@ dependencies = [
|
||||
"block2 0.6.2",
|
||||
"cookie",
|
||||
"crossbeam-channel",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"dpi",
|
||||
"dunce",
|
||||
"gdkx11",
|
||||
@@ -5117,8 +5417,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-executor",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-process",
|
||||
"async-recursion",
|
||||
"async-task",
|
||||
"async-trait",
|
||||
"blocking",
|
||||
"enumflags2",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.2.7",
|
||||
"version": "0.2.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "npx --yes @tauri-apps/cli@^2.9.4 dev",
|
||||
|
||||
@@ -18,3 +18,5 @@ anyhow = "1"
|
||||
which = "4"
|
||||
libc = "0.2"
|
||||
tauri-plugin-dialog = "2"
|
||||
dirs = "5"
|
||||
tauri-plugin-opener = "2"
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"dialog:allow-open"
|
||||
"dialog:allow-open",
|
||||
"opener:allow-default-urls"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
use dirs::home_dir;
|
||||
use parking_lot::Mutex;
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::collections::VecDeque;
|
||||
use std::env;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
@@ -41,6 +44,66 @@ fn navigate_main(app: &AppHandle, url: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PreferencesConfig {
|
||||
#[serde(rename = "listeningMode")]
|
||||
listening_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AppConfig {
|
||||
preferences: Option<PreferencesConfig>,
|
||||
}
|
||||
|
||||
fn resolve_config_path() -> PathBuf {
|
||||
let raw = env::var("CLI_CONFIG")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
|
||||
expand_home(&raw)
|
||||
}
|
||||
|
||||
fn expand_home(path: &str) -> PathBuf {
|
||||
if path.starts_with("~/") {
|
||||
if let Some(home) = home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from)) {
|
||||
return home.join(path.trim_start_matches("~/"));
|
||||
}
|
||||
}
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
fn resolve_listening_mode() -> String {
|
||||
let path = resolve_config_path();
|
||||
if let Ok(content) = fs::read_to_string(path) {
|
||||
if let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
|
||||
if let Some(mode) = config
|
||||
.preferences
|
||||
.as_ref()
|
||||
.and_then(|prefs| prefs.listening_mode.as_ref())
|
||||
{
|
||||
if mode == "local" {
|
||||
return "local".to_string();
|
||||
}
|
||||
if mode == "all" {
|
||||
return "all".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"local".to_string()
|
||||
}
|
||||
|
||||
fn resolve_listening_host() -> String {
|
||||
let mode = resolve_listening_mode();
|
||||
if mode == "local" {
|
||||
"127.0.0.1".to_string()
|
||||
} else {
|
||||
"0.0.0.0".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CliState {
|
||||
@@ -178,11 +241,12 @@ impl CliProcessManager {
|
||||
) -> anyhow::Result<()> {
|
||||
log_line("resolving CLI entry");
|
||||
let resolution = CliEntry::resolve(&app, dev)?;
|
||||
let host = resolve_listening_host();
|
||||
log_line(&format!(
|
||||
"resolved CLI entry runner={:?} entry={}",
|
||||
resolution.runner, resolution.entry
|
||||
"resolved CLI entry runner={:?} entry={} host={}",
|
||||
resolution.runner, resolution.entry, host
|
||||
));
|
||||
let args = resolution.build_args(dev);
|
||||
let args = resolution.build_args(dev, &host);
|
||||
log_line(&format!("CLI args: {:?}", args));
|
||||
if dev {
|
||||
log_line("development mode: will prefer tsx + source if present");
|
||||
@@ -280,7 +344,7 @@ impl CliProcessManager {
|
||||
let ready_clone = ready.clone();
|
||||
let child_holder_clone = child_holder.clone();
|
||||
thread::spawn(move || {
|
||||
let timeout = Duration::from_secs(15);
|
||||
let timeout = Duration::from_secs(60);
|
||||
thread::sleep(timeout);
|
||||
if ready_clone.load(Ordering::SeqCst) {
|
||||
return;
|
||||
@@ -480,11 +544,11 @@ impl CliEntry {
|
||||
))
|
||||
}
|
||||
|
||||
fn build_args(&self, dev: bool) -> Vec<String> {
|
||||
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"serve".to_string(),
|
||||
"--host".to_string(),
|
||||
"127.0.0.1".to_string(),
|
||||
host.to_string(),
|
||||
"--port".to_string(),
|
||||
"0".to_string(),
|
||||
];
|
||||
|
||||
@@ -5,7 +5,11 @@ mod cli_manager;
|
||||
use cli_manager::{CliProcessManager, CliStatus};
|
||||
use serde_json::json;
|
||||
use tauri::menu::Menu;
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tauri::plugin::Builder as PluginBuilder;
|
||||
use tauri::webview::Webview;
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
@@ -17,36 +21,89 @@ fn cli_get_status(state: tauri::State<AppState>) -> CliStatus {
|
||||
state.manager.status()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatus, String> {
|
||||
let dev_mode = is_dev_mode();
|
||||
state.manager.stop().map_err(|e| e.to_string())?;
|
||||
state
|
||||
.manager
|
||||
.start(app, dev_mode)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(state.manager.status())
|
||||
}
|
||||
|
||||
fn is_dev_mode() -> bool {
|
||||
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
|
||||
}
|
||||
|
||||
fn should_allow_internal(url: &Url) -> bool {
|
||||
match url.scheme() {
|
||||
"tauri" | "asset" | "file" => true,
|
||||
"http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "localhost")),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
||||
if should_allow_internal(url) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Err(err) = webview
|
||||
.app_handle()
|
||||
.opener()
|
||||
.open_url(url.as_str(), None::<&str>)
|
||||
{
|
||||
eprintln!("[tauri] failed to open external link {}: {}", url, err);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let navigation_guard = PluginBuilder::new("external-link-guard")
|
||||
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
||||
.build();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(navigation_guard)
|
||||
.manage(AppState {
|
||||
manager: CliProcessManager::new(),
|
||||
})
|
||||
.setup(|app| {
|
||||
build_menu(&app.handle())?;
|
||||
let dev_mode = cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok();
|
||||
let dev_mode = is_dev_mode();
|
||||
let app_handle = app.handle().clone();
|
||||
let manager = app.state::<AppState>().manager.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(err) = manager.start(app_handle.clone(), dev_mode) {
|
||||
let _ = app_handle.emit(
|
||||
"cli:error",
|
||||
json!({"message": err.to_string()}),
|
||||
);
|
||||
let _ = app_handle.emit("cli:error", json!({"message": err.to_string()}));
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![cli_get_status])
|
||||
.invoke_handler(tauri::generate_handler![cli_get_status, cli_restart])
|
||||
.on_menu_event(|_app_handle, _event| {
|
||||
// No menu items defined currently
|
||||
})
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app_handle, event| {
|
||||
match event {
|
||||
tauri::RunEvent::ExitRequested { .. } => {
|
||||
.run(|app_handle, event| match event {
|
||||
tauri::RunEvent::ExitRequested { .. } => {
|
||||
let app = app_handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
let _ = state.manager.stop();
|
||||
}
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
tauri::RunEvent::WindowEvent {
|
||||
event: tauri::WindowEvent::Destroyed,
|
||||
..
|
||||
} => {
|
||||
if app_handle.webview_windows().len() <= 1 {
|
||||
let app = app_handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
@@ -55,19 +112,8 @@ fn main() {
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
tauri::RunEvent::WindowEvent { event: tauri::WindowEvent::Destroyed, .. } => {
|
||||
if app_handle.webview_windows().len() <= 1 {
|
||||
let app = app_handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
let _ = state.manager.stop();
|
||||
}
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -26,8 +26,29 @@ This starts the Vite dev server at `http://localhost:3000`.
|
||||
|
||||
To build the production assets:
|
||||
|
||||
```bash
|
||||
```
|
||||
npm run build
|
||||
```
|
||||
|
||||
The output will be generated in the `dist` directory, which is then consumed by the Server or Electron app.
|
||||
|
||||
## Debug Logging
|
||||
|
||||
The UI now routes all logging through a lightweight wrapper around [`debug`](https://github.com/debug-js/debug). The logger exposes four namespaces that can be toggled at runtime:
|
||||
|
||||
- `sse` – Server-sent event transport and handlers
|
||||
- `api` – HTTP/API calls and workspace lifecycle
|
||||
- `session` – Session/model state, prompt handling, tool calls
|
||||
- `actions` – User-driven interactions in UI components
|
||||
|
||||
You can enable or disable namespaces from DevTools (in dev or production builds) via the global `window.codenomadLogger` helpers:
|
||||
|
||||
```js
|
||||
window.codenomadLogger?.listLoggerNamespaces() // => [{ name: "sse", enabled: false }, ...]
|
||||
window.codenomadLogger?.enableLogger("sse") // turn on SSE logs
|
||||
window.codenomadLogger?.disableLogger("sse") // turn them off again
|
||||
window.codenomadLogger?.enableAllLoggers() // optional helper
|
||||
```
|
||||
|
||||
Enabled namespaces are persisted in `localStorage` under `opencode:logger:namespaces`, so your preference survives reloads.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.2.7",
|
||||
"version": "0.2.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -12,11 +12,13 @@
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode-ai/sdk": "1.0.68",
|
||||
"@opencode-ai/sdk": "^1.0.133",
|
||||
"@solidjs/router": "^0.13.0",
|
||||
"debug": "^4.4.3",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
"lucide-solid": "^0.300.0",
|
||||
"marked": "^12.0.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"shiki": "^3.13.0",
|
||||
"solid-js": "^1.8.0",
|
||||
"solid-toast": "^0.5.0"
|
||||
|
||||
@@ -7,10 +7,13 @@ import { showConfirmDialog } from "./stores/alerts"
|
||||
import InstanceTabs from "./components/instance-tabs"
|
||||
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
||||
import InstanceShell from "./components/instance/instance-shell"
|
||||
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
|
||||
import { initMarkdown } from "./lib/markdown"
|
||||
import { useTheme } from "./lib/theme"
|
||||
import { useCommands } from "./lib/hooks/use-commands"
|
||||
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
||||
import { getLogger } from "./lib/logger"
|
||||
import { initReleaseNotifications } from "./stores/releases"
|
||||
import {
|
||||
hasInstances,
|
||||
isSelectingFolder,
|
||||
@@ -41,6 +44,8 @@ import {
|
||||
updateSessionModel,
|
||||
} from "./stores/sessions"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
const App: Component = () => {
|
||||
const { isDark } = useTheme()
|
||||
const {
|
||||
@@ -57,9 +62,14 @@ const App: Component = () => {
|
||||
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
||||
const [launchErrorBinary, setLaunchErrorBinary] = createSignal<string | null>(null)
|
||||
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
|
||||
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
||||
|
||||
createEffect(() => {
|
||||
void initMarkdown(isDark()).catch(console.error)
|
||||
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
initReleaseNotifications()
|
||||
})
|
||||
|
||||
const activeInstance = createMemo(() => getActiveInstance())
|
||||
@@ -104,13 +114,16 @@ const App: Component = () => {
|
||||
setShowFolderSelection(false)
|
||||
setIsAdvancedSettingsOpen(false)
|
||||
|
||||
console.log("Created instance:", instanceId, "Port:", instances().get(instanceId)?.port)
|
||||
log.info("Created instance", {
|
||||
instanceId,
|
||||
port: instances().get(instanceId)?.port,
|
||||
})
|
||||
} catch (error) {
|
||||
clearLaunchError()
|
||||
if (isMissingBinaryError(error)) {
|
||||
setLaunchErrorBinary(selectedBinary)
|
||||
}
|
||||
console.error("Failed to create instance:", error)
|
||||
log.error("Failed to create instance", error)
|
||||
} finally {
|
||||
setIsSelectingFolder(false)
|
||||
}
|
||||
@@ -135,7 +148,7 @@ const App: Component = () => {
|
||||
try {
|
||||
await acknowledgeDisconnectedInstance()
|
||||
} catch (error) {
|
||||
console.error("Failed to finalize disconnected instance:", error)
|
||||
log.error("Failed to finalize disconnected instance", error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +176,7 @@ const App: Component = () => {
|
||||
const session = await createSession(instanceId)
|
||||
setActiveParentSession(instanceId, session.id)
|
||||
} catch (error) {
|
||||
console.error("Failed to create session:", error)
|
||||
log.error("Failed to create session", error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,7 +200,7 @@ const App: Component = () => {
|
||||
try {
|
||||
await fetchSessions(instanceId)
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh sessions after closing:", error)
|
||||
log.error("Failed to refresh sessions after closing", error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,6 +297,7 @@ const App: Component = () => {
|
||||
onSelect={setActiveInstanceId}
|
||||
onClose={handleCloseInstance}
|
||||
onNew={handleNewInstanceRequest}
|
||||
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
||||
/>
|
||||
|
||||
<Show when={activeInstance()} keyed>
|
||||
@@ -309,6 +323,7 @@ const App: Component = () => {
|
||||
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
||||
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
||||
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
||||
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
@@ -338,7 +353,9 @@ const App: Component = () => {
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
|
||||
<RemoteAccessOverlay open={remoteAccessOpen()} onClose={() => setRemoteAccessOpen(false)} />
|
||||
|
||||
<AlertDialog />
|
||||
|
||||
<Toaster
|
||||
@@ -354,4 +371,5 @@ const App: Component = () => {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default App
|
||||
|
||||
@@ -3,6 +3,9 @@ import { For, Show, createEffect, createMemo } from "solid-js"
|
||||
import { agents, fetchAgents, sessions } from "../stores/sessions"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
import type { Agent } from "../types/session"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("session")
|
||||
|
||||
|
||||
interface AgentSelectorProps {
|
||||
instanceId: string
|
||||
@@ -49,10 +52,11 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
||||
|
||||
createEffect(() => {
|
||||
if (instanceAgents().length === 0) {
|
||||
fetchAgents(props.instanceId).catch(console.error)
|
||||
fetchAgents(props.instanceId).catch((error) => log.error("Failed to fetch agents", error))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const handleChange = async (value: Agent | null) => {
|
||||
if (value && value.name !== props.currentAgent) {
|
||||
await props.onAgentChange(value.name)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
|
||||
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
|
||||
import { disableCache } from "@git-diff-view/core"
|
||||
import type { DiffHighlighterLang } from "@git-diff-view/core"
|
||||
import { ErrorBoundary } from "solid-js"
|
||||
import { getLanguageFromPath } from "../lib/markdown"
|
||||
@@ -7,6 +8,11 @@ import { normalizeDiffText } from "../lib/diff-utils"
|
||||
import { setCacheEntry } from "../lib/global-cache"
|
||||
import type { CacheEntryParams } from "../lib/global-cache"
|
||||
import type { DiffViewMode } from "../stores/preferences"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("session")
|
||||
|
||||
|
||||
disableCache()
|
||||
|
||||
interface ToolCallDiffViewerProps {
|
||||
diffText: string
|
||||
@@ -107,7 +113,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
||||
>
|
||||
{(data) => (
|
||||
<ErrorBoundary fallback={(error) => {
|
||||
console.warn("Failed to render diff view", error)
|
||||
log.warn("Failed to render diff view", error)
|
||||
return <pre class="tool-call-diff-fallback">{props.diffText}</pre>
|
||||
}}>
|
||||
<DiffView
|
||||
|
||||
@@ -2,6 +2,9 @@ import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup
|
||||
import { Folder as FolderIcon, File as FileIcon, Loader2, Search, X, ArrowUpLeft } from "lucide-solid"
|
||||
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("actions")
|
||||
|
||||
|
||||
const MAX_RESULTS = 200
|
||||
|
||||
@@ -172,7 +175,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
||||
|
||||
function handleNavigateTo(path: string) {
|
||||
void fetchDirectory(path, true).catch((err) => {
|
||||
console.error("Failed to open directory", err)
|
||||
log.error("Failed to open directory", err)
|
||||
setError(err instanceof Error ? err.message : "Unable to open directory")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight } from "lucide-solid"
|
||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp } from "lucide-solid"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import AdvancedSettingsModal from "./advanced-settings-modal"
|
||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||
@@ -8,12 +8,14 @@ import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/nat
|
||||
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
|
||||
|
||||
interface FolderSelectionViewProps {
|
||||
onSelectFolder: (folder: string, binaryPath?: string) => void
|
||||
isLoading?: boolean
|
||||
advancedSettingsOpen?: boolean
|
||||
onAdvancedSettingsOpen?: () => void
|
||||
onAdvancedSettingsClose?: () => void
|
||||
onOpenRemoteAccess?: () => void
|
||||
}
|
||||
|
||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
@@ -229,6 +231,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
class="w-full max-w-3xl h-full px-8 pb-2 flex flex-col overflow-hidden"
|
||||
aria-busy={isLoading() ? "true" : "false"}
|
||||
>
|
||||
<Show when={props.onOpenRemoteAccess}>
|
||||
<div class="absolute top-4 right-6">
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary inline-flex items-center justify-center"
|
||||
onClick={() => props.onOpenRemoteAccess?.()}
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="mb-6 text-center shrink-0">
|
||||
<div class="mb-3 flex justify-center">
|
||||
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" />
|
||||
@@ -241,6 +254,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<div class="space-y-4 flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||
|
||||
<Show
|
||||
|
||||
|
||||
when={folders().length > 0}
|
||||
fallback={
|
||||
<div class="panel panel-empty-state flex-1">
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Component, Show, For, createSignal, createEffect, onCleanup } from "solid-js"
|
||||
import type { Instance, RawMcpStatus } from "../types/instance"
|
||||
import { fetchLspStatus, updateInstance } from "../stores/instances"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
interface InstanceInfoProps {
|
||||
instance: Instance
|
||||
@@ -113,7 +116,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.error("Failed to load instance metadata:", error)
|
||||
log.error("Failed to load instance metadata", error)
|
||||
}
|
||||
} finally {
|
||||
pendingMetadataRequests.delete(instanceId)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Component, For, Show } from "solid-js"
|
||||
import type { Instance } from "../types/instance"
|
||||
import InstanceTab from "./instance-tab"
|
||||
import KeyboardHint from "./keyboard-hint"
|
||||
import { Plus } from "lucide-solid"
|
||||
import { Plus, MonitorUp } from "lucide-solid"
|
||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||
|
||||
interface InstanceTabsProps {
|
||||
@@ -11,43 +11,60 @@ interface InstanceTabsProps {
|
||||
onSelect: (instanceId: string) => void
|
||||
onClose: (instanceId: string) => void
|
||||
onNew: () => void
|
||||
onOpenRemoteAccess?: () => void
|
||||
}
|
||||
|
||||
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
return (
|
||||
<div class="tab-bar tab-bar-instance">
|
||||
<div class="tab-container" role="tablist">
|
||||
<div class="flex items-center gap-1 overflow-x-auto">
|
||||
<For each={Array.from(props.instances.entries())}>
|
||||
{([id, instance]) => (
|
||||
<InstanceTab
|
||||
instance={instance}
|
||||
active={id === props.activeInstanceId}
|
||||
onSelect={() => props.onSelect(id)}
|
||||
onClose={() => props.onClose(id)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<button
|
||||
class="new-tab-button"
|
||||
onClick={props.onNew}
|
||||
title="New instance (Cmd/Ctrl+N)"
|
||||
aria-label="New instance"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<Show when={Array.from(props.instances.entries()).length > 1}>
|
||||
<div class="flex-shrink-0 ml-4">
|
||||
<KeyboardHint
|
||||
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(
|
||||
Boolean,
|
||||
)}
|
||||
/>
|
||||
<div class="tab-scroll">
|
||||
<div class="tab-strip">
|
||||
<div class="tab-strip-tabs">
|
||||
<For each={Array.from(props.instances.entries())}>
|
||||
{([id, instance]) => (
|
||||
<InstanceTab
|
||||
instance={instance}
|
||||
active={id === props.activeInstanceId}
|
||||
onSelect={() => props.onSelect(id)}
|
||||
onClose={() => props.onClose(id)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<button
|
||||
class="new-tab-button"
|
||||
onClick={props.onNew}
|
||||
title="New instance (Cmd/Ctrl+N)"
|
||||
aria-label="New instance"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="tab-strip-spacer" />
|
||||
<Show when={Array.from(props.instances.entries()).length > 1}>
|
||||
<div class="tab-shortcuts">
|
||||
<KeyboardHint
|
||||
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(
|
||||
Boolean,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={Boolean(props.onOpenRemoteAccess)}>
|
||||
<button
|
||||
class="new-tab-button tab-remote-button"
|
||||
onClick={() => props.onOpenRemoteAccess?.()}
|
||||
title="Remote connect"
|
||||
aria-label="Remote connect"
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Component, createSignal, Show, For, createEffect, onMount, onCleanup, createMemo } from "solid-js"
|
||||
import { Loader2, Trash2 } from "lucide-solid"
|
||||
|
||||
import type { Instance } from "../types/instance"
|
||||
import { getParentSessions, createSession, setActiveParentSession } from "../stores/sessions"
|
||||
import { getParentSessions, createSession, setActiveParentSession, deleteSession, loading } from "../stores/sessions"
|
||||
import InstanceInfo from "./instance-info"
|
||||
import KeyboardHint from "./keyboard-hint"
|
||||
import Kbd from "./kbd"
|
||||
import { keyboardRegistry, type KeyboardShortcut } from "../lib/keyboard-registry"
|
||||
import { isMac } from "../lib/keyboard-utils"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("actions")
|
||||
|
||||
|
||||
|
||||
interface InstanceWelcomeViewProps {
|
||||
@@ -16,8 +20,17 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
const [isCreating, setIsCreating] = createSignal(false)
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
const [focusMode, setFocusMode] = createSignal<"sessions" | "new-session" | null>("sessions")
|
||||
const [showInstanceInfoOverlay, setShowInstanceInfoOverlay] = createSignal(false)
|
||||
const [isDesktopLayout, setIsDesktopLayout] = createSignal(
|
||||
typeof window !== "undefined" ? window.matchMedia("(min-width: 1024px)").matches : false,
|
||||
)
|
||||
|
||||
const parentSessions = () => getParentSessions(props.instance.id)
|
||||
const isFetchingSessions = createMemo(() => Boolean(loading().fetchingSessions.get(props.instance.id)))
|
||||
const isSessionDeleting = (sessionId: string) => {
|
||||
const deleting = loading().deletingSession.get(props.instance.id)
|
||||
return deleting ? deleting.has(sessionId) : false
|
||||
}
|
||||
const newSessionShortcut = createMemo<KeyboardShortcut>(() => {
|
||||
const registered = keyboardRegistry.get("session-new")
|
||||
if (registered) return registered
|
||||
@@ -47,6 +60,12 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
}
|
||||
})
|
||||
|
||||
const openInstanceInfoOverlay = () => {
|
||||
if (isDesktopLayout()) return
|
||||
setShowInstanceInfoOverlay(true)
|
||||
}
|
||||
const closeInstanceInfoOverlay = () => setShowInstanceInfoOverlay(false)
|
||||
|
||||
function scrollToIndex(index: number) {
|
||||
const element = document.querySelector(`[data-session-index="${index}"]`)
|
||||
if (element) {
|
||||
@@ -55,6 +74,14 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (showInstanceInfoOverlay()) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
closeInstanceInfoOverlay()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const sessions = parentSessions()
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "n") {
|
||||
@@ -104,26 +131,79 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
scrollToIndex(newIndex)
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleEnterKey()
|
||||
void handleEnterKey()
|
||||
} else if (e.key === "Delete" || e.key === "Backspace") {
|
||||
e.preventDefault()
|
||||
void handleDeleteKey()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEnterKey() {
|
||||
const sessions = parentSessions()
|
||||
const index = selectedIndex()
|
||||
|
||||
|
||||
if (index < sessions.length) {
|
||||
await handleSessionSelect(sessions[index].id)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
|
||||
async function handleDeleteKey() {
|
||||
const sessions = parentSessions()
|
||||
const index = selectedIndex()
|
||||
|
||||
if (index >= sessions.length) {
|
||||
return
|
||||
}
|
||||
|
||||
await handleSessionDelete(sessions[index].id)
|
||||
|
||||
const updatedSessions = parentSessions()
|
||||
if (updatedSessions.length === 0) {
|
||||
setFocusMode("new-session")
|
||||
setSelectedIndex(0)
|
||||
return
|
||||
}
|
||||
|
||||
const nextIndex = Math.min(index, updatedSessions.length - 1)
|
||||
setSelectedIndex(nextIndex)
|
||||
setFocusMode("sessions")
|
||||
scrollToIndex(nextIndex)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const mediaQuery = window.matchMedia("(min-width: 1024px)")
|
||||
const handleMediaChange = (matches: boolean) => {
|
||||
setIsDesktopLayout(matches)
|
||||
if (matches) {
|
||||
closeInstanceInfoOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
const listener = (event: MediaQueryListEvent) => handleMediaChange(event.matches)
|
||||
|
||||
if (typeof mediaQuery.addEventListener === "function") {
|
||||
mediaQuery.addEventListener("change", listener)
|
||||
onCleanup(() => {
|
||||
mediaQuery.removeEventListener("change", listener)
|
||||
})
|
||||
} else {
|
||||
mediaQuery.addListener(listener)
|
||||
onCleanup(() => {
|
||||
mediaQuery.removeListener(listener)
|
||||
})
|
||||
}
|
||||
|
||||
handleMediaChange(mediaQuery.matches)
|
||||
})
|
||||
|
||||
function formatRelativeTime(timestamp: number): string {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
@@ -144,15 +224,26 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
setActiveParentSession(props.instance.id, sessionId)
|
||||
}
|
||||
|
||||
async function handleSessionDelete(sessionId: string) {
|
||||
if (isSessionDeleting(sessionId)) return
|
||||
|
||||
try {
|
||||
await deleteSession(props.instance.id, sessionId)
|
||||
} catch (error) {
|
||||
log.error("Failed to delete session:", error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNewSession() {
|
||||
if (isCreating()) return
|
||||
|
||||
setIsCreating(true)
|
||||
|
||||
try {
|
||||
const session = await createSession(props.instance.id)
|
||||
setActiveParentSession(props.instance.id, session.id)
|
||||
} catch (error) {
|
||||
console.error("Failed to create session:", error)
|
||||
log.error("Failed to create session:", error)
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
@@ -165,73 +256,138 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
<Show
|
||||
when={parentSessions().length > 0}
|
||||
fallback={
|
||||
<div class="panel panel-empty-state flex-1 flex flex-col justify-center">
|
||||
<div class="panel-empty-state-icon">
|
||||
<svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
<Show
|
||||
when={isFetchingSessions()}
|
||||
fallback={
|
||||
<div class="panel panel-empty-state flex-1 flex flex-col justify-center">
|
||||
<div class="panel-empty-state-icon">
|
||||
<svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="panel-empty-state-title">No Previous Sessions</p>
|
||||
<p class="panel-empty-state-description">Create a new session below to get started</p>
|
||||
<Show when={!isDesktopLayout() && !showInstanceInfoOverlay()}>
|
||||
<button type="button" class="button-tertiary mt-4 lg:hidden" onClick={openInstanceInfoOverlay}>
|
||||
View Instance Info
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="panel panel-empty-state flex-1 flex flex-col justify-center">
|
||||
<div class="panel-empty-state-icon">
|
||||
<Loader2 class="w-12 h-12 mx-auto animate-spin text-muted" />
|
||||
</div>
|
||||
<p class="panel-empty-state-title">Loading Sessions</p>
|
||||
<p class="panel-empty-state-description">Fetching your previous sessions...</p>
|
||||
</div>
|
||||
<p class="panel-empty-state-title">No Previous Sessions</p>
|
||||
<p class="panel-empty-state-description">Create a new session below to get started</p>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="panel flex flex-col flex-1 min-h-0">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">Resume Session</h2>
|
||||
<p class="panel-subtitle">
|
||||
{parentSessions().length} {parentSessions().length === 1 ? "session" : "sessions"} available
|
||||
</p>
|
||||
<div class="flex flex-row flex-wrap items-center gap-2 justify-between">
|
||||
<div>
|
||||
<h2 class="panel-title">Resume Session</h2>
|
||||
<p class="panel-subtitle">
|
||||
{parentSessions().length} {parentSessions().length === 1 ? "session" : "sessions"} available
|
||||
</p>
|
||||
</div>
|
||||
<Show when={!isDesktopLayout() && !showInstanceInfoOverlay()}>
|
||||
<button
|
||||
type="button"
|
||||
class="button-tertiary lg:hidden flex-shrink-0"
|
||||
onClick={openInstanceInfoOverlay}
|
||||
>
|
||||
View Instance Info
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto">
|
||||
<For each={parentSessions()}>
|
||||
{(session, index) => (
|
||||
<div
|
||||
class="panel-list-item"
|
||||
classList={{
|
||||
"panel-list-item-highlight": focusMode() === "sessions" && selectedIndex() === index(),
|
||||
}}
|
||||
>
|
||||
<button
|
||||
data-session-index={index()}
|
||||
class="panel-list-item-content group w-full"
|
||||
onClick={() => handleSessionSelect(session.id)}
|
||||
onMouseEnter={() => {
|
||||
setFocusMode("sessions")
|
||||
setSelectedIndex(index())
|
||||
{(session, index) => {
|
||||
const isFocused = () => focusMode() === "sessions" && selectedIndex() === index()
|
||||
return (
|
||||
<div
|
||||
class="panel-list-item"
|
||||
classList={{
|
||||
"panel-list-item-highlight": isFocused(),
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 w-full">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-sm font-medium text-primary truncate transition-colors"
|
||||
classList={{
|
||||
"text-accent":
|
||||
focusMode() === "sessions" && selectedIndex() === index(),
|
||||
<div class="flex items-center gap-2 w-full px-1">
|
||||
<button
|
||||
type="button"
|
||||
data-session-index={index()}
|
||||
class="panel-list-item-content group flex-1"
|
||||
onClick={() => handleSessionSelect(session.id)}
|
||||
onMouseEnter={() => {
|
||||
setFocusMode("sessions")
|
||||
setSelectedIndex(index())
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 w-full">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-sm font-medium text-primary truncate transition-colors"
|
||||
classList={{
|
||||
"text-accent": isFocused(),
|
||||
}}
|
||||
>
|
||||
{session.title || "Untitled Session"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-muted mt-0.5">
|
||||
<span>{session.agent}</span>
|
||||
<span>•</span>
|
||||
<span>{formatRelativeTime(session.time.updated)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<Show when={isFocused()}>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<kbd class="kbd flex-shrink-0">↵</kbd>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 rounded transition-colors text-muted hover:text-red-500 dark:hover:text-red-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
title="Delete session"
|
||||
disabled={isSessionDeleting(session.id)}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void handleSessionDelete(session.id)
|
||||
}}
|
||||
>
|
||||
{session.title || "Untitled Session"}
|
||||
</span>
|
||||
<Show
|
||||
when={!isSessionDeleting(session.id)}
|
||||
fallback={
|
||||
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-muted mt-0.5">
|
||||
<span>{session.agent}</span>
|
||||
<span>•</span>
|
||||
<span>{formatRelativeTime(session.time.updated)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={focusMode() === "sessions" && selectedIndex() === index()}>
|
||||
<kbd class="kbd flex-shrink-0">↵</kbd>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
@@ -274,14 +430,38 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:w-80 flex-shrink-0">
|
||||
<div class="hidden lg:block lg:w-80 flex-shrink-0">
|
||||
<div class="sticky top-0">
|
||||
<InstanceInfo instance={props.instance} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={!isDesktopLayout() && showInstanceInfoOverlay()}>
|
||||
<div
|
||||
class="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden"
|
||||
onClick={closeInstanceInfoOverlay}
|
||||
>
|
||||
<div class="flex min-h-full items-start justify-center p-4 overflow-y-auto">
|
||||
<div
|
||||
class="w-full max-w-md space-y-3"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" class="button-tertiary" onClick={closeInstanceInfoOverlay}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div class="max-h-[85vh] overflow-y-auto pr-1">
|
||||
<InstanceInfo instance={props.instance} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="panel-footer hidden sm:block">
|
||||
|
||||
<div class="panel-footer-hints">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">↑</kbd>
|
||||
@@ -302,12 +482,16 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>Resume</span>
|
||||
</div>
|
||||
<KeyboardHint shortcuts={[newSessionShortcut()]} separator="" />
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Del</kbd>
|
||||
<span>Delete</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InstanceWelcomeView
|
||||
|
||||
export default InstanceWelcomeView
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Show, createMemo, createSignal, type Component } from "solid-js"
|
||||
import { Show, createMemo, createSignal, onCleanup, onMount, type Component } from "solid-js"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { Instance } from "../../types/instance"
|
||||
import type { Command } from "../../lib/commands"
|
||||
@@ -17,6 +17,9 @@ import CommandPalette from "../command-palette"
|
||||
import Kbd from "../kbd"
|
||||
import ContextUsagePanel from "../session/context-usage-panel"
|
||||
import SessionView from "../session/session-view"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
const log = getLogger("session")
|
||||
|
||||
|
||||
interface InstanceShellProps {
|
||||
instance: Instance
|
||||
@@ -30,9 +33,38 @@ interface InstanceShellProps {
|
||||
}
|
||||
|
||||
const DEFAULT_SESSION_SIDEBAR_WIDTH = 350
|
||||
const MOBILE_SIDEBAR_BREAKPOINT = 1024
|
||||
|
||||
const InstanceShell: Component<InstanceShellProps> = (props) => {
|
||||
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
|
||||
const [isCompactLayout, setIsCompactLayout] = createSignal(false)
|
||||
const [isSidebarOpen, setIsSidebarOpen] = createSignal(true)
|
||||
const sidebarId = `session-sidebar-${props.instance.id}`
|
||||
let previousIsCompact = false
|
||||
|
||||
const shouldShowSidebarToggle = () => isCompactLayout() && !isSidebarOpen()
|
||||
|
||||
onMount(() => {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
const handleResize = () => {
|
||||
const compact = window.innerWidth < MOBILE_SIDEBAR_BREAKPOINT
|
||||
setIsCompactLayout(compact)
|
||||
if (!compact) {
|
||||
setIsSidebarOpen(true)
|
||||
} else if (!previousIsCompact && compact) {
|
||||
setIsSidebarOpen(false)
|
||||
}
|
||||
previousIsCompact = compact
|
||||
}
|
||||
|
||||
handleResize()
|
||||
window.addEventListener("resize", handleResize)
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("resize", handleResize)
|
||||
})
|
||||
})
|
||||
|
||||
const activeSessions = createMemo(() => {
|
||||
const parentId = activeParentSessionId().get(props.instance.id)
|
||||
@@ -68,8 +100,20 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
|
||||
return (
|
||||
<>
|
||||
<Show when={activeSessions().size > 0} fallback={<InstanceWelcomeView instance={props.instance} />}>
|
||||
<div class="flex flex-1 min-h-0">
|
||||
<div class="session-sidebar flex flex-col bg-surface-secondary" style={{ width: `${sessionSidebarWidth()}px` }}>
|
||||
<div
|
||||
class="flex flex-1 min-h-0 relative"
|
||||
classList={{ "session-layout-compact": isCompactLayout() }}
|
||||
>
|
||||
<div
|
||||
id={sidebarId}
|
||||
class="session-sidebar flex flex-col bg-surface-secondary"
|
||||
classList={{
|
||||
"session-sidebar-overlay": isCompactLayout(),
|
||||
"session-sidebar-collapsed": isCompactLayout() && !isSidebarOpen(),
|
||||
}}
|
||||
style={!isCompactLayout() ? { width: `${sessionSidebarWidth()}px` } : undefined}
|
||||
aria-hidden={isCompactLayout() && !isSidebarOpen()}
|
||||
>
|
||||
<SessionList
|
||||
instanceId={props.instance.id}
|
||||
sessions={activeSessions()}
|
||||
@@ -78,20 +122,32 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
|
||||
onClose={(id) => {
|
||||
const result = props.onCloseSession(id)
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => console.error("Failed to close session:", error))
|
||||
void result.catch((error) => log.error("Failed to close session:", error))
|
||||
}
|
||||
}}
|
||||
onNew={() => {
|
||||
const result = props.onNewSession()
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => console.error("Failed to create session:", error))
|
||||
void result.catch((error) => log.error("Failed to create session:", error))
|
||||
}
|
||||
}}
|
||||
showHeader
|
||||
showFooter={false}
|
||||
headerContent={
|
||||
<div class="session-sidebar-header">
|
||||
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">Sessions</span>
|
||||
<div class="session-sidebar-header-row">
|
||||
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">Sessions</span>
|
||||
<Show when={isCompactLayout()}>
|
||||
<button
|
||||
type="button"
|
||||
class="session-sidebar-close"
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
aria-label="Close session sidebar"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="session-sidebar-shortcuts">
|
||||
{keyboardShortcuts().length ? (
|
||||
<KeyboardHint shortcuts={keyboardShortcuts()} separator=" " showDescription={false} />
|
||||
@@ -138,6 +194,20 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
|
||||
</div>
|
||||
|
||||
<div class="content-area flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||
<Show
|
||||
when={shouldShowSidebarToggle() && (!activeSessionIdForInstance() || activeSessionIdForInstance() === "info")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="session-sidebar-menu-button session-sidebar-menu-button--floating"
|
||||
onClick={() => setIsSidebarOpen(true)}
|
||||
aria-controls={sidebarId}
|
||||
aria-expanded={isSidebarOpen()}
|
||||
aria-label="Open session list"
|
||||
>
|
||||
<span aria-hidden="true" class="session-sidebar-menu-icon">☰</span>
|
||||
</button>
|
||||
</Show>
|
||||
<Show
|
||||
when={activeSessionIdForInstance() === "info"}
|
||||
fallback={
|
||||
@@ -160,6 +230,9 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
|
||||
instanceId={props.instance.id}
|
||||
instanceFolder={props.instance.folder}
|
||||
escapeInDebounce={props.escapeInDebounce}
|
||||
showSidebarToggle={shouldShowSidebarToggle()}
|
||||
onSidebarToggle={() => setIsSidebarOpen(true)}
|
||||
forceCompactStatusLayout={shouldShowSidebarToggle()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
@@ -168,6 +241,15 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
|
||||
<InfoView instanceId={props.instance.id} />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={isCompactLayout() && isSidebarOpen()}>
|
||||
<button
|
||||
type="button"
|
||||
class="session-sidebar-backdrop"
|
||||
aria-label="Close session sidebar"
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createEffect, createSignal, onMount, onCleanup } from "solid-js"
|
||||
import { renderMarkdown, onLanguagesLoaded, initMarkdown, decodeHtmlEntities } from "../lib/markdown"
|
||||
import type { TextPart } from "../types/message"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("session")
|
||||
|
||||
|
||||
interface MarkdownProps {
|
||||
part: TextPart
|
||||
@@ -43,7 +46,7 @@ export function Markdown(props: MarkdownProps) {
|
||||
notifyRendered()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to render markdown:", error)
|
||||
log.error("Failed to render markdown:", error)
|
||||
if (latestRequestedText === text) {
|
||||
setHtml(text)
|
||||
notifyRendered()
|
||||
@@ -68,7 +71,7 @@ export function Markdown(props: MarkdownProps) {
|
||||
notifyRendered()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to render markdown:", error)
|
||||
log.error("Failed to render markdown:", error)
|
||||
if (latestRequestedText === text) {
|
||||
setHtml(text)
|
||||
part.renderCache = { text, html: text, theme: themeKey }
|
||||
@@ -124,7 +127,7 @@ export function Markdown(props: MarkdownProps) {
|
||||
notifyRendered()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to re-render markdown after language load:", error)
|
||||
log.error("Failed to re-render markdown after language load:", error)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
105
packages/ui/src/components/message-block-list.tsx
Normal file
105
packages/ui/src/components/message-block-list.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Index, createEffect, createSignal, type Accessor } from "solid-js"
|
||||
import VirtualItem from "./virtual-item"
|
||||
import MessageBlock from "./message-block"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
|
||||
const VIRTUAL_ITEM_MARGIN_PX = 800
|
||||
const ESTIMATED_MESSAGE_HEIGHT = 320
|
||||
const INITIAL_FORCE_MIN_ITEMS = 12
|
||||
const INITIAL_FORCE_OVERSCAN = 6
|
||||
|
||||
interface MessageBlockListProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
store: () => InstanceMessageStore
|
||||
messageIds: () => string[]
|
||||
messageIndexMap: () => Map<string, number>
|
||||
lastAssistantIndex: () => number
|
||||
showThinking: () => boolean
|
||||
thinkingDefaultExpanded: () => boolean
|
||||
showUsageMetrics: () => boolean
|
||||
scrollContainer: Accessor<HTMLDivElement | undefined>
|
||||
loading?: boolean
|
||||
onRevert?: (messageId: string) => void
|
||||
onFork?: (messageId?: string) => void
|
||||
onContentRendered?: () => void
|
||||
setBottomSentinel: (element: HTMLDivElement | null) => void
|
||||
}
|
||||
|
||||
export default function MessageBlockList(props: MessageBlockListProps) {
|
||||
const [initialForceActive, setInitialForceActive] = createSignal(true)
|
||||
const [initialForceInitialized, setInitialForceInitialized] = createSignal(false)
|
||||
const [initialForceStartIndex, setInitialForceStartIndex] = createSignal(0)
|
||||
const [, setInitialForceRemaining] = createSignal(0)
|
||||
|
||||
createEffect(() => {
|
||||
props.instanceId
|
||||
props.sessionId
|
||||
setInitialForceActive(true)
|
||||
setInitialForceInitialized(false)
|
||||
setInitialForceStartIndex(0)
|
||||
setInitialForceRemaining(0)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!initialForceActive() || initialForceInitialized()) return
|
||||
const ids = props.messageIds()
|
||||
if (ids.length === 0) return
|
||||
const viewportHeight = props.scrollContainer()?.clientHeight ?? (typeof window !== "undefined" ? window.innerHeight : 800)
|
||||
const estimatedCount = Math.min(
|
||||
ids.length,
|
||||
Math.max(INITIAL_FORCE_MIN_ITEMS, Math.ceil(viewportHeight / ESTIMATED_MESSAGE_HEIGHT) + INITIAL_FORCE_OVERSCAN),
|
||||
)
|
||||
setInitialForceStartIndex(Math.max(0, ids.length - estimatedCount))
|
||||
setInitialForceRemaining(estimatedCount)
|
||||
setInitialForceInitialized(true)
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Index each={props.messageIds()}>
|
||||
{(messageId) => {
|
||||
const messageIndex = () => props.messageIndexMap().get(messageId()) ?? 0
|
||||
const forceVisible = () => initialForceActive() && messageIndex() >= initialForceStartIndex()
|
||||
const handleMeasured = () => {
|
||||
if (!forceVisible()) return
|
||||
setInitialForceRemaining((value) => {
|
||||
const next = value > 0 ? value - 1 : 0
|
||||
if (next === 0) {
|
||||
setInitialForceActive(false)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
return (
|
||||
<VirtualItem
|
||||
cacheKey={messageId()}
|
||||
scrollContainer={props.scrollContainer}
|
||||
threshold={VIRTUAL_ITEM_MARGIN_PX}
|
||||
placeholderClass="message-stream-placeholder"
|
||||
virtualizationEnabled={() => !props.loading}
|
||||
forceVisible={forceVisible}
|
||||
onMeasured={handleMeasured}
|
||||
>
|
||||
<MessageBlock
|
||||
messageId={messageId()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={props.store}
|
||||
messageIndexMap={props.messageIndexMap}
|
||||
lastAssistantIndex={props.lastAssistantIndex}
|
||||
showThinking={props.showThinking}
|
||||
thinkingDefaultExpanded={props.thinkingDefaultExpanded}
|
||||
showUsageMetrics={props.showUsageMetrics}
|
||||
onRevert={props.onRevert}
|
||||
onFork={props.onFork}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
</VirtualItem>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
<div ref={props.setBottomSentinel} aria-hidden="true" style={{ height: "1px" }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +1,24 @@
|
||||
import { For, Index, Match, Show, Switch, createMemo, createSignal, createEffect, onCleanup } from "solid-js"
|
||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import MessageItem from "./message-item"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
import ToolCall from "./tool-call"
|
||||
import Kbd from "./kbd"
|
||||
import type { MessageInfo, ClientPart } from "../types/message"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
import type { ClientPart, MessageInfo } from "../types/message"
|
||||
import { partHasRenderableText } from "../types/message"
|
||||
import { getSessionInfo, sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
|
||||
import { showCommandPalette } from "../stores/command-palette"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import { buildRecordDisplayData, clearRecordDisplayCacheForInstance } from "../stores/message-v2/record-display-cache"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import { formatTokenTotal } from "../lib/formatters"
|
||||
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
|
||||
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
|
||||
import { setActiveInstanceId } from "../stores/instances"
|
||||
|
||||
const SCROLL_SCOPE = "session"
|
||||
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
||||
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
||||
|
||||
const TOOL_ICON = "🔧"
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
|
||||
const USER_BORDER_COLOR = "var(--message-user-border)"
|
||||
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
|
||||
const TOOL_BORDER_COLOR = "var(--message-tool-border)"
|
||||
|
||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
|
||||
|
||||
type ToolState = import("@opencode-ai/sdk").ToolState
|
||||
type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning
|
||||
type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted
|
||||
@@ -116,10 +106,6 @@ function navigateToTaskSession(location: TaskSessionLocation) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatTokens(tokens: number): string {
|
||||
return formatTokenTotal(tokens)
|
||||
}
|
||||
|
||||
interface CachedBlockEntry {
|
||||
signature: string
|
||||
block: MessageDisplayBlock
|
||||
@@ -154,7 +140,6 @@ function getSessionRenderCache(instanceId: string, sessionId: string): SessionRe
|
||||
}
|
||||
|
||||
function clearInstanceCaches(instanceId: string) {
|
||||
|
||||
clearRecordDisplayCacheForInstance(instanceId)
|
||||
const prefix = `${instanceId}:`
|
||||
for (const key of renderCaches.keys()) {
|
||||
@@ -166,16 +151,6 @@ function clearInstanceCaches(instanceId: string) {
|
||||
|
||||
messageStoreBus.onInstanceDestroyed(clearInstanceCaches)
|
||||
|
||||
|
||||
interface MessageStreamV2Props {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
loading?: boolean
|
||||
onRevert?: (messageId: string) => void
|
||||
onFork?: (messageId?: string) => void
|
||||
registerScrollToBottom?: (fn: () => void) => void
|
||||
}
|
||||
|
||||
interface ContentDisplayItem {
|
||||
type: "content"
|
||||
key: string
|
||||
@@ -220,482 +195,6 @@ interface MessageDisplayBlock {
|
||||
items: MessageBlockItem[]
|
||||
}
|
||||
|
||||
export default function MessageStreamV2(props: MessageStreamV2Props) {
|
||||
const { preferences } = useConfig()
|
||||
const showUsagePreference = () => preferences().showUsageMetrics ?? true
|
||||
const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||
const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId))
|
||||
|
||||
const sessionRevision = createMemo(() => store().getSessionRevision(props.sessionId))
|
||||
const usageSnapshot = createMemo(() => store().getSessionUsage(props.sessionId))
|
||||
const sessionInfo = createMemo(() =>
|
||||
getSessionInfo(props.instanceId, props.sessionId) ?? {
|
||||
cost: 0,
|
||||
contextWindow: 0,
|
||||
isSubscriptionModel: false,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
actualUsageTokens: 0,
|
||||
modelOutputLimit: 0,
|
||||
contextAvailableTokens: null,
|
||||
},
|
||||
)
|
||||
const tokenStats = createMemo(() => {
|
||||
const usage = usageSnapshot()
|
||||
const info = sessionInfo()
|
||||
return {
|
||||
used: usage?.actualUsageTokens ?? info.actualUsageTokens ?? 0,
|
||||
avail: info.contextAvailableTokens,
|
||||
}
|
||||
})
|
||||
|
||||
const preferenceSignature = createMemo(() => {
|
||||
const pref = preferences()
|
||||
const showThinking = pref.showThinkingBlocks ? 1 : 0
|
||||
const thinkingExpansion = pref.thinkingBlocksExpansion ?? "expanded"
|
||||
const showUsage = (pref.showUsageMetrics ?? true) ? 1 : 0
|
||||
return `${showThinking}|${thinkingExpansion}|${showUsage}`
|
||||
})
|
||||
|
||||
const connectionStatus = () => sseManager.getStatus(props.instanceId)
|
||||
|
||||
const handleCommandPaletteClick = () => {
|
||||
showCommandPalette(props.instanceId)
|
||||
}
|
||||
|
||||
const messageIndexMap = createMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
const ids = messageIds()
|
||||
ids.forEach((id, index) => map.set(id, index))
|
||||
return map
|
||||
})
|
||||
|
||||
const lastAssistantIndex = createMemo(() => {
|
||||
const ids = messageIds()
|
||||
const resolvedStore = store()
|
||||
for (let index = ids.length - 1; index >= 0; index--) {
|
||||
const record = resolvedStore.getMessage(ids[index])
|
||||
if (record?.role === "assistant") {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
})
|
||||
|
||||
const changeToken = createMemo(() => {
|
||||
// Any change that can affect layout (new message, part update, revert,
|
||||
// etc.) should bump the session revision. We use this as the primary
|
||||
// signal for auto-scroll decisions.
|
||||
return String(sessionRevision())
|
||||
})
|
||||
|
||||
const scrollCache = useScrollCache({
|
||||
instanceId: () => props.instanceId,
|
||||
sessionId: () => props.sessionId,
|
||||
scope: SCROLL_SCOPE,
|
||||
})
|
||||
|
||||
const [autoScroll, setAutoScroll] = createSignal(true)
|
||||
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
||||
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
let lastKnownScrollTop = 0
|
||||
let lastMeasuredScrollHeight = 0
|
||||
let pendingScrollFrame: number | null = null
|
||||
let userScrollIntentUntil = 0
|
||||
let detachScrollIntentListeners: (() => void) | undefined
|
||||
let hasRestoredScroll = false
|
||||
let hasInitialScroll = false
|
||||
// When the user explicitly clicks "scroll to bottom", we want the
|
||||
// smooth scroll animation to complete without being immediately
|
||||
// overridden by the auto-scroll effects that react to new messages.
|
||||
let suppressAutoScrollOnce = false
|
||||
|
||||
function markUserScrollIntent() {
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||
userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS
|
||||
}
|
||||
|
||||
function hasUserScrollIntent() {
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||
return now <= userScrollIntentUntil
|
||||
}
|
||||
|
||||
function attachScrollIntentListeners(element: HTMLDivElement | undefined) {
|
||||
if (detachScrollIntentListeners) {
|
||||
detachScrollIntentListeners()
|
||||
detachScrollIntentListeners = undefined
|
||||
}
|
||||
if (!element) return
|
||||
const handlePointerIntent = () => markUserScrollIntent()
|
||||
const handleKeyIntent = (event: KeyboardEvent) => {
|
||||
if (SCROLL_INTENT_KEYS.has(event.key)) {
|
||||
markUserScrollIntent()
|
||||
}
|
||||
}
|
||||
element.addEventListener("wheel", handlePointerIntent, { passive: true })
|
||||
element.addEventListener("pointerdown", handlePointerIntent)
|
||||
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
|
||||
element.addEventListener("keydown", handleKeyIntent)
|
||||
detachScrollIntentListeners = () => {
|
||||
element.removeEventListener("wheel", handlePointerIntent)
|
||||
element.removeEventListener("pointerdown", handlePointerIntent)
|
||||
element.removeEventListener("touchstart", handlePointerIntent)
|
||||
element.removeEventListener("keydown", handleKeyIntent)
|
||||
}
|
||||
}
|
||||
|
||||
function setContainerRef(element: HTMLDivElement | null) {
|
||||
containerRef = element || undefined
|
||||
lastKnownScrollTop = containerRef?.scrollTop ?? 0
|
||||
lastMeasuredScrollHeight = containerRef?.scrollHeight ?? 0
|
||||
attachScrollIntentListeners(containerRef)
|
||||
}
|
||||
|
||||
function isNearBottom(element: HTMLDivElement, offset = 48) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = element
|
||||
return scrollHeight - (scrollTop + clientHeight) <= offset
|
||||
}
|
||||
|
||||
function isNearTop(element: HTMLDivElement, offset = 48) {
|
||||
return element.scrollTop <= offset
|
||||
}
|
||||
|
||||
function updateScrollIndicators(element: HTMLDivElement) {
|
||||
const hasItems = messageIds().length > 0
|
||||
setShowScrollBottomButton(hasItems && !isNearBottom(element))
|
||||
setShowScrollTopButton(hasItems && !isNearTop(element))
|
||||
}
|
||||
|
||||
function scrollToBottom(immediate = false) {
|
||||
if (!containerRef) return
|
||||
const behavior = immediate ? "auto" : "smooth"
|
||||
if (!immediate) {
|
||||
// We initiated this scroll (e.g., via the button). Skip the
|
||||
// next auto-scroll reaction so the smooth animation isn't
|
||||
// overridden by changeToken/preference effects.
|
||||
suppressAutoScrollOnce = true
|
||||
}
|
||||
containerRef.scrollTo({ top: containerRef.scrollHeight, behavior })
|
||||
setAutoScroll(true)
|
||||
lastMeasuredScrollHeight = containerRef.scrollHeight
|
||||
lastKnownScrollTop = containerRef.scrollTop
|
||||
updateScrollIndicators(containerRef)
|
||||
scheduleScrollPersist()
|
||||
}
|
||||
|
||||
function scrollToBottomAndClamp(immediate = false) {
|
||||
scrollToBottom(immediate)
|
||||
if (hasInitialScroll) {
|
||||
requestAnimationFrame(() => clampScrollAfterShrink())
|
||||
} else {
|
||||
hasInitialScroll = true
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToTop(immediate = false) {
|
||||
if (!containerRef) return
|
||||
const behavior = immediate ? "auto" : "smooth"
|
||||
setAutoScroll(false)
|
||||
containerRef.scrollTo({ top: 0, behavior })
|
||||
lastMeasuredScrollHeight = containerRef.scrollHeight
|
||||
lastKnownScrollTop = containerRef.scrollTop
|
||||
updateScrollIndicators(containerRef)
|
||||
scheduleScrollPersist()
|
||||
}
|
||||
|
||||
function handleContentRendered() {
|
||||
if (!containerRef) return
|
||||
if (!autoScroll()) return
|
||||
scrollToBottomAndClamp(true)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (props.registerScrollToBottom) {
|
||||
props.registerScrollToBottom(() => scrollToBottomAndClamp(true))
|
||||
}
|
||||
})
|
||||
|
||||
let pendingScrollPersist: number | null = null
|
||||
|
||||
|
||||
function scheduleScrollPersist() {
|
||||
if (pendingScrollPersist !== null) return
|
||||
pendingScrollPersist = requestAnimationFrame(() => {
|
||||
pendingScrollPersist = null
|
||||
if (!containerRef) return
|
||||
scrollCache.persist(containerRef, { atBottomOffset: 48 })
|
||||
})
|
||||
}
|
||||
|
||||
function clampScrollAfterShrink() {
|
||||
if (!containerRef || !autoScroll()) return
|
||||
const currentHeight = containerRef.scrollHeight
|
||||
const clientHeight = containerRef.clientHeight
|
||||
if (currentHeight < lastMeasuredScrollHeight) {
|
||||
const maxScrollTop = Math.max(currentHeight - clientHeight, 0)
|
||||
containerRef.scrollTo({ top: maxScrollTop, behavior: "auto" })
|
||||
lastKnownScrollTop = containerRef.scrollTop
|
||||
}
|
||||
lastMeasuredScrollHeight = currentHeight
|
||||
}
|
||||
|
||||
|
||||
|
||||
function handleScroll(event: Event) {
|
||||
if (!containerRef) return
|
||||
if (pendingScrollFrame !== null) {
|
||||
cancelAnimationFrame(pendingScrollFrame)
|
||||
}
|
||||
const isUserScroll = hasUserScrollIntent()
|
||||
pendingScrollFrame = requestAnimationFrame(() => {
|
||||
pendingScrollFrame = null
|
||||
if (!containerRef) return
|
||||
const currentTop = containerRef.scrollTop
|
||||
lastKnownScrollTop = currentTop
|
||||
lastMeasuredScrollHeight = containerRef.scrollHeight
|
||||
const atBottom = isNearBottom(containerRef)
|
||||
|
||||
if (isUserScroll) {
|
||||
// If the user scrolls and ends near the bottom, enable auto-scroll.
|
||||
// If they scroll away from the bottom by more than our threshold,
|
||||
// disable auto-scroll until they explicitly return.
|
||||
if (atBottom) {
|
||||
if (!autoScroll()) setAutoScroll(true)
|
||||
} else {
|
||||
if (autoScroll()) setAutoScroll(false)
|
||||
}
|
||||
}
|
||||
|
||||
updateScrollIndicators(containerRef)
|
||||
scheduleScrollPersist()
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const target = containerRef
|
||||
const loading = props.loading
|
||||
|
||||
if (!target) return
|
||||
if (loading) return
|
||||
if (hasRestoredScroll) return
|
||||
|
||||
scrollCache.restore(target, {
|
||||
onApplied: (snapshot) => {
|
||||
if (snapshot) {
|
||||
setAutoScroll(snapshot.atBottom)
|
||||
} else {
|
||||
const atBottom = isNearBottom(target)
|
||||
setAutoScroll(atBottom)
|
||||
}
|
||||
lastMeasuredScrollHeight = target.scrollHeight
|
||||
updateScrollIndicators(target)
|
||||
},
|
||||
})
|
||||
|
||||
hasRestoredScroll = true
|
||||
})
|
||||
|
||||
let previousToken: string | undefined
|
||||
|
||||
createEffect(() => {
|
||||
const token = changeToken()
|
||||
const loading = props.loading
|
||||
|
||||
if (loading) return
|
||||
if (!token || token === previousToken) {
|
||||
return
|
||||
}
|
||||
previousToken = token
|
||||
if (suppressAutoScrollOnce) {
|
||||
suppressAutoScrollOnce = false
|
||||
return
|
||||
}
|
||||
if (autoScroll()) {
|
||||
scrollToBottomAndClamp(true)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
preferenceSignature()
|
||||
if (props.loading) return
|
||||
if (!autoScroll()) {
|
||||
return
|
||||
}
|
||||
if (suppressAutoScrollOnce) {
|
||||
suppressAutoScrollOnce = false
|
||||
return
|
||||
}
|
||||
scrollToBottomAndClamp(true)
|
||||
})
|
||||
|
||||
|
||||
createEffect(() => {
|
||||
if (messageIds().length === 0) {
|
||||
setShowScrollTopButton(false)
|
||||
setShowScrollBottomButton(false)
|
||||
setAutoScroll(true)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
onCleanup(() => {
|
||||
if (pendingScrollFrame !== null) {
|
||||
cancelAnimationFrame(pendingScrollFrame)
|
||||
pendingScrollFrame = null
|
||||
}
|
||||
if (pendingScrollPersist !== null) {
|
||||
cancelAnimationFrame(pendingScrollPersist)
|
||||
pendingScrollPersist = null
|
||||
}
|
||||
if (detachScrollIntentListeners) {
|
||||
detachScrollIntentListeners()
|
||||
detachScrollIntentListeners = undefined
|
||||
}
|
||||
if (containerRef) {
|
||||
scrollCache.persist(containerRef, { atBottomOffset: 48 })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
return (
|
||||
<div class="message-stream-container">
|
||||
<div class="connection-status">
|
||||
<div class="connection-status-text connection-status-info flex flex-wrap items-center gap-2 text-sm font-medium">
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">Used</span>
|
||||
<span class="font-semibold text-primary">{formatTokens(tokenStats().used)}</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||
<span class="uppercase text-[10px] tracking-wide text-primary/70">Avail</span>
|
||||
<span class="font-semibold text-primary">
|
||||
{sessionInfo().contextAvailableTokens !== null ? formatTokens(sessionInfo().contextAvailableTokens ?? 0) : "--"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="connection-status-text connection-status-shortcut">
|
||||
<div class="connection-status-shortcut-action">
|
||||
<button type="button" class="connection-status-button" onClick={handleCommandPaletteClick} aria-label="Open command palette">
|
||||
Command Palette
|
||||
</button>
|
||||
<span class="connection-status-shortcut-hint">
|
||||
<Kbd shortcut="cmd+shift+p" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="connection-status-meta flex items-center justify-end gap-3">
|
||||
<Show when={connectionStatus() === "connected"}>
|
||||
<span class="status-indicator connected">
|
||||
<span class="status-dot" />
|
||||
Connected
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={connectionStatus() === "connecting"}>
|
||||
<span class="status-indicator connecting">
|
||||
<span class="status-dot" />
|
||||
Connecting...
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={connectionStatus() === "error" || connectionStatus() === "disconnected"}>
|
||||
<span class="status-indicator disconnected">
|
||||
<span class="status-dot" />
|
||||
Disconnected
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="message-stream"
|
||||
ref={setContainerRef}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<Show when={!props.loading && messageIds().length === 0}>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-content">
|
||||
<div class="flex flex-col items-center gap-3 mb-6">
|
||||
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" />
|
||||
<h1 class="text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||
</div>
|
||||
<h3>Start a conversation</h3>
|
||||
<p>Type a message below or open the Command Palette:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<span>Command Palette</span>
|
||||
<Kbd shortcut="cmd+shift+p" class="ml-2" />
|
||||
</li>
|
||||
<li>Ask about your codebase</li>
|
||||
<li>
|
||||
Attach files with <code>@</code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.loading}>
|
||||
<div class="loading-state">
|
||||
<div class="spinner" />
|
||||
<p>Loading messages...</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Index each={messageIds()}>
|
||||
{(messageId) => (
|
||||
<MessageBlock
|
||||
messageId={messageId()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={store}
|
||||
messageIndexMap={messageIndexMap}
|
||||
lastAssistantIndex={lastAssistantIndex}
|
||||
showThinking={() => preferences().showThinkingBlocks}
|
||||
thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"}
|
||||
showUsageMetrics={showUsagePreference}
|
||||
onRevert={props.onRevert}
|
||||
onFork={props.onFork}
|
||||
onContentRendered={handleContentRendered}
|
||||
/>
|
||||
|
||||
|
||||
)}
|
||||
</Index>
|
||||
</div>
|
||||
|
||||
<Show when={showScrollTopButton() || showScrollBottomButton()}>
|
||||
<div class="message-scroll-button-wrapper">
|
||||
<Show when={showScrollTopButton()}>
|
||||
<button
|
||||
type="button"
|
||||
class="message-scroll-button"
|
||||
onClick={() => scrollToTop()}
|
||||
aria-label="Scroll to first message"
|
||||
>
|
||||
<span class="message-scroll-icon" aria-hidden="true">
|
||||
↑
|
||||
</span>
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={showScrollBottomButton()}>
|
||||
<button
|
||||
type="button"
|
||||
class="message-scroll-button"
|
||||
onClick={() => scrollToBottom()}
|
||||
aria-label="Scroll to latest message"
|
||||
>
|
||||
<span class="message-scroll-icon" aria-hidden="true">
|
||||
↓
|
||||
</span>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MessageBlockProps {
|
||||
messageId: string
|
||||
instanceId: string
|
||||
@@ -711,8 +210,7 @@ interface MessageBlockProps {
|
||||
onContentRendered?: () => void
|
||||
}
|
||||
|
||||
|
||||
function MessageBlock(props: MessageBlockProps) {
|
||||
export default function MessageBlock(props: MessageBlockProps) {
|
||||
const record = createMemo(() => props.store().getMessage(props.messageId))
|
||||
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
||||
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
|
||||
@@ -726,11 +224,12 @@ function MessageBlock(props: MessageBlockProps) {
|
||||
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
|
||||
const info = messageInfo()
|
||||
const infoTime = (info?.time ?? {}) as { created?: number; updated?: number; completed?: number }
|
||||
const infoTimestamp = typeof infoTime.completed === "number"
|
||||
? infoTime.completed
|
||||
: typeof infoTime.updated === "number"
|
||||
? infoTime.updated
|
||||
: infoTime.created ?? 0
|
||||
const infoTimestamp =
|
||||
typeof infoTime.completed === "number"
|
||||
? infoTime.completed
|
||||
: typeof infoTime.updated === "number"
|
||||
? infoTime.updated
|
||||
: infoTime.created ?? 0
|
||||
const infoError = (info as { error?: { name?: string } } | undefined)?.error
|
||||
const infoErrorName = typeof infoError?.name === "string" ? infoError.name : ""
|
||||
const cacheSignature = [
|
||||
@@ -908,12 +407,10 @@ function MessageBlock(props: MessageBlockProps) {
|
||||
sessionId={props.sessionId}
|
||||
isQueued={(item as ContentDisplayItem).isQueued}
|
||||
showAgentMeta={(item as ContentDisplayItem).showAgentMeta}
|
||||
|
||||
onRevert={props.onRevert}
|
||||
onFork={props.onFork}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
|
||||
</Match>
|
||||
<Match when={item.type === "tool"}>
|
||||
{(() => {
|
||||
@@ -960,18 +457,12 @@ function MessageBlock(props: MessageBlockProps) {
|
||||
sessionId={props.sessionId}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</Match>
|
||||
<Match when={item.type === "step-start"}>
|
||||
<StepCard
|
||||
kind="start"
|
||||
part={(item as StepDisplayItem).part}
|
||||
messageInfo={(item as StepDisplayItem).messageInfo}
|
||||
showAgentMeta
|
||||
/>
|
||||
<StepCard kind="start" part={(item as StepDisplayItem).part} messageInfo={(item as StepDisplayItem).messageInfo} showAgentMeta />
|
||||
</Match>
|
||||
<Match when={item.type === "step-finish"}>
|
||||
<StepCard
|
||||
@@ -1108,6 +599,7 @@ function StepCard(props: StepCardProps) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatCostValue(value: number) {
|
||||
if (!value) return "$0.00"
|
||||
if (value < 0.01) return `$${value.toPrecision(2)}`
|
||||
85
packages/ui/src/components/message-list-header.tsx
Normal file
85
packages/ui/src/components/message-list-header.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Show } from "solid-js"
|
||||
import Kbd from "./kbd"
|
||||
|
||||
const METRIC_CHIP_CLASS = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
|
||||
const METRIC_LABEL_CLASS = "uppercase text-[10px] tracking-wide text-primary/70"
|
||||
|
||||
interface MessageListHeaderProps {
|
||||
usedTokens: number
|
||||
|
||||
availableTokens?: number | null
|
||||
connectionStatus: "connected" | "connecting" | "error" | "disconnected" | "unknown" | null
|
||||
onCommandPalette: () => void
|
||||
formatTokens: (value: number) => string
|
||||
showSidebarToggle?: boolean
|
||||
onSidebarToggle?: () => void
|
||||
forceCompactStatusLayout?: boolean
|
||||
}
|
||||
|
||||
export default function MessageListHeader(props: MessageListHeaderProps) {
|
||||
|
||||
const hasAvailableTokens = () => typeof props.availableTokens === "number"
|
||||
const availableDisplay = () => (hasAvailableTokens() ? props.formatTokens(props.availableTokens as number) : "--")
|
||||
|
||||
return (
|
||||
<div class={props.forceCompactStatusLayout ? "connection-status connection-status--compact" : "connection-status"}>
|
||||
<Show when={props.showSidebarToggle}>
|
||||
<div class="connection-status-menu">
|
||||
<button
|
||||
type="button"
|
||||
class="session-sidebar-menu-button"
|
||||
onClick={() => props.onSidebarToggle?.()}
|
||||
aria-label="Open session list"
|
||||
>
|
||||
<span aria-hidden="true" class="session-sidebar-menu-icon">☰</span>
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="connection-status-text connection-status-info">
|
||||
<div class="connection-status-usage">
|
||||
<div class={METRIC_CHIP_CLASS}>
|
||||
<span class={METRIC_LABEL_CLASS}>Used</span>
|
||||
<span class="font-semibold text-primary">{props.formatTokens(props.usedTokens)}</span>
|
||||
</div>
|
||||
<div class={METRIC_CHIP_CLASS}>
|
||||
<span class={METRIC_LABEL_CLASS}>Avail</span>
|
||||
<span class="font-semibold text-primary">{hasAvailableTokens() ? availableDisplay() : "--"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="connection-status-text connection-status-shortcut">
|
||||
<div class="connection-status-shortcut-action">
|
||||
<button type="button" class="connection-status-button" onClick={props.onCommandPalette} aria-label="Open command palette">
|
||||
Command Palette
|
||||
</button>
|
||||
<span class="connection-status-shortcut-hint">
|
||||
<Kbd shortcut="cmd+shift+p" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="connection-status-meta flex items-center justify-end gap-3">
|
||||
<Show when={props.connectionStatus === "connected"}>
|
||||
<span class="status-indicator connected">
|
||||
<span class="status-dot" />
|
||||
<span class="status-text">Connected</span>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.connectionStatus === "connecting"}>
|
||||
<span class="status-indicator connecting">
|
||||
<span class="status-dot" />
|
||||
<span class="status-text">Connecting...</span>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.connectionStatus === "error" || props.connectionStatus === "disconnected"}>
|
||||
<span class="status-indicator disconnected">
|
||||
<span class="status-dot" />
|
||||
<span class="status-text">Disconnected</span>
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
437
packages/ui/src/components/message-section.tsx
Normal file
437
packages/ui/src/components/message-section.tsx
Normal file
@@ -0,0 +1,437 @@
|
||||
import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import Kbd from "./kbd"
|
||||
import MessageBlockList from "./message-block-list"
|
||||
import MessageListHeader from "./message-list-header"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { getSessionInfo } from "../stores/sessions"
|
||||
import { showCommandPalette } from "../stores/command-palette"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
import { formatTokenTotal } from "../lib/formatters"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
|
||||
const SCROLL_SCOPE = "session"
|
||||
const SCROLL_SENTINEL_MARGIN_PX = 48
|
||||
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
||||
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
|
||||
function formatTokens(tokens: number): string {
|
||||
return formatTokenTotal(tokens)
|
||||
}
|
||||
|
||||
export interface MessageSectionProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
loading?: boolean
|
||||
onRevert?: (messageId: string) => void
|
||||
onFork?: (messageId?: string) => void
|
||||
registerScrollToBottom?: (fn: () => void) => void
|
||||
showSidebarToggle?: boolean
|
||||
onSidebarToggle?: () => void
|
||||
forceCompactStatusLayout?: boolean
|
||||
}
|
||||
|
||||
export default function MessageSection(props: MessageSectionProps) {
|
||||
const { preferences } = useConfig()
|
||||
const showUsagePreference = () => preferences().showUsageMetrics ?? true
|
||||
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||
const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId))
|
||||
|
||||
const sessionRevision = createMemo(() => store().getSessionRevision(props.sessionId))
|
||||
const usageSnapshot = createMemo(() => store().getSessionUsage(props.sessionId))
|
||||
const sessionInfo = createMemo(() =>
|
||||
getSessionInfo(props.instanceId, props.sessionId) ?? {
|
||||
cost: 0,
|
||||
contextWindow: 0,
|
||||
isSubscriptionModel: false,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
actualUsageTokens: 0,
|
||||
modelOutputLimit: 0,
|
||||
contextAvailableTokens: null,
|
||||
},
|
||||
)
|
||||
|
||||
const tokenStats = createMemo(() => {
|
||||
const usage = usageSnapshot()
|
||||
const info = sessionInfo()
|
||||
return {
|
||||
used: usage?.actualUsageTokens ?? info.actualUsageTokens ?? 0,
|
||||
avail: info.contextAvailableTokens,
|
||||
}
|
||||
})
|
||||
|
||||
const preferenceSignature = createMemo(() => {
|
||||
const pref = preferences()
|
||||
const showThinking = pref.showThinkingBlocks ? 1 : 0
|
||||
const thinkingExpansion = pref.thinkingBlocksExpansion ?? "expanded"
|
||||
const showUsage = (pref.showUsageMetrics ?? true) ? 1 : 0
|
||||
return `${showThinking}|${thinkingExpansion}|${showUsage}`
|
||||
})
|
||||
|
||||
const connectionStatus = () => sseManager.getStatus(props.instanceId)
|
||||
const handleCommandPaletteClick = () => {
|
||||
showCommandPalette(props.instanceId)
|
||||
}
|
||||
|
||||
const messageIndexMap = createMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
const ids = messageIds()
|
||||
ids.forEach((id, index) => map.set(id, index))
|
||||
return map
|
||||
})
|
||||
|
||||
const lastAssistantIndex = createMemo(() => {
|
||||
const ids = messageIds()
|
||||
const resolvedStore = store()
|
||||
for (let index = ids.length - 1; index >= 0; index--) {
|
||||
const record = resolvedStore.getMessage(ids[index])
|
||||
if (record?.role === "assistant") {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
})
|
||||
|
||||
const changeToken = createMemo(() => String(sessionRevision()))
|
||||
|
||||
const scrollCache = useScrollCache({
|
||||
instanceId: () => props.instanceId,
|
||||
sessionId: () => props.sessionId,
|
||||
scope: SCROLL_SCOPE,
|
||||
})
|
||||
|
||||
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
|
||||
const [topSentinel, setTopSentinel] = createSignal<HTMLDivElement | null>(null)
|
||||
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null)
|
||||
const [autoScroll, setAutoScroll] = createSignal(true)
|
||||
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
||||
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
|
||||
const [topSentinelVisible, setTopSentinelVisible] = createSignal(true)
|
||||
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
|
||||
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
let pendingScrollFrame: number | null = null
|
||||
let pendingAnchorScroll: number | null = null
|
||||
let pendingScrollPersist: number | null = null
|
||||
let userScrollIntentUntil = 0
|
||||
let detachScrollIntentListeners: (() => void) | undefined
|
||||
let hasRestoredScroll = false
|
||||
let suppressAutoScrollOnce = false
|
||||
|
||||
function markUserScrollIntent() {
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||
userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS
|
||||
}
|
||||
|
||||
function hasUserScrollIntent() {
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||
return now <= userScrollIntentUntil
|
||||
}
|
||||
|
||||
function attachScrollIntentListeners(element: HTMLDivElement | undefined) {
|
||||
if (detachScrollIntentListeners) {
|
||||
detachScrollIntentListeners()
|
||||
detachScrollIntentListeners = undefined
|
||||
}
|
||||
if (!element) return
|
||||
const handlePointerIntent = () => markUserScrollIntent()
|
||||
const handleKeyIntent = (event: KeyboardEvent) => {
|
||||
if (SCROLL_INTENT_KEYS.has(event.key)) {
|
||||
markUserScrollIntent()
|
||||
}
|
||||
}
|
||||
element.addEventListener("wheel", handlePointerIntent, { passive: true })
|
||||
element.addEventListener("pointerdown", handlePointerIntent)
|
||||
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
|
||||
element.addEventListener("keydown", handleKeyIntent)
|
||||
detachScrollIntentListeners = () => {
|
||||
element.removeEventListener("wheel", handlePointerIntent)
|
||||
element.removeEventListener("pointerdown", handlePointerIntent)
|
||||
element.removeEventListener("touchstart", handlePointerIntent)
|
||||
element.removeEventListener("keydown", handleKeyIntent)
|
||||
}
|
||||
}
|
||||
|
||||
function setContainerRef(element: HTMLDivElement | null) {
|
||||
containerRef = element || undefined
|
||||
setScrollElement(containerRef)
|
||||
attachScrollIntentListeners(containerRef)
|
||||
}
|
||||
|
||||
function updateScrollIndicatorsFromVisibility() {
|
||||
const hasItems = messageIds().length > 0
|
||||
setShowScrollBottomButton(hasItems && !bottomSentinelVisible())
|
||||
setShowScrollTopButton(hasItems && !topSentinelVisible())
|
||||
}
|
||||
|
||||
function scheduleScrollPersist() {
|
||||
if (pendingScrollPersist !== null) return
|
||||
pendingScrollPersist = requestAnimationFrame(() => {
|
||||
pendingScrollPersist = null
|
||||
if (!containerRef) return
|
||||
scrollCache.persist(containerRef, { atBottomOffset: SCROLL_SENTINEL_MARGIN_PX })
|
||||
})
|
||||
}
|
||||
|
||||
function scrollToBottom(immediate = false) {
|
||||
if (!containerRef) return
|
||||
const sentinel = bottomSentinel()
|
||||
const behavior = immediate ? "auto" : "smooth"
|
||||
if (!immediate) {
|
||||
suppressAutoScrollOnce = true
|
||||
}
|
||||
sentinel?.scrollIntoView({ block: "end", inline: "nearest", behavior })
|
||||
setAutoScroll(true)
|
||||
scheduleScrollPersist()
|
||||
}
|
||||
|
||||
function scrollToTop(immediate = false) {
|
||||
if (!containerRef) return
|
||||
const behavior = immediate ? "auto" : "smooth"
|
||||
setAutoScroll(false)
|
||||
topSentinel()?.scrollIntoView({ block: "start", inline: "nearest", behavior })
|
||||
scheduleScrollPersist()
|
||||
}
|
||||
|
||||
|
||||
function scheduleAnchorScroll(immediate = false) {
|
||||
if (!autoScroll()) return
|
||||
const sentinel = bottomSentinel()
|
||||
if (!sentinel) return
|
||||
if (pendingAnchorScroll !== null) {
|
||||
cancelAnimationFrame(pendingAnchorScroll)
|
||||
pendingAnchorScroll = null
|
||||
}
|
||||
pendingAnchorScroll = requestAnimationFrame(() => {
|
||||
pendingAnchorScroll = null
|
||||
sentinel.scrollIntoView({ block: "end", inline: "nearest", behavior: immediate ? "auto" : "smooth" })
|
||||
})
|
||||
}
|
||||
|
||||
function handleContentRendered() {
|
||||
scheduleAnchorScroll()
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
if (!containerRef) return
|
||||
if (pendingScrollFrame !== null) {
|
||||
cancelAnimationFrame(pendingScrollFrame)
|
||||
}
|
||||
const isUserScroll = hasUserScrollIntent()
|
||||
pendingScrollFrame = requestAnimationFrame(() => {
|
||||
pendingScrollFrame = null
|
||||
if (!containerRef) return
|
||||
const atBottom = bottomSentinelVisible()
|
||||
|
||||
if (isUserScroll) {
|
||||
if (atBottom) {
|
||||
if (!autoScroll()) setAutoScroll(true)
|
||||
} else if (autoScroll()) {
|
||||
setAutoScroll(false)
|
||||
}
|
||||
}
|
||||
|
||||
scheduleScrollPersist()
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (props.registerScrollToBottom) {
|
||||
props.registerScrollToBottom(() => scrollToBottom(true))
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const target = containerRef
|
||||
const loading = props.loading
|
||||
if (!target || loading || hasRestoredScroll) return
|
||||
|
||||
scrollCache.restore(target, {
|
||||
onApplied: (snapshot) => {
|
||||
if (snapshot) {
|
||||
setAutoScroll(snapshot.atBottom)
|
||||
} else {
|
||||
setAutoScroll(bottomSentinelVisible())
|
||||
}
|
||||
updateScrollIndicatorsFromVisibility()
|
||||
},
|
||||
})
|
||||
|
||||
hasRestoredScroll = true
|
||||
})
|
||||
|
||||
let previousToken: string | undefined
|
||||
createEffect(() => {
|
||||
const token = changeToken()
|
||||
const loading = props.loading
|
||||
if (loading || !token || token === previousToken) {
|
||||
return
|
||||
}
|
||||
previousToken = token
|
||||
if (suppressAutoScrollOnce) {
|
||||
suppressAutoScrollOnce = false
|
||||
return
|
||||
}
|
||||
if (autoScroll()) {
|
||||
scheduleAnchorScroll(true)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
preferenceSignature()
|
||||
if (props.loading || !autoScroll()) {
|
||||
return
|
||||
}
|
||||
if (suppressAutoScrollOnce) {
|
||||
suppressAutoScrollOnce = false
|
||||
return
|
||||
}
|
||||
scheduleAnchorScroll(true)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (messageIds().length === 0) {
|
||||
setShowScrollTopButton(false)
|
||||
setShowScrollBottomButton(false)
|
||||
setAutoScroll(true)
|
||||
return
|
||||
}
|
||||
updateScrollIndicatorsFromVisibility()
|
||||
})
|
||||
createEffect(() => {
|
||||
const container = scrollElement()
|
||||
const topTarget = topSentinel()
|
||||
const bottomTarget = bottomSentinel()
|
||||
if (!container || !topTarget || !bottomTarget) return
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
let visibilityChanged = false
|
||||
for (const entry of entries) {
|
||||
if (entry.target === topTarget) {
|
||||
setTopSentinelVisible(entry.isIntersecting)
|
||||
visibilityChanged = true
|
||||
} else if (entry.target === bottomTarget) {
|
||||
setBottomSentinelVisible(entry.isIntersecting)
|
||||
visibilityChanged = true
|
||||
}
|
||||
}
|
||||
if (visibilityChanged) {
|
||||
updateScrollIndicatorsFromVisibility()
|
||||
}
|
||||
},
|
||||
{ root: container, threshold: 0, rootMargin: `${SCROLL_SENTINEL_MARGIN_PX}px 0px ${SCROLL_SENTINEL_MARGIN_PX}px 0px` },
|
||||
)
|
||||
observer.observe(topTarget)
|
||||
observer.observe(bottomTarget)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
|
||||
if (pendingScrollFrame !== null) {
|
||||
cancelAnimationFrame(pendingScrollFrame)
|
||||
}
|
||||
if (pendingScrollPersist !== null) {
|
||||
cancelAnimationFrame(pendingScrollPersist)
|
||||
}
|
||||
if (pendingAnchorScroll !== null) {
|
||||
cancelAnimationFrame(pendingAnchorScroll)
|
||||
}
|
||||
if (detachScrollIntentListeners) {
|
||||
detachScrollIntentListeners()
|
||||
}
|
||||
if (containerRef) {
|
||||
scrollCache.persist(containerRef, { atBottomOffset: SCROLL_SENTINEL_MARGIN_PX })
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="message-stream-container">
|
||||
<MessageListHeader
|
||||
usedTokens={tokenStats().used}
|
||||
availableTokens={tokenStats().avail}
|
||||
connectionStatus={connectionStatus()}
|
||||
onCommandPalette={handleCommandPaletteClick}
|
||||
formatTokens={formatTokens}
|
||||
showSidebarToggle={props.showSidebarToggle}
|
||||
onSidebarToggle={props.onSidebarToggle}
|
||||
forceCompactStatusLayout={props.forceCompactStatusLayout}
|
||||
/>
|
||||
|
||||
<div class="message-stream" ref={setContainerRef} onScroll={handleScroll}>
|
||||
<div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} />
|
||||
<Show when={!props.loading && messageIds().length === 0}>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-content">
|
||||
<div class="flex flex-col items-center gap-3 mb-6">
|
||||
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" />
|
||||
<h1 class="text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||
</div>
|
||||
<h3>Start a conversation</h3>
|
||||
<p>Type a message below or open the Command Palette:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<span>Command Palette</span>
|
||||
<Kbd shortcut="cmd+shift+p" class="ml-2" />
|
||||
</li>
|
||||
<li>Ask about your codebase</li>
|
||||
<li>
|
||||
Attach files with <code>@</code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.loading}>
|
||||
<div class="loading-state">
|
||||
<div class="spinner" />
|
||||
<p>Loading messages...</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<MessageBlockList
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={store}
|
||||
messageIds={messageIds}
|
||||
messageIndexMap={messageIndexMap}
|
||||
lastAssistantIndex={lastAssistantIndex}
|
||||
showThinking={() => preferences().showThinkingBlocks}
|
||||
thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"}
|
||||
showUsageMetrics={showUsagePreference}
|
||||
scrollContainer={scrollElement}
|
||||
loading={props.loading}
|
||||
onRevert={props.onRevert}
|
||||
onFork={props.onFork}
|
||||
onContentRendered={handleContentRendered}
|
||||
setBottomSentinel={setBottomSentinel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show when={showScrollTopButton() || showScrollBottomButton()}>
|
||||
<div class="message-scroll-button-wrapper">
|
||||
<Show when={showScrollTopButton()}>
|
||||
<button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label="Scroll to first message">
|
||||
<span class="message-scroll-icon" aria-hidden="true">↑</span>
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={showScrollBottomButton()}>
|
||||
<button
|
||||
type="button"
|
||||
class="message-scroll-button"
|
||||
onClick={() => scrollToBottom()}
|
||||
aria-label="Scroll to latest message"
|
||||
>
|
||||
<span class="message-scroll-icon" aria-hidden="true">↓</span>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import { createEffect, createMemo, createSignal } from "solid-js"
|
||||
import { providers, fetchProviders } from "../stores/sessions"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
import type { Model } from "../types/session"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("session")
|
||||
|
||||
|
||||
interface ModelSelectorProps {
|
||||
instanceId: string
|
||||
@@ -25,7 +28,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
||||
|
||||
createEffect(() => {
|
||||
if (instanceProviders().length === 0) {
|
||||
fetchProviders(props.instanceId).catch(console.error)
|
||||
fetchProviders(props.instanceId).catch((error) => log.error("Failed to fetch providers", error))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ import { useConfig } from "../stores/preferences"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
|
||||
import { openNativeFileDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("actions")
|
||||
|
||||
|
||||
interface BinaryOption {
|
||||
path: string
|
||||
@@ -83,7 +86,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
|
||||
setTimeout(() => {
|
||||
pathsToValidate.forEach((path) => {
|
||||
validateBinary(path).catch(console.error)
|
||||
validateBinary(path).catch((error) => log.error("Failed to validate binary", { path, error }))
|
||||
})
|
||||
}, 0)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createSignal, Show, onMount, For, onCleanup, createEffect, on, untrack } from "solid-js"
|
||||
import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
|
||||
import UnifiedPicker from "./unified-picker"
|
||||
import { addToHistory, getHistory } from "../stores/message-history"
|
||||
import { getAttachments, addAttachment, clearAttachments, removeAttachment } from "../stores/attachments"
|
||||
@@ -10,6 +11,9 @@ import Kbd from "./kbd"
|
||||
import { getActiveInstance } from "../stores/instances"
|
||||
import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt } from "../stores/sessions"
|
||||
import { showAlertDialog } from "../stores/alerts"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("actions")
|
||||
|
||||
|
||||
interface PromptInputProps {
|
||||
instanceId: string
|
||||
@@ -19,6 +23,8 @@ interface PromptInputProps {
|
||||
onRunShell?: (command: string) => Promise<void>
|
||||
disabled?: boolean
|
||||
escapeInDebounce?: boolean
|
||||
isSessionBusy?: boolean
|
||||
onAbortSession?: () => Promise<void>
|
||||
}
|
||||
|
||||
export default function PromptInput(props: PromptInputProps) {
|
||||
@@ -163,6 +169,53 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleExpandTextAttachment(attachment: Attachment) {
|
||||
if (attachment.source.type !== "text") return
|
||||
|
||||
const textarea = textareaRef
|
||||
const value = attachment.source.value
|
||||
const match = attachment.display.match(/pasted #(\d+)/)
|
||||
const placeholder = match ? `[pasted #${match[1]}]` : null
|
||||
const currentText = prompt()
|
||||
|
||||
let nextText = currentText
|
||||
let selectionTarget: number | null = null
|
||||
|
||||
if (placeholder) {
|
||||
const placeholderIndex = currentText.indexOf(placeholder)
|
||||
if (placeholderIndex !== -1) {
|
||||
nextText =
|
||||
currentText.substring(0, placeholderIndex) +
|
||||
value +
|
||||
currentText.substring(placeholderIndex + placeholder.length)
|
||||
selectionTarget = placeholderIndex + value.length
|
||||
}
|
||||
}
|
||||
|
||||
if (nextText === currentText) {
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
nextText = currentText.substring(0, start) + value + currentText.substring(end)
|
||||
selectionTarget = start + value.length
|
||||
} else {
|
||||
nextText = currentText + value
|
||||
}
|
||||
}
|
||||
|
||||
setPrompt(nextText)
|
||||
removeAttachment(props.instanceId, props.sessionId, attachment.id)
|
||||
|
||||
if (textarea) {
|
||||
setTimeout(() => {
|
||||
textarea.focus()
|
||||
if (selectionTarget !== null) {
|
||||
textarea.setSelectionRange(selectionTarget, selectionTarget)
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePaste(e: ClipboardEvent) {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
@@ -467,31 +520,19 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
return
|
||||
}
|
||||
|
||||
const atStart = textarea.selectionStart === 0 && textarea.selectionEnd === 0
|
||||
const currentHistory = history()
|
||||
|
||||
if (e.key === "ArrowUp" && !showPicker() && atStart && currentHistory.length > 0) {
|
||||
e.preventDefault()
|
||||
if (historyIndex() === -1) {
|
||||
setHistoryDraft(prompt())
|
||||
if (e.key === "ArrowUp") {
|
||||
const handled = selectPreviousHistory()
|
||||
if (handled) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
const newIndex = historyIndex() === -1 ? 0 : Math.min(historyIndex() + 1, currentHistory.length - 1)
|
||||
setHistoryIndex(newIndex)
|
||||
setPrompt(currentHistory[newIndex])
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === "ArrowDown" && !showPicker() && historyIndex() >= 0) {
|
||||
e.preventDefault()
|
||||
const newIndex = historyIndex() - 1
|
||||
if (newIndex >= 0) {
|
||||
setHistoryIndex(newIndex)
|
||||
setPrompt(currentHistory[newIndex])
|
||||
} else {
|
||||
setHistoryIndex(-1)
|
||||
const draft = historyDraft()
|
||||
setPrompt(draft ?? "")
|
||||
setHistoryDraft(null)
|
||||
if (e.key === "ArrowDown") {
|
||||
const handled = selectNextHistory()
|
||||
if (handled) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -516,7 +557,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
})
|
||||
setHistoryIndex(-1)
|
||||
} catch (historyError) {
|
||||
console.error("Failed to update prompt history:", historyError)
|
||||
log.error("Failed to update prompt history:", historyError)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -539,7 +580,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
}
|
||||
void refreshHistory()
|
||||
} catch (error) {
|
||||
console.error("Failed to send message:", error)
|
||||
log.error("Failed to send message:", error)
|
||||
showAlertDialog("Failed to send message", {
|
||||
title: "Send failed",
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
@@ -549,8 +590,68 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
textareaRef?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function focusTextareaEnd() {
|
||||
if (!textareaRef) return
|
||||
setTimeout(() => {
|
||||
if (!textareaRef) return
|
||||
const pos = textareaRef.value.length
|
||||
textareaRef.setSelectionRange(pos, pos)
|
||||
textareaRef.focus()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function canUseHistory(force = false) {
|
||||
if (force) return true
|
||||
if (showPicker()) return false
|
||||
const textarea = textareaRef
|
||||
if (!textarea) return false
|
||||
return textarea.selectionStart === 0 && textarea.selectionEnd === 0
|
||||
}
|
||||
|
||||
function selectPreviousHistory(force = false) {
|
||||
const entries = history()
|
||||
if (entries.length === 0) return false
|
||||
if (!canUseHistory(force)) return false
|
||||
|
||||
if (historyIndex() === -1) {
|
||||
setHistoryDraft(prompt())
|
||||
}
|
||||
|
||||
const newIndex = historyIndex() === -1 ? 0 : Math.min(historyIndex() + 1, entries.length - 1)
|
||||
setHistoryIndex(newIndex)
|
||||
setPrompt(entries[newIndex])
|
||||
focusTextareaEnd()
|
||||
return true
|
||||
}
|
||||
|
||||
function selectNextHistory(force = false) {
|
||||
const entries = history()
|
||||
if (entries.length === 0) return false
|
||||
if (!canUseHistory(force)) return false
|
||||
if (historyIndex() === -1) return false
|
||||
|
||||
const newIndex = historyIndex() - 1
|
||||
if (newIndex >= 0) {
|
||||
setHistoryIndex(newIndex)
|
||||
setPrompt(entries[newIndex])
|
||||
} else {
|
||||
setHistoryIndex(-1)
|
||||
const draft = historyDraft()
|
||||
setPrompt(draft ?? "")
|
||||
setHistoryDraft(null)
|
||||
}
|
||||
focusTextareaEnd()
|
||||
return true
|
||||
}
|
||||
|
||||
function handleAbort() {
|
||||
if (!props.onAbortSession || !props.isSessionBusy) return
|
||||
void props.onAbortSession()
|
||||
}
|
||||
|
||||
function handleInput(e: Event) {
|
||||
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
const value = target.value
|
||||
setPrompt(value)
|
||||
@@ -768,14 +869,21 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
textareaRef?.focus()
|
||||
}
|
||||
|
||||
const canStop = () => Boolean(props.isSessionBusy && props.onAbortSession)
|
||||
|
||||
const hasHistory = () => history().length > 0
|
||||
const canHistoryGoPrevious = () => hasHistory() && (historyIndex() === -1 || historyIndex() < history().length - 1)
|
||||
const canHistoryGoNext = () => historyIndex() >= 0
|
||||
|
||||
const canSend = () => {
|
||||
if (props.disabled) return false
|
||||
const hasText = prompt().trim().length > 0
|
||||
if (mode() === "shell") return hasText
|
||||
return hasText || attachments().length > 0
|
||||
}
|
||||
|
||||
|
||||
const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "for shell mode" })
|
||||
|
||||
const shouldShowOverlay = () => prompt().length === 0
|
||||
|
||||
const instance = () => getActiveInstance()
|
||||
@@ -813,13 +921,18 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
<For each={attachments()}>
|
||||
{(attachment) => {
|
||||
const isImage = attachment.mediaType.startsWith("image/")
|
||||
const textValue = attachment.source.type === "text" ? attachment.source.value : undefined
|
||||
const isTextAttachment = typeof textValue === "string"
|
||||
return (
|
||||
<div class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`}>
|
||||
<div
|
||||
class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`}
|
||||
title={textValue}
|
||||
>
|
||||
<Show
|
||||
when={isImage}
|
||||
fallback={
|
||||
<Show
|
||||
when={attachment.source.type === "text"}
|
||||
when={isTextAttachment}
|
||||
fallback={
|
||||
<Show
|
||||
when={attachment.source.type === "agent"}
|
||||
@@ -858,7 +971,20 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
>
|
||||
<img src={attachment.url} alt={attachment.filename} class="h-5 w-5 rounded object-cover" />
|
||||
</Show>
|
||||
<span>{attachment.source.type === "text" ? attachment.display : attachment.filename}</span>
|
||||
<span>{isTextAttachment ? attachment.display : attachment.filename}</span>
|
||||
<Show when={isTextAttachment}>
|
||||
<button
|
||||
onClick={() => handleExpandTextAttachment(attachment)}
|
||||
class="attachment-expand"
|
||||
aria-label="Expand pasted text"
|
||||
title="Insert pasted text"
|
||||
>
|
||||
<svg class="h-3 w-3" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 7h6v6H7z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4h12v12" />
|
||||
</svg>
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
onClick={() => handleRemoveAttachment(attachment.id)}
|
||||
class="attachment-remove"
|
||||
@@ -907,6 +1033,30 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
autoCapitalize="off"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<Show when={hasHistory()}>
|
||||
<div class="prompt-history-top">
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-history-button"
|
||||
onClick={() => selectPreviousHistory(true)}
|
||||
disabled={!canHistoryGoPrevious()}
|
||||
aria-label="Previous prompt"
|
||||
>
|
||||
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="prompt-history-bottom">
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-history-button"
|
||||
onClick={() => selectNextHistory(true)}
|
||||
disabled={!canHistoryGoNext()}
|
||||
aria-label="Next prompt"
|
||||
>
|
||||
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={shouldShowOverlay()}>
|
||||
<div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
|
||||
<Show
|
||||
@@ -942,22 +1092,37 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class={`send-button ${mode() === "shell" ? "shell-mode" : ""}`}
|
||||
onClick={handleSend}
|
||||
disabled={!canSend()}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<Show
|
||||
when={mode() === "shell"}
|
||||
fallback={<span class="send-icon">▶</span>}
|
||||
<div class="prompt-input-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="stop-button"
|
||||
onClick={handleAbort}
|
||||
disabled={!canStop()}
|
||||
aria-label="Stop session"
|
||||
title="Stop session"
|
||||
>
|
||||
<svg class="shell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 8l5 4-5 4" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h6" />
|
||||
<svg class="stop-icon" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<rect x="4" y="4" width="12" height="12" rx="2" />
|
||||
</svg>
|
||||
</Show>
|
||||
</button>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`send-button ${mode() === "shell" ? "shell-mode" : ""}`}
|
||||
onClick={handleSend}
|
||||
disabled={!canSend()}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<Show
|
||||
when={mode() === "shell"}
|
||||
fallback={<span class="send-icon">▶</span>}
|
||||
>
|
||||
<svg class="shell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 8l5 4-5 4" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h6" />
|
||||
</svg>
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
243
packages/ui/src/components/remote-access-overlay.tsx
Normal file
243
packages/ui/src/components/remote-access-overlay.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Switch } from "@kobalte/core/switch"
|
||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import { toDataURL } from "qrcode"
|
||||
import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
|
||||
import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { restartCli } from "../lib/native/cli"
|
||||
import { preferences, setListeningMode } from "../stores/preferences"
|
||||
import { showConfirmDialog } from "../stores/alerts"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("actions")
|
||||
|
||||
|
||||
interface RemoteAccessOverlayProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({})
|
||||
const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
|
||||
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
||||
const currentMode = createMemo(() => meta()?.listeningMode ?? preferences().listeningMode)
|
||||
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
||||
const displayAddresses = createMemo(() => {
|
||||
const list = addresses()
|
||||
if (allowExternalConnections()) {
|
||||
return list.filter((address) => address.scope !== "loopback")
|
||||
}
|
||||
return list.filter((address) => address.scope === "loopback")
|
||||
})
|
||||
|
||||
const refreshMeta = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await serverApi.fetchServerMeta()
|
||||
setMeta(result)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (props.open) {
|
||||
void refreshMeta()
|
||||
}
|
||||
})
|
||||
|
||||
const toggleExpanded = async (url: string) => {
|
||||
if (expandedUrl() === url) {
|
||||
setExpandedUrl(null)
|
||||
return
|
||||
}
|
||||
setExpandedUrl(url)
|
||||
if (!qrCodes()[url]) {
|
||||
try {
|
||||
const dataUrl = await toDataURL(url, { margin: 1, scale: 4 })
|
||||
setQrCodes((prev) => ({ ...prev, [url]: dataUrl }))
|
||||
} catch (err) {
|
||||
log.error("Failed to generate QR code", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleAllowConnectionsChange = async (checked: boolean) => {
|
||||
const allow = Boolean(checked)
|
||||
const targetMode: "local" | "all" = allow ? "all" : "local"
|
||||
if (targetMode === currentMode()) {
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = await showConfirmDialog("Restart to apply listening mode? This will stop all running instances.", {
|
||||
title: allow ? "Open to other devices" : "Limit to this device",
|
||||
variant: "warning",
|
||||
confirmLabel: "Restart now",
|
||||
cancelLabel: "Cancel",
|
||||
})
|
||||
|
||||
if (!confirmed) {
|
||||
// Switch will revert automatically since `checked` is derived from store state
|
||||
return
|
||||
}
|
||||
|
||||
setListeningMode(targetMode)
|
||||
const restarted = await restartCli()
|
||||
if (!restarted) {
|
||||
setError("Unable to restart automatically. Please restart the app to apply the change.")
|
||||
} else {
|
||||
setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev))
|
||||
}
|
||||
|
||||
void refreshMeta()
|
||||
}
|
||||
|
||||
const handleOpenUrl = (url: string) => {
|
||||
try {
|
||||
window.open(url, "_blank", "noopener,noreferrer")
|
||||
} catch (err) {
|
||||
log.error("Failed to open URL", err)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={props.open}
|
||||
modal
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
props.onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay remote-overlay-backdrop" />
|
||||
<div class="remote-overlay">
|
||||
<Dialog.Content class="modal-surface remote-panel" tabIndex={-1}>
|
||||
<header class="remote-header">
|
||||
<div>
|
||||
<p class="remote-eyebrow">Remote handover</p>
|
||||
<h2 class="remote-title">Connect to CodeNomad remotely</h2>
|
||||
<p class="remote-subtitle">Use the addresses below to open CodeNomad from another device.</p>
|
||||
</div>
|
||||
<button type="button" class="remote-close" onClick={props.onClose} aria-label="Close remote access">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="remote-body">
|
||||
<section class="remote-section">
|
||||
<div class="remote-section-heading">
|
||||
<div class="remote-section-title">
|
||||
<Shield class="remote-icon" />
|
||||
<div>
|
||||
<p class="remote-label">Listening mode</p>
|
||||
<p class="remote-help">Allow or limit remote handovers by binding to all interfaces or just localhost.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="remote-refresh" type="button" onClick={() => void refreshMeta()} disabled={loading()}>
|
||||
<RefreshCw class={`remote-icon ${loading() ? "remote-spin" : ""}`} />
|
||||
<span class="remote-refresh-label">Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
class="remote-toggle"
|
||||
checked={allowExternalConnections()}
|
||||
onChange={(nextChecked) => {
|
||||
void handleAllowConnectionsChange(nextChecked)
|
||||
}}
|
||||
>
|
||||
<Switch.Input />
|
||||
<Switch.Control class="remote-toggle-switch" data-checked={allowExternalConnections()}>
|
||||
<span class="remote-toggle-state">{allowExternalConnections() ? "On" : "Off"}</span>
|
||||
<Switch.Thumb class="remote-toggle-thumb" />
|
||||
</Switch.Control>
|
||||
<div class="remote-toggle-copy">
|
||||
<span class="remote-toggle-title">Allow connections from other IPs</span>
|
||||
<span class="remote-toggle-caption">
|
||||
{allowExternalConnections() ? "Binding to 0.0.0.0" : "Binding to 127.0.0.1"}
|
||||
</span>
|
||||
</div>
|
||||
</Switch>
|
||||
<p class="remote-toggle-note">
|
||||
Changing this requires a restart and temporarily stops all active instances. Share the addresses below once the
|
||||
server restarts.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="remote-section">
|
||||
<div class="remote-section-heading">
|
||||
<div class="remote-section-title">
|
||||
<Wifi class="remote-icon" />
|
||||
<div>
|
||||
<p class="remote-label">Reachable addresses</p>
|
||||
<p class="remote-help">Launch or scan from another machine to hand over control.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={!loading()} fallback={<div class="remote-card">Loading addresses…</div>}>
|
||||
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
||||
<Show when={displayAddresses().length > 0} fallback={<div class="remote-card">No addresses available yet.</div>}>
|
||||
<div class="remote-address-list">
|
||||
<For each={displayAddresses()}>
|
||||
{(address) => {
|
||||
const expandedState = () => expandedUrl() === address.url
|
||||
const qr = () => qrCodes()[address.url]
|
||||
return (
|
||||
<div class="remote-address">
|
||||
<div class="remote-address-main">
|
||||
<div>
|
||||
<p class="remote-address-url">{address.url}</p>
|
||||
<p class="remote-address-meta">
|
||||
{address.family.toUpperCase()} • {address.scope === "external" ? "Network" : address.scope === "loopback" ? "Loopback" : "Internal"} • {address.ip}
|
||||
</p>
|
||||
</div>
|
||||
<div class="remote-actions">
|
||||
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(address.url)}>
|
||||
<ExternalLink class="remote-icon" />
|
||||
Open
|
||||
</button>
|
||||
<button
|
||||
class="remote-pill"
|
||||
type="button"
|
||||
onClick={() => void toggleExpanded(address.url)}
|
||||
aria-expanded={expandedState()}
|
||||
>
|
||||
<Link2 class="remote-icon" />
|
||||
{expandedState() ? "Hide QR" : "Show QR"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={expandedState()}>
|
||||
<div class="remote-qr">
|
||||
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
||||
{(dataUrl) => <img src={dataUrl()} alt={`QR for ${address.url}`} class="remote-qr-img" />}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</section>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Component, For, Show, createSignal, createEffect, onCleanup, onMount, createMemo, JSX } from "solid-js"
|
||||
import type { Session, SessionStatus } from "../types/session"
|
||||
import { getSessionStatus } from "../stores/session-status"
|
||||
import { MessageSquare, Info, X, Copy } from "lucide-solid"
|
||||
import { MessageSquare, Info, X, Copy, Trash2 } from "lucide-solid"
|
||||
import KeyboardHint from "./keyboard-hint"
|
||||
import Kbd from "./kbd"
|
||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||
import { formatShortcut } from "../lib/keyboard-utils"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import { deleteSession, loading } from "../stores/sessions"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("session")
|
||||
|
||||
|
||||
|
||||
interface SessionListProps {
|
||||
@@ -63,11 +67,17 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
const [startX, setStartX] = createSignal(0)
|
||||
const [startWidth, setStartWidth] = createSignal(DEFAULT_WIDTH)
|
||||
const infoShortcut = keyboardRegistry.get("switch-to-info")
|
||||
|
||||
|
||||
const isSessionDeleting = (sessionId: string) => {
|
||||
const deleting = loading().deletingSession.get(props.instanceId)
|
||||
return deleting ? deleting.has(sessionId) : false
|
||||
}
|
||||
|
||||
const selectSession = (sessionId: string) => {
|
||||
props.onSelect(sessionId)
|
||||
}
|
||||
|
||||
|
||||
let mouseMoveHandler: ((event: MouseEvent) => void) | null = null
|
||||
let mouseUpHandler: (() => void) | null = null
|
||||
let touchMoveHandler: ((event: TouchEvent) => void) | null = null
|
||||
@@ -106,12 +116,25 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
await navigator.clipboard.writeText(sessionId)
|
||||
showToastNotification({ message: "Session ID copied", variant: "success" })
|
||||
} catch (error) {
|
||||
console.error(`Failed to copy session ID ${sessionId}:`, error)
|
||||
log.error(`Failed to copy session ID ${sessionId}:`, error)
|
||||
showToastNotification({ message: "Unable to copy session ID", variant: "error" })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteSession = async (event: MouseEvent, sessionId: string) => {
|
||||
event.stopPropagation()
|
||||
if (isSessionDeleting(sessionId)) return
|
||||
|
||||
try {
|
||||
await deleteSession(props.instanceId, sessionId)
|
||||
} catch (error) {
|
||||
log.error(`Failed to delete session ${sessionId}:`, error)
|
||||
showToastNotification({ message: "Unable to delete session", variant: "error" })
|
||||
}
|
||||
}
|
||||
|
||||
const clampWidth = (width: number) => Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, width))
|
||||
|
||||
|
||||
|
||||
const removeMouseListeners = () => {
|
||||
@@ -258,6 +281,30 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
>
|
||||
<Copy class="w-3 h-3" />
|
||||
</span>
|
||||
<span
|
||||
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
||||
onClick={(event) => handleDeleteSession(event, rowProps.sessionId)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Delete session"
|
||||
title="Delete session"
|
||||
>
|
||||
<Show
|
||||
when={!isSessionDeleting(rowProps.sessionId)}
|
||||
fallback={
|
||||
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
<Trash2 class="w-3 h-3" />
|
||||
</Show>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -299,9 +346,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
class="session-list-container bg-surface-secondary border-r border-base flex flex-col"
|
||||
style={{ width: `${sidebarWidth()}px` }}
|
||||
class="session-list-container bg-surface-secondary border-r border-base flex flex-col w-full"
|
||||
>
|
||||
<div
|
||||
class="session-resize-handle"
|
||||
|
||||
@@ -4,6 +4,9 @@ import type { Session, Agent } from "../types/session"
|
||||
import { getParentSessions, createSession, setActiveParentSession } from "../stores/sessions"
|
||||
import { instances, stopInstance } from "../stores/instances"
|
||||
import { agents } from "../stores/sessions"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("session")
|
||||
|
||||
|
||||
interface SessionPickerProps {
|
||||
instanceId: string
|
||||
@@ -55,7 +58,7 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
|
||||
setActiveParentSession(props.instanceId, session.id)
|
||||
props.onClose()
|
||||
} catch (error) {
|
||||
console.error("Failed to create session:", error)
|
||||
log.error("Failed to create session:", error)
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
|
||||
@@ -2,12 +2,16 @@ import { Show, createMemo, createEffect, type Component } from "solid-js"
|
||||
import type { Session } from "../../types/session"
|
||||
import type { Attachment } from "../../types/attachment"
|
||||
import type { ClientPart } from "../../types/message"
|
||||
import MessageStreamV2 from "../message-stream-v2"
|
||||
import MessageSection from "../message-section"
|
||||
import { messageStoreBus } from "../../stores/message-v2/bus"
|
||||
import PromptInput from "../prompt-input"
|
||||
import { instances } from "../../stores/instances"
|
||||
import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand } from "../../stores/sessions"
|
||||
import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
|
||||
import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status"
|
||||
import { showAlertDialog } from "../../stores/alerts"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
function isTextPart(part: ClientPart): part is ClientPart & { type: "text"; text: string } {
|
||||
return part?.type === "text" && typeof (part as any).text === "string"
|
||||
@@ -19,23 +23,31 @@ interface SessionViewProps {
|
||||
instanceId: string
|
||||
instanceFolder: string
|
||||
escapeInDebounce: boolean
|
||||
showSidebarToggle?: boolean
|
||||
onSidebarToggle?: () => void
|
||||
forceCompactStatusLayout?: boolean
|
||||
}
|
||||
|
||||
export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
const session = () => props.activeSessions.get(props.sessionId)
|
||||
const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId))
|
||||
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||
const sessionBusy = createMemo(() => {
|
||||
const currentSession = session()
|
||||
if (!currentSession) return false
|
||||
return getSessionBusyStatus(props.instanceId, currentSession.id)
|
||||
})
|
||||
let scrollToBottomHandle: (() => void) | undefined
|
||||
|
||||
createEffect(() => {
|
||||
|
||||
createEffect(() => {
|
||||
const currentSession = session()
|
||||
if (currentSession) {
|
||||
loadMessages(props.instanceId, currentSession.id).catch(console.error)
|
||||
loadMessages(props.instanceId, currentSession.id).catch((error) => log.error("Failed to load messages", error))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
async function handleSendMessage(prompt: string, attachments: Attachment[]) {
|
||||
|
||||
if (scrollToBottomHandle) {
|
||||
scrollToBottomHandle()
|
||||
}
|
||||
@@ -45,8 +57,26 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
async function handleRunShell(command: string) {
|
||||
await runShellCommand(props.instanceId, props.sessionId, command)
|
||||
}
|
||||
|
||||
|
||||
async function handleAbortSession() {
|
||||
const currentSession = session()
|
||||
if (!currentSession) return
|
||||
|
||||
try {
|
||||
await abortSession(props.instanceId, currentSession.id)
|
||||
log.info("Abort requested", { instanceId: props.instanceId, sessionId: currentSession.id })
|
||||
} catch (error) {
|
||||
log.error("Failed to abort session", error)
|
||||
showAlertDialog("Failed to stop session", {
|
||||
title: "Stop failed",
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function getUserMessageText(messageId: string): string | null {
|
||||
|
||||
const normalizedMessage = messageStore().getMessage(messageId)
|
||||
if (normalizedMessage && normalizedMessage.role === "user") {
|
||||
const parts = normalizedMessage.partIds
|
||||
@@ -82,7 +112,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to revert:", error)
|
||||
log.error("Failed to revert message", error)
|
||||
showAlertDialog("Failed to revert to message", {
|
||||
title: "Revert failed",
|
||||
variant: "error",
|
||||
@@ -92,7 +122,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
|
||||
async function handleFork(messageId?: string) {
|
||||
if (!messageId) {
|
||||
console.warn("Fork requires a user message id")
|
||||
log.warn("Fork requires a user message id")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -107,7 +137,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
setActiveSession(props.instanceId, forkedSession.id)
|
||||
}
|
||||
|
||||
await loadMessages(props.instanceId, forkedSession.id).catch(console.error)
|
||||
await loadMessages(props.instanceId, forkedSession.id).catch((error) => log.error("Failed to load forked session messages", error))
|
||||
|
||||
if (restoredText) {
|
||||
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
|
||||
@@ -118,7 +148,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fork session:", error)
|
||||
log.error("Failed to fork session", error)
|
||||
showAlertDialog("Failed to fork session", {
|
||||
title: "Fork failed",
|
||||
variant: "error",
|
||||
@@ -141,7 +171,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
if (!activeSession) return null
|
||||
return (
|
||||
<div class="session-view">
|
||||
<MessageStreamV2
|
||||
<MessageSection
|
||||
instanceId={props.instanceId}
|
||||
sessionId={activeSession.id}
|
||||
loading={messagesLoading()}
|
||||
@@ -150,6 +180,9 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
registerScrollToBottom={(fn) => {
|
||||
scrollToBottomHandle = fn
|
||||
}}
|
||||
showSidebarToggle={props.showSidebarToggle}
|
||||
onSidebarToggle={props.onSidebarToggle}
|
||||
forceCompactStatusLayout={props.forceCompactStatusLayout}
|
||||
/>
|
||||
|
||||
|
||||
@@ -160,6 +193,8 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
onSend={handleSendMessage}
|
||||
onRunShell={handleRunShell}
|
||||
escapeInDebounce={props.escapeInDebounce}
|
||||
isSessionBusy={sessionBusy()}
|
||||
onAbortSession={handleAbortSession}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
38
packages/ui/src/components/tool-call/renderers/bash.tsx
Normal file
38
packages/ui/src/components/tool-call/renderers/bash.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { ensureMarkdownContent, formatUnknown, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils"
|
||||
|
||||
export const bashRenderer: ToolRenderer = {
|
||||
tools: ["bash"],
|
||||
getAction: () => "Writing command...",
|
||||
getTitle({ toolState }) {
|
||||
const state = toolState()
|
||||
if (!state) return undefined
|
||||
const { input } = readToolStatePayload(state)
|
||||
const name = getToolName("bash")
|
||||
if (typeof input.description === "string" && input.description.length > 0) {
|
||||
return `${name} ${input.description}`
|
||||
}
|
||||
return name
|
||||
},
|
||||
renderBody({ toolState, renderMarkdown }) {
|
||||
const state = toolState()
|
||||
if (!state || state.status === "pending") return null
|
||||
|
||||
const { input, metadata } = readToolStatePayload(state)
|
||||
const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : ""
|
||||
const outputResult = formatUnknown(
|
||||
isToolStateCompleted(state)
|
||||
? state.output
|
||||
: (isToolStateRunning(state) || isToolStateError(state)) && metadata.output
|
||||
? metadata.output
|
||||
: undefined,
|
||||
)
|
||||
const parts = [command, outputResult?.text].filter(Boolean)
|
||||
if (parts.length === 0) return null
|
||||
|
||||
const content = ensureMarkdownContent(parts.join("\n"), "bash", true)
|
||||
if (!content) return null
|
||||
|
||||
return renderMarkdown({ content, disableHighlight: state.status === "running" })
|
||||
},
|
||||
}
|
||||
25
packages/ui/src/components/tool-call/renderers/default.tsx
Normal file
25
packages/ui/src/components/tool-call/renderers/default.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { ensureMarkdownContent, formatUnknown, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils"
|
||||
|
||||
export const defaultRenderer: ToolRenderer = {
|
||||
tools: ["*"],
|
||||
renderBody({ toolState, renderMarkdown }) {
|
||||
const state = toolState()
|
||||
if (!state || state.status === "pending") return null
|
||||
|
||||
const { metadata, input } = readToolStatePayload(state)
|
||||
const primaryOutput = isToolStateCompleted(state)
|
||||
? state.output
|
||||
: (isToolStateRunning(state) || isToolStateError(state)) && metadata.output
|
||||
? metadata.output
|
||||
: metadata.diff ?? metadata.preview ?? input.content
|
||||
|
||||
const result = formatUnknown(primaryOutput)
|
||||
if (!result) return null
|
||||
|
||||
const content = ensureMarkdownContent(result.text, result.language, true)
|
||||
if (!content) return null
|
||||
|
||||
return renderMarkdown({ content, disableHighlight: state.status === "running" })
|
||||
},
|
||||
}
|
||||
32
packages/ui/src/components/tool-call/renderers/edit.tsx
Normal file
32
packages/ui/src/components/tool-call/renderers/edit.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { ensureMarkdownContent, extractDiffPayload, getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils"
|
||||
|
||||
export const editRenderer: ToolRenderer = {
|
||||
tools: ["edit"],
|
||||
getAction: () => "Preparing edit...",
|
||||
getTitle({ toolState }) {
|
||||
const state = toolState()
|
||||
if (!state) return undefined
|
||||
const { input } = readToolStatePayload(state)
|
||||
const filePath = typeof input.filePath === "string" ? input.filePath : ""
|
||||
if (!filePath) return getToolName("edit")
|
||||
return `${getToolName("edit")} ${getRelativePath(filePath)}`
|
||||
},
|
||||
renderBody({ toolState, toolName, renderDiff, renderMarkdown }) {
|
||||
const state = toolState()
|
||||
if (!state || state.status === "pending") return null
|
||||
|
||||
const diffPayload = extractDiffPayload(toolName(), state)
|
||||
if (diffPayload) {
|
||||
return renderDiff(diffPayload)
|
||||
}
|
||||
|
||||
const { metadata } = readToolStatePayload(state)
|
||||
const diffText = typeof metadata.diff === "string" ? metadata.diff : null
|
||||
const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null
|
||||
const content = ensureMarkdownContent(diffText || fallback, "diff", true)
|
||||
if (!content) return null
|
||||
|
||||
return renderMarkdown({ content, size: "large", disableHighlight: state.status === "running" })
|
||||
},
|
||||
}
|
||||
36
packages/ui/src/components/tool-call/renderers/index.ts
Normal file
36
packages/ui/src/components/tool-call/renderers/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { bashRenderer } from "./bash"
|
||||
import { defaultRenderer } from "./default"
|
||||
import { editRenderer } from "./edit"
|
||||
import { patchRenderer } from "./patch"
|
||||
import { readRenderer } from "./read"
|
||||
import { taskRenderer } from "./task"
|
||||
import { todoRenderer } from "./todo"
|
||||
import { webfetchRenderer } from "./webfetch"
|
||||
import { writeRenderer } from "./write"
|
||||
import { invalidRenderer } from "./invalid"
|
||||
|
||||
const TOOL_RENDERERS: ToolRenderer[] = [
|
||||
bashRenderer,
|
||||
readRenderer,
|
||||
writeRenderer,
|
||||
editRenderer,
|
||||
patchRenderer,
|
||||
webfetchRenderer,
|
||||
todoRenderer,
|
||||
taskRenderer,
|
||||
invalidRenderer,
|
||||
]
|
||||
|
||||
const rendererMap = TOOL_RENDERERS.reduce<Record<string, ToolRenderer>>((acc, renderer) => {
|
||||
renderer.tools.forEach((tool) => {
|
||||
acc[tool] = renderer
|
||||
})
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
export function resolveToolRenderer(toolName: string): ToolRenderer {
|
||||
return rendererMap[toolName] ?? defaultRenderer
|
||||
}
|
||||
|
||||
export { defaultRenderer }
|
||||
19
packages/ui/src/components/tool-call/renderers/invalid.tsx
Normal file
19
packages/ui/src/components/tool-call/renderers/invalid.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { defaultRenderer } from "./default"
|
||||
import { getToolName, readToolStatePayload } from "../utils"
|
||||
|
||||
export const invalidRenderer: ToolRenderer = {
|
||||
tools: ["invalid"],
|
||||
getTitle({ toolState }) {
|
||||
const state = toolState()
|
||||
if (!state) return getToolName("invalid")
|
||||
const { input } = readToolStatePayload(state)
|
||||
if (typeof input.tool === "string") {
|
||||
return getToolName(input.tool)
|
||||
}
|
||||
return getToolName("invalid")
|
||||
},
|
||||
renderBody(context) {
|
||||
return defaultRenderer.renderBody(context)
|
||||
},
|
||||
}
|
||||
32
packages/ui/src/components/tool-call/renderers/patch.tsx
Normal file
32
packages/ui/src/components/tool-call/renderers/patch.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { ensureMarkdownContent, extractDiffPayload, getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils"
|
||||
|
||||
export const patchRenderer: ToolRenderer = {
|
||||
tools: ["patch"],
|
||||
getAction: () => "Preparing patch...",
|
||||
getTitle({ toolState }) {
|
||||
const state = toolState()
|
||||
if (!state) return undefined
|
||||
const { input } = readToolStatePayload(state)
|
||||
const filePath = typeof input.filePath === "string" ? input.filePath : ""
|
||||
if (!filePath) return getToolName("patch")
|
||||
return `${getToolName("patch")} ${getRelativePath(filePath)}`
|
||||
},
|
||||
renderBody({ toolState, toolName, renderDiff, renderMarkdown }) {
|
||||
const state = toolState()
|
||||
if (!state || state.status === "pending") return null
|
||||
|
||||
const diffPayload = extractDiffPayload(toolName(), state)
|
||||
if (diffPayload) {
|
||||
return renderDiff(diffPayload)
|
||||
}
|
||||
|
||||
const { metadata } = readToolStatePayload(state)
|
||||
const diffText = typeof metadata.diff === "string" ? metadata.diff : null
|
||||
const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null
|
||||
const content = ensureMarkdownContent(diffText || fallback, "diff", true)
|
||||
if (!content) return null
|
||||
|
||||
return renderMarkdown({ content, size: "large", disableHighlight: state.status === "running" })
|
||||
},
|
||||
}
|
||||
25
packages/ui/src/components/tool-call/renderers/read.tsx
Normal file
25
packages/ui/src/components/tool-call/renderers/read.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromPath, readToolStatePayload } from "../utils"
|
||||
|
||||
export const readRenderer: ToolRenderer = {
|
||||
tools: ["read"],
|
||||
getAction: () => "Reading file...",
|
||||
getTitle({ toolState }) {
|
||||
const state = toolState()
|
||||
if (!state) return undefined
|
||||
const { input } = readToolStatePayload(state)
|
||||
const filePath = typeof input.filePath === "string" ? input.filePath : ""
|
||||
if (!filePath) return getToolName("read")
|
||||
return `${getToolName("read")} ${getRelativePath(filePath)}`
|
||||
},
|
||||
renderBody({ toolState, renderMarkdown }) {
|
||||
const state = toolState()
|
||||
if (!state || state.status === "pending") return null
|
||||
const { metadata, input } = readToolStatePayload(state)
|
||||
const preview = typeof metadata.preview === "string" ? metadata.preview : null
|
||||
const language = inferLanguageFromPath(typeof input.filePath === "string" ? input.filePath : undefined)
|
||||
const content = ensureMarkdownContent(preview, language, true)
|
||||
if (!content) return null
|
||||
return renderMarkdown({ content, disableHighlight: state.status === "running" })
|
||||
},
|
||||
}
|
||||
90
packages/ui/src/components/tool-call/renderers/task.tsx
Normal file
90
packages/ui/src/components/tool-call/renderers/task.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { For, createMemo } from "solid-js"
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { getRelativePath, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
||||
|
||||
interface TaskSummaryItem {
|
||||
id: string
|
||||
tool: string
|
||||
input: Record<string, any>
|
||||
}
|
||||
|
||||
function describeTaskItem(item: TaskSummaryItem): string {
|
||||
const input = item.input || {}
|
||||
switch (item.tool) {
|
||||
case "bash":
|
||||
return typeof input.description === "string" ? input.description : input.command || "bash"
|
||||
case "edit":
|
||||
case "read":
|
||||
case "write":
|
||||
return `${item.tool} ${getRelativePath(typeof input.filePath === "string" ? input.filePath : "")}`.trim()
|
||||
default:
|
||||
return item.tool
|
||||
}
|
||||
}
|
||||
|
||||
export const taskRenderer: ToolRenderer = {
|
||||
tools: ["task"],
|
||||
getAction: () => "Delegating...",
|
||||
getTitle({ toolState }) {
|
||||
const state = toolState()
|
||||
if (!state) return undefined
|
||||
const { input } = readToolStatePayload(state)
|
||||
const description = input.description
|
||||
const subagent = input.subagent_type
|
||||
const base = getToolName("task")
|
||||
if (description && subagent) {
|
||||
return `${base}[${subagent}] ${description}`
|
||||
}
|
||||
if (description) {
|
||||
return `${base} ${description}`
|
||||
}
|
||||
return base
|
||||
},
|
||||
renderBody({ toolState, toolCall, messageVersion, partVersion, scrollHelpers }) {
|
||||
const items = createMemo(() => {
|
||||
// Track the reactive change points so we only recompute when the part/message changes
|
||||
messageVersion?.()
|
||||
partVersion?.()
|
||||
|
||||
const state = toolState()
|
||||
if (!state) return []
|
||||
|
||||
const { metadata } = readToolStatePayload(state)
|
||||
const summary = Array.isArray((metadata as any).summary) ? ((metadata as any).summary as any[]) : []
|
||||
|
||||
return summary.map((entry, index) => {
|
||||
const tool = typeof entry?.tool === "string" ? (entry.tool as string) : "unknown"
|
||||
const input = typeof (entry as any)?.state?.input === "object" && entry.state?.input ? entry.state.input : {}
|
||||
const id = typeof entry?.id === "string" && entry.id.length > 0 ? entry.id : `${tool}-${index}`
|
||||
return { id, tool, input }
|
||||
})
|
||||
})
|
||||
|
||||
if (items().length === 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-task-container"
|
||||
ref={(element) => scrollHelpers?.registerContainer(element)}
|
||||
onScroll={scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined}
|
||||
>
|
||||
<div class="tool-call-task-summary">
|
||||
<For each={items()}>
|
||||
{(item) => {
|
||||
const icon = getToolIcon(item.tool)
|
||||
const description = describeTaskItem(item)
|
||||
return (
|
||||
<div class="tool-call-task-item" data-task-id={item.id}>
|
||||
<span class="tool-call-task-icon">{icon}</span>
|
||||
<span class="tool-call-task-text">{description}</span>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
{scrollHelpers?.renderSentinel?.()}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
121
packages/ui/src/components/tool-call/renderers/todo.tsx
Normal file
121
packages/ui/src/components/tool-call/renderers/todo.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { For } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { readToolStatePayload } from "../utils"
|
||||
|
||||
export type TodoViewStatus = "pending" | "in_progress" | "completed" | "cancelled"
|
||||
|
||||
interface TodoViewItem {
|
||||
id: string
|
||||
content: string
|
||||
status: TodoViewStatus
|
||||
}
|
||||
|
||||
function normalizeTodoStatus(rawStatus: unknown): TodoViewStatus {
|
||||
if (rawStatus === "completed" || rawStatus === "in_progress" || rawStatus === "cancelled") return rawStatus
|
||||
return "pending"
|
||||
}
|
||||
|
||||
function extractTodosFromState(state?: ToolState): TodoViewItem[] {
|
||||
if (!state) return []
|
||||
const { metadata } = readToolStatePayload(state)
|
||||
const todos = Array.isArray((metadata as any).todos) ? (metadata as any).todos : []
|
||||
const items: TodoViewItem[] = []
|
||||
|
||||
for (let index = 0; index < todos.length; index++) {
|
||||
const todo = todos[index]
|
||||
const content = typeof todo?.content === "string" ? todo.content.trim() : ""
|
||||
if (!content) continue
|
||||
const status = normalizeTodoStatus((todo as any).status)
|
||||
const id = typeof todo?.id === "string" && todo.id.length > 0 ? todo.id : `${index}-${content}`
|
||||
items.push({ id, content, status })
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
function summarizeTodos(todos: TodoViewItem[]) {
|
||||
return todos.reduce(
|
||||
(acc, todo) => {
|
||||
acc.total += 1
|
||||
acc[todo.status] = (acc[todo.status] || 0) + 1
|
||||
return acc
|
||||
},
|
||||
{ total: 0, pending: 0, in_progress: 0, completed: 0, cancelled: 0 } as Record<TodoViewStatus | "total", number>,
|
||||
)
|
||||
}
|
||||
|
||||
function getTodoStatusLabel(status: TodoViewStatus): string {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "Completed"
|
||||
case "in_progress":
|
||||
return "In progress"
|
||||
case "cancelled":
|
||||
return "Cancelled"
|
||||
default:
|
||||
return "Pending"
|
||||
}
|
||||
}
|
||||
|
||||
function getTodoTitle(state?: ToolState): string {
|
||||
if (!state) return "Plan"
|
||||
|
||||
const todos = extractTodosFromState(state)
|
||||
if (state.status !== "completed" || todos.length === 0) return "Plan"
|
||||
|
||||
const counts = summarizeTodos(todos)
|
||||
if (counts.pending === counts.total) return "Creating plan"
|
||||
if (counts.completed === counts.total) return "Completing plan"
|
||||
return "Updating plan"
|
||||
}
|
||||
|
||||
export const todoRenderer: ToolRenderer = {
|
||||
tools: ["todowrite", "todoread"],
|
||||
getAction: () => "Planning...",
|
||||
getTitle({ toolState }) {
|
||||
return getTodoTitle(toolState())
|
||||
},
|
||||
renderBody({ toolState }) {
|
||||
const state = toolState()
|
||||
if (!state) return null
|
||||
|
||||
const todos = extractTodosFromState(state)
|
||||
const counts = summarizeTodos(todos)
|
||||
|
||||
if (counts.total === 0) {
|
||||
return <div class="tool-call-todo-empty">No plan items yet.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="tool-call-todo-region">
|
||||
<div class="tool-call-todos" role="list">
|
||||
<For each={todos}>
|
||||
{(todo) => {
|
||||
const label = getTodoStatusLabel(todo.status)
|
||||
return (
|
||||
<div
|
||||
class="tool-call-todo-item"
|
||||
classList={{
|
||||
"tool-call-todo-item-completed": todo.status === "completed",
|
||||
"tool-call-todo-item-cancelled": todo.status === "cancelled",
|
||||
"tool-call-todo-item-active": todo.status === "in_progress",
|
||||
}}
|
||||
role="listitem"
|
||||
>
|
||||
<span class="tool-call-todo-checkbox" data-status={todo.status} aria-label={label}></span>
|
||||
<div class="tool-call-todo-body">
|
||||
<div class="tool-call-todo-heading">
|
||||
<span class="tool-call-todo-text">{todo.content}</span>
|
||||
<span class={`tool-call-todo-status tool-call-todo-status-${todo.status}`}>{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
33
packages/ui/src/components/tool-call/renderers/webfetch.tsx
Normal file
33
packages/ui/src/components/tool-call/renderers/webfetch.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { ensureMarkdownContent, formatUnknown, getToolName, readToolStatePayload } from "../utils"
|
||||
|
||||
export const webfetchRenderer: ToolRenderer = {
|
||||
tools: ["webfetch"],
|
||||
getAction: () => "Fetching from the web...",
|
||||
getTitle({ toolState }) {
|
||||
const state = toolState()
|
||||
if (!state) return undefined
|
||||
const { input } = readToolStatePayload(state)
|
||||
if (typeof input.url === "string" && input.url.length > 0) {
|
||||
return `${getToolName("webfetch")} ${input.url}`
|
||||
}
|
||||
return getToolName("webfetch")
|
||||
},
|
||||
renderBody({ toolState, renderMarkdown }) {
|
||||
const state = toolState()
|
||||
if (!state || state.status === "pending") return null
|
||||
|
||||
const { metadata } = readToolStatePayload(state)
|
||||
const result = formatUnknown(
|
||||
state.status === "completed"
|
||||
? state.output
|
||||
: metadata.output,
|
||||
)
|
||||
if (!result) return null
|
||||
|
||||
const content = ensureMarkdownContent(result.text, result.language, true)
|
||||
if (!content) return null
|
||||
|
||||
return renderMarkdown({ content, disableHighlight: state.status === "running" })
|
||||
},
|
||||
}
|
||||
25
packages/ui/src/components/tool-call/renderers/write.tsx
Normal file
25
packages/ui/src/components/tool-call/renderers/write.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromPath, readToolStatePayload } from "../utils"
|
||||
|
||||
export const writeRenderer: ToolRenderer = {
|
||||
tools: ["write"],
|
||||
getAction: () => "Preparing write...",
|
||||
getTitle({ toolState }) {
|
||||
const state = toolState()
|
||||
if (!state) return undefined
|
||||
const { input } = readToolStatePayload(state)
|
||||
const filePath = typeof input.filePath === "string" ? input.filePath : ""
|
||||
if (!filePath) return getToolName("write")
|
||||
return `${getToolName("write")} ${getRelativePath(filePath)}`
|
||||
},
|
||||
renderBody({ toolState, renderMarkdown }) {
|
||||
const state = toolState()
|
||||
if (!state || state.status === "pending") return null
|
||||
const { metadata, input } = readToolStatePayload(state)
|
||||
const contentValue = typeof input.content === "string" ? input.content : metadata.content
|
||||
const filePath = typeof input.filePath === "string" ? input.filePath : undefined
|
||||
const content = ensureMarkdownContent(contentValue ?? null, inferLanguageFromPath(filePath), true)
|
||||
if (!content) return null
|
||||
return renderMarkdown({ content, size: "large", disableHighlight: state.status === "running" })
|
||||
},
|
||||
}
|
||||
48
packages/ui/src/components/tool-call/types.ts
Normal file
48
packages/ui/src/components/tool-call/types.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { Accessor, JSXElement } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { ClientPart } from "../../types/message"
|
||||
|
||||
export type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
|
||||
export interface DiffPayload {
|
||||
diffText: string
|
||||
filePath?: string
|
||||
}
|
||||
|
||||
export interface MarkdownRenderOptions {
|
||||
content: string
|
||||
size?: "default" | "large"
|
||||
disableHighlight?: boolean
|
||||
}
|
||||
|
||||
export interface DiffRenderOptions {
|
||||
variant?: string
|
||||
disableScrollTracking?: boolean
|
||||
label?: string
|
||||
}
|
||||
|
||||
export interface ToolScrollHelpers {
|
||||
registerContainer(element: HTMLDivElement | null, options?: { disableTracking?: boolean }): void
|
||||
handleScroll(event: Event & { currentTarget: HTMLDivElement }): void
|
||||
renderSentinel(options?: { disableTracking?: boolean }): JSXElement | null
|
||||
}
|
||||
|
||||
export interface ToolRendererContext {
|
||||
toolCall: Accessor<ToolCallPart>
|
||||
toolState: Accessor<ToolState | undefined>
|
||||
toolName: Accessor<string>
|
||||
messageVersion?: Accessor<number | undefined>
|
||||
partVersion?: Accessor<number | undefined>
|
||||
renderMarkdown(options: MarkdownRenderOptions): JSXElement | null
|
||||
renderDiff(payload: DiffPayload, options?: DiffRenderOptions): JSXElement | null
|
||||
scrollHelpers?: ToolScrollHelpers
|
||||
}
|
||||
|
||||
export interface ToolRenderer {
|
||||
tools: string[]
|
||||
getTitle?(context: ToolRendererContext): string | undefined
|
||||
getAction?(context: ToolRendererContext): string | undefined
|
||||
renderBody(context: ToolRendererContext): JSXElement | null
|
||||
}
|
||||
|
||||
export type ToolRendererMap = Record<string, ToolRenderer>
|
||||
194
packages/ui/src/components/tool-call/utils.ts
Normal file
194
packages/ui/src/components/tool-call/utils.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { isRenderableDiffText } from "../../lib/diff-utils"
|
||||
import { getLanguageFromPath } from "../../lib/markdown"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { DiffPayload } from "./types"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
const log = getLogger("session")
|
||||
|
||||
|
||||
export type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning
|
||||
export type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted
|
||||
export type ToolStateError = import("@opencode-ai/sdk").ToolStateError
|
||||
|
||||
export const diffCapableTools = new Set(["edit", "patch"])
|
||||
|
||||
export function isToolStateRunning(state: ToolState): state is ToolStateRunning {
|
||||
return state.status === "running"
|
||||
}
|
||||
|
||||
export function isToolStateCompleted(state: ToolState): state is ToolStateCompleted {
|
||||
return state.status === "completed"
|
||||
}
|
||||
|
||||
export function isToolStateError(state: ToolState): state is ToolStateError {
|
||||
return state.status === "error"
|
||||
}
|
||||
|
||||
export function getToolIcon(tool: string): string {
|
||||
switch (tool) {
|
||||
case "bash":
|
||||
return "⚡"
|
||||
case "edit":
|
||||
return "✏️"
|
||||
case "read":
|
||||
return "📖"
|
||||
case "write":
|
||||
return "📝"
|
||||
case "glob":
|
||||
return "🔍"
|
||||
case "grep":
|
||||
return "🔎"
|
||||
case "webfetch":
|
||||
return "🌐"
|
||||
case "task":
|
||||
return "🎯"
|
||||
case "todowrite":
|
||||
case "todoread":
|
||||
return "📋"
|
||||
case "list":
|
||||
return "📁"
|
||||
case "patch":
|
||||
return "🔧"
|
||||
default:
|
||||
return "🔧"
|
||||
}
|
||||
}
|
||||
|
||||
export function getToolName(tool: string): string {
|
||||
switch (tool) {
|
||||
case "bash":
|
||||
return "Shell"
|
||||
case "webfetch":
|
||||
return "Fetch"
|
||||
case "invalid":
|
||||
return "Invalid"
|
||||
case "todowrite":
|
||||
case "todoread":
|
||||
return "Plan"
|
||||
default: {
|
||||
const normalized = tool.replace(/^opencode_/, "")
|
||||
return normalized.charAt(0).toUpperCase() + normalized.slice(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getRelativePath(path: string): string {
|
||||
if (!path) return ""
|
||||
const parts = path.split("/")
|
||||
return parts.slice(-1)[0] || path
|
||||
}
|
||||
|
||||
export function ensureMarkdownContent(
|
||||
value: string | null,
|
||||
language?: string,
|
||||
forceFence = false,
|
||||
): string | null {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const trimmed = value.replace(/\s+$/, "")
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
|
||||
const startsWithFence = trimmed.trimStart().startsWith("```")
|
||||
if (startsWithFence && !forceFence) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const langSuffix = language ? language : ""
|
||||
if (language || forceFence) {
|
||||
return `\u0060\u0060\u0060${langSuffix}\n${trimmed}\n\u0060\u0060\u0060`
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export function formatUnknown(value: unknown): { text: string; language?: string } | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return { text: value }
|
||||
}
|
||||
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return { text: String(value) }
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const parts = value
|
||||
.map((item) => {
|
||||
const formatted = formatUnknown(item)
|
||||
return formatted?.text ?? ""
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
if (parts.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { text: parts.join("\n") }
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
try {
|
||||
return { text: JSON.stringify(value, null, 2), language: "json" }
|
||||
} catch (error) {
|
||||
log.error("Failed to stringify tool call output", error)
|
||||
return { text: String(value) }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function inferLanguageFromPath(path?: string): string | undefined {
|
||||
return getLanguageFromPath(path || "")
|
||||
}
|
||||
|
||||
export function extractDiffPayload(toolName: string, state?: ToolState): DiffPayload | null {
|
||||
if (!state) return null
|
||||
if (!diffCapableTools.has(toolName)) return null
|
||||
|
||||
const { metadata, input, output } = readToolStatePayload(state)
|
||||
const candidates = [metadata.diff, output, metadata.output]
|
||||
let diffText: string | null = null
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === "string" && isRenderableDiffText(candidate)) {
|
||||
diffText = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!diffText) {
|
||||
return null
|
||||
}
|
||||
|
||||
const filePath =
|
||||
(typeof input.filePath === "string" ? input.filePath : undefined) ||
|
||||
(typeof metadata.filePath === "string" ? metadata.filePath : undefined) ||
|
||||
(typeof input.path === "string" ? input.path : undefined)
|
||||
|
||||
return { diffText, filePath }
|
||||
}
|
||||
|
||||
export function readToolStatePayload(state?: ToolState): {
|
||||
input: Record<string, any>
|
||||
metadata: Record<string, any>
|
||||
output: unknown
|
||||
} {
|
||||
if (!state) {
|
||||
return { input: {}, metadata: {}, output: undefined }
|
||||
}
|
||||
|
||||
const supportsMetadata = isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)
|
||||
return {
|
||||
input: supportsMetadata ? ((state.input || {}) as Record<string, any>) : {},
|
||||
metadata: supportsMetadata ? ((state.metadata || {}) as Record<string, any>) : {},
|
||||
output: isToolStateCompleted(state) ? state.output : undefined,
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import { Component, createSignal, createEffect, For, Show, onCleanup } from "sol
|
||||
import type { Agent } from "../types/session"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/client"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("actions")
|
||||
|
||||
|
||||
const SEARCH_RESULT_LIMIT = 100
|
||||
const SEARCH_DEBOUNCE_MS = 200
|
||||
@@ -124,7 +127,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
return snapshot
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`[UnifiedPicker] Failed to load workspace files:`, error)
|
||||
log.error(`[UnifiedPicker] Failed to load workspace files:`, error)
|
||||
setAllFiles([])
|
||||
setCachedWorkspaceId(null)
|
||||
throw error
|
||||
@@ -178,7 +181,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
applyFileResults(mapEntriesToFileItems(results))
|
||||
} catch (error) {
|
||||
if (workspaceId === props.workspaceId) {
|
||||
console.error(`[UnifiedPicker] Failed to fetch files:`, error)
|
||||
log.error(`[UnifiedPicker] Failed to fetch files:`, error)
|
||||
if (shouldApplyResults(requestId, workspaceId)) {
|
||||
applyFileResults([])
|
||||
}
|
||||
|
||||
286
packages/ui/src/components/virtual-item.tsx
Normal file
286
packages/ui/src/components/virtual-item.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import { JSX, Show, Accessor, children as resolveChildren, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
|
||||
const sizeCache = new Map<string, number>()
|
||||
const DEFAULT_MARGIN_PX = 600
|
||||
const MIN_PLACEHOLDER_HEIGHT = 32
|
||||
|
||||
type ObserverRoot = Element | Document | null
|
||||
|
||||
type IntersectionCallback = (entry: IntersectionObserverEntry) => void
|
||||
|
||||
interface SharedObserver {
|
||||
observer: IntersectionObserver
|
||||
listeners: Map<Element, Set<IntersectionCallback>>
|
||||
}
|
||||
|
||||
const NULL_ROOT_KEY = "__null__"
|
||||
const rootIds = new WeakMap<Element | Document, number>()
|
||||
let sharedRootId = 0
|
||||
const sharedObservers = new Map<string, SharedObserver>()
|
||||
|
||||
function getRootKey(root: ObserverRoot, margin: number): string {
|
||||
if (!root) {
|
||||
return `${NULL_ROOT_KEY}:${margin}`
|
||||
}
|
||||
let id = rootIds.get(root)
|
||||
if (id === undefined) {
|
||||
id = ++sharedRootId
|
||||
rootIds.set(root, id)
|
||||
}
|
||||
return `${id}:${margin}`
|
||||
}
|
||||
|
||||
function createSharedObserver(root: ObserverRoot, margin: number): SharedObserver {
|
||||
const listeners = new Map<Element, Set<IntersectionCallback>>()
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
const callbacks = listeners.get(entry.target as Element)
|
||||
if (!callbacks) return
|
||||
callbacks.forEach((fn) => fn(entry))
|
||||
})
|
||||
},
|
||||
{
|
||||
root: root ?? undefined,
|
||||
rootMargin: `${margin}px 0px ${margin}px 0px`,
|
||||
},
|
||||
)
|
||||
return { observer, listeners }
|
||||
}
|
||||
|
||||
function subscribeToSharedObserver(
|
||||
target: Element,
|
||||
root: ObserverRoot,
|
||||
margin: number,
|
||||
callback: IntersectionCallback,
|
||||
): () => void {
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
callback({ isIntersecting: true } as IntersectionObserverEntry)
|
||||
return () => {}
|
||||
}
|
||||
const key = getRootKey(root, margin)
|
||||
let shared = sharedObservers.get(key)
|
||||
if (!shared) {
|
||||
shared = createSharedObserver(root, margin)
|
||||
sharedObservers.set(key, shared)
|
||||
}
|
||||
let targetCallbacks = shared.listeners.get(target)
|
||||
if (!targetCallbacks) {
|
||||
targetCallbacks = new Set()
|
||||
shared.listeners.set(target, targetCallbacks)
|
||||
shared.observer.observe(target)
|
||||
}
|
||||
targetCallbacks.add(callback)
|
||||
return () => {
|
||||
const current = shared?.listeners.get(target)
|
||||
if (current) {
|
||||
current.delete(callback)
|
||||
if (current.size === 0) {
|
||||
shared?.listeners.delete(target)
|
||||
shared?.observer.unobserve(target)
|
||||
}
|
||||
}
|
||||
if (shared && shared.listeners.size === 0) {
|
||||
shared.observer.disconnect()
|
||||
sharedObservers.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface VirtualItemProps {
|
||||
cacheKey: string
|
||||
children: JSX.Element
|
||||
scrollContainer?: Accessor<HTMLElement | undefined | null>
|
||||
threshold?: number
|
||||
minPlaceholderHeight?: number
|
||||
class?: string
|
||||
contentClass?: string
|
||||
placeholderClass?: string
|
||||
virtualizationEnabled?: Accessor<boolean>
|
||||
forceVisible?: Accessor<boolean>
|
||||
onMeasured?: () => void
|
||||
}
|
||||
|
||||
export default function VirtualItem(props: VirtualItemProps) {
|
||||
const resolved = resolveChildren(() => props.children)
|
||||
const cachedHeight = sizeCache.get(props.cacheKey)
|
||||
const [isIntersecting, setIsIntersecting] = createSignal(true)
|
||||
const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? 0)
|
||||
const [hasMeasured, setHasMeasured] = createSignal(cachedHeight !== undefined)
|
||||
let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0)
|
||||
let pendingVisibility: boolean | null = null
|
||||
let visibilityFrame: number | null = null
|
||||
const flushVisibility = () => {
|
||||
if (visibilityFrame !== null) {
|
||||
cancelAnimationFrame(visibilityFrame)
|
||||
visibilityFrame = null
|
||||
}
|
||||
if (pendingVisibility !== null) {
|
||||
setIsIntersecting(pendingVisibility)
|
||||
pendingVisibility = null
|
||||
}
|
||||
}
|
||||
const queueVisibility = (nextValue: boolean) => {
|
||||
pendingVisibility = nextValue
|
||||
if (visibilityFrame !== null) return
|
||||
visibilityFrame = requestAnimationFrame(() => {
|
||||
visibilityFrame = null
|
||||
if (pendingVisibility !== null) {
|
||||
setIsIntersecting(pendingVisibility)
|
||||
pendingVisibility = null
|
||||
}
|
||||
})
|
||||
}
|
||||
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
|
||||
|
||||
let wrapperRef: HTMLDivElement | undefined
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
let intersectionCleanup: (() => void) | undefined
|
||||
|
||||
function cleanupResizeObserver() {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupIntersectionObserver() {
|
||||
if (intersectionCleanup) {
|
||||
intersectionCleanup()
|
||||
intersectionCleanup = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function persistMeasurement(nextHeight: number) {
|
||||
if (!Number.isFinite(nextHeight) || nextHeight < 0) {
|
||||
return
|
||||
}
|
||||
const normalized = nextHeight
|
||||
if (normalized > 0) {
|
||||
sizeCache.set(props.cacheKey, normalized)
|
||||
setHasMeasured(true)
|
||||
if (!hasReportedMeasurement) {
|
||||
hasReportedMeasurement = true
|
||||
props.onMeasured?.()
|
||||
}
|
||||
}
|
||||
setMeasuredHeight(normalized)
|
||||
}
|
||||
|
||||
function updateMeasuredHeight() {
|
||||
if (!contentRef) return
|
||||
const next = contentRef.offsetHeight
|
||||
if (next === measuredHeight()) return
|
||||
persistMeasurement(next)
|
||||
}
|
||||
|
||||
function setupResizeObserver() {
|
||||
if (!contentRef) return
|
||||
cleanupResizeObserver()
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
updateMeasuredHeight()
|
||||
return
|
||||
}
|
||||
resizeObserver = new ResizeObserver(() => updateMeasuredHeight())
|
||||
resizeObserver.observe(contentRef)
|
||||
}
|
||||
|
||||
function refreshIntersectionObserver(targetRoot: Element | Document | null) {
|
||||
cleanupIntersectionObserver()
|
||||
if (!wrapperRef) {
|
||||
setIsIntersecting(true)
|
||||
return
|
||||
}
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
setIsIntersecting(true)
|
||||
return
|
||||
}
|
||||
const margin = props.threshold ?? DEFAULT_MARGIN_PX
|
||||
intersectionCleanup = subscribeToSharedObserver(wrapperRef, targetRoot, margin, (entry) => {
|
||||
queueVisibility(entry.isIntersecting)
|
||||
})
|
||||
}
|
||||
|
||||
function setWrapperRef(element: HTMLDivElement | null) {
|
||||
wrapperRef = element ?? undefined
|
||||
const root = props.scrollContainer ? props.scrollContainer() : null
|
||||
refreshIntersectionObserver(root ?? null)
|
||||
}
|
||||
|
||||
function setContentRef(element: HTMLDivElement | null) {
|
||||
contentRef = element ?? undefined
|
||||
if (contentRef) {
|
||||
queueMicrotask(() => {
|
||||
updateMeasuredHeight()
|
||||
setupResizeObserver()
|
||||
})
|
||||
} else {
|
||||
cleanupResizeObserver()
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const key = props.cacheKey
|
||||
const cached = sizeCache.get(key)
|
||||
if (cached !== undefined) {
|
||||
setMeasuredHeight(cached)
|
||||
setHasMeasured(true)
|
||||
} else {
|
||||
setMeasuredHeight(0)
|
||||
setHasMeasured(false)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const root = props.scrollContainer ? props.scrollContainer() : null
|
||||
refreshIntersectionObserver(root ?? null)
|
||||
})
|
||||
|
||||
const shouldHideContent = createMemo(() => {
|
||||
if (props.forceVisible?.()) return false
|
||||
if (!virtualizationEnabled()) return false
|
||||
return !isIntersecting()
|
||||
})
|
||||
|
||||
const placeholderHeight = createMemo(() => {
|
||||
const seenHeight = measuredHeight()
|
||||
if (seenHeight > 0) {
|
||||
return seenHeight
|
||||
}
|
||||
return props.minPlaceholderHeight ?? MIN_PLACEHOLDER_HEIGHT
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
cleanupResizeObserver()
|
||||
cleanupIntersectionObserver()
|
||||
flushVisibility()
|
||||
})
|
||||
|
||||
const wrapperClass = () => ["virtual-item-wrapper", props.class].filter(Boolean).join(" ")
|
||||
const contentClass = () => {
|
||||
const classes = ["virtual-item-content", props.contentClass]
|
||||
if (shouldHideContent()) {
|
||||
classes.push("virtual-item-content-hidden")
|
||||
}
|
||||
return classes.filter(Boolean).join(" ")
|
||||
}
|
||||
const placeholderClass = () => ["virtual-item-placeholder", props.placeholderClass].filter(Boolean).join(" ")
|
||||
|
||||
return (
|
||||
<div ref={setWrapperRef} class={wrapperClass()} style={{ width: "100%" }}>
|
||||
<div
|
||||
class={placeholderClass()}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: shouldHideContent() ? `${placeholderHeight()}px` : undefined,
|
||||
}}
|
||||
>
|
||||
<div ref={setContentRef} class={contentClass()}>
|
||||
{resolved()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
WorkspaceEventPayload,
|
||||
WorkspaceEventType,
|
||||
} from "../../../server/src/api-types"
|
||||
import { getLogger } from "./logger"
|
||||
|
||||
const FALLBACK_API_BASE = "http://127.0.0.1:9898"
|
||||
const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined
|
||||
@@ -38,15 +39,15 @@ function buildEventsUrl(base: string | undefined, path: string): string {
|
||||
return path
|
||||
}
|
||||
|
||||
const HTTP_PREFIX = "[HTTP]"
|
||||
const httpLogger = getLogger("api")
|
||||
const sseLogger = getLogger("sse")
|
||||
|
||||
function logHttp(message: string, context?: Record<string, unknown>) {
|
||||
|
||||
if (context) {
|
||||
console.log(`${HTTP_PREFIX} ${message}`, context)
|
||||
httpLogger.info(message, context)
|
||||
return
|
||||
}
|
||||
console.log(`${HTTP_PREFIX} ${message}`)
|
||||
httpLogger.info(message)
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
@@ -186,18 +187,18 @@ export const serverApi = {
|
||||
return request(`/api/storage/instances/${encodeURIComponent(id)}`, { method: "DELETE" })
|
||||
},
|
||||
connectEvents(onEvent: (event: WorkspaceEventPayload) => void, onError?: () => void) {
|
||||
console.log(`[SSE] Connecting to ${EVENTS_URL}`)
|
||||
sseLogger.info(`Connecting to ${EVENTS_URL}`)
|
||||
const source = new EventSource(EVENTS_URL)
|
||||
source.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as WorkspaceEventPayload
|
||||
onEvent(payload)
|
||||
} catch (error) {
|
||||
console.error("[SSE] Failed to parse event", error)
|
||||
sseLogger.error("Failed to parse event", error)
|
||||
}
|
||||
}
|
||||
source.onerror = () => {
|
||||
console.warn("[SSE] EventSource error, closing stream")
|
||||
sseLogger.warn("EventSource error, closing stream")
|
||||
onError?.()
|
||||
}
|
||||
return source
|
||||
|
||||
@@ -2,6 +2,9 @@ import type { Command } from "./commands"
|
||||
import type { Command as SDKCommand } from "@opencode-ai/sdk"
|
||||
import { showAlertDialog } from "../stores/alerts"
|
||||
import { activeSessionId, executeCustomCommand } from "../stores/sessions"
|
||||
import { getLogger } from "./logger"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
export function commandRequiresArguments(template?: string): boolean {
|
||||
if (!template) return false
|
||||
@@ -47,7 +50,7 @@ export function buildCustomCommandEntries(instanceId: string, commands: SDKComma
|
||||
try {
|
||||
await executeCustomCommand(instanceId, sessionId, cmd.name, args)
|
||||
} catch (error) {
|
||||
console.error("Failed to run custom command:", error)
|
||||
log.error("Failed to run custom command", error)
|
||||
showAlertDialog("Failed to run custom command. Check the console for details.", {
|
||||
title: "Command failed",
|
||||
variant: "error",
|
||||
|
||||
@@ -8,6 +8,9 @@ import { keyboardRegistry } from "../keyboard-registry"
|
||||
import { abortSession, getSessions, isSessionBusy } from "../../stores/sessions"
|
||||
import { showCommandPalette, hideCommandPalette } from "../../stores/command-palette"
|
||||
import type { Instance } from "../../types/instance"
|
||||
import { getLogger } from "../logger"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
interface UseAppLifecycleOptions {
|
||||
setEscapeInDebounce: (value: boolean) => void
|
||||
@@ -115,9 +118,9 @@ export function useAppLifecycle(options: UseAppLifecycleOptions) {
|
||||
|
||||
try {
|
||||
await abortSession(instance.id, sessionId)
|
||||
console.log("Session aborted successfully")
|
||||
log.info("Session aborted successfully", { instanceId: instance.id, sessionId })
|
||||
} catch (error) {
|
||||
console.error("Failed to abort session:", error)
|
||||
log.error("Failed to abort session", error)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
|
||||
@@ -17,6 +17,9 @@ import type { Instance } from "../../types/instance"
|
||||
import type { MessageRecord } from "../../stores/message-v2/types"
|
||||
import { messageStoreBus } from "../../stores/message-v2/bus"
|
||||
import { cleanupBlankSessions } from "../../stores/session-state"
|
||||
import { getLogger } from "../logger"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
export interface UseCommandsOptions {
|
||||
preferences: Accessor<Preferences>
|
||||
@@ -236,15 +239,16 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
modelID: session.model.modelId,
|
||||
},
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
} catch (error) {
|
||||
setSessionCompactionState(instance.id, sessionId, false)
|
||||
console.error("Failed to compact session:", error)
|
||||
log.error("Failed to compact session", error)
|
||||
const message = error instanceof Error ? error.message : "Failed to compact session"
|
||||
showAlertDialog(`Compact failed: ${message}`, {
|
||||
title: "Compact failed",
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
|
||||
},
|
||||
})
|
||||
|
||||
@@ -322,12 +326,13 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to revert message:", error)
|
||||
log.error("Failed to revert message", error)
|
||||
showAlertDialog("Failed to revert message", {
|
||||
title: "Undo failed",
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
|
||||
},
|
||||
})
|
||||
|
||||
@@ -503,7 +508,7 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
category: "System",
|
||||
keywords: ["/help", "shortcuts", "help"],
|
||||
action: () => {
|
||||
console.log("Show help modal (not implemented)")
|
||||
log.info("Show help modal (not implemented)")
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -513,11 +518,11 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
const result = command.action?.()
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => {
|
||||
console.error("Command execution failed:", error)
|
||||
log.error("Command execution failed", error)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Command execution failed:", error)
|
||||
log.error("Command execution failed", error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
151
packages/ui/src/lib/logger.ts
Normal file
151
packages/ui/src/lib/logger.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import debug from "debug"
|
||||
|
||||
export type LoggerNamespace = "sse" | "api" | "session" | "actions"
|
||||
|
||||
interface Logger {
|
||||
log: (...args: unknown[]) => void
|
||||
info: (...args: unknown[]) => void
|
||||
warn: (...args: unknown[]) => void
|
||||
error: (...args: unknown[]) => void
|
||||
}
|
||||
|
||||
export interface NamespaceState {
|
||||
name: LoggerNamespace
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface LoggerControls {
|
||||
listLoggerNamespaces: () => NamespaceState[]
|
||||
enableLogger: (namespace: LoggerNamespace) => void
|
||||
disableLogger: (namespace: LoggerNamespace) => void
|
||||
enableAllLoggers: () => void
|
||||
disableAllLoggers: () => void
|
||||
}
|
||||
|
||||
const KNOWN_NAMESPACES: LoggerNamespace[] = ["sse", "api", "session", "actions"]
|
||||
const STORAGE_KEY = "opencode:logger:namespaces"
|
||||
|
||||
const namespaceLoggers = new Map<LoggerNamespace, Logger>()
|
||||
const enabledNamespaces = new Set<LoggerNamespace>()
|
||||
const rawConsole = typeof globalThis !== "undefined" ? globalThis.console : undefined
|
||||
|
||||
function applyEnabledNamespaces(): void {
|
||||
if (enabledNamespaces.size === 0) {
|
||||
debug.disable()
|
||||
} else {
|
||||
debug.enable(Array.from(enabledNamespaces).join(","))
|
||||
}
|
||||
}
|
||||
|
||||
function persistEnabledNamespaces(): void {
|
||||
if (typeof window === "undefined" || !window?.localStorage) return
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(enabledNamespaces)))
|
||||
} catch (error) {
|
||||
rawConsole?.warn?.("Failed to persist logger namespaces", error)
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateNamespacesFromStorage(): void {
|
||||
if (typeof window === "undefined" || !window?.localStorage) return
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (!stored) return
|
||||
const parsed: unknown = JSON.parse(stored)
|
||||
if (!Array.isArray(parsed)) return
|
||||
for (const name of parsed) {
|
||||
if (KNOWN_NAMESPACES.includes(name as LoggerNamespace)) {
|
||||
enabledNamespaces.add(name as LoggerNamespace)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
rawConsole?.warn?.("Failed to hydrate logger namespaces", error)
|
||||
}
|
||||
}
|
||||
|
||||
hydrateNamespacesFromStorage()
|
||||
applyEnabledNamespaces()
|
||||
|
||||
function buildLogger(namespace: LoggerNamespace): Logger {
|
||||
const base = debug(namespace)
|
||||
const baseLogger: (...args: any[]) => void = base
|
||||
const formatAndLog = (level: string, args: any[]) => {
|
||||
baseLogger(level, ...args)
|
||||
}
|
||||
return {
|
||||
log: (...args: any[]) => baseLogger(...args),
|
||||
info: (...args: any[]) => baseLogger(...args),
|
||||
warn: (...args: any[]) => formatAndLog("[warn]", args),
|
||||
error: (...args: any[]) => formatAndLog("[error]", args),
|
||||
}
|
||||
}
|
||||
|
||||
function getLogger(namespace: LoggerNamespace): Logger {
|
||||
if (!KNOWN_NAMESPACES.includes(namespace)) {
|
||||
throw new Error(`Unknown logger namespace: ${namespace}`)
|
||||
}
|
||||
if (!namespaceLoggers.has(namespace)) {
|
||||
namespaceLoggers.set(namespace, buildLogger(namespace))
|
||||
}
|
||||
return namespaceLoggers.get(namespace)!
|
||||
}
|
||||
|
||||
function listLoggerNamespaces(): NamespaceState[] {
|
||||
return KNOWN_NAMESPACES.map((name) => ({ name, enabled: enabledNamespaces.has(name) }))
|
||||
}
|
||||
|
||||
function enableLogger(namespace: LoggerNamespace): void {
|
||||
if (!KNOWN_NAMESPACES.includes(namespace)) {
|
||||
throw new Error(`Unknown logger namespace: ${namespace}`)
|
||||
}
|
||||
if (enabledNamespaces.has(namespace)) return
|
||||
enabledNamespaces.add(namespace)
|
||||
persistEnabledNamespaces()
|
||||
applyEnabledNamespaces()
|
||||
}
|
||||
|
||||
function disableLogger(namespace: LoggerNamespace): void {
|
||||
if (!KNOWN_NAMESPACES.includes(namespace)) {
|
||||
throw new Error(`Unknown logger namespace: ${namespace}`)
|
||||
}
|
||||
if (!enabledNamespaces.has(namespace)) return
|
||||
enabledNamespaces.delete(namespace)
|
||||
persistEnabledNamespaces()
|
||||
applyEnabledNamespaces()
|
||||
}
|
||||
|
||||
function disableAllLoggers(): void {
|
||||
enabledNamespaces.clear()
|
||||
persistEnabledNamespaces()
|
||||
applyEnabledNamespaces()
|
||||
}
|
||||
|
||||
function enableAllLoggers(): void {
|
||||
KNOWN_NAMESPACES.forEach((namespace) => enabledNamespaces.add(namespace))
|
||||
persistEnabledNamespaces()
|
||||
applyEnabledNamespaces()
|
||||
}
|
||||
|
||||
const loggerControls: LoggerControls = {
|
||||
listLoggerNamespaces,
|
||||
enableLogger,
|
||||
disableLogger,
|
||||
enableAllLoggers,
|
||||
disableAllLoggers,
|
||||
}
|
||||
|
||||
function exposeLoggerControls(): void {
|
||||
if (typeof window === "undefined") return
|
||||
window.codenomadLogger = loggerControls
|
||||
}
|
||||
|
||||
exposeLoggerControls()
|
||||
|
||||
export {
|
||||
getLogger,
|
||||
listLoggerNamespaces,
|
||||
enableLogger,
|
||||
disableLogger,
|
||||
enableAllLoggers,
|
||||
disableAllLoggers,
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { marked } from "marked"
|
||||
import { createHighlighter, type Highlighter, bundledLanguages } from "shiki/bundle/full"
|
||||
import { getLogger } from "./logger"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
let highlighter: Highlighter | null = null
|
||||
let highlighterPromise: Promise<Highlighter> | null = null
|
||||
@@ -71,7 +74,7 @@ function triggerLanguageListeners() {
|
||||
try {
|
||||
listener()
|
||||
} catch (error) {
|
||||
console.error("Error in language listener:", error)
|
||||
log.error("Error in language listener", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
31
packages/ui/src/lib/native/cli.ts
Normal file
31
packages/ui/src/lib/native/cli.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
import { getLogger } from "../logger"
|
||||
const log = getLogger("actions")
|
||||
|
||||
|
||||
export async function restartCli(): Promise<boolean> {
|
||||
try {
|
||||
if (runtimeEnv.host === "electron") {
|
||||
const api = (window as typeof window & { electronAPI?: { restartCli?: () => Promise<unknown> } }).electronAPI
|
||||
if (api?.restartCli) {
|
||||
await api.restartCli()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (runtimeEnv.host === "tauri") {
|
||||
const tauri = (window as typeof window & { __TAURI__?: { invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T> } }).__TAURI__
|
||||
if (tauri?.invoke) {
|
||||
await tauri.invoke("cli_restart")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to restart CLI", error)
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { NativeDialogOptions } from "../native-functions"
|
||||
import { getLogger } from "../../logger"
|
||||
const log = getLogger("actions")
|
||||
|
||||
|
||||
interface ElectronDialogResult {
|
||||
canceled?: boolean
|
||||
@@ -33,7 +36,7 @@ export async function openElectronNativeDialog(options: NativeDialogOptions): Pr
|
||||
const result = await api.openDialog(options)
|
||||
return coerceFirstPath(result)
|
||||
} catch (error) {
|
||||
console.error("[native] electron dialog failed", error)
|
||||
log.error("[native] electron dialog failed", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { NativeDialogOptions } from "../native-functions"
|
||||
import { getLogger } from "../../logger"
|
||||
const log = getLogger("actions")
|
||||
|
||||
|
||||
interface TauriDialogModule {
|
||||
open?: (
|
||||
@@ -49,7 +52,7 @@ export async function openTauriNativeDialog(options: NativeDialogOptions): Promi
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error("[native] tauri dialog failed", error)
|
||||
log.error("[native] tauri dialog failed", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,23 @@ import toast from "solid-toast"
|
||||
|
||||
export type ToastVariant = "info" | "success" | "warning" | "error"
|
||||
|
||||
export type ToastHandle = {
|
||||
id: string
|
||||
dismiss: () => void
|
||||
}
|
||||
|
||||
type ToastPosition = "top-left" | "top-right" | "top-center" | "bottom-left" | "bottom-right" | "bottom-center"
|
||||
|
||||
export type ToastPayload = {
|
||||
title?: string
|
||||
message: string
|
||||
variant: ToastVariant
|
||||
duration?: number
|
||||
position?: ToastPosition
|
||||
action?: {
|
||||
label: string
|
||||
href: string
|
||||
}
|
||||
}
|
||||
|
||||
const variantAccent: Record<
|
||||
@@ -44,11 +56,11 @@ const variantAccent: Record<
|
||||
},
|
||||
}
|
||||
|
||||
export function showToastNotification(payload: ToastPayload) {
|
||||
export function showToastNotification(payload: ToastPayload): ToastHandle {
|
||||
const accent = variantAccent[payload.variant]
|
||||
const duration = payload.duration ?? 10000
|
||||
|
||||
toast.custom(
|
||||
const id = toast.custom(
|
||||
() => (
|
||||
<div class={`pointer-events-auto w-[320px] max-w-[360px] rounded-lg border px-4 py-3 shadow-xl ${accent.container}`}>
|
||||
<div class="flex items-start gap-3">
|
||||
@@ -56,16 +68,32 @@ export function showToastNotification(payload: ToastPayload) {
|
||||
<div class="flex-1 text-sm leading-snug">
|
||||
{payload.title && <p class={`font-semibold ${accent.headline}`}>{payload.title}</p>}
|
||||
<p class={`${accent.body} ${payload.title ? "mt-1" : ""}`}>{payload.message}</p>
|
||||
{payload.action && (
|
||||
<a
|
||||
class="mt-3 inline-flex items-center text-xs font-semibold uppercase tracking-wide text-sky-300 hover:text-sky-200"
|
||||
href={payload.action.href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{payload.action.label}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
duration,
|
||||
position: payload.position ?? "top-right",
|
||||
ariaProps: {
|
||||
role: "status",
|
||||
"aria-live": "polite",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
id,
|
||||
dismiss: () => toast.dismiss(id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getLogger } from "./logger"
|
||||
|
||||
export type HostRuntime = "electron" | "tauri" | "web"
|
||||
export type PlatformKind = "desktop" | "mobile"
|
||||
|
||||
@@ -61,6 +63,8 @@ function detectPlatform(): PlatformKind {
|
||||
return "desktop"
|
||||
}
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
let cachedEnv: RuntimeEnvironment | null = null
|
||||
|
||||
export function detectRuntimeEnvironment(): RuntimeEnvironment {
|
||||
@@ -71,9 +75,8 @@ export function detectRuntimeEnvironment(): RuntimeEnvironment {
|
||||
host: detectHost(),
|
||||
platform: detectPlatform(),
|
||||
}
|
||||
if (typeof console !== "undefined") {
|
||||
const message = `[runtime] host=${cachedEnv.host} platform=${cachedEnv.platform}`
|
||||
console.info(message)
|
||||
if (typeof window !== "undefined") {
|
||||
log.info(`[runtime] host=${cachedEnv.host} platform=${cachedEnv.platform}`)
|
||||
}
|
||||
return cachedEnv
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import type { WorkspaceEventPayload, WorkspaceEventType } from "../../../server/src/api-types"
|
||||
import { serverApi } from "./api-client"
|
||||
import { getLogger } from "./logger"
|
||||
|
||||
const RETRY_BASE_DELAY = 1000
|
||||
const RETRY_MAX_DELAY = 10000
|
||||
const SSE_PREFIX = "[SSE]"
|
||||
const log = getLogger("sse")
|
||||
|
||||
function logSse(message: string, context?: Record<string, unknown>) {
|
||||
if (context) {
|
||||
console.log(`${SSE_PREFIX} ${message}`, context)
|
||||
log.info(message, context)
|
||||
return
|
||||
}
|
||||
console.log(`${SSE_PREFIX} ${message}`)
|
||||
log.info(message)
|
||||
}
|
||||
|
||||
class ServerEvents {
|
||||
|
||||
@@ -4,8 +4,8 @@ import { serverApi } from "./api-client"
|
||||
let cachedMeta: ServerMeta | null = null
|
||||
let pendingMeta: Promise<ServerMeta> | null = null
|
||||
|
||||
export async function getServerMeta(): Promise<ServerMeta> {
|
||||
if (cachedMeta) {
|
||||
export async function getServerMeta(forceRefresh = false): Promise<ServerMeta> {
|
||||
if (cachedMeta && !forceRefresh) {
|
||||
return cachedMeta
|
||||
}
|
||||
if (pendingMeta) {
|
||||
|
||||
@@ -20,6 +20,9 @@ import type {
|
||||
InstanceStreamStatus,
|
||||
WorkspaceEventPayload,
|
||||
} from "../../../server/src/api-types"
|
||||
import { getLogger } from "./logger"
|
||||
|
||||
const log = getLogger("sse")
|
||||
|
||||
type InstanceEventPayload = Extract<WorkspaceEventPayload, { type: "instance.event" }>
|
||||
type InstanceStatusPayload = Extract<WorkspaceEventPayload, { type: "instance.eventStatus" }>
|
||||
@@ -80,11 +83,11 @@ class SSEManager {
|
||||
|
||||
private handleEvent(instanceId: string, event: SSEEvent | InstanceStreamEvent): void {
|
||||
if (!event || typeof event !== "object" || typeof (event as { type?: unknown }).type !== "string") {
|
||||
console.warn("[SSE] Dropping malformed event", event)
|
||||
log.warn("Dropping malformed event", event)
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[SSE] Received event:", event.type, event)
|
||||
log.info("Received event", { type: event.type, event })
|
||||
|
||||
switch (event.type) {
|
||||
case "message.updated":
|
||||
@@ -124,7 +127,7 @@ class SSEManager {
|
||||
this.onLspUpdated?.(instanceId, event as EventLspUpdated)
|
||||
break
|
||||
default:
|
||||
console.warn("[SSE] Unknown event type:", event.type)
|
||||
log.warn("Unknown SSE event type", { type: event.type })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { AppConfig, InstanceData } from "../../../server/src/api-types"
|
||||
import { serverApi } from "./api-client"
|
||||
import { serverEvents } from "./server-events"
|
||||
import { getLogger } from "./logger"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
export type ConfigData = AppConfig
|
||||
|
||||
@@ -19,7 +22,7 @@ function isDeepEqual(a: unknown, b: unknown): boolean {
|
||||
try {
|
||||
return JSON.stringify(a) === JSON.stringify(b)
|
||||
} catch (error) {
|
||||
console.warn("Failed to compare config objects", error)
|
||||
log.warn("Failed to compare config objects", error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
try {
|
||||
document.documentElement.setAttribute('data-theme', 'dark')
|
||||
} catch (error) {
|
||||
console.warn('Failed to apply initial theme', error)
|
||||
const rawConsole = globalThis?.["console"]
|
||||
rawConsole?.warn?.('Failed to apply initial theme', error)
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
try {
|
||||
document.documentElement.setAttribute('data-theme', 'dark')
|
||||
} catch (error) {
|
||||
console.warn('Failed to apply initial theme', error)
|
||||
const rawConsole = globalThis?.["console"]
|
||||
rawConsole?.warn?.('Failed to apply initial theme', error)
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createContext, createMemo, createSignal, onCleanup, type Accessor, type ParentComponent, useContext } from "solid-js"
|
||||
import type { InstanceData } from "../../../server/src/api-types"
|
||||
import { storage } from "../lib/storage"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
const log = getLogger("api")
|
||||
|
||||
const DEFAULT_INSTANCE_DATA: InstanceData = { messageHistory: [], agentModelSelections: {} }
|
||||
|
||||
@@ -54,7 +57,7 @@ async function ensureInstanceConfig(instanceId: string): Promise<void> {
|
||||
attachSubscription(instanceId)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("Failed to load instance data:", error)
|
||||
log.warn("Failed to load instance data", error)
|
||||
setInstanceData(instanceId, DEFAULT_INSTANCE_DATA)
|
||||
attachSubscription(instanceId)
|
||||
})
|
||||
@@ -74,7 +77,7 @@ async function updateInstanceConfig(instanceId: string, mutator: (draft: Instanc
|
||||
try {
|
||||
await storage.saveInstanceData(instanceId, draft)
|
||||
} catch (error) {
|
||||
console.warn("Failed to persist instance data:", error)
|
||||
log.warn("Failed to persist instance data", error)
|
||||
}
|
||||
setInstanceData(instanceId, draft)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import { produce } from "solid-js/store"
|
||||
import type { Instance, LogEntry } from "../types/instance"
|
||||
import type { LspStatus, Permission } from "@opencode-ai/sdk"
|
||||
import type { ClientPart } from "../types/message"
|
||||
import { sdkManager } from "../lib/sdk-manager"
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
@@ -21,10 +19,12 @@ import { setSessionPendingPermission } from "./session-state"
|
||||
import { setHasInstances } from "./ui"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import { clearCacheForInstance } from "../lib/global-cache"
|
||||
import type { MessageRecord } from "./message-v2/types"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
const log = getLogger("api")
|
||||
|
||||
const [instances, setInstances] = createSignal<Map<string, Instance>>(new Map())
|
||||
|
||||
const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null)
|
||||
const [instanceLogs, setInstanceLogs] = createSignal<Map<string, LogEntry[]>>(new Map())
|
||||
const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boolean>>(new Map())
|
||||
@@ -102,7 +102,7 @@ function attachClient(descriptor: WorkspaceDescriptor) {
|
||||
})
|
||||
sseManager.seedStatus(descriptor.id, "connecting")
|
||||
void hydrateInstanceData(descriptor.id).catch((error) => {
|
||||
console.error("Failed to hydrate instance data", error)
|
||||
log.error("Failed to hydrate instance data", error)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ async function hydrateInstanceData(instanceId: string) {
|
||||
if (!instance?.client) return
|
||||
await fetchCommands(instanceId, instance.client)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch initial data:", error)
|
||||
log.error("Failed to fetch initial data", error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ void (async function initializeWorkspaces() {
|
||||
setHasInstances(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load workspaces", error)
|
||||
log.error("Failed to load workspaces", error)
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -308,7 +308,7 @@ async function createInstance(folder: string, _binaryPath?: string): Promise<str
|
||||
setActiveInstanceId(workspace.id)
|
||||
return workspace.id
|
||||
} catch (error) {
|
||||
console.error("Failed to create workspace", error)
|
||||
log.error("Failed to create workspace", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -322,7 +322,7 @@ async function stopInstance(id: string) {
|
||||
try {
|
||||
await serverApi.deleteWorkspace(id)
|
||||
} catch (error) {
|
||||
console.error("Failed to stop workspace", error)
|
||||
log.error("Failed to stop workspace", error)
|
||||
}
|
||||
|
||||
removeInstance(id)
|
||||
@@ -334,19 +334,19 @@ async function stopInstance(id: string) {
|
||||
async function fetchLspStatus(instanceId: string): Promise<LspStatus[] | undefined> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance) {
|
||||
console.warn(`[LSP] Skipping fetch; instance ${instanceId} not found`)
|
||||
log.warn("[LSP] Skipping status fetch; instance not found", { instanceId })
|
||||
return undefined
|
||||
}
|
||||
if (!instance.client) {
|
||||
console.warn(`[LSP] Skipping fetch; instance ${instanceId} client not ready`)
|
||||
log.warn("[LSP] Skipping status fetch; client not ready", { instanceId })
|
||||
return undefined
|
||||
}
|
||||
const lsp = instance.client.lsp
|
||||
if (!lsp?.status) {
|
||||
console.warn(`[LSP] Skipping fetch; lsp.status API unavailable for instance ${instanceId}`)
|
||||
log.warn("[LSP] Skipping status fetch; API unavailable", { instanceId })
|
||||
return undefined
|
||||
}
|
||||
console.log(`[HTTP] GET /lsp.status for instance ${instanceId}`)
|
||||
log.info("lsp.status", { instanceId })
|
||||
const response = await lsp.status()
|
||||
return response.data ?? []
|
||||
}
|
||||
@@ -461,17 +461,6 @@ function addPermissionToQueue(instanceId: string, permission: Permission): void
|
||||
const sessionId = getPermissionSessionId(permission)
|
||||
incrementSessionPendingCount(instanceId, sessionId)
|
||||
setSessionPendingPermission(instanceId, sessionId, true)
|
||||
|
||||
const isActive = getActivePermission(instanceId)?.id === permission.id
|
||||
attachPermissionToToolPart(instanceId, permission, isActive)
|
||||
}
|
||||
|
||||
function getActivePermission(instanceId: string): Permission | null {
|
||||
const activeId = activePermissionId().get(instanceId)
|
||||
if (!activeId) return null
|
||||
|
||||
const queue = getPermissionQueue(instanceId)
|
||||
return queue.find(p => p.id === activeId) ?? null
|
||||
}
|
||||
|
||||
function removePermissionFromQueue(instanceId: string, permissionId: string): void {
|
||||
@@ -512,16 +501,10 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo
|
||||
|
||||
const removed = removedPermission
|
||||
if (removed) {
|
||||
clearPermissionFromToolPart(instanceId, removed)
|
||||
const removedSessionId = getPermissionSessionId(removed)
|
||||
const remaining = decrementSessionPendingCount(instanceId, removedSessionId)
|
||||
setSessionPendingPermission(instanceId, removedSessionId, remaining > 0)
|
||||
}
|
||||
|
||||
const nextActivePermission = getActivePermission(instanceId)
|
||||
if (nextActivePermission) {
|
||||
attachPermissionToToolPart(instanceId, nextActivePermission, true)
|
||||
}
|
||||
}
|
||||
|
||||
function clearPermissionQueue(instanceId: string): void {
|
||||
@@ -542,131 +525,6 @@ function getPermissionSessionId(permission: Permission): string {
|
||||
return (permission as any).sessionID
|
||||
}
|
||||
|
||||
function getPermissionMessageId(permission: Permission): string | undefined {
|
||||
return (permission as any).messageID ?? (permission as any).messageId ?? undefined
|
||||
}
|
||||
|
||||
function getPermissionCallIdentifier(permission: Permission): string | undefined {
|
||||
return (
|
||||
(permission as any).callID ??
|
||||
(permission as any).callId ??
|
||||
(permission as any).toolCallID ??
|
||||
(permission as any).toolCallId ??
|
||||
undefined
|
||||
)
|
||||
}
|
||||
|
||||
function findToolPartForPermission(record: MessageRecord, permission: Permission): { partId: string; part: ClientPart } | null {
|
||||
const expectedCallId = getPermissionCallIdentifier(permission)
|
||||
const permissionId = permission.id
|
||||
const permissionMessageId = getPermissionMessageId(permission)
|
||||
|
||||
for (const partId of record.partIds) {
|
||||
const entry = record.parts[partId]
|
||||
if (!entry) continue
|
||||
const part = entry.data
|
||||
if (!part || part.type !== "tool") continue
|
||||
const toolCallId = (part as any).callID ?? (part as any).callId
|
||||
const partMessageId = (part as any).messageID ?? (part as any).messageId
|
||||
|
||||
if (expectedCallId) {
|
||||
if (toolCallId === expectedCallId) {
|
||||
return { partId, part }
|
||||
}
|
||||
if (!toolCallId && (part.id === expectedCallId || (permissionMessageId && partMessageId === permissionMessageId))) {
|
||||
return { partId, part }
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
(toolCallId && toolCallId === permissionId) ||
|
||||
part.id === permissionId ||
|
||||
(permissionMessageId && partMessageId === permissionMessageId)
|
||||
) {
|
||||
return { partId, part }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function mutateToolPartPermission(
|
||||
instanceId: string,
|
||||
permission: Permission,
|
||||
mutator: (part: ClientPart) => boolean,
|
||||
): void {
|
||||
const messageId = getPermissionMessageId(permission)
|
||||
if (!messageId) return
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const messageRecord = store.getMessage(messageId)
|
||||
if (!messageRecord) return
|
||||
const targetPart = findToolPartForPermission(messageRecord, permission)
|
||||
if (!targetPart) return
|
||||
|
||||
store.setState(
|
||||
"messages",
|
||||
messageId,
|
||||
produce((draft: MessageRecord) => {
|
||||
const partRecord = draft.parts[targetPart.partId]
|
||||
if (!partRecord) return
|
||||
const changed = mutator(partRecord.data)
|
||||
if (!changed) return
|
||||
const nextVersion = typeof partRecord.data.version === "number" ? partRecord.data.version + 1 : 1
|
||||
partRecord.data.version = nextVersion
|
||||
partRecord.revision += 1
|
||||
draft.revision += 1
|
||||
draft.updatedAt = Date.now()
|
||||
}),
|
||||
)
|
||||
|
||||
// Permission attachment/removal can change the rendered height of the
|
||||
// message list (e.g., permission blocks or diffs), so bump the
|
||||
// session revision to ensure auto-scroll reacts.
|
||||
if (messageRecord.sessionId) {
|
||||
store.setState("sessionRevisions", messageRecord.sessionId, (value: number = 0) => value + 1)
|
||||
}
|
||||
}
|
||||
|
||||
function attachPermissionToToolPart(instanceId: string, permission: Permission, active: boolean): void {
|
||||
mutateToolPartPermission(instanceId, permission, (part) => {
|
||||
const existing = part.pendingPermission
|
||||
if (existing && existing.permission.id === permission.id && existing.active === active) {
|
||||
return false
|
||||
}
|
||||
part.pendingPermission = { permission, active }
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function clearPermissionFromToolPart(instanceId: string, permission: Permission): void {
|
||||
mutateToolPartPermission(instanceId, permission, (part) => {
|
||||
if (!part.pendingPermission || part.pendingPermission.permission.id !== permission.id) {
|
||||
return false
|
||||
}
|
||||
delete part.pendingPermission
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function refreshPermissionsForSession(instanceId: string, sessionId: string): void {
|
||||
const queue = getPermissionQueue(instanceId)
|
||||
if (queue.length === 0) {
|
||||
setSessionPendingPermission(instanceId, sessionId, false)
|
||||
return
|
||||
}
|
||||
|
||||
const activeId = activePermissionId().get(instanceId)
|
||||
|
||||
for (const permission of queue) {
|
||||
if (getPermissionSessionId(permission) !== sessionId) continue
|
||||
const isActive = permission.id === activeId
|
||||
attachPermissionToToolPart(instanceId, permission, isActive)
|
||||
}
|
||||
|
||||
const pendingCount = permissionSessionCounts.get(instanceId)?.get(sessionId) ?? 0
|
||||
setSessionPendingPermission(instanceId, sessionId, pendingCount > 0)
|
||||
}
|
||||
|
||||
async function sendPermissionResponse(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
@@ -681,13 +539,13 @@ async function sendPermissionResponse(
|
||||
try {
|
||||
await instance.client.postSessionIdPermissionsPermissionId({
|
||||
path: { id: sessionId, permissionID: permissionId },
|
||||
body: { response }
|
||||
body: { response },
|
||||
})
|
||||
|
||||
// Remove from queue after successful response
|
||||
removePermissionFromQueue(instanceId, permissionId)
|
||||
} catch (error) {
|
||||
console.error("Failed to send permission response:", error)
|
||||
log.error("Failed to send permission response", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -706,7 +564,7 @@ sseManager.onConnectionLost = (instanceId, reason) => {
|
||||
}
|
||||
|
||||
sseManager.onLspUpdated = async (instanceId) => {
|
||||
console.log(`[LSP] Received lsp.updated event for instance ${instanceId}`)
|
||||
log.info("lsp.updated", { instanceId })
|
||||
try {
|
||||
const lspStatus = await fetchLspStatus(instanceId)
|
||||
if (!lspStatus) {
|
||||
@@ -714,7 +572,7 @@ sseManager.onLspUpdated = async (instanceId) => {
|
||||
}
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance) {
|
||||
console.warn(`[LSP] Instance ${instanceId} disappeared before metadata update`)
|
||||
log.warn("[LSP] Instance disappeared before metadata update", { instanceId })
|
||||
return
|
||||
}
|
||||
updateInstance(instanceId, {
|
||||
@@ -724,7 +582,7 @@ sseManager.onLspUpdated = async (instanceId) => {
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh LSP status:", error)
|
||||
log.error("Failed to refresh LSP status", error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -737,7 +595,7 @@ async function acknowledgeDisconnectedInstance(): Promise<void> {
|
||||
try {
|
||||
await stopInstance(pending.id)
|
||||
} catch (error) {
|
||||
console.error("Failed to stop disconnected instance:", error)
|
||||
log.error("Failed to stop disconnected instance", error)
|
||||
} finally {
|
||||
setDisconnectedInstance(null)
|
||||
if (instances().size === 0) {
|
||||
@@ -768,10 +626,8 @@ export {
|
||||
getPermissionQueue,
|
||||
getPermissionQueueLength,
|
||||
addPermissionToQueue,
|
||||
getActivePermission,
|
||||
removePermissionFromQueue,
|
||||
clearPermissionQueue,
|
||||
refreshPermissionsForSession,
|
||||
sendPermissionResponse,
|
||||
disconnectedInstance,
|
||||
acknowledgeDisconnectedInstance,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createInstanceMessageStore } from "./instance-store"
|
||||
import type { InstanceMessageStore } from "./instance-store"
|
||||
import { clearCacheForInstance } from "../../lib/global-cache"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
class MessageStoreBus {
|
||||
private stores = new Map<string, InstanceMessageStore>()
|
||||
@@ -55,7 +58,7 @@ class MessageStoreBus {
|
||||
try {
|
||||
handler(instanceId)
|
||||
} catch (error) {
|
||||
console.error("Failed to run message store teardown handler", error)
|
||||
log.error("Failed to run message store teardown handler", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,22 @@ function ensurePartId(messageId: string, part: ClientPart, index: number): strin
|
||||
if (typeof part.id === "string" && part.id.length > 0) {
|
||||
return part.id
|
||||
}
|
||||
return `${messageId}-part-${index}`
|
||||
|
||||
const toolCallId =
|
||||
(part as any).callID ??
|
||||
(part as any).callId ??
|
||||
(part as any).toolCallID ??
|
||||
(part as any).toolCallId ??
|
||||
undefined
|
||||
|
||||
if (part.type === "tool" && typeof toolCallId === "string" && toolCallId.length > 0) {
|
||||
part.id = toolCallId
|
||||
return toolCallId
|
||||
}
|
||||
|
||||
const fallbackId = `${messageId}-part-${index}`
|
||||
part.id = fallbackId
|
||||
return fallbackId
|
||||
}
|
||||
|
||||
const PENDING_PART_MAX_AGE_MS = 30_000
|
||||
@@ -281,9 +296,6 @@ export function createInstanceMessageStore(instanceId: string): InstanceMessageS
|
||||
ensureSessionEntry(sessionId)
|
||||
|
||||
const incomingIds = inputs.map((item) => item.id)
|
||||
const incomingIdSet = new Set(incomingIds)
|
||||
const existingIds = state.sessions[sessionId]?.messageIds ?? []
|
||||
const removedIds = existingIds.filter((id) => !incomingIdSet.has(id))
|
||||
|
||||
const normalizedRecords: Record<string, MessageRecord> = {}
|
||||
const now = Date.now()
|
||||
@@ -316,18 +328,6 @@ export function createInstanceMessageStore(instanceId: string): InstanceMessageS
|
||||
...state.permissions.byMessage,
|
||||
}
|
||||
|
||||
removedIds.forEach((id) => {
|
||||
if (nextMessages[id]?.sessionId === sessionId) {
|
||||
delete nextMessages[id]
|
||||
delete nextMessageInfoVersion[id]
|
||||
delete nextPendingParts[id]
|
||||
if (nextPermissionsByMessage[id]) {
|
||||
delete nextPermissionsByMessage[id]
|
||||
}
|
||||
}
|
||||
messageInfoCache.delete(id)
|
||||
})
|
||||
|
||||
Object.entries(normalizedRecords).forEach(([id, record]) => {
|
||||
nextMessages[id] = record
|
||||
})
|
||||
|
||||
@@ -26,11 +26,37 @@ function decodeTextSegment(segment: any): any {
|
||||
return segment
|
||||
}
|
||||
|
||||
function deriveToolPartId(part: any): string | undefined {
|
||||
if (!part || typeof part !== "object") {
|
||||
return undefined
|
||||
}
|
||||
if (part.type !== "tool") {
|
||||
return undefined
|
||||
}
|
||||
const callId =
|
||||
part.callID ??
|
||||
part.callId ??
|
||||
part.toolCallID ??
|
||||
part.toolCallId ??
|
||||
undefined
|
||||
if (typeof callId === "string" && callId.length > 0) {
|
||||
return callId
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function normalizeMessagePart(part: any): any {
|
||||
if (!part || typeof part !== "object") {
|
||||
return part
|
||||
}
|
||||
|
||||
if ((typeof part.id !== "string" || part.id.length === 0) && part.type === "tool") {
|
||||
const inferredId = deriveToolPartId(part)
|
||||
if (inferredId) {
|
||||
part = { ...part, id: inferredId }
|
||||
}
|
||||
}
|
||||
|
||||
if (part.type !== "text") {
|
||||
return part
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
getInstanceConfig,
|
||||
updateInstanceConfig as updateInstanceData,
|
||||
} from "./instance-config"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
type DeepReadonly<T> = T extends (...args: any[]) => unknown
|
||||
? T
|
||||
@@ -27,6 +30,8 @@ export interface AgentModelSelections {
|
||||
export type DiffViewMode = "split" | "unified"
|
||||
export type ExpansionPreference = "expanded" | "collapsed"
|
||||
|
||||
export type ListeningMode = "local" | "all"
|
||||
|
||||
export interface Preferences {
|
||||
showThinkingBlocks: boolean
|
||||
thinkingBlocksExpansion: ExpansionPreference
|
||||
@@ -37,10 +42,13 @@ export interface Preferences {
|
||||
toolOutputExpansion: ExpansionPreference
|
||||
diagnosticsExpansion: ExpansionPreference
|
||||
showUsageMetrics: boolean
|
||||
autoCleanupBlankSessions?: boolean
|
||||
autoCleanupBlankSessions: boolean
|
||||
listeningMode: ListeningMode
|
||||
}
|
||||
|
||||
|
||||
export interface OpenCodeBinary {
|
||||
|
||||
path: string
|
||||
version?: string
|
||||
lastUsed: number
|
||||
@@ -66,15 +74,17 @@ const defaultPreferences: Preferences = {
|
||||
diagnosticsExpansion: "expanded",
|
||||
showUsageMetrics: true,
|
||||
autoCleanupBlankSessions: true,
|
||||
listeningMode: "local",
|
||||
}
|
||||
|
||||
|
||||
function deepEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true
|
||||
if (typeof a === "object" && a !== null && typeof b === "object" && b !== null) {
|
||||
try {
|
||||
return JSON.stringify(a) === JSON.stringify(b)
|
||||
} catch (error) {
|
||||
console.warn("Failed to compare preference values", error)
|
||||
log.warn("Failed to compare preference values", error)
|
||||
}
|
||||
}
|
||||
return false
|
||||
@@ -101,10 +111,12 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
|
||||
diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion,
|
||||
showUsageMetrics: sanitized.showUsageMetrics ?? defaultPreferences.showUsageMetrics,
|
||||
autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultPreferences.autoCleanupBlankSessions,
|
||||
listeningMode: sanitized.listeningMode ?? defaultPreferences.listeningMode,
|
||||
}
|
||||
}
|
||||
|
||||
const [internalConfig, setInternalConfig] = createSignal<ConfigData>(buildFallbackConfig())
|
||||
|
||||
const config = createMemo<DeepReadonly<ConfigData>>(() => internalConfig())
|
||||
const [isConfigLoaded, setIsConfigLoaded] = createSignal(false)
|
||||
const preferences = createMemo<Preferences>(() => internalConfig().preferences)
|
||||
@@ -139,11 +151,11 @@ async function syncConfig(source?: ConfigData): Promise<void> {
|
||||
applyConfig(cleaned)
|
||||
if (migrated) {
|
||||
void storage.updateConfig(cleaned).catch((error: unknown) => {
|
||||
console.error("Failed to persist legacy config cleanup:", error)
|
||||
log.error("Failed to persist legacy config cleanup", error)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load config:", error)
|
||||
log.error("Failed to load config", error)
|
||||
applyConfig(buildFallbackConfig())
|
||||
}
|
||||
}
|
||||
@@ -163,7 +175,7 @@ function logConfigDiff(previous: ConfigData, next: ConfigData) {
|
||||
}
|
||||
const changes = diffObjects(previous, next)
|
||||
if (changes.length > 0) {
|
||||
console.debug("[Config] Changes", changes)
|
||||
log.info("[Config] Changes", changes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,9 +217,9 @@ async function persistFullConfig(next: ConfigData): Promise<void> {
|
||||
await ensureConfigLoaded()
|
||||
await storage.updateConfig(next)
|
||||
} catch (error) {
|
||||
console.error("Failed to save config:", error)
|
||||
log.error("Failed to save config", error)
|
||||
void syncConfig().catch((syncError: unknown) => {
|
||||
console.error("Failed to refresh config:", syncError)
|
||||
log.error("Failed to refresh config", syncError)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -260,6 +272,11 @@ function updatePreferences(updates: Partial<Preferences>): void {
|
||||
})
|
||||
}
|
||||
|
||||
function setListeningMode(mode: ListeningMode): void {
|
||||
if (preferences().listeningMode === mode) return
|
||||
updatePreferences({ listeningMode: mode })
|
||||
}
|
||||
|
||||
function setDiffViewMode(mode: DiffViewMode): void {
|
||||
if (preferences().diffViewMode === mode) return
|
||||
updatePreferences({ diffViewMode: mode })
|
||||
@@ -289,8 +306,9 @@ function toggleUsageMetrics(): void {
|
||||
}
|
||||
|
||||
function toggleAutoCleanupBlankSessions(): void {
|
||||
console.log("toggle auto cleanup")
|
||||
updatePreferences({ autoCleanupBlankSessions: !preferences().autoCleanupBlankSessions })
|
||||
const nextValue = !preferences().autoCleanupBlankSessions
|
||||
log.info("toggle auto cleanup", { value: nextValue })
|
||||
updatePreferences({ autoCleanupBlankSessions: nextValue })
|
||||
}
|
||||
|
||||
function addRecentFolder(path: string): void {
|
||||
@@ -380,7 +398,7 @@ async function getAgentModelPreference(instanceId: string, agent: string): Promi
|
||||
}
|
||||
|
||||
void ensureConfigLoaded().catch((error: unknown) => {
|
||||
console.error("Failed to initialize config:", error)
|
||||
log.error("Failed to initialize config", error)
|
||||
})
|
||||
|
||||
interface ConfigContextValue {
|
||||
@@ -399,6 +417,7 @@ interface ConfigContextValue {
|
||||
setToolOutputExpansion: typeof setToolOutputExpansion
|
||||
setDiagnosticsExpansion: typeof setDiagnosticsExpansion
|
||||
setThinkingBlocksExpansion: typeof setThinkingBlocksExpansion
|
||||
setListeningMode: typeof setListeningMode
|
||||
addRecentFolder: typeof addRecentFolder
|
||||
removeRecentFolder: typeof removeRecentFolder
|
||||
addOpenCodeBinary: typeof addOpenCodeBinary
|
||||
@@ -432,6 +451,7 @@ const configContextValue: ConfigContextValue = {
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
setThinkingBlocksExpansion,
|
||||
setListeningMode,
|
||||
addRecentFolder,
|
||||
removeRecentFolder,
|
||||
addOpenCodeBinary,
|
||||
@@ -450,12 +470,12 @@ const configContextValue: ConfigContextValue = {
|
||||
const ConfigProvider: ParentComponent = (props) => {
|
||||
onMount(() => {
|
||||
ensureConfigLoaded().catch((error: unknown) => {
|
||||
console.error("Failed to initialize config:", error)
|
||||
log.error("Failed to initialize config", error)
|
||||
})
|
||||
|
||||
const unsubscribe = storage.onConfigChanged((config) => {
|
||||
syncConfig(config).catch((error: unknown) => {
|
||||
console.error("Failed to refresh config:", error)
|
||||
log.error("Failed to refresh config", error)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -502,8 +522,11 @@ export {
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
setThinkingBlocksExpansion,
|
||||
setListeningMode,
|
||||
themePreference,
|
||||
setThemePreference,
|
||||
recordWorkspaceLaunch,
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
95
packages/ui/src/stores/releases.ts
Normal file
95
packages/ui/src/stores/releases.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { createEffect, createSignal } from "solid-js"
|
||||
import type { LatestReleaseInfo, WorkspaceEventPayload } from "../../../server/src/api-types"
|
||||
import { getServerMeta } from "../lib/server-meta"
|
||||
import { serverEvents } from "../lib/server-events"
|
||||
import { showToastNotification, ToastHandle } from "../lib/notifications"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { hasInstances, showFolderSelection } from "./ui"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
const [availableRelease, setAvailableRelease] = createSignal<LatestReleaseInfo | null>(null)
|
||||
|
||||
let initialized = false
|
||||
let visibilityEffectInitialized = false
|
||||
let activeToast: ToastHandle | null = null
|
||||
let activeToastVersion: string | null = null
|
||||
|
||||
function dismissActiveToast() {
|
||||
if (activeToast) {
|
||||
activeToast.dismiss()
|
||||
activeToast = null
|
||||
activeToastVersion = null
|
||||
}
|
||||
}
|
||||
|
||||
function ensureVisibilityEffect() {
|
||||
if (visibilityEffectInitialized) {
|
||||
return
|
||||
}
|
||||
visibilityEffectInitialized = true
|
||||
|
||||
createEffect(() => {
|
||||
const release = availableRelease()
|
||||
const shouldShow = Boolean(release) && (!hasInstances() || showFolderSelection())
|
||||
|
||||
if (!shouldShow || !release) {
|
||||
dismissActiveToast()
|
||||
return
|
||||
}
|
||||
|
||||
if (!activeToast || activeToastVersion !== release.version) {
|
||||
dismissActiveToast()
|
||||
activeToast = showToastNotification({
|
||||
title: `CodeNomad ${release.version}`,
|
||||
message: release.channel === "dev" ? "Dev release build available." : "New stable build on GitHub.",
|
||||
variant: "info",
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
position: "bottom-right",
|
||||
action: {
|
||||
label: "View release",
|
||||
href: release.url,
|
||||
},
|
||||
})
|
||||
activeToastVersion = release.version
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function initReleaseNotifications() {
|
||||
if (initialized) {
|
||||
return
|
||||
}
|
||||
initialized = true
|
||||
|
||||
ensureVisibilityEffect()
|
||||
void refreshFromMeta()
|
||||
|
||||
serverEvents.on("app.releaseAvailable", (event) => {
|
||||
const typedEvent = event as Extract<WorkspaceEventPayload, { type: "app.releaseAvailable" }>
|
||||
applyRelease(typedEvent.release)
|
||||
})
|
||||
}
|
||||
|
||||
async function refreshFromMeta() {
|
||||
try {
|
||||
const meta = await getServerMeta(true)
|
||||
if (meta.latestRelease) {
|
||||
applyRelease(meta.latestRelease)
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn("Unable to load server metadata for release info", error)
|
||||
}
|
||||
}
|
||||
|
||||
function applyRelease(release: LatestReleaseInfo | null | undefined) {
|
||||
if (!release) {
|
||||
setAvailableRelease(null)
|
||||
return
|
||||
}
|
||||
setAvailableRelease(release)
|
||||
}
|
||||
|
||||
export function useAvailableRelease() {
|
||||
return availableRelease
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import { sessions, withSession } from "./session-state"
|
||||
import { getDefaultModel, isModelValid } from "./session-models"
|
||||
import { updateSessionInfo } from "./message-v2/session-info"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
const ID_LENGTH = 26
|
||||
const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
@@ -168,26 +171,27 @@ async function sendMessage(
|
||||
}),
|
||||
}
|
||||
|
||||
console.log("[sendMessage] Sending prompt:", {
|
||||
log.info("sendMessage", {
|
||||
instanceId,
|
||||
sessionId,
|
||||
requestBody,
|
||||
})
|
||||
|
||||
try {
|
||||
console.log(`[HTTP] POST /session.prompt for instance ${instanceId}`, { sessionId, requestBody })
|
||||
log.info("session.prompt", { instanceId, sessionId, requestBody })
|
||||
const response = await instance.client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: requestBody,
|
||||
})
|
||||
|
||||
console.log("[sendMessage] Response:", response)
|
||||
log.info("sendMessage response", response)
|
||||
|
||||
if (response.error) {
|
||||
console.error("[sendMessage] Server returned error:", response.error)
|
||||
log.error("sendMessage server error", response.error)
|
||||
throw new Error(JSON.stringify(response.error) || "Failed to send message")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[sendMessage] Failed to send prompt:", error)
|
||||
log.error("Failed to send prompt", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -262,16 +266,16 @@ async function abortSession(instanceId: string, sessionId: string): Promise<void
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
console.log("[abortSession] Aborting session:", { instanceId, sessionId })
|
||||
log.info("abortSession", { instanceId, sessionId })
|
||||
|
||||
try {
|
||||
console.log(`[HTTP] POST /session.abort for instance ${instanceId}`, { sessionId })
|
||||
log.info("session.abort", { instanceId, sessionId })
|
||||
await instance.client.session.abort({
|
||||
path: { id: sessionId },
|
||||
})
|
||||
console.log("[abortSession] Session aborted successfully")
|
||||
log.info("abortSession complete", { instanceId, sessionId })
|
||||
} catch (error) {
|
||||
console.error("[abortSession] Failed to abort session:", error)
|
||||
log.error("Failed to abort session", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -314,7 +318,7 @@ async function updateSessionModel(
|
||||
}
|
||||
|
||||
if (!isModelValid(instanceId, model)) {
|
||||
console.warn("Invalid model selection", model)
|
||||
log.warn("Invalid model selection", model)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Session } from "../types/session"
|
||||
import type { Message } from "../types/message"
|
||||
|
||||
import { instances, refreshPermissionsForSession } from "./instances"
|
||||
import { instances } from "./instances"
|
||||
import { preferences, setAgentModelPreference } from "./preferences"
|
||||
import { setSessionCompactionState } from "./session-compaction"
|
||||
import {
|
||||
@@ -30,6 +30,9 @@ import { updateSessionInfo } from "./message-v2/session-info"
|
||||
import { seedSessionMessagesV2 } from "./message-v2/bridge"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import { clearCacheForSession } from "../lib/global-cache"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
const log = getLogger("api")
|
||||
|
||||
interface SessionForkResponse {
|
||||
id: string
|
||||
@@ -65,7 +68,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
||||
})
|
||||
|
||||
try {
|
||||
console.log(`[HTTP] GET /session.list for instance ${instanceId}`)
|
||||
log.info("session.list", { instanceId })
|
||||
const response = await instance.client.session.list()
|
||||
|
||||
const sessionMap = new Map<string, Session>()
|
||||
@@ -132,7 +135,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
||||
|
||||
pruneDraftPrompts(instanceId, new Set(sessionMap.keys()))
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch sessions:", error)
|
||||
log.error("Failed to fetch sessions:", error)
|
||||
throw error
|
||||
} finally {
|
||||
setLoading((prev) => {
|
||||
@@ -166,7 +169,7 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
|
||||
})
|
||||
|
||||
try {
|
||||
console.log(`[HTTP] POST /session.create for instance ${instanceId}`)
|
||||
log.info(`[HTTP] POST /session.create for instance ${instanceId}`)
|
||||
const response = await instance.client.session.create()
|
||||
|
||||
if (!response.data) {
|
||||
@@ -237,7 +240,7 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
|
||||
|
||||
return session
|
||||
} catch (error) {
|
||||
console.error("Failed to create session:", error)
|
||||
log.error("Failed to create session:", error)
|
||||
throw error
|
||||
} finally {
|
||||
setLoading((prev) => {
|
||||
@@ -269,7 +272,7 @@ async function forkSession(
|
||||
request.body = { messageID: options.messageId }
|
||||
}
|
||||
|
||||
console.log(`[HTTP] POST /session.fork for instance ${instanceId}`, request)
|
||||
log.info(`[HTTP] POST /session.fork for instance ${instanceId}`, request)
|
||||
const response = await instance.client.session.fork(request)
|
||||
|
||||
if (!response.data) {
|
||||
@@ -352,7 +355,7 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
|
||||
})
|
||||
|
||||
try {
|
||||
console.log(`[HTTP] DELETE /session.delete for instance ${instanceId}`, { sessionId })
|
||||
log.info(`[HTTP] DELETE /session.delete for instance ${instanceId}`, { sessionId })
|
||||
await instance.client.session.delete({ path: { id: sessionId } })
|
||||
|
||||
setSessions((prev) => {
|
||||
@@ -394,7 +397,7 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete session:", error)
|
||||
log.error("Failed to delete session:", error)
|
||||
throw error
|
||||
} finally {
|
||||
setLoading((prev) => {
|
||||
@@ -415,7 +418,7 @@ async function fetchAgents(instanceId: string): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[HTTP] GET /app.agents for instance ${instanceId}`)
|
||||
log.info(`[HTTP] GET /app.agents for instance ${instanceId}`)
|
||||
const response = await instance.client.app.agents()
|
||||
const agentList = (response.data ?? []).map((agent) => ({
|
||||
name: agent.name,
|
||||
@@ -435,7 +438,7 @@ async function fetchAgents(instanceId: string): Promise<void> {
|
||||
return next
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch agents:", error)
|
||||
log.error("Failed to fetch agents:", error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,7 +449,7 @@ async function fetchProviders(instanceId: string): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[HTTP] GET /config.providers for instance ${instanceId}`)
|
||||
log.info(`[HTTP] GET /config.providers for instance ${instanceId}`)
|
||||
const response = await instance.client.config.providers()
|
||||
if (!response.data) return
|
||||
|
||||
@@ -469,7 +472,7 @@ async function fetchProviders(instanceId: string): Promise<void> {
|
||||
return next
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch providers:", error)
|
||||
log.error("Failed to fetch providers:", error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,7 +518,7 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
||||
})
|
||||
|
||||
try {
|
||||
console.log(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId })
|
||||
log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId })
|
||||
const response = await instance.client.session["messages"]({ path: { id: sessionId } })
|
||||
|
||||
if (!response.data || !Array.isArray(response.data)) {
|
||||
@@ -604,7 +607,7 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
||||
seedSessionMessagesV2(instanceId, sessionForV2, messages, messagesInfo)
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to load messages:", error)
|
||||
log.error("Failed to load messages:", error)
|
||||
throw error
|
||||
} finally {
|
||||
setLoading((prev) => {
|
||||
@@ -618,7 +621,6 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
||||
}
|
||||
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
refreshPermissionsForSession(instanceId, sessionId)
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
@@ -15,16 +15,15 @@ import type {
|
||||
} from "@opencode-ai/sdk"
|
||||
import type { MessageStatus } from "./message-v2/types"
|
||||
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { showToastNotification, ToastVariant } from "../lib/notifications"
|
||||
import { instances, addPermissionToQueue, removePermissionFromQueue, refreshPermissionsForSession } from "./instances"
|
||||
import { instances, addPermissionToQueue, removePermissionFromQueue } from "./instances"
|
||||
import { showAlertDialog } from "./alerts"
|
||||
import {
|
||||
sessions,
|
||||
setSessions,
|
||||
withSession,
|
||||
} from "./session-state"
|
||||
import { sessions, setSessions, withSession } from "./session-state"
|
||||
import { normalizeMessagePart } from "./message-v2/normalizers"
|
||||
import { updateSessionInfo } from "./message-v2/session-info"
|
||||
|
||||
const log = getLogger("sse")
|
||||
import { loadMessages } from "./session-api"
|
||||
import { setSessionCompactionState } from "./session-compaction"
|
||||
import {
|
||||
@@ -62,16 +61,13 @@ function findPendingMessageId(
|
||||
role: MessageRole,
|
||||
): string | undefined {
|
||||
const messageIds = store.getSessionMessageIds(sessionId)
|
||||
for (let i = messageIds.length - 1; i >= 0; i -= 1) {
|
||||
const record = store.getMessage(messageIds[i])
|
||||
if (!record) continue
|
||||
if (record.sessionId !== sessionId) continue
|
||||
if (record.role !== role) continue
|
||||
if (record.status === "sending") {
|
||||
return record.id
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
const lastId = messageIds[messageIds.length - 1]
|
||||
if (!lastId) return undefined
|
||||
const record = store.getMessage(lastId)
|
||||
if (!record) return undefined
|
||||
if (record.sessionId !== sessionId) return undefined
|
||||
if (record.role !== role) return undefined
|
||||
return record.status === "sending" ? record.id : undefined
|
||||
}
|
||||
|
||||
function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | MessagePartUpdatedEvent): void {
|
||||
@@ -129,7 +125,6 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
||||
|
||||
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
refreshPermissionsForSession(instanceId, sessionId)
|
||||
} else if (event.type === "message.updated") {
|
||||
const info = event.properties?.info
|
||||
if (!info) return
|
||||
@@ -171,13 +166,12 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
||||
upsertMessageInfoV2(instanceId, info, { status, bumpRevision: true })
|
||||
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
refreshPermissionsForSession(instanceId, sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): void {
|
||||
const info = event.properties?.info
|
||||
|
||||
if (!info) return
|
||||
|
||||
const compactingFlag = info.time?.compacting
|
||||
@@ -218,7 +212,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
|
||||
})
|
||||
setSessionRevertV2(instanceId, info.id, info.revert ?? null)
|
||||
|
||||
console.log(`[SSE] New session created: ${info.id}`, newSession)
|
||||
log.info(`[SSE] New session created: ${info.id}`, newSession)
|
||||
} else {
|
||||
const mergedTime = {
|
||||
...existingSession.time,
|
||||
@@ -257,14 +251,14 @@ function handleSessionIdle(_instanceId: string, event: EventSessionIdle): void {
|
||||
const sessionId = event.properties?.sessionID
|
||||
if (!sessionId) return
|
||||
|
||||
console.log(`[SSE] Session idle: ${sessionId}`)
|
||||
log.info(`[SSE] Session idle: ${sessionId}`)
|
||||
}
|
||||
|
||||
function handleSessionCompacted(instanceId: string, event: EventSessionCompacted): void {
|
||||
const sessionID = event.properties?.sessionID
|
||||
if (!sessionID) return
|
||||
|
||||
console.log(`[SSE] Session compacted: ${sessionID}`)
|
||||
log.info(`[SSE] Session compacted: ${sessionID}`)
|
||||
|
||||
setSessionCompactionState(instanceId, sessionID, false)
|
||||
|
||||
@@ -274,7 +268,7 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted
|
||||
session.time = time
|
||||
})
|
||||
|
||||
loadMessages(instanceId, sessionID, true).catch(console.error)
|
||||
loadMessages(instanceId, sessionID, true).catch((error) => log.error("Failed to reload session after compaction", error))
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const session = instanceSessions?.get(sessionID)
|
||||
@@ -292,7 +286,7 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted
|
||||
|
||||
function handleSessionError(_instanceId: string, event: EventSessionError): void {
|
||||
const error = event.properties?.error
|
||||
console.error(`[SSE] Session error:`, error)
|
||||
log.error(`[SSE] Session error:`, error)
|
||||
|
||||
let message = "Unknown error"
|
||||
|
||||
@@ -314,16 +308,16 @@ function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): v
|
||||
const sessionID = event.properties?.sessionID
|
||||
if (!sessionID) return
|
||||
|
||||
console.log(`[SSE] Message removed from session ${sessionID}, reloading messages`)
|
||||
loadMessages(instanceId, sessionID, true).catch(console.error)
|
||||
log.info(`[SSE] Message removed from session ${sessionID}, reloading messages`)
|
||||
loadMessages(instanceId, sessionID, true).catch((error) => log.error("Failed to reload messages after removal", error))
|
||||
}
|
||||
|
||||
function handleMessagePartRemoved(instanceId: string, event: MessagePartRemovedEvent): void {
|
||||
const sessionID = event.properties?.sessionID
|
||||
if (!sessionID) return
|
||||
|
||||
console.log(`[SSE] Message part removed from session ${sessionID}, reloading messages`)
|
||||
loadMessages(instanceId, sessionID, true).catch(console.error)
|
||||
log.info(`[SSE] Message part removed from session ${sessionID}, reloading messages`)
|
||||
loadMessages(instanceId, sessionID, true).catch((error) => log.error("Failed to reload messages after part removal", error))
|
||||
}
|
||||
|
||||
function handleTuiToast(_instanceId: string, event: TuiToastEvent): void {
|
||||
@@ -347,7 +341,7 @@ function handlePermissionUpdated(instanceId: string, event: EventPermissionUpdat
|
||||
const permission = event.properties
|
||||
if (!permission) return
|
||||
|
||||
console.log(`[SSE] Permission updated: ${permission.id} (${permission.type})`)
|
||||
log.info(`[SSE] Permission updated: ${permission.id} (${permission.type})`)
|
||||
addPermissionToQueue(instanceId, permission)
|
||||
upsertPermissionV2(instanceId, permission)
|
||||
}
|
||||
@@ -356,7 +350,7 @@ function handlePermissionReplied(instanceId: string, event: EventPermissionRepli
|
||||
const { permissionID } = event.properties
|
||||
if (!permissionID) return
|
||||
|
||||
console.log(`[SSE] Permission replied: ${permissionID}`)
|
||||
log.info(`[SSE] Permission replied: ${permissionID}`)
|
||||
removePermissionFromQueue(instanceId, permissionID)
|
||||
removePermissionV2(instanceId, permissionID)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import { showToastNotification } from "../lib/notifications"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import { instances } from "./instances"
|
||||
import { showConfirmDialog } from "./alerts"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
export interface SessionInfo {
|
||||
cost: number
|
||||
@@ -248,7 +251,7 @@ async function isBlankSession(session: Session, instanceId: string, fetchIfNeede
|
||||
const response = await instance.client.session.messages({ path: { id: session.id } })
|
||||
messages = response.data || []
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch messages for session ${session.id}:`, error)
|
||||
log.error(`Failed to fetch messages for session ${session.id}`, error)
|
||||
return isFreshSession
|
||||
}
|
||||
|
||||
@@ -309,13 +312,13 @@ async function cleanupBlankSessions(instanceId: string, excludeSessionId?: strin
|
||||
if (!isBlank) return false
|
||||
|
||||
await deleteSession(instanceId, sessionId).catch((error: Error) => {
|
||||
console.error(`Failed to delete blank session ${sessionId}:`, error)
|
||||
log.error(`Failed to delete blank session ${sessionId}`, error)
|
||||
})
|
||||
return true
|
||||
})
|
||||
|
||||
if (cleanupPromises.length > 0) {
|
||||
console.log(`Cleaning up ${cleanupPromises.length} blank sessions`)
|
||||
log.info(`Cleaning up ${cleanupPromises.length} blank sessions`)
|
||||
const deletionResults = await Promise.all(cleanupPromises)
|
||||
const deletedCount = deletionResults.filter(Boolean).length
|
||||
|
||||
|
||||
302
packages/ui/src/styles/components/remote-access.css
Normal file
302
packages/ui/src/styles/components/remote-access.css
Normal file
@@ -0,0 +1,302 @@
|
||||
.remote-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 41;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.modal-overlay.remote-overlay-backdrop {
|
||||
background: var(--overlay-scrim);
|
||||
backdrop-filter: blur(6px);
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.remote-panel {
|
||||
width: min(960px, 100%);
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.remote-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.remote-eyebrow {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-size: 11px;
|
||||
color: var(--text-subtle);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.remote-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.remote-subtitle {
|
||||
margin: 4px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.remote-close {
|
||||
border: 1px solid var(--border-base);
|
||||
background: var(--surface-secondary);
|
||||
color: var(--text-primary);
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.remote-body {
|
||||
padding: 16px 24px 24px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.remote-section {
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 12px;
|
||||
background: var(--surface-secondary);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.remote-section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.remote-section-title {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.remote-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.remote-label {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.remote-help {
|
||||
margin: 2px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.remote-refresh {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-base);
|
||||
background: var(--surface-primary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remote-refresh-label {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.remote-refresh-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.remote-toggle {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-base);
|
||||
background: var(--surface-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remote-toggle-switch {
|
||||
width: 58px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-secondary);
|
||||
border: 1px solid var(--border-base);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 8px 0 6px;
|
||||
transition: background 0.2s ease, border-color 0.2s ease;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.remote-toggle-state {
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.remote-toggle-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-primary);
|
||||
transition: transform 0.2s ease;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.remote-toggle-switch[data-checked="true"] {
|
||||
background: var(--accent-primary);
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--surface-primary);
|
||||
}
|
||||
|
||||
.remote-toggle-switch[data-checked="true"] .remote-toggle-thumb {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.remote-toggle-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.remote-toggle-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.remote-toggle-caption {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.remote-toggle-note {
|
||||
margin: 12px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.remote-address-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.remote-address {
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
background: var(--surface-primary);
|
||||
}
|
||||
|
||||
.remote-address-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.remote-address-url {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.remote-address-meta {
|
||||
margin: 4px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.remote-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.remote-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-base);
|
||||
background: var(--surface-secondary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remote-qr {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
border: 1px dashed var(--border-base);
|
||||
border-radius: 10px;
|
||||
background: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.remote-qr-img {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.remote-card {
|
||||
border: 1px dashed var(--border-base);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.remote-error {
|
||||
border: 1px solid var(--border-critical, #e65c5c);
|
||||
background: color-mix(in srgb, var(--border-critical, #e65c5c) 10%, transparent);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.remote-spin {
|
||||
animation: remote-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes remote-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -5,3 +5,4 @@
|
||||
@import "./components/selector.css";
|
||||
@import "./components/env-vars.css";
|
||||
@import "./components/directory-browser.css";
|
||||
@import "./components/remote-access.css";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@import "./messaging/message-base.css";
|
||||
@import "./messaging/prompt-input.css";
|
||||
@import "./messaging/message-stream.css";
|
||||
@import "./messaging/message-section.css";
|
||||
@import "./messaging/message-block-list.css";
|
||||
@import "./messaging/tool-call.css";
|
||||
@import "./messaging/log-view.css";
|
||||
|
||||
@@ -51,61 +52,6 @@
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Message stream component utilities */
|
||||
.message-stream-container {
|
||||
@apply relative flex-1 min-h-0 flex flex-col overflow-hidden;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
@apply grid items-center px-4 py-2 gap-4;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
background-color: var(--surface-secondary);
|
||||
border-bottom: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.message-stream {
|
||||
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-1;
|
||||
background-color: var(--surface-base);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.message-scroll-button-wrapper {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-scroll-button {
|
||||
@apply inline-flex items-center justify-center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--border-base);
|
||||
background-color: transparent;
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--scroll-elevation-shadow);
|
||||
transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.message-scroll-button:hover {
|
||||
background-color: var(--surface-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.message-scroll-button:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--surface-base), 0 0 0 4px var(--accent-primary);
|
||||
}
|
||||
|
||||
.message-scroll-icon {
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.message-text {
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--line-height-normal);
|
||||
|
||||
35
packages/ui/src/styles/messaging/message-block-list.css
Normal file
35
packages/ui/src/styles/messaging/message-block-list.css
Normal file
@@ -0,0 +1,35 @@
|
||||
.message-stream {
|
||||
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-0.5;
|
||||
background-color: var(--surface-base);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.message-stream-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.0625rem;
|
||||
}
|
||||
|
||||
.virtual-item-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.virtual-item-placeholder,
|
||||
.message-stream-placeholder {
|
||||
display: block;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.virtual-item-content {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.virtual-item-content-hidden {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
230
packages/ui/src/styles/messaging/message-section.css
Normal file
230
packages/ui/src/styles/messaging/message-section.css
Normal file
@@ -0,0 +1,230 @@
|
||||
.message-stream-container {
|
||||
@apply relative flex-1 min-h-0 flex flex-col overflow-hidden;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
@apply grid items-center px-4 py-2 gap-4;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
grid-template-areas: "info shortcut meta";
|
||||
background-color: var(--surface-secondary);
|
||||
border-bottom: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.connection-status-menu {
|
||||
display: none;
|
||||
grid-area: menu;
|
||||
}
|
||||
|
||||
.connection-status--compact {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-areas:
|
||||
"menu shortcut meta"
|
||||
"info info info";
|
||||
row-gap: 0.5rem;
|
||||
}
|
||||
|
||||
.connection-status--compact .connection-status-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.connection-status--compact .connection-status-info {
|
||||
justify-self: stretch;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.connection-status--compact .connection-status-usage {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.connection-status--compact .connection-status-shortcut {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.connection-status--compact .connection-status-meta {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.session-sidebar-menu-button {
|
||||
@apply inline-flex items-center justify-center border rounded-md px-2 py-1 text-sm font-medium;
|
||||
border-color: var(--border-base);
|
||||
background-color: transparent;
|
||||
color: var(--text-primary);
|
||||
transition: color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.session-sidebar-menu-button:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.session-sidebar-menu-button:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.session-sidebar-menu-icon {
|
||||
font-size: var(--font-size-base);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
@apply flex items-center gap-1.5 text-xs;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-indicator .status-dot {
|
||||
@apply w-2 h-2 rounded-full;
|
||||
}
|
||||
|
||||
.status-indicator .status-text {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.status-indicator .status-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.connection-status-info {
|
||||
@apply flex flex-wrap items-center gap-3 text-sm font-medium;
|
||||
grid-area: info;
|
||||
justify-self: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.connection-status-usage {
|
||||
@apply flex flex-wrap items-center justify-center gap-2 text-xs;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.connection-status-shortcut {
|
||||
grid-area: shortcut;
|
||||
justify-self: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.connection-status-meta {
|
||||
grid-area: meta;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.connection-status-text {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.connection-status-shortcut-action {
|
||||
@apply flex items-center justify-center gap-2;
|
||||
}
|
||||
|
||||
.connection-status-button {
|
||||
@apply inline-flex items-center gap-2 px-3 py-1 text-sm font-medium border rounded-md transition-colors;
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.connection-status-button:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.connection-status-button:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.connection-status-shortcut-hint {
|
||||
@apply inline-flex items-center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.connection-status-shortcut-hint {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.connection-status-button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.connection-status {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-areas:
|
||||
"menu shortcut meta"
|
||||
"info info info";
|
||||
row-gap: 0.5rem;
|
||||
}
|
||||
|
||||
.connection-status-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.connection-status-info {
|
||||
justify-self: stretch;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.connection-status-usage {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.connection-status-shortcut {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.connection-status-meta {
|
||||
justify-self: end;
|
||||
}
|
||||
}
|
||||
|
||||
.message-scroll-button-wrapper {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-scroll-button {
|
||||
@apply inline-flex items-center justify-center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--border-base);
|
||||
background-color: transparent;
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--scroll-elevation-shadow);
|
||||
transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.message-scroll-button:hover {
|
||||
background-color: var(--surface-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.message-scroll-button:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--surface-base), 0 0 0 4px var(--accent-primary);
|
||||
}
|
||||
|
||||
.message-scroll-icon {
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user