Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06be455358 | ||
|
|
450f5bf0b4 | ||
|
|
997d4f4129 | ||
|
|
ff5c698131 | ||
|
|
14497f2082 | ||
|
|
f3e1966b5d | ||
|
|
78592f229e | ||
|
|
c8161669ac | ||
|
|
8ec57da275 | ||
|
|
c00b29145a | ||
|
|
7d2a349e95 | ||
|
|
6c326b18ca | ||
|
|
09229259d1 | ||
|
|
b20bfc34b2 | ||
|
|
4e1f08bfcf | ||
|
|
ef4f8ac45f | ||
|
|
6a7255d9d2 | ||
|
|
f37fcaed3d | ||
|
|
d9fd22c29f | ||
|
|
3fcab5b80a | ||
|
|
4ed2361387 | ||
|
|
75b3699649 | ||
|
|
a6404f25d9 | ||
|
|
7591e5c1c9 | ||
|
|
5e8b3fd5c9 | ||
|
|
20b82496a1 | ||
|
|
542b59940a | ||
|
|
8d5c6b37e9 | ||
|
|
8155fc9956 | ||
|
|
cd4afb5314 | ||
|
|
557c2500c7 | ||
|
|
74f8b6c31f | ||
|
|
da517416a5 | ||
|
|
b8f93bf768 | ||
|
|
0110052758 | ||
|
|
0e0da1a142 | ||
|
|
da3b66a3bd | ||
|
|
088e5f1eea | ||
|
|
0da2e1d7bb | ||
|
|
90c6835ee7 | ||
|
|
92bef8bfb8 | ||
|
|
766be00ded | ||
|
|
ce5eaa1841 | ||
|
|
c323667729 | ||
|
|
67a12d6126 | ||
|
|
bd0cb04b78 |
145
package-lock.json
generated
145
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
"google-auth-library": "^10.5.0"
|
"google-auth-library": "^10.5.0"
|
||||||
@@ -1276,9 +1276,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@opencode-ai/sdk": {
|
"node_modules/@opencode-ai/sdk": {
|
||||||
"version": "1.0.133",
|
"version": "1.0.138",
|
||||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.0.133.tgz",
|
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.0.138.tgz",
|
||||||
"integrity": "sha512-kM+VJJ09SU51aruQ78DSy+6CjNc4wMytvGBrZ1IIJ8etUIdGA59wrnIOSxBVs4u/Gb9pjjgsF8sWp59UdLWP9w=="
|
"integrity": "sha512-9vXmpiAVVrhMZ3YNr7BGScyULFLyN0vnRx7iCDtN5qQDKxtsdQcXSQCz35XiVyD3A8lH5KOf5Zn0ByLYXuNeFQ=="
|
||||||
},
|
},
|
||||||
"node_modules/@pinojs/redact": {
|
"node_modules/@pinojs/redact": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
@@ -1296,6 +1296,16 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@popperjs/core": {
|
||||||
|
"version": "2.11.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||||
|
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/popperjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.52.5",
|
"version": "4.52.5",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz",
|
||||||
@@ -1531,6 +1541,109 @@
|
|||||||
"solid-js": "^1.8.6"
|
"solid-js": "^1.8.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@suid/base": {
|
||||||
|
"version": "0.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@suid/base/-/base-0.11.0.tgz",
|
||||||
|
"integrity": "sha512-jNe+LlXuxfkSZo8/MP9koqYYWswucDWSCwc7ViqUhQ0Y/V7sP2RiQ/Bnms+ePSMBZsk5k1b9fAjvj7DtNbbHXw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@popperjs/core": "^2.11.8",
|
||||||
|
"@suid/css": "0.4.1",
|
||||||
|
"@suid/system": "0.14.0",
|
||||||
|
"@suid/types": "0.8.0",
|
||||||
|
"@suid/utils": "0.11.0",
|
||||||
|
"clsx": "^2.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"solid-js": "^1.9.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@suid/css": {
|
||||||
|
"version": "0.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@suid/css/-/css-0.4.1.tgz",
|
||||||
|
"integrity": "sha512-Hsi4O3dBOm7rrlqKoWfNoTeRFAXm/7TPaeEmyxNx+wFaT3eROjMVdhadAIiagFT+PsHrq/6fDauUI5TkL+5Zvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@suid/icons-material": {
|
||||||
|
"version": "0.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@suid/icons-material/-/icons-material-0.9.0.tgz",
|
||||||
|
"integrity": "sha512-2idgaT/JARd12dwDfocZBQizaiZVgR0ujRsVc61OlAuPZbeH+3TrSxUJkE3Z7+TPftw9+6p0A24GhJjJLvi6RQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@suid/material": "0.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"solid-js": "^1.9.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@suid/material": {
|
||||||
|
"version": "0.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@suid/material/-/material-0.19.0.tgz",
|
||||||
|
"integrity": "sha512-vfudxYpHdur5CWTjd3eBb7q1b6A9X/pDWTEf2twc0gXVTcErS9VtY/VPBLa65AzO2SPJsdjAE+BCdVZiXASBbA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@suid/base": "0.11.0",
|
||||||
|
"@suid/css": "0.4.1",
|
||||||
|
"@suid/system": "0.14.0",
|
||||||
|
"@suid/types": "0.8.0",
|
||||||
|
"@suid/utils": "0.11.0",
|
||||||
|
"clsx": "^2.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"solid-js": "^1.9.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@suid/styled-engine": {
|
||||||
|
"version": "0.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@suid/styled-engine/-/styled-engine-0.9.0.tgz",
|
||||||
|
"integrity": "sha512-IfNHjQ3Im63mFIjFl/doiwdn5qbwgcwi/vUXnX7dmIUC/Cw1f3LPhzVT9V8Z3eqyvvFToy53O+BsuLy2e/WmDw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@suid/css": "0.4.1",
|
||||||
|
"@suid/utils": "0.11.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"solid-js": "^1.9.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@suid/system": {
|
||||||
|
"version": "0.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@suid/system/-/system-0.14.0.tgz",
|
||||||
|
"integrity": "sha512-aRVilPP53hHkqyAyQp2pasT/u8aQCcELwU4kFDnt3b+rj4fsPQRlhMumlX5mZ5aijIboH1CngU6TDG6Z9Mr3UA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@suid/css": "0.4.1",
|
||||||
|
"@suid/styled-engine": "0.9.0",
|
||||||
|
"@suid/types": "0.8.0",
|
||||||
|
"@suid/utils": "0.11.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"csstype": "^3.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"solid-js": "^1.9.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@suid/types": {
|
||||||
|
"version": "0.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@suid/types/-/types-0.8.0.tgz",
|
||||||
|
"integrity": "sha512-/Z2abkbypMjF6ygSpnjqnWohcmPqvgw8Xpx1wPPHeh+LajBP2imNT6uEa5dBqNEkJY8O3wEUCVqErAad/rmn5Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"solid-js": "^1.9.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@suid/utils": {
|
||||||
|
"version": "0.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@suid/utils/-/utils-0.11.0.tgz",
|
||||||
|
"integrity": "sha512-dk+6YJkex9kcU2qQHCOk8J0/zkOKKbng0SsjC0LBLyBrf2OC3OtDQq7o22pH3m/8CU/0M6uyM7tnyzZA4eWF3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@suid/types": "0.8.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"solid-js": "^1.9.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.17",
|
"version": "0.5.17",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
|
||||||
@@ -3102,6 +3215,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -8815,7 +8937,7 @@
|
|||||||
},
|
},
|
||||||
"packages/electron-app": {
|
"packages/electron-app": {
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codenomad/ui": "file:../ui",
|
"@codenomad/ui": "file:../ui",
|
||||||
"@neuralnomads/codenomad": "file:../server"
|
"@neuralnomads/codenomad": "file:../server"
|
||||||
@@ -8843,7 +8965,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"@fastify/reply-from": "^9.8.0",
|
"@fastify/reply-from": "^9.8.0",
|
||||||
@@ -8882,19 +9004,22 @@
|
|||||||
},
|
},
|
||||||
"packages/tauri-app": {
|
"packages/tauri-app": {
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@git-diff-view/solid": "^0.0.8",
|
"@git-diff-view/solid": "^0.0.8",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@opencode-ai/sdk": "^1.0.133",
|
"@opencode-ai/sdk": "^1.0.138",
|
||||||
"@solidjs/router": "^0.13.0",
|
"@solidjs/router": "^0.13.0",
|
||||||
|
"@suid/icons-material": "^0.9.0",
|
||||||
|
"@suid/material": "^0.19.0",
|
||||||
|
"@suid/system": "^0.14.0",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"github-markdown-css": "^5.8.1",
|
"github-markdown-css": "^5.8.1",
|
||||||
"lucide-solid": "^0.300.0",
|
"lucide-solid": "^0.300.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "CodeNomad monorepo workspace",
|
"description": "CodeNomad monorepo workspace",
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"description": "CodeNomad - AI coding assistant",
|
"description": "CodeNomad - AI coding assistant",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Neural Nomads",
|
"name": "Neural Nomads",
|
||||||
|
|||||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"description": "CodeNomad Server",
|
"description": "CodeNomad Server",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Neural Nomads",
|
"name": "Neural Nomads",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npx --yes @tauri-apps/cli@^2.9.4 dev",
|
"dev": "npx --yes @tauri-apps/cli@^2.9.4 dev",
|
||||||
|
|||||||
@@ -622,6 +622,18 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
|
|||||||
candidates.push(Some(resources.join("resources/server/dist/index.js")));
|
candidates.push(Some(resources.join("resources/server/dist/index.js")));
|
||||||
candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
|
candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
|
||||||
candidates.push(Some(resources.join("resources/server/dist/server/index.js")));
|
candidates.push(Some(resources.join("resources/server/dist/server/index.js")));
|
||||||
|
|
||||||
|
let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")];
|
||||||
|
for root in linux_resource_roots {
|
||||||
|
candidates.push(Some(root.join("server/dist/bin.js")));
|
||||||
|
candidates.push(Some(root.join("server/dist/index.js")));
|
||||||
|
candidates.push(Some(root.join("server/dist/server/bin.js")));
|
||||||
|
candidates.push(Some(root.join("server/dist/server/index.js")));
|
||||||
|
candidates.push(Some(root.join("resources/server/dist/bin.js")));
|
||||||
|
candidates.push(Some(root.join("resources/server/dist/index.js")));
|
||||||
|
candidates.push(Some(root.join("resources/server/dist/server/bin.js")));
|
||||||
|
candidates.push(Some(root.join("resources/server/dist/server/index.js")));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -12,8 +12,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@git-diff-view/solid": "^0.0.8",
|
"@git-diff-view/solid": "^0.0.8",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@opencode-ai/sdk": "^1.0.133",
|
"@opencode-ai/sdk": "^1.0.138",
|
||||||
"@solidjs/router": "^0.13.0",
|
"@solidjs/router": "^0.13.0",
|
||||||
|
"@suid/icons-material": "^0.9.0",
|
||||||
|
"@suid/material": "^0.19.0",
|
||||||
|
"@suid/system": "^0.14.0",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"github-markdown-css": "^5.8.1",
|
"github-markdown-css": "^5.8.1",
|
||||||
"lucide-solid": "^0.300.0",
|
"lucide-solid": "^0.300.0",
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import FolderSelectionView from "./components/folder-selection-view"
|
|||||||
import { showConfirmDialog } from "./stores/alerts"
|
import { showConfirmDialog } from "./stores/alerts"
|
||||||
import InstanceTabs from "./components/instance-tabs"
|
import InstanceTabs from "./components/instance-tabs"
|
||||||
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
||||||
import InstanceShell from "./components/instance/instance-shell"
|
import InstanceShell from "./components/instance/instance-shell2"
|
||||||
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
|
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
|
||||||
|
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
||||||
import { initMarkdown } from "./lib/markdown"
|
import { initMarkdown } from "./lib/markdown"
|
||||||
|
|
||||||
import { useTheme } from "./lib/theme"
|
import { useTheme } from "./lib/theme"
|
||||||
import { useCommands } from "./lib/hooks/use-commands"
|
import { useCommands } from "./lib/hooks/use-commands"
|
||||||
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
||||||
@@ -23,6 +25,7 @@ import {
|
|||||||
showFolderSelection,
|
showFolderSelection,
|
||||||
setShowFolderSelection,
|
setShowFolderSelection,
|
||||||
} from "./stores/ui"
|
} from "./stores/ui"
|
||||||
|
import { instances as instanceStore } from "./stores/instances"
|
||||||
import { useConfig } from "./stores/preferences"
|
import { useConfig } from "./stores/preferences"
|
||||||
import {
|
import {
|
||||||
createInstance,
|
createInstance,
|
||||||
@@ -65,6 +68,13 @@ const App: Component = () => {
|
|||||||
const [launchErrorBinary, setLaunchErrorBinary] = createSignal<string | null>(null)
|
const [launchErrorBinary, setLaunchErrorBinary] = createSignal<string | null>(null)
|
||||||
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
|
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
|
||||||
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
||||||
|
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
||||||
|
|
||||||
|
const updateInstanceTabBarHeight = () => {
|
||||||
|
if (typeof document === "undefined") return
|
||||||
|
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
|
||||||
|
setInstanceTabBarHeight(element?.offsetHeight ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
|
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
|
||||||
@@ -74,6 +84,19 @@ const App: Component = () => {
|
|||||||
initReleaseNotifications()
|
initReleaseNotifications()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
instances()
|
||||||
|
hasInstances()
|
||||||
|
requestAnimationFrame(() => updateInstanceTabBarHeight())
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
updateInstanceTabBarHeight()
|
||||||
|
const handleResize = () => updateInstanceTabBarHeight()
|
||||||
|
window.addEventListener("resize", handleResize)
|
||||||
|
onCleanup(() => window.removeEventListener("resize", handleResize))
|
||||||
|
})
|
||||||
|
|
||||||
const activeInstance = createMemo(() => getActiveInstance())
|
const activeInstance = createMemo(() => getActiveInstance())
|
||||||
const activeSessionIdForInstance = createMemo(() => {
|
const activeSessionIdForInstance = createMemo(() => {
|
||||||
const instance = activeInstance()
|
const instance = activeInstance()
|
||||||
@@ -328,8 +351,10 @@ const App: Component = () => {
|
|||||||
<For each={Array.from(instances().values())}>
|
<For each={Array.from(instances().values())}>
|
||||||
{(instance) => {
|
{(instance) => {
|
||||||
const isActiveInstance = () => activeInstanceId() === instance.id
|
const isActiveInstance = () => activeInstanceId() === instance.id
|
||||||
|
const isVisible = () => isActiveInstance() && !showFolderSelection()
|
||||||
return (
|
return (
|
||||||
<div class="flex-1 min-h-0" style={{ display: isActiveInstance() ? "flex" : "none" }}>
|
<div class="flex-1 min-h-0 overflow-hidden" style={{ display: isVisible() ? "flex" : "none" }}>
|
||||||
|
<InstanceMetadataProvider instance={instance}>
|
||||||
<InstanceShell
|
<InstanceShell
|
||||||
instance={instance}
|
instance={instance}
|
||||||
escapeInDebounce={escapeInDebounce()}
|
escapeInDebounce={escapeInDebounce()}
|
||||||
@@ -339,9 +364,13 @@ const App: Component = () => {
|
|||||||
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
||||||
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
||||||
onExecuteCommand={executeCommand}
|
onExecuteCommand={executeCommand}
|
||||||
|
tabBarOffset={instanceTabBarHeight()}
|
||||||
/>
|
/>
|
||||||
|
</InstanceMetadataProvider>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
|||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
|
|
||||||
<Select.Portal>
|
<Select.Portal>
|
||||||
<Select.Content class="selector-popover max-h-80 overflow-auto p-1 z-50">
|
<Select.Content class="selector-popover max-h-80 overflow-auto p-1">
|
||||||
<Select.Listbox class="selector-listbox" />
|
<Select.Listbox class="selector-listbox" />
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Portal>
|
</Select.Portal>
|
||||||
|
|||||||
@@ -224,11 +224,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
class="flex h-screen w-full items-start justify-center overflow-hidden py-6 relative"
|
class="flex h-screen w-full items-start justify-center overflow-hidden py-6 px-4 sm:px-6 relative"
|
||||||
style="background-color: var(--surface-secondary)"
|
style="background-color: var(--surface-secondary)"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-full max-w-3xl h-full px-8 pb-2 flex flex-col overflow-hidden"
|
class="w-full max-w-3xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
||||||
aria-busy={isLoading() ? "true" : "false"}
|
aria-busy={isLoading() ? "true" : "false"}
|
||||||
>
|
>
|
||||||
<Show when={props.onOpenRemoteAccess}>
|
<Show when={props.onOpenRemoteAccess}>
|
||||||
|
|||||||
@@ -1,134 +1,26 @@
|
|||||||
import { Component, Show, For, createSignal, createEffect, onCleanup } from "solid-js"
|
import { Component, For, Show, createMemo } from "solid-js"
|
||||||
import type { Instance, RawMcpStatus } from "../types/instance"
|
import type { Instance } from "../types/instance"
|
||||||
import { fetchLspStatus, updateInstance } from "../stores/instances"
|
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
|
||||||
import { getLogger } from "../lib/logger"
|
import InstanceServiceStatus from "./instance-service-status"
|
||||||
|
|
||||||
const log = getLogger("session")
|
|
||||||
|
|
||||||
interface InstanceInfoProps {
|
interface InstanceInfoProps {
|
||||||
instance: Instance
|
instance: Instance
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type ParsedMcpStatus = {
|
|
||||||
name: string
|
|
||||||
status: "running" | "stopped" | "error"
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseMcpStatus(status: RawMcpStatus): ParsedMcpStatus[] {
|
|
||||||
if (!status || typeof status !== "object") return []
|
|
||||||
|
|
||||||
const result: ParsedMcpStatus[] = []
|
|
||||||
|
|
||||||
for (const [name, value] of Object.entries(status)) {
|
|
||||||
if (!value || typeof value !== "object") continue
|
|
||||||
const rawStatus = (value as { status?: string }).status
|
|
||||||
if (!rawStatus) continue
|
|
||||||
|
|
||||||
let mappedStatus: ParsedMcpStatus["status"]
|
|
||||||
if (rawStatus === "connected") {
|
|
||||||
mappedStatus = "running"
|
|
||||||
} else if (rawStatus === "failed") {
|
|
||||||
mappedStatus = "error"
|
|
||||||
} else {
|
|
||||||
mappedStatus = "stopped"
|
|
||||||
}
|
|
||||||
|
|
||||||
result.push({
|
|
||||||
name,
|
|
||||||
status: mappedStatus,
|
|
||||||
error: typeof (value as { error?: unknown }).error === "string" ? (value as { error?: string }).error : undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
const pendingMetadataRequests = new Set<string>()
|
|
||||||
|
|
||||||
const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||||
const [isLoadingMetadata, setIsLoadingMetadata] = createSignal(true)
|
const metadataContext = useOptionalInstanceMetadataContext()
|
||||||
|
const isLoadingMetadata = metadataContext?.isLoading ?? (() => false)
|
||||||
|
const instanceAccessor = metadataContext?.instance ?? (() => props.instance)
|
||||||
|
const metadataAccessor = metadataContext?.metadata ?? (() => props.instance.metadata)
|
||||||
|
|
||||||
const metadata = () => props.instance.metadata
|
const currentInstance = () => instanceAccessor()
|
||||||
const binaryVersion = () => props.instance.binaryVersion || metadata()?.version
|
const metadata = () => metadataAccessor()
|
||||||
const mcpServers = () => {
|
const binaryVersion = () => currentInstance().binaryVersion || metadata()?.version
|
||||||
const status = metadata()?.mcpStatus
|
const environmentVariables = () => currentInstance().environmentVariables
|
||||||
return status ? parseMcpStatus(status) : []
|
const environmentEntries = createMemo(() => {
|
||||||
}
|
const env = environmentVariables()
|
||||||
const lspServers = () => metadata()?.lspStatus ?? []
|
return env ? Object.entries(env) : []
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const instance = props.instance
|
|
||||||
const instanceId = instance.id
|
|
||||||
const client = instance.client
|
|
||||||
const hasMetadata = Boolean(instance.metadata)
|
|
||||||
|
|
||||||
if (!client) {
|
|
||||||
setIsLoadingMetadata(false)
|
|
||||||
pendingMetadataRequests.delete(instanceId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasMetadata) {
|
|
||||||
setIsLoadingMetadata(false)
|
|
||||||
pendingMetadataRequests.delete(instanceId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pendingMetadataRequests.has(instanceId)) {
|
|
||||||
setIsLoadingMetadata(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false
|
|
||||||
pendingMetadataRequests.add(instanceId)
|
|
||||||
setIsLoadingMetadata(true)
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const [projectResult, mcpResult, lspResult] = await Promise.allSettled([
|
|
||||||
client.project.current(),
|
|
||||||
client.mcp.status(),
|
|
||||||
fetchLspStatus(instanceId),
|
|
||||||
])
|
|
||||||
|
|
||||||
if (cancelled) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const project = projectResult.status === "fulfilled" ? projectResult.value.data : undefined
|
|
||||||
const mcpStatus = mcpResult.status === "fulfilled" ? (mcpResult.value.data as RawMcpStatus) : undefined
|
|
||||||
const lspStatus = lspResult.status === "fulfilled" ? lspResult.value ?? [] : undefined
|
|
||||||
|
|
||||||
const nextMetadata = {
|
|
||||||
...(instance.metadata ?? {}),
|
|
||||||
...(project ? { project } : {}),
|
|
||||||
...(mcpStatus ? { mcpStatus } : {}),
|
|
||||||
...(lspStatus ? { lspStatus } : {}),
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!nextMetadata.version && instance.binaryVersion) {
|
|
||||||
nextMetadata.version = instance.binaryVersion
|
|
||||||
}
|
|
||||||
|
|
||||||
updateInstance(instanceId, { metadata: nextMetadata })
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
if (!cancelled) {
|
|
||||||
log.error("Failed to load instance metadata", error)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
pendingMetadataRequests.delete(instanceId)
|
|
||||||
if (!cancelled) {
|
|
||||||
setIsLoadingMetadata(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
cancelled = true
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -140,7 +32,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
<div>
|
<div>
|
||||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Folder</div>
|
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Folder</div>
|
||||||
<div class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
<div class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||||
{props.instance.folder}
|
{currentInstance().folder}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -189,24 +81,24 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={props.instance.binaryPath}>
|
<Show when={currentInstance().binaryPath}>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||||
Binary Path
|
Binary Path
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
<div class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
||||||
{props.instance.binaryPath}
|
{currentInstance().binaryPath}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={props.instance.environmentVariables && Object.keys(props.instance.environmentVariables).length > 0}>
|
<Show when={environmentEntries().length > 0}>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
|
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
|
||||||
Environment Variables ({Object.keys(props.instance.environmentVariables!).length})
|
Environment Variables ({environmentEntries().length})
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<For each={Object.entries(props.instance.environmentVariables!)}>
|
<For each={environmentEntries()}>
|
||||||
{([key, value]) => (
|
{([key, value]) => (
|
||||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
<div class="flex items-center gap-2 px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||||
<span class="text-xs font-mono font-medium flex-1 text-primary" title={key}>
|
<span class="text-xs font-mono font-medium flex-1 text-primary" title={key}>
|
||||||
@@ -222,79 +114,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={!isLoadingMetadata() && lspServers().length > 0}>
|
<InstanceServiceStatus initialInstance={props.instance} class="space-y-3" />
|
||||||
<div>
|
|
||||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
|
|
||||||
LSP Servers
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<For each={lspServers()}>
|
|
||||||
{(server) => (
|
|
||||||
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<div class="flex flex-col flex-1 min-w-0">
|
|
||||||
<span class="text-xs text-primary font-medium truncate">{server.name ?? server.id}</span>
|
|
||||||
<span class="text-[11px] text-secondary truncate" title={server.root}>
|
|
||||||
{server.root}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1.5 flex-shrink-0 text-xs text-secondary">
|
|
||||||
<div class={`status-dot ${server.status === "connected" ? "ready animate-pulse" : "error"}`} />
|
|
||||||
<span>{server.status === "connected" ? "Connected" : "Error"}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={!isLoadingMetadata() && mcpServers().length > 0}>
|
|
||||||
<div>
|
|
||||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
|
|
||||||
MCP Servers
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<For each={mcpServers()}>
|
|
||||||
{(server) => (
|
|
||||||
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<span class="text-xs text-primary font-medium truncate">{server.name}</span>
|
|
||||||
<div class="flex items-center gap-1.5 flex-shrink-0 text-xs text-secondary">
|
|
||||||
<div
|
|
||||||
class={`status-dot ${
|
|
||||||
server.status === "running"
|
|
||||||
? "ready animate-pulse"
|
|
||||||
: server.status === "error"
|
|
||||||
? "error"
|
|
||||||
: "stopped"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
{
|
|
||||||
server.status === "running"
|
|
||||||
? "Connected"
|
|
||||||
: server.status === "error"
|
|
||||||
? "Error"
|
|
||||||
: "Disabled"
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Show when={server.error}>
|
|
||||||
{(error) => (
|
|
||||||
<div class="text-[11px] mt-1 break-words" style={{ color: "var(--status-error)" }}>
|
|
||||||
{error()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={isLoadingMetadata()}>
|
<Show when={isLoadingMetadata()}>
|
||||||
<div class="text-xs text-muted py-1">
|
<div class="text-xs text-muted py-1">
|
||||||
@@ -317,21 +137,19 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
<div class="space-y-1 text-xs">
|
<div class="space-y-1 text-xs">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<span class="text-secondary">Port:</span>
|
<span class="text-secondary">Port:</span>
|
||||||
<span class="text-primary font-mono">{props.instance.port}</span>
|
<span class="text-primary font-mono">{currentInstance().port}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<span class="text-secondary">PID:</span>
|
<span class="text-secondary">PID:</span>
|
||||||
<span class="text-primary font-mono">{props.instance.pid}</span>
|
<span class="text-primary font-mono">{currentInstance().pid}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<span class="text-secondary">Status:</span>
|
<span class="text-secondary">Status:</span>
|
||||||
<span
|
<span class={`status-badge ${currentInstance().status}`}>
|
||||||
class={`status-badge ${props.instance.status}`}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class={`status-dot ${props.instance.status === "ready" ? "ready" : props.instance.status === "starting" ? "starting" : props.instance.status === "error" ? "error" : "stopped"} ${props.instance.status === "ready" || props.instance.status === "starting" ? "animate-pulse" : ""}`}
|
class={`status-dot ${currentInstance().status === "ready" ? "ready" : currentInstance().status === "starting" ? "starting" : currentInstance().status === "error" ? "error" : "stopped"} ${currentInstance().status === "ready" || currentInstance().status === "starting" ? "animate-pulse" : ""}`}
|
||||||
/>
|
/>
|
||||||
{props.instance.status}
|
{currentInstance().status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
224
packages/ui/src/components/instance-service-status.tsx
Normal file
224
packages/ui/src/components/instance-service-status.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import { For, Show, createMemo, createSignal, type Component } from "solid-js"
|
||||||
|
import Switch from "@suid/material/Switch"
|
||||||
|
import type { Instance, RawMcpStatus } from "../types/instance"
|
||||||
|
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
|
||||||
|
import { getLogger } from "../lib/logger"
|
||||||
|
|
||||||
|
const log = getLogger("session")
|
||||||
|
|
||||||
|
type ServiceSection = "lsp" | "mcp"
|
||||||
|
|
||||||
|
interface InstanceServiceStatusProps {
|
||||||
|
sections?: ServiceSection[]
|
||||||
|
showSectionHeadings?: boolean
|
||||||
|
class?: string
|
||||||
|
initialInstance?: Instance
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParsedMcpStatus = {
|
||||||
|
name: string
|
||||||
|
status: "running" | "stopped" | "error"
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMcpStatus(status?: RawMcpStatus): ParsedMcpStatus[] {
|
||||||
|
if (!status || typeof status !== "object") return []
|
||||||
|
const result: ParsedMcpStatus[] = []
|
||||||
|
for (const [name, value] of Object.entries(status)) {
|
||||||
|
if (!value || typeof value !== "object") continue
|
||||||
|
const rawStatus = (value as { status?: string }).status
|
||||||
|
if (!rawStatus) continue
|
||||||
|
let mapped: ParsedMcpStatus["status"]
|
||||||
|
if (rawStatus === "connected") mapped = "running"
|
||||||
|
else if (rawStatus === "failed") mapped = "error"
|
||||||
|
else mapped = "stopped"
|
||||||
|
result.push({
|
||||||
|
name,
|
||||||
|
status: mapped,
|
||||||
|
error: typeof (value as { error?: unknown }).error === "string" ? (value as { error?: string }).error : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) => {
|
||||||
|
const metadataContext = useOptionalInstanceMetadataContext()
|
||||||
|
const instance = metadataContext?.instance ?? (() => {
|
||||||
|
if (props.initialInstance) {
|
||||||
|
return props.initialInstance
|
||||||
|
}
|
||||||
|
throw new Error("InstanceServiceStatus requires InstanceMetadataProvider or initialInstance prop")
|
||||||
|
})
|
||||||
|
const isLoading = metadataContext?.isLoading ?? (() => false)
|
||||||
|
const refreshMetadata = metadataContext?.refreshMetadata ?? (async () => Promise.resolve())
|
||||||
|
const sections = createMemo<ServiceSection[]>(() => props.sections ?? ["lsp", "mcp"])
|
||||||
|
const includeLsp = createMemo(() => sections().includes("lsp"))
|
||||||
|
const includeMcp = createMemo(() => sections().includes("mcp"))
|
||||||
|
const showHeadings = () => props.showSectionHeadings !== false
|
||||||
|
|
||||||
|
const metadataAccessor = metadataContext?.metadata ?? (() => instance().metadata)
|
||||||
|
const metadata = createMemo(() => metadataAccessor())
|
||||||
|
const hasLspMetadata = () => metadata()?.lspStatus !== undefined
|
||||||
|
const hasMcpMetadata = () => metadata()?.mcpStatus !== undefined
|
||||||
|
const lspServers = createMemo(() => metadata()?.lspStatus ?? [])
|
||||||
|
const mcpServers = createMemo(() => parseMcpStatus(metadata()?.mcpStatus ?? undefined))
|
||||||
|
|
||||||
|
const isLspLoading = () => isLoading() || !hasLspMetadata()
|
||||||
|
const isMcpLoading = () => isLoading() || !hasMcpMetadata()
|
||||||
|
|
||||||
|
|
||||||
|
const [pendingMcpActions, setPendingMcpActions] = createSignal<Record<string, "connect" | "disconnect">>({})
|
||||||
|
|
||||||
|
const setPendingMcpAction = (name: string, action?: "connect" | "disconnect") => {
|
||||||
|
setPendingMcpActions((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
if (action) next[name] = action
|
||||||
|
else delete next[name]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleMcpServer = async (serverName: string, shouldEnable: boolean) => {
|
||||||
|
const client = instance().client
|
||||||
|
if (!client?.mcp) return
|
||||||
|
const action: "connect" | "disconnect" = shouldEnable ? "connect" : "disconnect"
|
||||||
|
setPendingMcpAction(serverName, action)
|
||||||
|
try {
|
||||||
|
if (shouldEnable) {
|
||||||
|
await client.mcp.connect({ path: { name: serverName } })
|
||||||
|
} else {
|
||||||
|
await client.mcp.disconnect({ path: { name: serverName } })
|
||||||
|
}
|
||||||
|
await refreshMetadata()
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to toggle MCP server", { serverName, action, error })
|
||||||
|
} finally {
|
||||||
|
setPendingMcpAction(serverName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderEmptyState = (message: string) => (
|
||||||
|
<p class="text-[11px] text-secondary italic" role="status">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderLspSection = () => (
|
||||||
|
<section class="space-y-1.5">
|
||||||
|
<Show when={showHeadings()}>
|
||||||
|
<div class="text-xs font-medium text-muted uppercase tracking-wide">
|
||||||
|
LSP Servers
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show
|
||||||
|
when={!isLspLoading() && lspServers().length > 0}
|
||||||
|
fallback={renderEmptyState(isLspLoading() ? "Loading LSP servers..." : "No LSP servers detected.")}
|
||||||
|
>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<For each={lspServers()}>
|
||||||
|
{(server) => (
|
||||||
|
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<div class="flex flex-col flex-1 min-w-0">
|
||||||
|
<span class="text-xs text-primary font-medium truncate">{server.name ?? server.id}</span>
|
||||||
|
<span class="text-[11px] text-secondary truncate" title={server.root}>
|
||||||
|
{server.root}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5 flex-shrink-0 text-xs text-secondary">
|
||||||
|
<div class={`status-dot ${server.status === "connected" ? "ready animate-pulse" : "error"}`} />
|
||||||
|
<span>{server.status === "connected" ? "Connected" : "Error"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderMcpSection = () => (
|
||||||
|
<section class="space-y-1.5">
|
||||||
|
<Show when={showHeadings()}>
|
||||||
|
<div class="text-xs font-medium text-muted uppercase tracking-wide">
|
||||||
|
MCP Servers
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show
|
||||||
|
when={!isMcpLoading() && mcpServers().length > 0}
|
||||||
|
fallback={renderEmptyState(isMcpLoading() ? "Loading MCP servers..." : "No MCP servers detected.")}
|
||||||
|
>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<For each={mcpServers()}>
|
||||||
|
{(server) => {
|
||||||
|
const pendingAction = () => pendingMcpActions()[server.name]
|
||||||
|
const isPending = () => Boolean(pendingAction())
|
||||||
|
const isRunning = () => server.status === "running"
|
||||||
|
const switchDisabled = () => isPending() || !instance().client
|
||||||
|
const statusDotClass = () => {
|
||||||
|
if (isPending()) return "status-dot animate-pulse"
|
||||||
|
if (server.status === "running") return "status-dot ready animate-pulse"
|
||||||
|
if (server.status === "error") return "status-dot error"
|
||||||
|
return "status-dot stopped"
|
||||||
|
}
|
||||||
|
const statusDotStyle = () => (isPending() ? { background: "var(--status-warning)" } : undefined)
|
||||||
|
return (
|
||||||
|
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs text-primary font-medium truncate">{server.name}</span>
|
||||||
|
<div class="flex items-center gap-3 flex-shrink-0">
|
||||||
|
<div class="flex items-center gap-1.5 text-xs text-secondary">
|
||||||
|
<Show when={isPending()}>
|
||||||
|
<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>
|
||||||
|
</Show>
|
||||||
|
<div class={statusDotClass()} style={statusDotStyle()} />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<Switch
|
||||||
|
checked={isRunning()}
|
||||||
|
disabled={switchDisabled()}
|
||||||
|
color="success"
|
||||||
|
size="small"
|
||||||
|
inputProps={{ "aria-label": `Toggle ${server.name} MCP server` }}
|
||||||
|
onChange={(_, checked) => {
|
||||||
|
if (switchDisabled()) return
|
||||||
|
void toggleMcpServer(server.name, Boolean(checked))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<Show when={server.error}>
|
||||||
|
{(error) => (
|
||||||
|
<div class="text-[11px] mt-1 break-words" style={{ color: "var(--status-error)" }}>
|
||||||
|
{error()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={props.class}>
|
||||||
|
<Show when={includeLsp()}>{renderLspSection()}</Show>
|
||||||
|
<Show when={includeMcp()}>{renderMcpSection()}</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InstanceServiceStatus
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Component, createSignal, Show, For, createEffect, onMount, onCleanup, createMemo } from "solid-js"
|
import { Component, createSignal, Show, For, createEffect, onMount, onCleanup, createMemo } from "solid-js"
|
||||||
import { Loader2, Trash2 } from "lucide-solid"
|
import { Loader2, Pencil, Trash2 } from "lucide-solid"
|
||||||
|
|
||||||
import type { Instance } from "../types/instance"
|
import type { Instance } from "../types/instance"
|
||||||
import { getParentSessions, createSession, setActiveParentSession, deleteSession, loading } from "../stores/sessions"
|
import { getParentSessions, createSession, setActiveParentSession, deleteSession, loading, renameSession } from "../stores/sessions"
|
||||||
import InstanceInfo from "./instance-info"
|
import InstanceInfo from "./instance-info"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
|
import SessionRenameDialog from "./session-rename-dialog"
|
||||||
import { keyboardRegistry, type KeyboardShortcut } from "../lib/keyboard-registry"
|
import { keyboardRegistry, type KeyboardShortcut } from "../lib/keyboard-registry"
|
||||||
import { isMac } from "../lib/keyboard-utils"
|
import { isMac } from "../lib/keyboard-utils"
|
||||||
|
import { showToastNotification } from "../lib/notifications"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
@@ -24,6 +26,8 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
const [isDesktopLayout, setIsDesktopLayout] = createSignal(
|
const [isDesktopLayout, setIsDesktopLayout] = createSignal(
|
||||||
typeof window !== "undefined" ? window.matchMedia("(min-width: 1024px)").matches : false,
|
typeof window !== "undefined" ? window.matchMedia("(min-width: 1024px)").matches : false,
|
||||||
)
|
)
|
||||||
|
const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null)
|
||||||
|
const [isRenaming, setIsRenaming] = createSignal(false)
|
||||||
|
|
||||||
const parentSessions = () => getParentSessions(props.instance.id)
|
const parentSessions = () => getParentSessions(props.instance.id)
|
||||||
const isFetchingSessions = createMemo(() => Boolean(loading().fetchingSessions.get(props.instance.id)))
|
const isFetchingSessions = createMemo(() => Boolean(loading().fetchingSessions.get(props.instance.id)))
|
||||||
@@ -74,6 +78,25 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
let activeElement: HTMLElement | null = null
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
activeElement = document.activeElement as HTMLElement | null
|
||||||
|
}
|
||||||
|
const insideModal = activeElement?.closest(".modal-surface") || activeElement?.closest("[role='dialog']")
|
||||||
|
const isEditingField =
|
||||||
|
activeElement &&
|
||||||
|
(["INPUT", "TEXTAREA", "SELECT"].includes(activeElement.tagName) ||
|
||||||
|
activeElement.isContentEditable ||
|
||||||
|
Boolean(insideModal))
|
||||||
|
|
||||||
|
if (isEditingField) {
|
||||||
|
if (insideModal && e.key === "Escape" && renameTarget()) {
|
||||||
|
e.preventDefault()
|
||||||
|
closeRenameDialog()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (showInstanceInfoOverlay()) {
|
if (showInstanceInfoOverlay()) {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -92,42 +115,56 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
|
|
||||||
if (sessions.length === 0) return
|
if (sessions.length === 0) return
|
||||||
|
|
||||||
|
const listFocused = focusMode() === "sessions"
|
||||||
|
|
||||||
if (e.key === "ArrowDown") {
|
if (e.key === "ArrowDown") {
|
||||||
|
if (!listFocused) {
|
||||||
|
setFocusMode("sessions")
|
||||||
|
setSelectedIndex(0)
|
||||||
|
}
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const newIndex = Math.min(selectedIndex() + 1, sessions.length - 1)
|
const newIndex = Math.min(selectedIndex() + 1, sessions.length - 1)
|
||||||
setSelectedIndex(newIndex)
|
setSelectedIndex(newIndex)
|
||||||
setFocusMode("sessions")
|
|
||||||
scrollToIndex(newIndex)
|
scrollToIndex(newIndex)
|
||||||
} else if (e.key === "ArrowUp") {
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
if (!listFocused) {
|
||||||
|
setFocusMode("sessions")
|
||||||
|
setSelectedIndex(Math.max(parentSessions().length - 1, 0))
|
||||||
|
}
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const newIndex = Math.max(selectedIndex() - 1, 0)
|
const newIndex = Math.max(selectedIndex() - 1, 0)
|
||||||
setSelectedIndex(newIndex)
|
setSelectedIndex(newIndex)
|
||||||
setFocusMode("sessions")
|
|
||||||
scrollToIndex(newIndex)
|
scrollToIndex(newIndex)
|
||||||
} else if (e.key === "PageDown") {
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!listFocused) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "PageDown") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const pageSize = 5
|
const pageSize = 5
|
||||||
const newIndex = Math.min(selectedIndex() + pageSize, sessions.length - 1)
|
const newIndex = Math.min(selectedIndex() + pageSize, sessions.length - 1)
|
||||||
setSelectedIndex(newIndex)
|
setSelectedIndex(newIndex)
|
||||||
setFocusMode("sessions")
|
|
||||||
scrollToIndex(newIndex)
|
scrollToIndex(newIndex)
|
||||||
} else if (e.key === "PageUp") {
|
} else if (e.key === "PageUp") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const pageSize = 5
|
const pageSize = 5
|
||||||
const newIndex = Math.max(selectedIndex() - pageSize, 0)
|
const newIndex = Math.max(selectedIndex() - pageSize, 0)
|
||||||
setSelectedIndex(newIndex)
|
setSelectedIndex(newIndex)
|
||||||
setFocusMode("sessions")
|
|
||||||
scrollToIndex(newIndex)
|
scrollToIndex(newIndex)
|
||||||
} else if (e.key === "Home") {
|
} else if (e.key === "Home") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSelectedIndex(0)
|
setSelectedIndex(0)
|
||||||
setFocusMode("sessions")
|
|
||||||
scrollToIndex(0)
|
scrollToIndex(0)
|
||||||
} else if (e.key === "End") {
|
} else if (e.key === "End") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const newIndex = sessions.length - 1
|
const newIndex = sessions.length - 1
|
||||||
setSelectedIndex(newIndex)
|
setSelectedIndex(newIndex)
|
||||||
setFocusMode("sessions")
|
|
||||||
scrollToIndex(newIndex)
|
scrollToIndex(newIndex)
|
||||||
} else if (e.key === "Enter") {
|
} else if (e.key === "Enter") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -138,6 +175,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function handleEnterKey() {
|
async function handleEnterKey() {
|
||||||
const sessions = parentSessions()
|
const sessions = parentSessions()
|
||||||
const index = selectedIndex()
|
const index = selectedIndex()
|
||||||
@@ -234,6 +272,31 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openRenameDialogForSession(sessionId: string, title: string) {
|
||||||
|
const label = title && title.trim() ? title : sessionId
|
||||||
|
setRenameTarget({ id: sessionId, title: title ?? "", label })
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRenameDialog() {
|
||||||
|
setRenameTarget(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRenameSubmit(nextTitle: string) {
|
||||||
|
const target = renameTarget()
|
||||||
|
if (!target) return
|
||||||
|
|
||||||
|
setIsRenaming(true)
|
||||||
|
try {
|
||||||
|
await renameSession(props.instance.id, target.id, nextTitle)
|
||||||
|
setRenameTarget(null)
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to rename session:", error)
|
||||||
|
showToastNotification({ message: "Unable to rename session", variant: "error" })
|
||||||
|
} finally {
|
||||||
|
setIsRenaming(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleNewSession() {
|
async function handleNewSession() {
|
||||||
if (isCreating()) return
|
if (isCreating()) return
|
||||||
|
|
||||||
@@ -251,8 +314,8 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex-1 flex flex-col overflow-hidden bg-surface-secondary">
|
<div class="flex-1 flex flex-col overflow-hidden bg-surface-secondary">
|
||||||
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-auto">
|
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-auto min-w-0">
|
||||||
<div class="flex-1 flex flex-col gap-4 min-h-0">
|
<div class="flex-1 flex flex-col gap-4 min-h-0 min-w-0">
|
||||||
<Show
|
<Show
|
||||||
when={parentSessions().length > 0}
|
when={parentSessions().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -336,7 +399,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
class="text-sm font-medium text-primary truncate transition-colors"
|
class="text-sm font-medium text-primary whitespace-normal break-words transition-colors"
|
||||||
classList={{
|
classList={{
|
||||||
"text-accent": isFocused(),
|
"text-accent": isFocused(),
|
||||||
}}
|
}}
|
||||||
@@ -355,6 +418,18 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
<Show when={isFocused()}>
|
<Show when={isFocused()}>
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
<kbd class="kbd flex-shrink-0">↵</kbd>
|
<kbd class="kbd flex-shrink-0">↵</kbd>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="p-1.5 rounded transition-colors text-muted hover:text-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||||
|
title="Rename session"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
openRenameDialogForSession(session.id, session.title || "")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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"
|
||||||
@@ -431,7 +506,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hidden lg:block lg:w-80 flex-shrink-0">
|
<div class="hidden lg:block lg:w-80 flex-shrink-0">
|
||||||
<div class="sticky top-0">
|
<div class="sticky top-0 max-h-full overflow-y-auto pr-1">
|
||||||
<InstanceInfo instance={props.instance} />
|
<InstanceInfo instance={props.instance} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -488,10 +563,17 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SessionRenameDialog
|
||||||
|
open={Boolean(renameTarget())}
|
||||||
|
currentTitle={renameTarget()?.title ?? ""}
|
||||||
|
sessionLabel={renameTarget()?.label}
|
||||||
|
isSubmitting={isRenaming()}
|
||||||
|
onRename={handleRenameSubmit}
|
||||||
|
onClose={closeRenameDialog}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default InstanceWelcomeView
|
export default InstanceWelcomeView
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,356 +0,0 @@
|
|||||||
import { For, Show, createEffect, 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"
|
|
||||||
import { activeParentSessionId, activeSessionId as activeSessionMap, getSessionFamily, setActiveSession } from "../../stores/sessions"
|
|
||||||
import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry"
|
|
||||||
import { messageStoreBus } from "../../stores/message-v2/bus"
|
|
||||||
import { clearSessionRenderCache } from "../message-block"
|
|
||||||
import { buildCustomCommandEntries } from "../../lib/command-utils"
|
|
||||||
import { getCommands as getInstanceCommands } from "../../stores/commands"
|
|
||||||
import { isOpen as isCommandPaletteOpen, hideCommandPalette } from "../../stores/command-palette"
|
|
||||||
import SessionList from "../session-list"
|
|
||||||
import KeyboardHint from "../keyboard-hint"
|
|
||||||
import InstanceWelcomeView from "../instance-welcome-view"
|
|
||||||
import InfoView from "../info-view"
|
|
||||||
import AgentSelector from "../agent-selector"
|
|
||||||
import ModelSelector from "../model-selector"
|
|
||||||
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
|
|
||||||
escapeInDebounce: boolean
|
|
||||||
paletteCommands: Accessor<Command[]>
|
|
||||||
onCloseSession: (sessionId: string) => Promise<void> | void
|
|
||||||
onNewSession: () => Promise<void> | void
|
|
||||||
handleSidebarAgentChange: (sessionId: string, agent: string) => Promise<void>
|
|
||||||
handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void>
|
|
||||||
onExecuteCommand: (command: Command) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_SESSION_SIDEBAR_WIDTH = 350
|
|
||||||
const MOBILE_SIDEBAR_BREAKPOINT = 1024
|
|
||||||
const SESSION_CACHE_LIMIT = 2
|
|
||||||
|
|
||||||
const InstanceShell: Component<InstanceShellProps> = (props) => {
|
|
||||||
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
|
|
||||||
const [isCompactLayout, setIsCompactLayout] = createSignal(false)
|
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = createSignal(true)
|
|
||||||
const [cachedSessionIds, setCachedSessionIds] = createSignal<string[]>([])
|
|
||||||
const [pendingEvictions, setPendingEvictions] = createSignal<string[]>([])
|
|
||||||
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)
|
|
||||||
if (!parentId) return new Map<string, ReturnType<typeof getSessionFamily>[number]>()
|
|
||||||
const sessionFamily = getSessionFamily(props.instance.id, parentId)
|
|
||||||
return new Map(sessionFamily.map((s) => [s.id, s]))
|
|
||||||
})
|
|
||||||
|
|
||||||
const activeSessionIdForInstance = createMemo(() => {
|
|
||||||
return activeSessionMap().get(props.instance.id) || null
|
|
||||||
})
|
|
||||||
|
|
||||||
const parentSessionIdForInstance = createMemo(() => {
|
|
||||||
return activeParentSessionId().get(props.instance.id) || null
|
|
||||||
})
|
|
||||||
|
|
||||||
const activeSessionForInstance = createMemo(() => {
|
|
||||||
const sessionId = activeSessionIdForInstance()
|
|
||||||
if (!sessionId || sessionId === "info") return null
|
|
||||||
return activeSessions().get(sessionId) ?? null
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
const customCommands = createMemo(() => buildCustomCommandEntries(props.instance.id, getInstanceCommands(props.instance.id)))
|
|
||||||
const instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()])
|
|
||||||
const paletteOpen = createMemo(() => isCommandPaletteOpen(props.instance.id))
|
|
||||||
|
|
||||||
const keyboardShortcuts = createMemo(() =>
|
|
||||||
[keyboardRegistry.get("session-prev"), keyboardRegistry.get("session-next")].filter(
|
|
||||||
(shortcut): shortcut is KeyboardShortcut => Boolean(shortcut),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleSessionSelect = (sessionId: string) => {
|
|
||||||
setActiveSession(props.instance.id, sessionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const evictSession = (sessionId: string) => {
|
|
||||||
if (!sessionId) return
|
|
||||||
log.info("Evicting cached session", { instanceId: props.instance.id, sessionId })
|
|
||||||
const store = messageStoreBus.getInstance(props.instance.id)
|
|
||||||
store?.clearSession(sessionId)
|
|
||||||
clearSessionRenderCache(props.instance.id, sessionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const scheduleEvictions = (ids: string[]) => {
|
|
||||||
if (!ids.length) return
|
|
||||||
setPendingEvictions((current) => {
|
|
||||||
const existing = new Set(current)
|
|
||||||
const next = [...current]
|
|
||||||
ids.forEach((id) => {
|
|
||||||
if (!existing.has(id)) {
|
|
||||||
next.push(id)
|
|
||||||
existing.add(id)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const pending = pendingEvictions()
|
|
||||||
if (!pending.length) return
|
|
||||||
const cached = new Set(cachedSessionIds())
|
|
||||||
const remaining: string[] = []
|
|
||||||
pending.forEach((id) => {
|
|
||||||
if (cached.has(id)) {
|
|
||||||
remaining.push(id)
|
|
||||||
} else {
|
|
||||||
evictSession(id)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (remaining.length !== pending.length) {
|
|
||||||
setPendingEvictions(remaining)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const sessionsMap = activeSessions()
|
|
||||||
const parentId = parentSessionIdForInstance()
|
|
||||||
const activeId = activeSessionIdForInstance()
|
|
||||||
setCachedSessionIds((current) => {
|
|
||||||
const next: string[] = []
|
|
||||||
const append = (id: string | null) => {
|
|
||||||
if (!id || id === "info") return
|
|
||||||
if (!sessionsMap.has(id)) return
|
|
||||||
if (next.includes(id)) return
|
|
||||||
next.push(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
append(parentId)
|
|
||||||
append(activeId)
|
|
||||||
current.forEach((id) => append(id))
|
|
||||||
|
|
||||||
const limit = parentId ? SESSION_CACHE_LIMIT + 1 : SESSION_CACHE_LIMIT
|
|
||||||
const trimmed = next.length > limit ? next.slice(0, limit) : next
|
|
||||||
const trimmedSet = new Set(trimmed)
|
|
||||||
const removed = current.filter((id) => !trimmedSet.has(id))
|
|
||||||
if (removed.length) {
|
|
||||||
scheduleEvictions(removed)
|
|
||||||
}
|
|
||||||
return trimmed
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Show when={activeSessions().size > 0} fallback={<InstanceWelcomeView instance={props.instance} />}>
|
|
||||||
<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()}
|
|
||||||
activeSessionId={activeSessionIdForInstance()}
|
|
||||||
onSelect={handleSessionSelect}
|
|
||||||
onClose={(id) => {
|
|
||||||
const result = props.onCloseSession(id)
|
|
||||||
if (result instanceof Promise) {
|
|
||||||
void result.catch((error) => log.error("Failed to close session:", error))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onNew={() => {
|
|
||||||
const result = props.onNewSession()
|
|
||||||
if (result instanceof Promise) {
|
|
||||||
void result.catch((error) => log.error("Failed to create session:", error))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
showHeader
|
|
||||||
showFooter={false}
|
|
||||||
headerContent={
|
|
||||||
<div class="session-sidebar-header">
|
|
||||||
<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} />
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
onWidthChange={setSessionSidebarWidth}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="session-sidebar-separator border-t border-base" />
|
|
||||||
<Show when={activeSessionForInstance()}>
|
|
||||||
{(activeSession) => (
|
|
||||||
<>
|
|
||||||
<ContextUsagePanel instanceId={props.instance.id} sessionId={activeSession().id} />
|
|
||||||
<div class="session-sidebar-controls px-3 py-3 border-r border-base flex flex-col gap-3">
|
|
||||||
<AgentSelector
|
|
||||||
instanceId={props.instance.id}
|
|
||||||
sessionId={activeSession().id}
|
|
||||||
currentAgent={activeSession().agent}
|
|
||||||
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="sidebar-selector-hints" aria-hidden="true">
|
|
||||||
<span class="hint sidebar-selector-hint sidebar-selector-hint--left">
|
|
||||||
<Kbd shortcut="cmd+shift+a" />
|
|
||||||
</span>
|
|
||||||
<span class="hint sidebar-selector-hint sidebar-selector-hint--right">
|
|
||||||
<Kbd shortcut="cmd+shift+m" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ModelSelector
|
|
||||||
instanceId={props.instance.id}
|
|
||||||
sessionId={activeSession().id}
|
|
||||||
currentModel={activeSession().model}
|
|
||||||
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</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={
|
|
||||||
<Show
|
|
||||||
when={cachedSessionIds().length > 0 && activeSessionIdForInstance()}
|
|
||||||
fallback={
|
|
||||||
<div class="flex items-center justify-center h-full">
|
|
||||||
<div class="text-center text-gray-500 dark:text-gray-400">
|
|
||||||
<p class="mb-2">No session selected</p>
|
|
||||||
<p class="text-sm">Select a session to view messages</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<For each={cachedSessionIds()}>
|
|
||||||
{(sessionId) => {
|
|
||||||
const isActive = () => activeSessionIdForInstance() === sessionId
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class="session-cache-pane flex flex-col flex-1 min-h-0"
|
|
||||||
style={{ display: isActive() ? "flex" : "none" }}
|
|
||||||
data-session-id={sessionId}
|
|
||||||
aria-hidden={!isActive()}
|
|
||||||
>
|
|
||||||
<SessionView
|
|
||||||
sessionId={sessionId}
|
|
||||||
activeSessions={activeSessions()}
|
|
||||||
instanceId={props.instance.id}
|
|
||||||
instanceFolder={props.instance.folder}
|
|
||||||
escapeInDebounce={props.escapeInDebounce}
|
|
||||||
showSidebarToggle={shouldShowSidebarToggle()}
|
|
||||||
onSidebarToggle={() => setIsSidebarOpen(true)}
|
|
||||||
forceCompactStatusLayout={shouldShowSidebarToggle()}
|
|
||||||
isActive={isActive()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<CommandPalette
|
|
||||||
open={paletteOpen()}
|
|
||||||
onClose={() => hideCommandPalette(props.instance.id)}
|
|
||||||
commands={instancePaletteCommands()}
|
|
||||||
onExecute={props.onExecuteCommand}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default InstanceShell
|
|
||||||
1308
packages/ui/src/components/instance/instance-shell2.tsx
Normal file
1308
packages/ui/src/components/instance/instance-shell2.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,14 @@
|
|||||||
import { createEffect, createSignal, onMount, onCleanup } from "solid-js"
|
import { createEffect, createSignal, onMount, onCleanup } from "solid-js"
|
||||||
import { renderMarkdown, onLanguagesLoaded, initMarkdown, decodeHtmlEntities } from "../lib/markdown"
|
import { renderMarkdown, onLanguagesLoaded, initMarkdown, decodeHtmlEntities } from "../lib/markdown"
|
||||||
import type { TextPart } from "../types/message"
|
import type { TextPart, RenderCache } from "../types/message"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
|
const markdownRenderCache = new Map<string, RenderCache>()
|
||||||
|
|
||||||
|
function makeMarkdownCacheKey(partId: string, themeKey: string, highlightEnabled: boolean) {
|
||||||
|
return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}`
|
||||||
|
}
|
||||||
|
|
||||||
interface MarkdownProps {
|
interface MarkdownProps {
|
||||||
part: TextPart
|
part: TextPart
|
||||||
@@ -29,11 +34,25 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
const dark = Boolean(props.isDark)
|
const dark = Boolean(props.isDark)
|
||||||
const themeKey = dark ? "dark" : "light"
|
const themeKey = dark ? "dark" : "light"
|
||||||
const highlightEnabled = !props.disableHighlight
|
const highlightEnabled = !props.disableHighlight
|
||||||
|
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : "__anonymous__"
|
||||||
|
const cacheKey = makeMarkdownCacheKey(partId, themeKey, highlightEnabled)
|
||||||
|
|
||||||
latestRequestedText = text
|
latestRequestedText = text
|
||||||
|
|
||||||
// Markdown initialization is now handled globally in App.
|
const localCache = part.renderCache
|
||||||
// initMarkdown is idempotent but we avoid per-part calls here.
|
if (localCache && localCache.text === text && localCache.theme === themeKey) {
|
||||||
|
setHtml(localCache.html)
|
||||||
|
notifyRendered()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalCache = markdownRenderCache.get(cacheKey)
|
||||||
|
if (globalCache && globalCache.text === text) {
|
||||||
|
setHtml(globalCache.html)
|
||||||
|
part.renderCache = globalCache
|
||||||
|
notifyRendered()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!highlightEnabled) {
|
if (!highlightEnabled) {
|
||||||
part.renderCache = undefined
|
part.renderCache = undefined
|
||||||
@@ -42,39 +61,42 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
const rendered = await renderMarkdown(text, { suppressHighlight: true })
|
const rendered = await renderMarkdown(text, { suppressHighlight: true })
|
||||||
|
|
||||||
if (latestRequestedText === text) {
|
if (latestRequestedText === text) {
|
||||||
|
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey }
|
||||||
setHtml(rendered)
|
setHtml(rendered)
|
||||||
|
part.renderCache = cacheEntry
|
||||||
|
markdownRenderCache.set(cacheKey, cacheEntry)
|
||||||
notifyRendered()
|
notifyRendered()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to render markdown:", error)
|
log.error("Failed to render markdown:", error)
|
||||||
if (latestRequestedText === text) {
|
if (latestRequestedText === text) {
|
||||||
|
const cacheEntry: RenderCache = { text, html: text, theme: themeKey }
|
||||||
setHtml(text)
|
setHtml(text)
|
||||||
|
part.renderCache = cacheEntry
|
||||||
|
markdownRenderCache.set(cacheKey, cacheEntry)
|
||||||
notifyRendered()
|
notifyRendered()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const cache = part.renderCache
|
|
||||||
if (cache && cache.text === text && cache.theme === themeKey) {
|
|
||||||
setHtml(cache.html)
|
|
||||||
notifyRendered()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rendered = await renderMarkdown(text)
|
const rendered = await renderMarkdown(text)
|
||||||
|
|
||||||
if (latestRequestedText === text) {
|
if (latestRequestedText === text) {
|
||||||
|
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey }
|
||||||
setHtml(rendered)
|
setHtml(rendered)
|
||||||
part.renderCache = { text, html: rendered, theme: themeKey }
|
part.renderCache = cacheEntry
|
||||||
|
markdownRenderCache.set(cacheKey, cacheEntry)
|
||||||
notifyRendered()
|
notifyRendered()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to render markdown:", error)
|
log.error("Failed to render markdown:", error)
|
||||||
if (latestRequestedText === text) {
|
if (latestRequestedText === text) {
|
||||||
|
const cacheEntry: RenderCache = { text, html: text, theme: themeKey }
|
||||||
setHtml(text)
|
setHtml(text)
|
||||||
part.renderCache = { text, html: text, theme: themeKey }
|
part.renderCache = cacheEntry
|
||||||
|
markdownRenderCache.set(cacheKey, cacheEntry)
|
||||||
notifyRendered()
|
notifyRendered()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Index, createEffect, createSignal, type Accessor } from "solid-js"
|
import { Index, type Accessor } from "solid-js"
|
||||||
import VirtualItem from "./virtual-item"
|
import VirtualItem from "./virtual-item"
|
||||||
import MessageBlock from "./message-block"
|
import MessageBlock from "./message-block"
|
||||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
@@ -10,12 +10,10 @@ export function getMessageAnchorId(messageId: string) {
|
|||||||
const VIRTUAL_ITEM_MARGIN_PX = 800
|
const VIRTUAL_ITEM_MARGIN_PX = 800
|
||||||
|
|
||||||
interface MessageBlockListProps {
|
interface MessageBlockListProps {
|
||||||
|
|
||||||
instanceId: string
|
instanceId: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
store: () => InstanceMessageStore
|
store: () => InstanceMessageStore
|
||||||
messageIds: () => string[]
|
messageIds: () => string[]
|
||||||
messageIndexMap: () => Map<string, number>
|
|
||||||
lastAssistantIndex: () => number
|
lastAssistantIndex: () => number
|
||||||
showThinking: () => boolean
|
showThinking: () => boolean
|
||||||
thinkingDefaultExpanded: () => boolean
|
thinkingDefaultExpanded: () => boolean
|
||||||
@@ -27,35 +25,13 @@ interface MessageBlockListProps {
|
|||||||
onContentRendered?: () => void
|
onContentRendered?: () => void
|
||||||
setBottomSentinel: (element: HTMLDivElement | null) => void
|
setBottomSentinel: (element: HTMLDivElement | null) => void
|
||||||
suspendMeasurements?: () => boolean
|
suspendMeasurements?: () => boolean
|
||||||
onInitialRenderComplete?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageBlockList(props: MessageBlockListProps) {
|
export default function MessageBlockList(props: MessageBlockListProps) {
|
||||||
const totalMessages = () => props.messageIds().length
|
|
||||||
let renderedCount = 0
|
|
||||||
let initialRenderReported = false
|
|
||||||
const handleBlockRendered = () => {
|
|
||||||
if (initialRenderReported) return
|
|
||||||
renderedCount += 1
|
|
||||||
if (renderedCount >= totalMessages() && totalMessages() > 0) {
|
|
||||||
initialRenderReported = true
|
|
||||||
renderedCount = 0
|
|
||||||
props.onInitialRenderComplete?.()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (props.loading) {
|
|
||||||
renderedCount = 0
|
|
||||||
initialRenderReported = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Index each={props.messageIds()}>
|
<Index each={props.messageIds()}>
|
||||||
{(messageId) => {
|
{(messageId, index) => (
|
||||||
return (
|
|
||||||
<VirtualItem
|
<VirtualItem
|
||||||
id={getMessageAnchorId(messageId())}
|
id={getMessageAnchorId(messageId())}
|
||||||
cacheKey={messageId()}
|
cacheKey={messageId()}
|
||||||
@@ -64,14 +40,13 @@ export default function MessageBlockList(props: MessageBlockListProps) {
|
|||||||
placeholderClass="message-stream-placeholder"
|
placeholderClass="message-stream-placeholder"
|
||||||
virtualizationEnabled={() => !props.loading}
|
virtualizationEnabled={() => !props.loading}
|
||||||
suspendMeasurements={props.suspendMeasurements}
|
suspendMeasurements={props.suspendMeasurements}
|
||||||
onMeasured={handleBlockRendered}
|
|
||||||
>
|
>
|
||||||
<MessageBlock
|
<MessageBlock
|
||||||
messageId={messageId()}
|
messageId={messageId()}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
store={props.store}
|
store={props.store}
|
||||||
messageIndexMap={props.messageIndexMap}
|
messageIndex={index}
|
||||||
lastAssistantIndex={props.lastAssistantIndex}
|
lastAssistantIndex={props.lastAssistantIndex}
|
||||||
showThinking={props.showThinking}
|
showThinking={props.showThinking}
|
||||||
thinkingDefaultExpanded={props.thinkingDefaultExpanded}
|
thinkingDefaultExpanded={props.thinkingDefaultExpanded}
|
||||||
@@ -81,8 +56,7 @@ export default function MessageBlockList(props: MessageBlockListProps) {
|
|||||||
onContentRendered={props.onContentRendered}
|
onContentRendered={props.onContentRendered}
|
||||||
/>
|
/>
|
||||||
</VirtualItem>
|
</VirtualItem>
|
||||||
)
|
)}
|
||||||
}}
|
|
||||||
</Index>
|
</Index>
|
||||||
<div ref={props.setBottomSentinel} aria-hidden="true" style={{ height: "1px" }} />
|
<div ref={props.setBottomSentinel} aria-hidden="true" style={{ height: "1px" }} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ interface MessageBlockProps {
|
|||||||
instanceId: string
|
instanceId: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
store: () => InstanceMessageStore
|
store: () => InstanceMessageStore
|
||||||
messageIndexMap: () => Map<string, number>
|
messageIndex: number
|
||||||
lastAssistantIndex: () => number
|
lastAssistantIndex: () => number
|
||||||
showThinking: () => boolean
|
showThinking: () => boolean
|
||||||
thinkingDefaultExpanded: () => boolean
|
thinkingDefaultExpanded: () => boolean
|
||||||
@@ -223,7 +223,7 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
const current = record()
|
const current = record()
|
||||||
if (!current) return null
|
if (!current) return null
|
||||||
|
|
||||||
const index = props.messageIndexMap().get(current.id) ?? 0
|
const index = props.messageIndex
|
||||||
const lastAssistantIdx = props.lastAssistantIndex()
|
const lastAssistantIdx = props.lastAssistantIndex()
|
||||||
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
|
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
|
||||||
const info = messageInfo()
|
const info = messageInfo()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { For, Show } from "solid-js"
|
import { For, Show, createSignal } from "solid-js"
|
||||||
import type { MessageInfo, ClientPart } from "../types/message"
|
import type { MessageInfo, ClientPart } from "../types/message"
|
||||||
import { partHasRenderableText } from "../types/message"
|
import { partHasRenderableText } from "../types/message"
|
||||||
import type { MessageRecord } from "../stores/message-v2/types"
|
import type { MessageRecord } from "../stores/message-v2/types"
|
||||||
@@ -18,6 +18,7 @@ interface MessageItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageItem(props: MessageItemProps) {
|
export default function MessageItem(props: MessageItemProps) {
|
||||||
|
const [copied, setCopied] = createSignal(false)
|
||||||
|
|
||||||
const isUser = () => props.record.role === "user"
|
const isUser = () => props.record.role === "user"
|
||||||
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
|
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
|
||||||
@@ -143,6 +144,22 @@ interface MessageItemProps {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getRawContent = () => {
|
||||||
|
return props.parts
|
||||||
|
.filter(part => part.type === "text")
|
||||||
|
.map(part => (part as { text?: string }).text || "")
|
||||||
|
.filter(text => text.trim().length > 0)
|
||||||
|
.join("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
const content = getRawContent()
|
||||||
|
if (!content) return
|
||||||
|
await navigator.clipboard.writeText(content)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
if (!isUser() && !hasContent()) {
|
if (!isUser() && !hasContent()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -218,8 +235,30 @@ interface MessageItemProps {
|
|||||||
Fork
|
Fork
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
|
<button
|
||||||
|
class="message-action-button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
title="Copy message"
|
||||||
|
aria-label="Copy message"
|
||||||
|
>
|
||||||
|
<Show when={copied()} fallback="Copy">
|
||||||
|
Copied!
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
<Show when={!isUser()}>
|
||||||
|
<button
|
||||||
|
class="message-action-button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
title="Copy message"
|
||||||
|
aria-label="Copy message"
|
||||||
|
>
|
||||||
|
<Show when={copied()} fallback="Copy">
|
||||||
|
Copied!
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
|
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createMemo, type Component } from "solid-js"
|
import type { Component } from "solid-js"
|
||||||
import MessageBlock from "./message-block"
|
import MessageBlock from "./message-block"
|
||||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
|
|
||||||
@@ -10,14 +10,7 @@ interface MessagePreviewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MessagePreview: Component<MessagePreviewProps> = (props) => {
|
const MessagePreview: Component<MessagePreviewProps> = (props) => {
|
||||||
const indexMap = createMemo(() => new Map([[props.messageId, 0]]))
|
const lastAssistantIndex = () => 0
|
||||||
const lastAssistantIndex = createMemo(() => {
|
|
||||||
const record = props.store().getMessage(props.messageId)
|
|
||||||
if (record?.role === "assistant") {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="message-preview message-stream">
|
<div class="message-preview message-stream">
|
||||||
@@ -26,7 +19,7 @@ const MessagePreview: Component<MessagePreviewProps> = (props) => {
|
|||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
store={props.store}
|
store={props.store}
|
||||||
messageIndexMap={indexMap}
|
messageIndex={0}
|
||||||
lastAssistantIndex={lastAssistantIndex}
|
lastAssistantIndex={lastAssistantIndex}
|
||||||
showThinking={() => false}
|
showThinking={() => false}
|
||||||
thinkingDefaultExpanded={() => false}
|
thinkingDefaultExpanded={() => false}
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
import { Show, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import MessageBlockList, { getMessageAnchorId } from "./message-block-list"
|
import MessageBlockList, { getMessageAnchorId } from "./message-block-list"
|
||||||
import MessageListHeader from "./message-list-header"
|
|
||||||
import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline"
|
import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import { getSessionInfo } from "../stores/sessions"
|
import { getSessionInfo } from "../stores/sessions"
|
||||||
import { showCommandPalette } from "../stores/command-palette"
|
|
||||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
|
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"
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
|
|
||||||
const SCROLL_SCOPE = "session"
|
const SCROLL_SCOPE = "session"
|
||||||
@@ -19,10 +15,6 @@ const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown"
|
|||||||
const QUOTE_SELECTION_MAX_LENGTH = 2000
|
const QUOTE_SELECTION_MAX_LENGTH = 2000
|
||||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
|
|
||||||
function formatTokens(tokens: number): string {
|
|
||||||
return formatTokenTotal(tokens)
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MessageSectionProps {
|
export interface MessageSectionProps {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
@@ -77,25 +69,12 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
return `${showThinking}|${thinkingExpansion}|${showUsage}`
|
return `${showThinking}|${thinkingExpansion}|${showUsage}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const connectionStatus = () => sseManager.getStatus(props.instanceId)
|
|
||||||
const handleCommandPaletteClick = () => {
|
|
||||||
showCommandPalette(props.instanceId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTimelineSegmentClick = (segment: TimelineSegment) => {
|
const handleTimelineSegmentClick = (segment: TimelineSegment) => {
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
const anchor = document.getElementById(getMessageAnchorId(segment.messageId))
|
const anchor = document.getElementById(getMessageAnchorId(segment.messageId))
|
||||||
anchor?.scrollIntoView({ block: "start", behavior: "smooth" })
|
anchor?.scrollIntoView({ block: "start", behavior: "smooth" })
|
||||||
}
|
}
|
||||||
|
|
||||||
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 lastAssistantIndex = createMemo(() => {
|
||||||
const ids = messageIds()
|
const ids = messageIds()
|
||||||
const resolvedStore = store()
|
const resolvedStore = store()
|
||||||
@@ -108,23 +87,57 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
return -1
|
return -1
|
||||||
})
|
})
|
||||||
|
|
||||||
const timelineSegments = createMemo<TimelineSegment[]>(() => {
|
const [timelineSegments, setTimelineSegments] = createSignal<TimelineSegment[]>([])
|
||||||
const ids = messageIds()
|
const hasTimelineSegments = () => timelineSegments().length > 0
|
||||||
const resolvedStore = store()
|
|
||||||
|
const seenTimelineMessageIds = new Set<string>()
|
||||||
|
const seenTimelineSegmentKeys = new Set<string>()
|
||||||
|
|
||||||
|
function makeTimelineKey(segment: TimelineSegment) {
|
||||||
|
return `${segment.messageId}:${segment.id}:${segment.type}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedTimeline() {
|
||||||
|
seenTimelineMessageIds.clear()
|
||||||
|
seenTimelineSegmentKeys.clear()
|
||||||
|
const ids = untrack(messageIds)
|
||||||
|
const resolvedStore = untrack(store)
|
||||||
const segments: TimelineSegment[] = []
|
const segments: TimelineSegment[] = []
|
||||||
ids.forEach((messageId) => {
|
ids.forEach((messageId) => {
|
||||||
const record = resolvedStore.getMessage(messageId)
|
const record = resolvedStore.getMessage(messageId)
|
||||||
if (!record) return
|
if (!record) return
|
||||||
|
seenTimelineMessageIds.add(messageId)
|
||||||
const built = buildTimelineSegments(props.instanceId, record)
|
const built = buildTimelineSegments(props.instanceId, record)
|
||||||
segments.push(...built)
|
built.forEach((segment) => {
|
||||||
|
const key = makeTimelineKey(segment)
|
||||||
|
if (seenTimelineSegmentKeys.has(key)) return
|
||||||
|
seenTimelineSegmentKeys.add(key)
|
||||||
|
segments.push(segment)
|
||||||
})
|
})
|
||||||
return segments
|
|
||||||
})
|
})
|
||||||
|
setTimelineSegments(segments)
|
||||||
|
}
|
||||||
|
|
||||||
const hasTimelineSegments = () => timelineSegments().length > 0
|
function appendTimelineForMessage(messageId: string) {
|
||||||
|
const record = untrack(() => store().getMessage(messageId))
|
||||||
|
if (!record) return
|
||||||
|
const built = buildTimelineSegments(props.instanceId, record)
|
||||||
|
if (built.length === 0) return
|
||||||
|
const newSegments: TimelineSegment[] = []
|
||||||
|
built.forEach((segment) => {
|
||||||
|
const key = makeTimelineKey(segment)
|
||||||
|
if (seenTimelineSegmentKeys.has(key)) return
|
||||||
|
seenTimelineSegmentKeys.add(key)
|
||||||
|
newSegments.push(segment)
|
||||||
|
})
|
||||||
|
if (newSegments.length > 0) {
|
||||||
|
setTimelineSegments((prev) => [...prev, ...newSegments])
|
||||||
|
}
|
||||||
|
}
|
||||||
const [activeMessageId, setActiveMessageId] = createSignal<string | null>(null)
|
const [activeMessageId, setActiveMessageId] = createSignal<string | null>(null)
|
||||||
|
|
||||||
const changeToken = createMemo(() => String(sessionRevision()))
|
const changeToken = createMemo(() => String(sessionRevision()))
|
||||||
|
const isActive = createMemo(() => props.isActive !== false)
|
||||||
|
|
||||||
|
|
||||||
const scrollCache = useScrollCache({
|
const scrollCache = useScrollCache({
|
||||||
@@ -164,8 +177,6 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
let scrollToBottomDelayedFrame: number | null = null
|
let scrollToBottomDelayedFrame: number | null = null
|
||||||
let pendingInitialScroll = true
|
let pendingInitialScroll = true
|
||||||
|
|
||||||
const [initialRenderComplete, setInitialRenderComplete] = createSignal(false)
|
|
||||||
|
|
||||||
function markUserScrollIntent() {
|
function markUserScrollIntent() {
|
||||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||||
userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS
|
userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS
|
||||||
@@ -236,11 +247,12 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToBottom(immediate = false) {
|
function scrollToBottom(immediate = false, options?: { suppressAutoAnchor?: boolean }) {
|
||||||
if (!containerRef) return
|
if (!containerRef) return
|
||||||
const sentinel = bottomSentinel()
|
const sentinel = bottomSentinel()
|
||||||
const behavior = immediate ? "auto" : "smooth"
|
const behavior = immediate ? "auto" : "smooth"
|
||||||
if (!immediate) {
|
const suppressAutoAnchor = options?.suppressAutoAnchor ?? !immediate
|
||||||
|
if (suppressAutoAnchor) {
|
||||||
suppressAutoScrollOnce = true
|
suppressAutoScrollOnce = true
|
||||||
}
|
}
|
||||||
sentinel?.scrollIntoView({ block: "end", inline: "nearest", behavior })
|
sentinel?.scrollIntoView({ block: "end", inline: "nearest", behavior })
|
||||||
@@ -260,6 +272,10 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function requestScrollToBottom(immediate = true) {
|
function requestScrollToBottom(immediate = true) {
|
||||||
|
if (!isActive()) {
|
||||||
|
pendingActiveScroll = true
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!containerRef || !bottomSentinel()) {
|
if (!containerRef || !bottomSentinel()) {
|
||||||
pendingActiveScroll = true
|
pendingActiveScroll = true
|
||||||
return
|
return
|
||||||
@@ -277,7 +293,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
|
|
||||||
function resolvePendingActiveScroll() {
|
function resolvePendingActiveScroll() {
|
||||||
if (!pendingActiveScroll) return
|
if (!pendingActiveScroll) return
|
||||||
if (!props.isActive) return
|
if (!isActive()) return
|
||||||
requestScrollToBottom(true)
|
requestScrollToBottom(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,8 +308,15 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
|
|
||||||
function scheduleAnchorScroll(immediate = false) {
|
function scheduleAnchorScroll(immediate = false) {
|
||||||
if (!autoScroll()) return
|
if (!autoScroll()) return
|
||||||
|
if (!isActive()) {
|
||||||
|
pendingActiveScroll = true
|
||||||
|
return
|
||||||
|
}
|
||||||
const sentinel = bottomSentinel()
|
const sentinel = bottomSentinel()
|
||||||
if (!sentinel) return
|
if (!sentinel) {
|
||||||
|
pendingActiveScroll = true
|
||||||
|
return
|
||||||
|
}
|
||||||
if (pendingAnchorScroll !== null) {
|
if (pendingAnchorScroll !== null) {
|
||||||
cancelAnimationFrame(pendingAnchorScroll)
|
cancelAnimationFrame(pendingAnchorScroll)
|
||||||
pendingAnchorScroll = null
|
pendingAnchorScroll = null
|
||||||
@@ -377,10 +400,6 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
scheduleAnchorScroll()
|
scheduleAnchorScroll()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleInitialRenderComplete() {
|
|
||||||
setInitialRenderComplete(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
|
|
||||||
if (!containerRef) return
|
if (!containerRef) return
|
||||||
@@ -404,6 +423,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
clearQuoteSelection()
|
clearQuoteSelection()
|
||||||
scheduleScrollPersist()
|
scheduleScrollPersist()
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -415,10 +435,15 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
|
|
||||||
let lastActiveState = false
|
let lastActiveState = false
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const active = Boolean(props.isActive)
|
const active = isActive()
|
||||||
if (active && !lastActiveState) {
|
if (active) {
|
||||||
|
resolvePendingActiveScroll()
|
||||||
|
if (!lastActiveState && autoScroll()) {
|
||||||
requestScrollToBottom(true)
|
requestScrollToBottom(true)
|
||||||
}
|
}
|
||||||
|
} else if (autoScroll()) {
|
||||||
|
pendingActiveScroll = true
|
||||||
|
}
|
||||||
lastActiveState = active
|
lastActiveState = active
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -426,12 +451,123 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
const loading = Boolean(props.loading)
|
const loading = Boolean(props.loading)
|
||||||
if (loading) {
|
if (loading) {
|
||||||
pendingInitialScroll = true
|
pendingInitialScroll = true
|
||||||
setInitialRenderComplete(false)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (pendingInitialScroll && initialRenderComplete()) {
|
if (!pendingInitialScroll) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const container = scrollElement()
|
||||||
|
const sentinel = bottomSentinel()
|
||||||
|
if (!container || !sentinel || messageIds().length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
pendingInitialScroll = false
|
pendingInitialScroll = false
|
||||||
requestScrollToBottom(false)
|
requestScrollToBottom(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
let previousTimelineIds: string[] = []
|
||||||
|
let previousLastTimelineMessageId: string | null = null
|
||||||
|
let previousLastTimelinePartCount = 0
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const loading = Boolean(props.loading)
|
||||||
|
const ids = messageIds()
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
previousTimelineIds = []
|
||||||
|
previousLastTimelineMessageId = null
|
||||||
|
previousLastTimelinePartCount = 0
|
||||||
|
setTimelineSegments([])
|
||||||
|
seenTimelineMessageIds.clear()
|
||||||
|
seenTimelineSegmentKeys.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousTimelineIds.length === 0 && ids.length > 0) {
|
||||||
|
seedTimeline()
|
||||||
|
previousTimelineIds = ids.slice()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ids.length < previousTimelineIds.length) {
|
||||||
|
seedTimeline()
|
||||||
|
previousTimelineIds = ids.slice()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ids.length === previousTimelineIds.length) {
|
||||||
|
let changedIndex = -1
|
||||||
|
let changeCount = 0
|
||||||
|
for (let index = 0; index < ids.length; index++) {
|
||||||
|
if (ids[index] !== previousTimelineIds[index]) {
|
||||||
|
changedIndex = index
|
||||||
|
changeCount += 1
|
||||||
|
if (changeCount > 1) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changeCount === 1 && changedIndex >= 0) {
|
||||||
|
const oldId = previousTimelineIds[changedIndex]
|
||||||
|
const newId = ids[changedIndex]
|
||||||
|
if (seenTimelineMessageIds.has(oldId) && !seenTimelineMessageIds.has(newId)) {
|
||||||
|
seenTimelineMessageIds.delete(oldId)
|
||||||
|
seenTimelineMessageIds.add(newId)
|
||||||
|
setTimelineSegments((prev) => {
|
||||||
|
const next = prev.map((segment) => {
|
||||||
|
if (segment.messageId !== oldId) return segment
|
||||||
|
const updatedId = segment.id.replace(oldId, newId)
|
||||||
|
return { ...segment, messageId: newId, id: updatedId }
|
||||||
|
})
|
||||||
|
seenTimelineSegmentKeys.clear()
|
||||||
|
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
previousTimelineIds = ids.slice()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newIds: string[] = []
|
||||||
|
ids.forEach((id) => {
|
||||||
|
if (!seenTimelineMessageIds.has(id)) {
|
||||||
|
newIds.push(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (newIds.length > 0) {
|
||||||
|
newIds.forEach((id) => {
|
||||||
|
seenTimelineMessageIds.add(id)
|
||||||
|
appendTimelineForMessage(id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
previousTimelineIds = ids.slice()
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (props.loading) return
|
||||||
|
const ids = messageIds()
|
||||||
|
if (ids.length === 0) return
|
||||||
|
const lastId = ids[ids.length - 1]
|
||||||
|
if (!lastId) return
|
||||||
|
const record = store().getMessage(lastId)
|
||||||
|
if (!record) return
|
||||||
|
const partCount = record.partIds.length
|
||||||
|
if (lastId === previousLastTimelineMessageId && partCount === previousLastTimelinePartCount) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
previousLastTimelineMessageId = lastId
|
||||||
|
previousLastTimelinePartCount = partCount
|
||||||
|
const built = buildTimelineSegments(props.instanceId, record)
|
||||||
|
const newSegments: TimelineSegment[] = []
|
||||||
|
built.forEach((segment) => {
|
||||||
|
const key = makeTimelineKey(segment)
|
||||||
|
if (seenTimelineSegmentKeys.has(key)) return
|
||||||
|
seenTimelineSegmentKeys.add(key)
|
||||||
|
newSegments.push(segment)
|
||||||
|
})
|
||||||
|
if (newSegments.length > 0) {
|
||||||
|
setTimelineSegments((prev) => [...prev, ...newSegments])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -609,17 +745,6 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="message-stream-container">
|
<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-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`}>
|
<div class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`}>
|
||||||
<div class="message-stream-shell" ref={setShellElement}>
|
<div class="message-stream-shell" ref={setShellElement}>
|
||||||
<div class="message-stream" ref={setContainerRef} onScroll={handleScroll} onMouseUp={handleStreamMouseUp}>
|
<div class="message-stream" ref={setContainerRef} onScroll={handleScroll} onMouseUp={handleStreamMouseUp}>
|
||||||
@@ -659,7 +784,6 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
store={store}
|
store={store}
|
||||||
messageIds={messageIds}
|
messageIds={messageIds}
|
||||||
messageIndexMap={messageIndexMap}
|
|
||||||
lastAssistantIndex={lastAssistantIndex}
|
lastAssistantIndex={lastAssistantIndex}
|
||||||
showThinking={() => preferences().showThinkingBlocks}
|
showThinking={() => preferences().showThinkingBlocks}
|
||||||
thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"}
|
thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"}
|
||||||
@@ -670,8 +794,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
onFork={props.onFork}
|
onFork={props.onFork}
|
||||||
onContentRendered={handleContentRendered}
|
onContentRendered={handleContentRendered}
|
||||||
setBottomSentinel={setBottomSentinel}
|
setBottomSentinel={setBottomSentinel}
|
||||||
suspendMeasurements={() => props.isActive === false}
|
suspendMeasurements={() => !isActive()}
|
||||||
onInitialRenderComplete={handleInitialRenderComplete}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
@@ -688,7 +811,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="message-scroll-button"
|
class="message-scroll-button"
|
||||||
onClick={() => scrollToBottom()}
|
onClick={() => scrollToBottom(false, { suppressAutoAnchor: false })}
|
||||||
aria-label="Scroll to latest message"
|
aria-label="Scroll to latest message"
|
||||||
>
|
>
|
||||||
<span class="message-scroll-icon" aria-hidden="true">↓</span>
|
<span class="message-scroll-icon" aria-hidden="true">↓</span>
|
||||||
|
|||||||
@@ -1086,6 +1086,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
<div class="prompt-input-field-container">
|
||||||
<div class="prompt-input-field">
|
<div class="prompt-input-field">
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
@@ -1167,6 +1168,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="prompt-input-actions">
|
<div class="prompt-input-actions">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { Component, For, Show, createSignal, createEffect, onCleanup, onMount, createMemo, JSX } from "solid-js"
|
import { Component, For, Show, createSignal, createMemo, JSX } from "solid-js"
|
||||||
import type { Session, SessionStatus } from "../types/session"
|
import type { Session, SessionStatus } from "../types/session"
|
||||||
import { getSessionStatus } from "../stores/session-status"
|
import { getSessionStatus } from "../stores/session-status"
|
||||||
import { MessageSquare, Info, X, Copy, Trash2 } from "lucide-solid"
|
import { MessageSquare, Info, X, Copy, Trash2, Pencil } from "lucide-solid"
|
||||||
import KeyboardHint from "./keyboard-hint"
|
import KeyboardHint from "./keyboard-hint"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
|
import SessionRenameDialog from "./session-rename-dialog"
|
||||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||||
import { formatShortcut } from "../lib/keyboard-utils"
|
import { formatShortcut } from "../lib/keyboard-utils"
|
||||||
import { showToastNotification } from "../lib/notifications"
|
import { showToastNotification } from "../lib/notifications"
|
||||||
import { deleteSession, loading } from "../stores/sessions"
|
import { deleteSession, loading, renameSession } from "../stores/sessions"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
@@ -24,14 +25,8 @@ interface SessionListProps {
|
|||||||
showFooter?: boolean
|
showFooter?: boolean
|
||||||
headerContent?: JSX.Element
|
headerContent?: JSX.Element
|
||||||
footerContent?: JSX.Element
|
footerContent?: JSX.Element
|
||||||
onWidthChange?: (width: number) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MIN_WIDTH = 200
|
|
||||||
const MAX_WIDTH = 520
|
|
||||||
const DEFAULT_WIDTH = 360
|
|
||||||
const STORAGE_KEY = "opencode-session-sidebar-width-v7"
|
|
||||||
|
|
||||||
function formatSessionStatus(status: SessionStatus): string {
|
function formatSessionStatus(status: SessionStatus): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "working":
|
case "working":
|
||||||
@@ -62,10 +57,8 @@ function arraysEqual(prev: readonly string[] | undefined, next: readonly string[
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SessionList: Component<SessionListProps> = (props) => {
|
const SessionList: Component<SessionListProps> = (props) => {
|
||||||
const [sidebarWidth, setSidebarWidth] = createSignal(DEFAULT_WIDTH)
|
const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null)
|
||||||
const [isResizing, setIsResizing] = createSignal(false)
|
const [isRenaming, setIsRenaming] = createSignal(false)
|
||||||
const [startX, setStartX] = createSignal(0)
|
|
||||||
const [startWidth, setStartWidth] = createSignal(DEFAULT_WIDTH)
|
|
||||||
const infoShortcut = keyboardRegistry.get("switch-to-info")
|
const infoShortcut = keyboardRegistry.get("switch-to-info")
|
||||||
|
|
||||||
const isSessionDeleting = (sessionId: string) => {
|
const isSessionDeleting = (sessionId: string) => {
|
||||||
@@ -77,34 +70,6 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
props.onSelect(sessionId)
|
props.onSelect(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
let mouseMoveHandler: ((event: MouseEvent) => void) | null = null
|
|
||||||
let mouseUpHandler: (() => void) | null = null
|
|
||||||
let touchMoveHandler: ((event: TouchEvent) => void) | null = null
|
|
||||||
let touchEndHandler: (() => void) | null = null
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (typeof window === "undefined") return
|
|
||||||
const saved = window.localStorage.getItem(STORAGE_KEY)
|
|
||||||
if (!saved) return
|
|
||||||
|
|
||||||
const width = Number.parseInt(saved, 10)
|
|
||||||
if (Number.isFinite(width) && width >= MIN_WIDTH && width <= MAX_WIDTH) {
|
|
||||||
setSidebarWidth(width)
|
|
||||||
setStartWidth(width)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (typeof window === "undefined") return
|
|
||||||
const width = sidebarWidth()
|
|
||||||
window.localStorage.setItem(STORAGE_KEY, width.toString())
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
props.onWidthChange?.(sidebarWidth())
|
|
||||||
})
|
|
||||||
|
|
||||||
const copySessionId = async (event: MouseEvent, sessionId: string) => {
|
const copySessionId = async (event: MouseEvent, sessionId: string) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|
||||||
@@ -133,94 +98,33 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const clampWidth = (width: number) => Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, width))
|
const openRenameDialog = (sessionId: string) => {
|
||||||
|
const session = props.sessions.get(sessionId)
|
||||||
|
if (!session) return
|
||||||
|
const label = session.title && session.title.trim() ? session.title : sessionId
|
||||||
const removeMouseListeners = () => {
|
setRenameTarget({ id: sessionId, title: session.title ?? "", label })
|
||||||
if (mouseMoveHandler) {
|
|
||||||
document.removeEventListener("mousemove", mouseMoveHandler)
|
|
||||||
mouseMoveHandler = null
|
|
||||||
}
|
}
|
||||||
if (mouseUpHandler) {
|
|
||||||
document.removeEventListener("mouseup", mouseUpHandler)
|
const closeRenameDialog = () => {
|
||||||
mouseUpHandler = null
|
setRenameTarget(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRenameSubmit = async (nextTitle: string) => {
|
||||||
|
const target = renameTarget()
|
||||||
|
if (!target) return
|
||||||
|
|
||||||
|
setIsRenaming(true)
|
||||||
|
try {
|
||||||
|
await renameSession(props.instanceId, target.id, nextTitle)
|
||||||
|
setRenameTarget(null)
|
||||||
|
} catch (error) {
|
||||||
|
log.error(`Failed to rename session ${target.id}:`, error)
|
||||||
|
showToastNotification({ message: "Unable to rename session", variant: "error" })
|
||||||
|
} finally {
|
||||||
|
setIsRenaming(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeTouchListeners = () => {
|
|
||||||
if (touchMoveHandler) {
|
|
||||||
document.removeEventListener("touchmove", touchMoveHandler)
|
|
||||||
touchMoveHandler = null
|
|
||||||
}
|
|
||||||
if (touchEndHandler) {
|
|
||||||
document.removeEventListener("touchend", touchEndHandler)
|
|
||||||
touchEndHandler = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const stopResizing = () => {
|
|
||||||
setIsResizing(false)
|
|
||||||
removeMouseListeners()
|
|
||||||
removeTouchListeners()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMouseMove = (event: MouseEvent) => {
|
|
||||||
if (!isResizing()) return
|
|
||||||
const diff = event.clientX - startX()
|
|
||||||
const newWidth = clampWidth(startWidth() + diff)
|
|
||||||
setSidebarWidth(newWidth)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
|
||||||
stopResizing()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTouchMove = (event: TouchEvent) => {
|
|
||||||
if (!isResizing()) return
|
|
||||||
const touch = event.touches[0]
|
|
||||||
if (!touch) return
|
|
||||||
const diff = touch.clientX - startX()
|
|
||||||
const newWidth = clampWidth(startWidth() + diff)
|
|
||||||
setSidebarWidth(newWidth)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTouchEnd = () => {
|
|
||||||
stopResizing()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMouseDown = (event: MouseEvent) => {
|
|
||||||
event.preventDefault()
|
|
||||||
setIsResizing(true)
|
|
||||||
setStartX(event.clientX)
|
|
||||||
setStartWidth(sidebarWidth())
|
|
||||||
|
|
||||||
mouseMoveHandler = handleMouseMove
|
|
||||||
mouseUpHandler = handleMouseUp
|
|
||||||
|
|
||||||
document.addEventListener("mousemove", handleMouseMove)
|
|
||||||
document.addEventListener("mouseup", handleMouseUp)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTouchStart = (event: TouchEvent) => {
|
|
||||||
event.preventDefault()
|
|
||||||
const touch = event.touches[0]
|
|
||||||
if (!touch) return
|
|
||||||
setIsResizing(true)
|
|
||||||
setStartX(touch.clientX)
|
|
||||||
setStartWidth(sidebarWidth())
|
|
||||||
|
|
||||||
touchMoveHandler = handleTouchMove
|
|
||||||
touchEndHandler = handleTouchEnd
|
|
||||||
|
|
||||||
document.addEventListener("touchmove", handleTouchMove)
|
|
||||||
document.addEventListener("touchend", handleTouchEnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
removeMouseListeners()
|
|
||||||
removeTouchListeners()
|
|
||||||
})
|
|
||||||
|
|
||||||
const SessionRow: Component<{ sessionId: string; canClose?: boolean }> = (rowProps) => {
|
const SessionRow: Component<{ sessionId: string; canClose?: boolean }> = (rowProps) => {
|
||||||
const session = () => props.sessions.get(rowProps.sessionId)
|
const session = () => props.sessions.get(rowProps.sessionId)
|
||||||
@@ -281,6 +185,19 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<Copy class="w-3 h-3" />
|
<Copy class="w-3 h-3" />
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
openRenameDialog(rowProps.sessionId)
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label="Rename session"
|
||||||
|
title="Rename session"
|
||||||
|
>
|
||||||
|
<Pencil class="w-3 h-3" />
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
||||||
onClick={(event) => handleDeleteSession(event, rowProps.sessionId)}
|
onClick={(event) => handleDeleteSession(event, rowProps.sessionId)}
|
||||||
@@ -348,14 +265,6 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
<div
|
<div
|
||||||
class="session-list-container bg-surface-secondary border-r border-base flex flex-col w-full"
|
class="session-list-container bg-surface-secondary border-r border-base flex flex-col w-full"
|
||||||
>
|
>
|
||||||
<div
|
|
||||||
class="session-resize-handle"
|
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
onTouchStart={handleTouchStart}
|
|
||||||
role="presentation"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Show when={props.showHeader !== false}>
|
<Show when={props.showHeader !== false}>
|
||||||
<div class="session-list-header p-3 border-b border-base">
|
<div class="session-list-header p-3 border-b border-base">
|
||||||
{props.headerContent ?? (
|
{props.headerContent ?? (
|
||||||
@@ -418,8 +327,18 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
{props.footerContent ?? null}
|
{props.footerContent ?? null}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
<SessionRenameDialog
|
||||||
|
open={Boolean(renameTarget())}
|
||||||
|
currentTitle={renameTarget()?.title ?? ""}
|
||||||
|
sessionLabel={renameTarget()?.label}
|
||||||
|
isSubmitting={isRenaming()}
|
||||||
|
onRename={handleRenameSubmit}
|
||||||
|
onClose={closeRenameDialog}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SessionList
|
export default SessionList
|
||||||
|
|
||||||
|
|||||||
130
packages/ui/src/components/session-rename-dialog.tsx
Normal file
130
packages/ui/src/components/session-rename-dialog.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
|
import { Component, Show, createEffect, createSignal } from "solid-js"
|
||||||
|
|
||||||
|
interface SessionRenameDialogProps {
|
||||||
|
open: boolean
|
||||||
|
currentTitle: string
|
||||||
|
sessionLabel?: string
|
||||||
|
isSubmitting?: boolean
|
||||||
|
onRename: (nextTitle: string) => Promise<void> | void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
|
||||||
|
const [title, setTitle] = createSignal("")
|
||||||
|
const inputId = `session-rename-${Math.random().toString(36).slice(2)}`
|
||||||
|
let inputRef: HTMLInputElement | undefined
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!props.open) return
|
||||||
|
setTitle(props.currentTitle ?? "")
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!props.open) return
|
||||||
|
if (typeof window === "undefined" || typeof window.requestAnimationFrame !== "function") return
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
inputRef?.focus()
|
||||||
|
inputRef?.select()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const isSubmitting = () => Boolean(props.isSubmitting)
|
||||||
|
const isRenameDisabled = () => isSubmitting() || !title().trim()
|
||||||
|
|
||||||
|
async function handleRename(event?: Event) {
|
||||||
|
event?.preventDefault()
|
||||||
|
if (isRenameDisabled()) return
|
||||||
|
await props.onRename(title().trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
const description = () => {
|
||||||
|
if (props.sessionLabel && props.sessionLabel.trim()) {
|
||||||
|
return `Update the title for "${props.sessionLabel}".`
|
||||||
|
}
|
||||||
|
return "Set a new title for this session."
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={props.open}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open && !isSubmitting()) {
|
||||||
|
props.onClose()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<Dialog.Content class="modal-surface w-full max-w-sm p-6" tabIndex={-1}>
|
||||||
|
<Dialog.Title class="text-lg font-semibold text-primary">Rename Session</Dialog.Title>
|
||||||
|
<Dialog.Description class="text-sm text-secondary mt-1">
|
||||||
|
{description()}
|
||||||
|
</Dialog.Description>
|
||||||
|
|
||||||
|
<form class="mt-4 space-y-4" onSubmit={handleRename}>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium text-secondary" for={inputId}>
|
||||||
|
Session name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
ref={(element) => {
|
||||||
|
inputRef = element
|
||||||
|
}}
|
||||||
|
type="text"
|
||||||
|
value={title()}
|
||||||
|
onInput={(event) => setTitle(event.currentTarget.value)}
|
||||||
|
placeholder="Enter a session name"
|
||||||
|
class="w-full px-3 py-2 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button-tertiary"
|
||||||
|
onClick={() => {
|
||||||
|
if (!isSubmitting()) {
|
||||||
|
props.onClose()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting()}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="button-primary flex items-center gap-2 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
disabled={isRenameDisabled()}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={!isSubmitting()}
|
||||||
|
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>
|
||||||
|
<span>Renaming…</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog.Content>
|
||||||
|
</div>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SessionRenameDialog
|
||||||
@@ -74,9 +74,6 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSendMessage(prompt: string, attachments: Attachment[]) {
|
async function handleSendMessage(prompt: string, attachments: Attachment[]) {
|
||||||
if (scrollToBottomHandle && import.meta.env?.DEV) {
|
|
||||||
console.debug("[SessionView] handleSendMessage scroll", props.sessionId)
|
|
||||||
}
|
|
||||||
scheduleScrollToBottom()
|
scheduleScrollToBottom()
|
||||||
await sendMessage(props.instanceId, props.sessionId, prompt, attachments)
|
await sendMessage(props.instanceId, props.sessionId, prompt, attachments)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import type {
|
|||||||
ToolRendererContext,
|
ToolRendererContext,
|
||||||
ToolScrollHelpers,
|
ToolScrollHelpers,
|
||||||
} from "./tool-call/types"
|
} from "./tool-call/types"
|
||||||
import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning } from "./tool-call/utils"
|
import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, getDefaultToolAction } from "./tool-call/utils"
|
||||||
|
import { resolveTitleForTool } from "./tool-call/tool-title"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
@@ -117,21 +118,16 @@ function extractDiagnostics(state: ToolState | undefined): DiagnosticEntry[] {
|
|||||||
].find((value) => typeof value === "string" && value.length > 0) as string | undefined
|
].find((value) => typeof value === "string" && value.length > 0) as string | undefined
|
||||||
|
|
||||||
const normalizedPreferred = preferredPath ? normalizeDiagnosticPath(preferredPath) : undefined
|
const normalizedPreferred = preferredPath ? normalizeDiagnosticPath(preferredPath) : undefined
|
||||||
|
if (!normalizedPreferred) return []
|
||||||
const candidateEntries = Object.entries(diagnosticsMap).filter(([, items]) => Array.isArray(items) && items.length > 0)
|
const candidateEntries = Object.entries(diagnosticsMap).filter(([, items]) => Array.isArray(items) && items.length > 0)
|
||||||
if (candidateEntries.length === 0) return []
|
if (candidateEntries.length === 0) return []
|
||||||
|
|
||||||
const prioritizedEntries = (() => {
|
const prioritizedEntries = candidateEntries.filter(([path]) => {
|
||||||
if (!normalizedPreferred) return candidateEntries
|
|
||||||
const matched = candidateEntries.filter(([path]) => {
|
|
||||||
const normalized = normalizeDiagnosticPath(path)
|
const normalized = normalizeDiagnosticPath(path)
|
||||||
if (normalized === normalizedPreferred) return true
|
return normalized === normalizedPreferred
|
||||||
if (normalized.endsWith(`/${normalizedPreferred}`)) return true
|
|
||||||
const normalizedBase = normalized.split("/").pop()
|
|
||||||
const preferredBase = normalizedPreferred.split("/").pop()
|
|
||||||
return normalizedBase && preferredBase ? normalizedBase === preferredBase : false
|
|
||||||
})
|
})
|
||||||
return matched.length > 0 ? matched : candidateEntries
|
|
||||||
})()
|
if (prioritizedEntries.length === 0) return []
|
||||||
|
|
||||||
const entries: DiagnosticEntry[] = []
|
const entries: DiagnosticEntry[] = []
|
||||||
for (const [pathKey, list] of prioritizedEntries) {
|
for (const [pathKey, list] of prioritizedEntries) {
|
||||||
@@ -632,6 +628,17 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
const disableHighlight = options.disableHighlight || false
|
const disableHighlight = options.disableHighlight || false
|
||||||
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
|
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
|
||||||
|
|
||||||
|
const state = toolState()
|
||||||
|
const shouldDeferMarkdown = Boolean(state && (state.status === "running" || state.status === "pending") && disableHighlight)
|
||||||
|
if (shouldDeferMarkdown) {
|
||||||
|
return (
|
||||||
|
<div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}>
|
||||||
|
<pre class="whitespace-pre-wrap break-words text-sm font-mono">{options.content}</pre>
|
||||||
|
{scrollHelpers.renderSentinel()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const markdownPart: TextPart = { type: "text", text: options.content }
|
const markdownPart: TextPart = { type: "text", text: options.content }
|
||||||
const cached = markdownCache.get<RenderCache>()
|
const cached = markdownCache.get<RenderCache>()
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@@ -699,6 +706,12 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
|
|
||||||
const renderToolTitle = () => {
|
const renderToolTitle = () => {
|
||||||
const state = toolState()
|
const state = toolState()
|
||||||
|
const currentTool = toolName()
|
||||||
|
|
||||||
|
if (currentTool !== "task") {
|
||||||
|
return resolveTitleForTool({ toolName: currentTool, state })
|
||||||
|
}
|
||||||
|
|
||||||
if (!state) return getRendererAction()
|
if (!state) return getRendererAction()
|
||||||
if (state.status === "pending") return getRendererAction()
|
if (state.status === "pending") return getRendererAction()
|
||||||
|
|
||||||
@@ -713,7 +726,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
return state.title
|
return state.title
|
||||||
}
|
}
|
||||||
|
|
||||||
return getToolName(toolName())
|
return getToolName(currentTool)
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderToolBody = () => {
|
const renderToolBody = () => {
|
||||||
@@ -907,33 +920,3 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultToolAction(toolName: string) {
|
|
||||||
switch (toolName) {
|
|
||||||
case "task":
|
|
||||||
return "Delegating..."
|
|
||||||
case "bash":
|
|
||||||
return "Writing command..."
|
|
||||||
case "edit":
|
|
||||||
return "Preparing edit..."
|
|
||||||
case "webfetch":
|
|
||||||
return "Fetching from the web..."
|
|
||||||
case "glob":
|
|
||||||
return "Finding files..."
|
|
||||||
case "grep":
|
|
||||||
return "Searching content..."
|
|
||||||
case "list":
|
|
||||||
return "Listing directory..."
|
|
||||||
case "read":
|
|
||||||
return "Reading file..."
|
|
||||||
case "write":
|
|
||||||
return "Preparing write..."
|
|
||||||
case "todowrite":
|
|
||||||
case "todoread":
|
|
||||||
return "Planning..."
|
|
||||||
case "patch":
|
|
||||||
return "Preparing patch..."
|
|
||||||
default:
|
|
||||||
return "Working..."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,25 +1,84 @@
|
|||||||
import { For, createMemo } from "solid-js"
|
import { For, Show, createMemo } from "solid-js"
|
||||||
|
import type { ToolState } from "@opencode-ai/sdk"
|
||||||
import type { ToolRenderer } from "../types"
|
import type { ToolRenderer } from "../types"
|
||||||
import { getRelativePath, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
import { getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
||||||
|
import { getTodoTitle } from "./todo"
|
||||||
|
import { resolveTitleForTool } from "../tool-title"
|
||||||
|
|
||||||
interface TaskSummaryItem {
|
interface TaskSummaryItem {
|
||||||
id: string
|
id: string
|
||||||
tool: string
|
tool: string
|
||||||
input: Record<string, any>
|
input: Record<string, any>
|
||||||
|
metadata: Record<string, any>
|
||||||
|
state?: ToolState
|
||||||
|
status?: ToolState["status"]
|
||||||
|
title?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function describeTaskItem(item: TaskSummaryItem): string {
|
function normalizeStatus(status?: string | null): ToolState["status"] | undefined {
|
||||||
const input = item.input || {}
|
if (status === "pending" || status === "running" || status === "completed" || status === "error") {
|
||||||
switch (item.tool) {
|
return status
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeStatusIcon(status?: ToolState["status"]) {
|
||||||
|
switch (status) {
|
||||||
|
case "pending":
|
||||||
|
return "⏸"
|
||||||
|
case "running":
|
||||||
|
return "⏳"
|
||||||
|
case "completed":
|
||||||
|
return "✓"
|
||||||
|
case "error":
|
||||||
|
return "✗"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeStatusLabel(status?: ToolState["status"]) {
|
||||||
|
switch (status) {
|
||||||
|
case "pending":
|
||||||
|
return "Pending"
|
||||||
|
case "running":
|
||||||
|
return "Running"
|
||||||
|
case "completed":
|
||||||
|
return "Completed"
|
||||||
|
case "error":
|
||||||
|
return "Error"
|
||||||
|
default:
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeTaskTitle(input: Record<string, any>) {
|
||||||
|
const description = typeof input.description === "string" ? input.description : undefined
|
||||||
|
const subagent = typeof input.subagent_type === "string" ? input.subagent_type : undefined
|
||||||
|
const base = getToolName("task")
|
||||||
|
if (description && subagent) {
|
||||||
|
return `${base}[${subagent}] ${description}`
|
||||||
|
}
|
||||||
|
if (description) {
|
||||||
|
return `${base} ${description}`
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeToolTitle(item: TaskSummaryItem): string {
|
||||||
|
if (item.title && item.title.length > 0) {
|
||||||
|
return item.title
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.tool === "task") {
|
||||||
|
return describeTaskTitle({ ...item.metadata, ...item.input })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.state) {
|
||||||
|
return resolveTitleForTool({ toolName: item.tool, state: item.state })
|
||||||
|
}
|
||||||
|
|
||||||
|
return getDefaultToolAction(item.tool)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const taskRenderer: ToolRenderer = {
|
export const taskRenderer: ToolRenderer = {
|
||||||
@@ -29,18 +88,9 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
const state = toolState()
|
const state = toolState()
|
||||||
if (!state) return undefined
|
if (!state) return undefined
|
||||||
const { input } = readToolStatePayload(state)
|
const { input } = readToolStatePayload(state)
|
||||||
const description = input.description
|
return describeTaskTitle(input)
|
||||||
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 }) {
|
renderBody({ toolState, messageVersion, partVersion, scrollHelpers }) {
|
||||||
const items = createMemo(() => {
|
const items = createMemo(() => {
|
||||||
// Track the reactive change points so we only recompute when the part/message changes
|
// Track the reactive change points so we only recompute when the part/message changes
|
||||||
messageVersion?.()
|
messageVersion?.()
|
||||||
@@ -54,9 +104,13 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
|
|
||||||
return summary.map((entry, index) => {
|
return summary.map((entry, index) => {
|
||||||
const tool = typeof entry?.tool === "string" ? (entry.tool as string) : "unknown"
|
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 stateValue = typeof entry?.state === "object" ? (entry.state as ToolState) : undefined
|
||||||
|
const metadataFromEntry = typeof entry?.metadata === "object" && entry.metadata ? entry.metadata : {}
|
||||||
|
const fallbackInput = typeof entry?.input === "object" && entry.input ? entry.input : {}
|
||||||
const id = typeof entry?.id === "string" && entry.id.length > 0 ? entry.id : `${tool}-${index}`
|
const id = typeof entry?.id === "string" && entry.id.length > 0 ? entry.id : `${tool}-${index}`
|
||||||
return { id, tool, input }
|
const statusValue = normalizeStatus((entry?.status as string | undefined) ?? stateValue?.status)
|
||||||
|
const title = typeof entry?.title === "string" ? entry.title : undefined
|
||||||
|
return { id, tool, input: fallbackInput, metadata: metadataFromEntry, state: stateValue, status: statusValue, title }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -72,11 +126,23 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
<For each={items()}>
|
<For each={items()}>
|
||||||
{(item) => {
|
{(item) => {
|
||||||
const icon = getToolIcon(item.tool)
|
const icon = getToolIcon(item.tool)
|
||||||
const description = describeTaskItem(item)
|
const description = describeToolTitle(item)
|
||||||
|
const toolLabel = getToolName(item.tool)
|
||||||
|
const status = normalizeStatus(item.status ?? item.state?.status)
|
||||||
|
const statusIcon = summarizeStatusIcon(status)
|
||||||
|
const statusLabel = summarizeStatusLabel(status)
|
||||||
|
const statusAttr = status ?? "pending"
|
||||||
return (
|
return (
|
||||||
<div class="tool-call-task-item" data-task-id={item.id}>
|
<div class="tool-call-task-item" data-task-id={item.id} data-task-status={statusAttr}>
|
||||||
<span class="tool-call-task-icon">{icon}</span>
|
<span class="tool-call-task-icon">{icon}</span>
|
||||||
|
<span class="tool-call-task-label">{toolLabel}</span>
|
||||||
|
<span class="tool-call-task-separator" aria-hidden="true">—</span>
|
||||||
<span class="tool-call-task-text">{description}</span>
|
<span class="tool-call-task-text">{description}</span>
|
||||||
|
<Show when={statusIcon}>
|
||||||
|
<span class="tool-call-task-status" aria-label={statusLabel} title={statusLabel}>
|
||||||
|
{statusIcon}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@@ -87,4 +153,3 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { For } from "solid-js"
|
import { For, Show } from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk"
|
import type { ToolState } from "@opencode-ai/sdk"
|
||||||
import type { ToolRenderer } from "../types"
|
import type { ToolRenderer } from "../types"
|
||||||
import { readToolStatePayload } from "../utils"
|
import { readToolStatePayload } from "../utils"
|
||||||
|
|
||||||
export type TodoViewStatus = "pending" | "in_progress" | "completed" | "cancelled"
|
export type TodoViewStatus = "pending" | "in_progress" | "completed" | "cancelled"
|
||||||
|
|
||||||
interface TodoViewItem {
|
export interface TodoViewItem {
|
||||||
id: string
|
id: string
|
||||||
content: string
|
content: string
|
||||||
status: TodoViewStatus
|
status: TodoViewStatus
|
||||||
@@ -58,33 +58,18 @@ function getTodoStatusLabel(status: TodoViewStatus): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTodoTitle(state?: ToolState): string {
|
interface TodoListViewProps {
|
||||||
if (!state) return "Plan"
|
state?: ToolState
|
||||||
|
emptyLabel?: string
|
||||||
const todos = extractTodosFromState(state)
|
showStatusLabel?: boolean
|
||||||
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 = {
|
export function TodoListView(props: TodoListViewProps) {
|
||||||
tools: ["todowrite", "todoread"],
|
const todos = extractTodosFromState(props.state)
|
||||||
getAction: () => "Planning...",
|
|
||||||
getTitle({ toolState }) {
|
|
||||||
return getTodoTitle(toolState())
|
|
||||||
},
|
|
||||||
renderBody({ toolState }) {
|
|
||||||
const state = toolState()
|
|
||||||
if (!state) return null
|
|
||||||
|
|
||||||
const todos = extractTodosFromState(state)
|
|
||||||
const counts = summarizeTodos(todos)
|
const counts = summarizeTodos(todos)
|
||||||
|
|
||||||
if (counts.total === 0) {
|
if (counts.total === 0) {
|
||||||
return <div class="tool-call-todo-empty">No plan items yet.</div>
|
return <div class="tool-call-todo-empty">{props.emptyLabel ?? "No plan items yet."}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -107,9 +92,12 @@ export const todoRenderer: ToolRenderer = {
|
|||||||
<div class="tool-call-todo-body">
|
<div class="tool-call-todo-body">
|
||||||
<div class="tool-call-todo-heading">
|
<div class="tool-call-todo-heading">
|
||||||
<span class="tool-call-todo-text">{todo.content}</span>
|
<span class="tool-call-todo-text">{todo.content}</span>
|
||||||
|
<Show when={props.showStatusLabel !== false}>
|
||||||
<span class={`tool-call-todo-status tool-call-todo-status-${todo.status}`}>{label}</span>
|
<span class={`tool-call-todo-status tool-call-todo-status-${todo.status}`}>{label}</span>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@@ -117,5 +105,30 @@ export const todoRenderer: ToolRenderer = {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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
|
||||||
|
|
||||||
|
return <TodoListView state={state} />
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
86
packages/ui/src/components/tool-call/tool-title.ts
Normal file
86
packages/ui/src/components/tool-call/tool-title.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type { ToolState } from "@opencode-ai/sdk"
|
||||||
|
import type { ToolRendererContext, ToolRenderer, ToolCallPart } from "./types"
|
||||||
|
import { getDefaultToolAction, getToolName, isToolStateCompleted, isToolStateRunning } from "./utils"
|
||||||
|
import { defaultRenderer } from "./renderers/default"
|
||||||
|
import { bashRenderer } from "./renderers/bash"
|
||||||
|
import { readRenderer } from "./renderers/read"
|
||||||
|
import { writeRenderer } from "./renderers/write"
|
||||||
|
import { editRenderer } from "./renderers/edit"
|
||||||
|
import { patchRenderer } from "./renderers/patch"
|
||||||
|
import { webfetchRenderer } from "./renderers/webfetch"
|
||||||
|
import { todoRenderer } from "./renderers/todo"
|
||||||
|
import { invalidRenderer } from "./renderers/invalid"
|
||||||
|
|
||||||
|
const TITLE_RENDERERS: Record<string, ToolRenderer> = {
|
||||||
|
bash: bashRenderer,
|
||||||
|
read: readRenderer,
|
||||||
|
write: writeRenderer,
|
||||||
|
edit: editRenderer,
|
||||||
|
patch: patchRenderer,
|
||||||
|
webfetch: webfetchRenderer,
|
||||||
|
todowrite: todoRenderer,
|
||||||
|
todoread: todoRenderer,
|
||||||
|
invalid: invalidRenderer,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TitleSnapshot {
|
||||||
|
toolName: string
|
||||||
|
state?: ToolState
|
||||||
|
}
|
||||||
|
|
||||||
|
function lookupRenderer(toolName: string): ToolRenderer {
|
||||||
|
return TITLE_RENDERERS[toolName] ?? defaultRenderer
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStaticToolPart(snapshot: TitleSnapshot): ToolCallPart {
|
||||||
|
return {
|
||||||
|
id: "",
|
||||||
|
type: "tool",
|
||||||
|
tool: snapshot.toolName,
|
||||||
|
state: snapshot.state,
|
||||||
|
} as ToolCallPart
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStaticContext(snapshot: TitleSnapshot): ToolRendererContext {
|
||||||
|
const toolStateAccessor = () => snapshot.state
|
||||||
|
const toolNameAccessor = () => snapshot.toolName
|
||||||
|
const toolCallAccessor = () => createStaticToolPart(snapshot)
|
||||||
|
const messageVersionAccessor = () => undefined
|
||||||
|
const partVersionAccessor = () => undefined
|
||||||
|
const renderMarkdown: ToolRendererContext["renderMarkdown"] = () => null
|
||||||
|
const renderDiff: ToolRendererContext["renderDiff"] = () => null
|
||||||
|
|
||||||
|
return {
|
||||||
|
toolCall: toolCallAccessor,
|
||||||
|
toolState: toolStateAccessor,
|
||||||
|
toolName: toolNameAccessor,
|
||||||
|
messageVersion: messageVersionAccessor,
|
||||||
|
partVersion: partVersionAccessor,
|
||||||
|
renderMarkdown,
|
||||||
|
renderDiff,
|
||||||
|
scrollHelpers: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTitleForTool(snapshot: TitleSnapshot): string {
|
||||||
|
const renderer = lookupRenderer(snapshot.toolName)
|
||||||
|
const context = createStaticContext(snapshot)
|
||||||
|
const state = snapshot.state
|
||||||
|
const defaultAction = renderer.getAction?.(context) ?? getDefaultToolAction(snapshot.toolName)
|
||||||
|
|
||||||
|
if (!state || state.status === "pending") {
|
||||||
|
return defaultAction
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateTitle = typeof (state as { title?: string }).title === "string" ? (state as { title?: string }).title : undefined
|
||||||
|
if (stateTitle && stateTitle.length > 0) {
|
||||||
|
return stateTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
const customTitle = renderer.getTitle?.(context)
|
||||||
|
if (customTitle) {
|
||||||
|
return customTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
return getToolName(snapshot.toolName)
|
||||||
|
}
|
||||||
@@ -192,3 +192,33 @@ export function readToolStatePayload(state?: ToolState): {
|
|||||||
output: isToolStateCompleted(state) ? state.output : undefined,
|
output: isToolStateCompleted(state) ? state.output : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDefaultToolAction(toolName: string) {
|
||||||
|
switch (toolName) {
|
||||||
|
case "task":
|
||||||
|
return "Delegating..."
|
||||||
|
case "bash":
|
||||||
|
return "Writing command..."
|
||||||
|
case "edit":
|
||||||
|
return "Preparing edit..."
|
||||||
|
case "webfetch":
|
||||||
|
return "Fetching from the web..."
|
||||||
|
case "glob":
|
||||||
|
return "Finding files..."
|
||||||
|
case "grep":
|
||||||
|
return "Searching content..."
|
||||||
|
case "list":
|
||||||
|
return "Listing directory..."
|
||||||
|
case "read":
|
||||||
|
return "Reading file..."
|
||||||
|
case "write":
|
||||||
|
return "Preparing write..."
|
||||||
|
case "todowrite":
|
||||||
|
case "todoread":
|
||||||
|
return "Planning..."
|
||||||
|
case "patch":
|
||||||
|
return "Preparing patch..."
|
||||||
|
default:
|
||||||
|
return "Working..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { JSX, Accessor, children as resolveChildren, createEffect, createMemo, c
|
|||||||
const sizeCache = new Map<string, number>()
|
const sizeCache = new Map<string, number>()
|
||||||
const DEFAULT_MARGIN_PX = 600
|
const DEFAULT_MARGIN_PX = 600
|
||||||
const MIN_PLACEHOLDER_HEIGHT = 32
|
const MIN_PLACEHOLDER_HEIGHT = 32
|
||||||
|
const VISIBILITY_BUFFER_PX = 48
|
||||||
|
|
||||||
type ObserverRoot = Element | Document | null
|
type ObserverRoot = Element | Document | null
|
||||||
|
|
||||||
@@ -48,6 +49,19 @@ function createSharedObserver(root: ObserverRoot, margin: number): SharedObserve
|
|||||||
return { observer, listeners }
|
return { observer, listeners }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldRenderEntry(entry: IntersectionObserverEntry) {
|
||||||
|
const rootBounds = entry.rootBounds
|
||||||
|
if (!rootBounds) {
|
||||||
|
return entry.isIntersecting
|
||||||
|
}
|
||||||
|
const distanceAbove = rootBounds.top - entry.boundingClientRect.bottom
|
||||||
|
const distanceBelow = entry.boundingClientRect.top - rootBounds.bottom
|
||||||
|
if (distanceAbove > VISIBILITY_BUFFER_PX || distanceBelow > VISIBILITY_BUFFER_PX) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
function subscribeToSharedObserver(
|
function subscribeToSharedObserver(
|
||||||
target: Element,
|
target: Element,
|
||||||
root: ObserverRoot,
|
root: ObserverRoot,
|
||||||
@@ -167,6 +181,18 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const normalized = nextHeight
|
const normalized = nextHeight
|
||||||
|
const previous = sizeCache.get(props.cacheKey) ?? measuredHeight()
|
||||||
|
const shouldKeepPrevious = previous > 0 && (normalized === 0 || (normalized > 0 && normalized < previous))
|
||||||
|
if (shouldKeepPrevious) {
|
||||||
|
if (!hasReportedMeasurement) {
|
||||||
|
hasReportedMeasurement = true
|
||||||
|
props.onMeasured?.()
|
||||||
|
}
|
||||||
|
setHasMeasured(true)
|
||||||
|
sizeCache.set(props.cacheKey, previous)
|
||||||
|
setMeasuredHeight(previous)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (normalized > 0) {
|
if (normalized > 0) {
|
||||||
sizeCache.set(props.cacheKey, normalized)
|
sizeCache.set(props.cacheKey, normalized)
|
||||||
setHasMeasured(true)
|
setHasMeasured(true)
|
||||||
@@ -212,7 +238,8 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
}
|
}
|
||||||
const margin = props.threshold ?? DEFAULT_MARGIN_PX
|
const margin = props.threshold ?? DEFAULT_MARGIN_PX
|
||||||
intersectionCleanup = subscribeToSharedObserver(wrapperRef, targetRoot, margin, (entry) => {
|
intersectionCleanup = subscribeToSharedObserver(wrapperRef, targetRoot, margin, (entry) => {
|
||||||
queueVisibility(entry.isIntersecting)
|
const nextVisible = shouldRenderEntry(entry)
|
||||||
|
queueVisibility(nextVisible)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,6 +289,7 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
measurementsSuspended()
|
||||||
const root = props.scrollContainer ? props.scrollContainer() : null
|
const root = props.scrollContainer ? props.scrollContainer() : null
|
||||||
refreshIntersectionObserver(root ?? null)
|
refreshIntersectionObserver(root ?? null)
|
||||||
})
|
})
|
||||||
|
|||||||
83
packages/ui/src/lib/contexts/instance-metadata-context.tsx
Normal file
83
packages/ui/src/lib/contexts/instance-metadata-context.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { Component, JSX, createContext, createEffect, createMemo, createSignal, useContext, type Accessor } from "solid-js"
|
||||||
|
import type { Instance } from "../../types/instance"
|
||||||
|
import { instances } from "../../stores/instances"
|
||||||
|
import { getInstanceMetadata } from "../../stores/instance-metadata"
|
||||||
|
import { loadInstanceMetadata, hasMetadataLoaded } from "../hooks/use-instance-metadata"
|
||||||
|
|
||||||
|
interface InstanceMetadataContextValue {
|
||||||
|
isLoading: Accessor<boolean>
|
||||||
|
instance: Accessor<Instance>
|
||||||
|
metadata: Accessor<Instance["metadata"] | undefined>
|
||||||
|
refreshMetadata: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const InstanceMetadataContext = createContext<InstanceMetadataContextValue | null>(null)
|
||||||
|
|
||||||
|
interface InstanceMetadataProviderProps {
|
||||||
|
instance: Instance
|
||||||
|
children: JSX.Element
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InstanceMetadataProvider: Component<InstanceMetadataProviderProps> = (props) => {
|
||||||
|
const resolvedInstance = createMemo(() => instances().get(props.instance.id) ?? props.instance)
|
||||||
|
const [isLoading, setIsLoading] = createSignal(true)
|
||||||
|
|
||||||
|
const ensureMetadata = async (force = false) => {
|
||||||
|
const current = resolvedInstance()
|
||||||
|
if (!current) {
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedMetadata = getInstanceMetadata(current.id) ?? current.metadata
|
||||||
|
if (!force && hasMetadataLoaded(cachedMetadata)) {
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
await loadInstanceMetadata(current, { force })
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const current = resolvedInstance()
|
||||||
|
if (!current) {
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const tracked = getInstanceMetadata(current.id) ?? current.metadata
|
||||||
|
if (!tracked || !hasMetadataLoaded(tracked)) {
|
||||||
|
void ensureMetadata()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
const contextValue: InstanceMetadataContextValue = {
|
||||||
|
isLoading,
|
||||||
|
instance: resolvedInstance,
|
||||||
|
metadata: () => getInstanceMetadata(resolvedInstance().id) ?? resolvedInstance().metadata,
|
||||||
|
refreshMetadata: () => ensureMetadata(true),
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InstanceMetadataContext.Provider value={contextValue}>
|
||||||
|
{props.children}
|
||||||
|
</InstanceMetadataContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInstanceMetadataContext(): InstanceMetadataContextValue {
|
||||||
|
const ctx = useContext(InstanceMetadataContext)
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useInstanceMetadataContext must be used within InstanceMetadataProvider")
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOptionalInstanceMetadataContext(): InstanceMetadataContextValue | null {
|
||||||
|
return useContext(InstanceMetadataContext)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { abortSession, getSessions, isSessionBusy } from "../../stores/sessions"
|
|||||||
import { showCommandPalette, hideCommandPalette } from "../../stores/command-palette"
|
import { showCommandPalette, hideCommandPalette } from "../../stores/command-palette"
|
||||||
import type { Instance } from "../../types/instance"
|
import type { Instance } from "../../types/instance"
|
||||||
import { getLogger } from "../logger"
|
import { getLogger } from "../logger"
|
||||||
|
import { emitSessionSidebarRequest } from "../session-sidebar-events"
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
@@ -55,38 +56,14 @@ export function useAppLifecycle(options: UseAppLifecycleOptions) {
|
|||||||
|
|
||||||
registerAgentShortcuts(
|
registerAgentShortcuts(
|
||||||
() => {
|
() => {
|
||||||
const modelInput = document.querySelector("[data-model-selector]") as HTMLInputElement
|
const instance = options.getActiveInstance()
|
||||||
if (modelInput) {
|
if (!instance) return
|
||||||
modelInput.focus()
|
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-model-selector" })
|
||||||
setTimeout(() => {
|
|
||||||
const event = new KeyboardEvent("keydown", {
|
|
||||||
key: "ArrowDown",
|
|
||||||
code: "ArrowDown",
|
|
||||||
keyCode: 40,
|
|
||||||
which: 40,
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
})
|
|
||||||
modelInput.dispatchEvent(event)
|
|
||||||
}, 10)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
const agentTrigger = document.querySelector("[data-agent-selector]") as HTMLElement
|
const instance = options.getActiveInstance()
|
||||||
if (agentTrigger) {
|
if (!instance) return
|
||||||
agentTrigger.focus()
|
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-agent-selector" })
|
||||||
setTimeout(() => {
|
|
||||||
const event = new KeyboardEvent("keydown", {
|
|
||||||
key: "Enter",
|
|
||||||
code: "Enter",
|
|
||||||
keyCode: 13,
|
|
||||||
which: 13,
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
})
|
|
||||||
agentTrigger.dispatchEvent(event)
|
|
||||||
}, 50)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ import type { MessageRecord } from "../../stores/message-v2/types"
|
|||||||
import { messageStoreBus } from "../../stores/message-v2/bus"
|
import { messageStoreBus } from "../../stores/message-v2/bus"
|
||||||
import { cleanupBlankSessions } from "../../stores/session-state"
|
import { cleanupBlankSessions } from "../../stores/session-state"
|
||||||
import { getLogger } from "../logger"
|
import { getLogger } from "../logger"
|
||||||
|
import { emitSessionSidebarRequest } from "../session-sidebar-events"
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
|
|
||||||
export interface UseCommandsOptions {
|
export interface UseCommandsOptions {
|
||||||
preferences: Accessor<Preferences>
|
preferences: Accessor<Preferences>
|
||||||
toggleShowThinkingBlocks: () => void
|
toggleShowThinkingBlocks: () => void
|
||||||
@@ -191,7 +193,10 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
if (ids.length <= 1) return
|
if (ids.length <= 1) return
|
||||||
const current = ids.indexOf(activeSessionMap().get(instanceId) || "")
|
const current = ids.indexOf(activeSessionMap().get(instanceId) || "")
|
||||||
const next = (current + 1) % ids.length
|
const next = (current + 1) % ids.length
|
||||||
if (ids[next]) setActiveSession(instanceId, ids[next])
|
if (ids[next]) {
|
||||||
|
setActiveSession(instanceId, ids[next])
|
||||||
|
emitSessionSidebarRequest({ instanceId, action: "show-session-list" })
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -212,7 +217,10 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
if (ids.length <= 1) return
|
if (ids.length <= 1) return
|
||||||
const current = ids.indexOf(activeSessionMap().get(instanceId) || "")
|
const current = ids.indexOf(activeSessionMap().get(instanceId) || "")
|
||||||
const prev = current <= 0 ? ids.length - 1 : current - 1
|
const prev = current <= 0 ? ids.length - 1 : current - 1
|
||||||
if (ids[prev]) setActiveSession(instanceId, ids[prev])
|
if (ids[prev]) {
|
||||||
|
setActiveSession(instanceId, ids[prev])
|
||||||
|
emitSessionSidebarRequest({ instanceId, action: "show-session-list" })
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -345,21 +353,9 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
keywords: ["model", "llm", "ai"],
|
keywords: ["model", "llm", "ai"],
|
||||||
shortcut: { key: "M", meta: true, shift: true },
|
shortcut: { key: "M", meta: true, shift: true },
|
||||||
action: () => {
|
action: () => {
|
||||||
const modelInput = document.querySelector("[data-model-selector]") as HTMLInputElement
|
const instance = activeInstance()
|
||||||
if (modelInput) {
|
if (!instance) return
|
||||||
modelInput.focus()
|
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-model-selector" })
|
||||||
setTimeout(() => {
|
|
||||||
const event = new KeyboardEvent("keydown", {
|
|
||||||
key: "ArrowDown",
|
|
||||||
code: "ArrowDown",
|
|
||||||
keyCode: 40,
|
|
||||||
which: 40,
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
})
|
|
||||||
modelInput.dispatchEvent(event)
|
|
||||||
}, 10)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -371,21 +367,9 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
keywords: ["agent", "mode"],
|
keywords: ["agent", "mode"],
|
||||||
shortcut: { key: "A", meta: true, shift: true },
|
shortcut: { key: "A", meta: true, shift: true },
|
||||||
action: () => {
|
action: () => {
|
||||||
const agentTrigger = document.querySelector("[data-agent-selector]") as HTMLElement
|
const instance = activeInstance()
|
||||||
if (agentTrigger) {
|
if (!instance) return
|
||||||
agentTrigger.focus()
|
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-agent-selector" })
|
||||||
setTimeout(() => {
|
|
||||||
const event = new KeyboardEvent("keydown", {
|
|
||||||
key: "Enter",
|
|
||||||
code: "Enter",
|
|
||||||
keyCode: 13,
|
|
||||||
which: 13,
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
})
|
|
||||||
agentTrigger.dispatchEvent(event)
|
|
||||||
}, 50)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
71
packages/ui/src/lib/hooks/use-instance-metadata.ts
Normal file
71
packages/ui/src/lib/hooks/use-instance-metadata.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import type { Instance, RawMcpStatus } from "../../types/instance"
|
||||||
|
import { fetchLspStatus } from "../../stores/instances"
|
||||||
|
import { getLogger } from "../../lib/logger"
|
||||||
|
import { getInstanceMetadata, mergeInstanceMetadata } from "../../stores/instance-metadata"
|
||||||
|
|
||||||
|
const log = getLogger("session")
|
||||||
|
const pendingMetadataRequests = new Set<string>()
|
||||||
|
|
||||||
|
function hasMetadataLoaded(metadata?: Instance["metadata"]): boolean {
|
||||||
|
if (!metadata) return false
|
||||||
|
return "project" in metadata && "mcpStatus" in metadata && "lspStatus" in metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadInstanceMetadata(instance: Instance, options?: { force?: boolean }): Promise<void> {
|
||||||
|
const client = instance.client
|
||||||
|
if (!client) {
|
||||||
|
log.warn("[metadata] Skipping fetch; client missing", { instanceId: instance.id })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentMetadata = getInstanceMetadata(instance.id) ?? instance.metadata
|
||||||
|
if (!options?.force && hasMetadataLoaded(currentMetadata)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingMetadataRequests.has(instance.id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingMetadataRequests.add(instance.id)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [projectResult, mcpResult, lspResult] = await Promise.allSettled([
|
||||||
|
client.project.current(),
|
||||||
|
client.mcp.status(),
|
||||||
|
fetchLspStatus(instance.id),
|
||||||
|
])
|
||||||
|
|
||||||
|
const project = projectResult.status === "fulfilled" ? projectResult.value.data : undefined
|
||||||
|
const mcpStatus = mcpResult.status === "fulfilled" ? (mcpResult.value.data as RawMcpStatus) : undefined
|
||||||
|
const lspStatus = lspResult.status === "fulfilled" ? lspResult.value ?? [] : undefined
|
||||||
|
|
||||||
|
const updates: Instance["metadata"] = { ...(currentMetadata ?? {}) }
|
||||||
|
|
||||||
|
if (projectResult.status === "fulfilled") {
|
||||||
|
updates.project = project ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mcpResult.status === "fulfilled") {
|
||||||
|
updates.mcpStatus = mcpStatus ?? {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lspResult.status === "fulfilled") {
|
||||||
|
updates.lspStatus = lspStatus ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updates?.version && instance.binaryVersion) {
|
||||||
|
updates.version = instance.binaryVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
mergeInstanceMetadata(instance.id, updates)
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to load instance metadata", error)
|
||||||
|
} finally {
|
||||||
|
pendingMetadataRequests.delete(instance.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { hasMetadataLoaded }
|
||||||
|
|
||||||
|
|
||||||
13
packages/ui/src/lib/session-sidebar-events.ts
Normal file
13
packages/ui/src/lib/session-sidebar-events.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export type SessionSidebarRequestAction = "focus-agent-selector" | "focus-model-selector" | "show-session-list"
|
||||||
|
|
||||||
|
export interface SessionSidebarRequestDetail {
|
||||||
|
instanceId: string
|
||||||
|
action: SessionSidebarRequestAction
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SESSION_SIDEBAR_EVENT = "opencode:session-sidebar-request"
|
||||||
|
|
||||||
|
export function emitSessionSidebarRequest(detail: SessionSidebarRequestDetail) {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
window.dispatchEvent(new CustomEvent<SessionSidebarRequestDetail>(SESSION_SIDEBAR_EVENT, { detail }))
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { createContext, createEffect, createSignal, onMount, useContext, type JSX } from "solid-js"
|
import { createContext, createEffect, createMemo, createSignal, onMount, useContext, type JSX } from "solid-js"
|
||||||
|
import { createTheme, ThemeProvider as MuiThemeProvider } from "@suid/material/styles"
|
||||||
|
import CssBaseline from "@suid/material/CssBaseline"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
|
|
||||||
interface ThemeContextValue {
|
interface ThemeContextValue {
|
||||||
@@ -10,6 +12,7 @@ interface ThemeContextValue {
|
|||||||
const ThemeContext = createContext<ThemeContextValue>()
|
const ThemeContext = createContext<ThemeContextValue>()
|
||||||
|
|
||||||
function applyTheme(dark: boolean) {
|
function applyTheme(dark: boolean) {
|
||||||
|
if (typeof document === "undefined") return
|
||||||
if (dark) {
|
if (dark) {
|
||||||
document.documentElement.setAttribute("data-theme", "dark")
|
document.documentElement.setAttribute("data-theme", "dark")
|
||||||
return
|
return
|
||||||
@@ -18,8 +21,61 @@ function applyTheme(dark: boolean) {
|
|||||||
document.documentElement.removeAttribute("data-theme")
|
document.documentElement.removeAttribute("data-theme")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ResolvedPaletteColors {
|
||||||
|
backgroundDefault: string
|
||||||
|
backgroundPaper: string
|
||||||
|
primary: string
|
||||||
|
primaryContrast: string
|
||||||
|
textPrimary: string
|
||||||
|
textSecondary: string
|
||||||
|
divider: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const lightPaletteFallbacks: ResolvedPaletteColors = {
|
||||||
|
backgroundDefault: "#ffffff",
|
||||||
|
backgroundPaper: "#f5f5f5",
|
||||||
|
primary: "#0066ff",
|
||||||
|
primaryContrast: "#ffffff",
|
||||||
|
textPrimary: "#1a1a1a",
|
||||||
|
textSecondary: "#666666",
|
||||||
|
divider: "#e0e0e0",
|
||||||
|
}
|
||||||
|
|
||||||
|
const darkPaletteFallbacks: ResolvedPaletteColors = {
|
||||||
|
backgroundDefault: "#1a1a1a",
|
||||||
|
backgroundPaper: "#2a2a2a",
|
||||||
|
primary: "#0080ff",
|
||||||
|
primaryContrast: "#1a1a1a",
|
||||||
|
textPrimary: "#cfd4dc",
|
||||||
|
textSecondary: "#999999",
|
||||||
|
divider: "#3a3a3a",
|
||||||
|
}
|
||||||
|
|
||||||
|
const readCssVar = (token: string, fallback: string, rootStyle: CSSStyleDeclaration | null) => {
|
||||||
|
if (!rootStyle) return fallback
|
||||||
|
const value = rootStyle.getPropertyValue(token)
|
||||||
|
if (!value) return fallback
|
||||||
|
const trimmed = value.trim()
|
||||||
|
return trimmed || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvePaletteColors = (dark: boolean): ResolvedPaletteColors => {
|
||||||
|
const fallbackSet = dark ? darkPaletteFallbacks : lightPaletteFallbacks
|
||||||
|
const rootStyle = typeof window !== "undefined" ? getComputedStyle(document.documentElement) : null
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundDefault: readCssVar("--surface-base", fallbackSet.backgroundDefault, rootStyle),
|
||||||
|
backgroundPaper: readCssVar("--surface-secondary", fallbackSet.backgroundPaper, rootStyle),
|
||||||
|
primary: readCssVar("--accent-primary", fallbackSet.primary, rootStyle),
|
||||||
|
primaryContrast: readCssVar("--text-inverted", fallbackSet.primaryContrast, rootStyle),
|
||||||
|
textPrimary: readCssVar("--text-primary", fallbackSet.textPrimary, rootStyle),
|
||||||
|
textSecondary: readCssVar("--text-secondary", fallbackSet.textSecondary, rootStyle),
|
||||||
|
divider: readCssVar("--border-base", fallbackSet.divider, rootStyle),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function ThemeProvider(props: { children: JSX.Element }) {
|
export function ThemeProvider(props: { children: JSX.Element }) {
|
||||||
const systemPrefersDark = window.matchMedia("(prefers-color-scheme: dark)")
|
const mediaQuery = typeof window !== "undefined" ? window.matchMedia("(prefers-color-scheme: dark)") : null
|
||||||
const { themePreference, setThemePreference } = useConfig()
|
const { themePreference, setThemePreference } = useConfig()
|
||||||
const [isDark, setIsDarkSignal] = createSignal(true)
|
const [isDark, setIsDarkSignal] = createSignal(true)
|
||||||
|
|
||||||
@@ -39,14 +95,15 @@ export function ThemeProvider(props: { children: JSX.Element }) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
if (!mediaQuery) return
|
||||||
const handleSystemThemeChange = () => {
|
const handleSystemThemeChange = () => {
|
||||||
applyResolvedTheme()
|
applyResolvedTheme()
|
||||||
}
|
}
|
||||||
|
|
||||||
systemPrefersDark.addEventListener("change", handleSystemThemeChange)
|
mediaQuery.addEventListener("change", handleSystemThemeChange)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
systemPrefersDark.removeEventListener("change", handleSystemThemeChange)
|
mediaQuery.removeEventListener("change", handleSystemThemeChange)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -58,7 +115,73 @@ export function ThemeProvider(props: { children: JSX.Element }) {
|
|||||||
setTheme(true)
|
setTheme(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ThemeContext.Provider value={{ isDark, toggleTheme, setTheme }}>{props.children}</ThemeContext.Provider>
|
const muiTheme = createMemo(() => {
|
||||||
|
const paletteColors = resolvePaletteColors(isDark())
|
||||||
|
return createTheme({
|
||||||
|
palette: {
|
||||||
|
mode: isDark() ? "dark" : "light",
|
||||||
|
primary: {
|
||||||
|
main: paletteColors.primary,
|
||||||
|
contrastText: paletteColors.primaryContrast,
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: paletteColors.primary,
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: paletteColors.backgroundDefault,
|
||||||
|
paper: paletteColors.backgroundPaper,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
primary: paletteColors.textPrimary,
|
||||||
|
secondary: paletteColors.textSecondary,
|
||||||
|
},
|
||||||
|
divider: paletteColors.divider,
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
fontFamily: "var(--font-family-sans)",
|
||||||
|
},
|
||||||
|
shape: {
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MuiDrawer: {
|
||||||
|
styleOverrides: {
|
||||||
|
paper: {
|
||||||
|
backgroundColor: paletteColors.backgroundPaper,
|
||||||
|
color: paletteColors.textPrimary,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiAppBar: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
backgroundColor: paletteColors.backgroundPaper,
|
||||||
|
color: paletteColors.textPrimary,
|
||||||
|
boxShadow: "none",
|
||||||
|
borderBottom: `1px solid ${paletteColors.divider}`,
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiToolbar: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
minHeight: "56px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ isDark, toggleTheme, setTheme }}>
|
||||||
|
<MuiThemeProvider theme={muiTheme()}>
|
||||||
|
<CssBaseline />
|
||||||
|
{props.children}
|
||||||
|
</MuiThemeProvider>
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTheme() {
|
export function useTheme() {
|
||||||
|
|||||||
35
packages/ui/src/stores/instance-metadata.ts
Normal file
35
packages/ui/src/stores/instance-metadata.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { createSignal } from "solid-js"
|
||||||
|
import type { InstanceMetadata } from "../types/instance"
|
||||||
|
|
||||||
|
const [metadataMap, setMetadataMap] = createSignal<Map<string, InstanceMetadata | undefined>>(new Map())
|
||||||
|
|
||||||
|
function getInstanceMetadata(instanceId: string): InstanceMetadata | undefined {
|
||||||
|
return metadataMap().get(instanceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setInstanceMetadata(instanceId: string, metadata: InstanceMetadata | undefined): void {
|
||||||
|
setMetadataMap((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
if (metadata === undefined) {
|
||||||
|
next.delete(instanceId)
|
||||||
|
} else {
|
||||||
|
next.set(instanceId, metadata)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeInstanceMetadata(instanceId: string, updates: InstanceMetadata): void {
|
||||||
|
setMetadataMap((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
const existing = next.get(instanceId) ?? {}
|
||||||
|
next.set(instanceId, { ...existing, ...updates })
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearInstanceMetadata(instanceId: string): void {
|
||||||
|
setInstanceMetadata(instanceId, undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { metadataMap, getInstanceMetadata, setInstanceMetadata, mergeInstanceMetadata, clearInstanceMetadata }
|
||||||
@@ -20,6 +20,7 @@ import { setHasInstances } from "./ui"
|
|||||||
import { messageStoreBus } from "./message-v2/bus"
|
import { messageStoreBus } from "./message-v2/bus"
|
||||||
import { clearCacheForInstance } from "../lib/global-cache"
|
import { clearCacheForInstance } from "../lib/global-cache"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
|
import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata"
|
||||||
|
|
||||||
const log = getLogger("api")
|
const log = getLogger("api")
|
||||||
|
|
||||||
@@ -290,6 +291,7 @@ function removeInstance(id: string) {
|
|||||||
removeLogContainer(id)
|
removeLogContainer(id)
|
||||||
clearCommands(id)
|
clearCommands(id)
|
||||||
clearPermissionQueue(id)
|
clearPermissionQueue(id)
|
||||||
|
clearInstanceMetadata(id)
|
||||||
|
|
||||||
if (activeInstanceId() === id) {
|
if (activeInstanceId() === id) {
|
||||||
setActiveInstanceId(nextActiveId)
|
setActiveInstanceId(nextActiveId)
|
||||||
@@ -570,17 +572,7 @@ sseManager.onLspUpdated = async (instanceId) => {
|
|||||||
if (!lspStatus) {
|
if (!lspStatus) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const instance = instances().get(instanceId)
|
mergeInstanceMetadata(instanceId, { lspStatus })
|
||||||
if (!instance) {
|
|
||||||
log.warn("[LSP] Instance disappeared before metadata update", { instanceId })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
updateInstance(instanceId, {
|
|
||||||
metadata: {
|
|
||||||
...(instance.metadata ?? {}),
|
|
||||||
lspStatus,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to refresh LSP status", error)
|
log.error("Failed to refresh LSP status", error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { ClientPart, MessageInfo } from "../../types/message"
|
|||||||
import { clearRecordDisplayCacheForMessages } from "./record-display-cache"
|
import { clearRecordDisplayCacheForMessages } from "./record-display-cache"
|
||||||
import type {
|
import type {
|
||||||
InstanceMessageState,
|
InstanceMessageState,
|
||||||
|
LatestTodoSnapshot,
|
||||||
MessageRecord,
|
MessageRecord,
|
||||||
MessageUpsertInput,
|
MessageUpsertInput,
|
||||||
PartUpdateInput,
|
PartUpdateInput,
|
||||||
@@ -41,6 +42,7 @@ function createInitialState(instanceId: string): InstanceMessageState {
|
|||||||
},
|
},
|
||||||
usage: {},
|
usage: {},
|
||||||
scrollState: {},
|
scrollState: {},
|
||||||
|
latestTodos: {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +208,7 @@ export interface InstanceMessageStore {
|
|||||||
getSessionRevision: (sessionId: string) => number
|
getSessionRevision: (sessionId: string) => number
|
||||||
getSessionMessageIds: (sessionId: string) => string[]
|
getSessionMessageIds: (sessionId: string) => string[]
|
||||||
getMessage: (messageId: string) => MessageRecord | undefined
|
getMessage: (messageId: string) => MessageRecord | undefined
|
||||||
|
getLatestTodoSnapshot: (sessionId: string) => LatestTodoSnapshot | undefined
|
||||||
clearSession: (sessionId: string) => void
|
clearSession: (sessionId: string) => void
|
||||||
clearInstance: () => void
|
clearInstance: () => void
|
||||||
}
|
}
|
||||||
@@ -213,9 +216,55 @@ export interface InstanceMessageStore {
|
|||||||
export function createInstanceMessageStore(instanceId: string, hooks?: MessageStoreHooks): InstanceMessageStore {
|
export function createInstanceMessageStore(instanceId: string, hooks?: MessageStoreHooks): InstanceMessageStore {
|
||||||
const [state, setState] = createStore<InstanceMessageState>(createInitialState(instanceId))
|
const [state, setState] = createStore<InstanceMessageState>(createInitialState(instanceId))
|
||||||
|
|
||||||
|
const TODO_TOOL_NAME = "todowrite"
|
||||||
|
|
||||||
const messageInfoCache = new Map<string, MessageInfo>()
|
const messageInfoCache = new Map<string, MessageInfo>()
|
||||||
|
|
||||||
|
function isCompletedTodoPart(part: ClientPart | undefined): boolean {
|
||||||
|
if (!part || (part as any).type !== "tool") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const toolName = typeof (part as any).tool === "string" ? (part as any).tool : ""
|
||||||
|
if (toolName !== TODO_TOOL_NAME) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const toolState = (part as any).state
|
||||||
|
if (!toolState || typeof toolState !== "object") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (toolState as { status?: string }).status === "completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordLatestTodoSnapshot(sessionId: string, snapshot: LatestTodoSnapshot) {
|
||||||
|
if (!sessionId) return
|
||||||
|
setState("latestTodos", sessionId, (existing) => {
|
||||||
|
if (existing && existing.timestamp > snapshot.timestamp) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
return snapshot
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeUpdateLatestTodoFromRecord(record: MessageRecord | undefined) {
|
||||||
|
if (!record || !Array.isArray(record.partIds) || record.partIds.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (let index = record.partIds.length - 1; index >= 0; index -= 1) {
|
||||||
|
const partId = record.partIds[index]
|
||||||
|
const partRecord = record.parts[partId]
|
||||||
|
if (!partRecord) continue
|
||||||
|
if (isCompletedTodoPart(partRecord.data)) {
|
||||||
|
const timestamp = typeof record.updatedAt === "number" ? record.updatedAt : Date.now()
|
||||||
|
recordLatestTodoSnapshot(record.sessionId, { messageId: record.id, partId, timestamp })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLatestTodoSnapshot(sessionId: string) {
|
||||||
|
setState("latestTodos", sessionId, undefined)
|
||||||
|
}
|
||||||
|
|
||||||
function bumpSessionRevision(sessionId: string) {
|
function bumpSessionRevision(sessionId: string) {
|
||||||
if (!sessionId) return
|
if (!sessionId) return
|
||||||
setState("sessionRevisions", sessionId, (value = 0) => value + 1)
|
setState("sessionRevisions", sessionId, (value = 0) => value + 1)
|
||||||
@@ -365,6 +414,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
Object.values(normalizedRecords).forEach((record) => {
|
||||||
|
maybeUpdateLatestTodoFromRecord(record)
|
||||||
|
})
|
||||||
|
|
||||||
bumpSessionRevision(sessionId)
|
bumpSessionRevision(sessionId)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -405,9 +458,11 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
const shouldBump = Boolean(input.bumpRevision || normalizedParts)
|
const shouldBump = Boolean(input.bumpRevision || normalizedParts)
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
|
|
||||||
|
let nextRecord: MessageRecord | undefined
|
||||||
|
|
||||||
setState("messages", input.id, (previous) => {
|
setState("messages", input.id, (previous) => {
|
||||||
const revision = previous ? previous.revision + (shouldBump ? 1 : 0) : 0
|
const revision = previous ? previous.revision + (shouldBump ? 1 : 0) : 0
|
||||||
return {
|
const record: MessageRecord = {
|
||||||
id: input.id,
|
id: input.id,
|
||||||
sessionId: input.sessionId,
|
sessionId: input.sessionId,
|
||||||
role: input.role,
|
role: input.role,
|
||||||
@@ -419,8 +474,14 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
partIds: normalizedParts ? normalizedParts.ids : previous?.partIds ?? [],
|
partIds: normalizedParts ? normalizedParts.ids : previous?.partIds ?? [],
|
||||||
parts: normalizedParts ? normalizedParts.map : previous?.parts ?? {},
|
parts: normalizedParts ? normalizedParts.map : previous?.parts ?? {},
|
||||||
}
|
}
|
||||||
|
nextRecord = record
|
||||||
|
return record
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (nextRecord) {
|
||||||
|
maybeUpdateLatestTodoFromRecord(nextRecord)
|
||||||
|
}
|
||||||
|
|
||||||
insertMessageIntoSession(input.sessionId, input.id)
|
insertMessageIntoSession(input.sessionId, input.id)
|
||||||
flushPendingParts(input.id)
|
flushPendingParts(input.id)
|
||||||
bumpSessionRevision(input.sessionId)
|
bumpSessionRevision(input.sessionId)
|
||||||
@@ -472,6 +533,14 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (isCompletedTodoPart(cloned)) {
|
||||||
|
recordLatestTodoSnapshot(message.sessionId, {
|
||||||
|
messageId: input.messageId,
|
||||||
|
partId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Any part update can change the rendered height of the message
|
// Any part update can change the rendered height of the message
|
||||||
// list, so we treat it as a session revision for scroll purposes.
|
// list, so we treat it as a session revision for scroll purposes.
|
||||||
bumpSessionRevision(message.sessionId)
|
bumpSessionRevision(message.sessionId)
|
||||||
@@ -557,6 +626,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
setState("pendingParts", options.newId, pending)
|
setState("pendingParts", options.newId, pending)
|
||||||
}
|
}
|
||||||
clearPendingPartsForMessage(options.oldId)
|
clearPendingPartsForMessage(options.oldId)
|
||||||
|
maybeUpdateLatestTodoFromRecord(cloned)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMessageInfo(messageId: string, info: MessageInfo) {
|
function setMessageInfo(messageId: string, info: MessageInfo) {
|
||||||
@@ -779,6 +849,8 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
setState("sessionOrder", (ids) => ids.filter((id) => id !== sessionId))
|
setState("sessionOrder", (ids) => ids.filter((id) => id !== sessionId))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
clearLatestTodoSnapshot(sessionId)
|
||||||
|
|
||||||
hooks?.onSessionCleared?.(instanceId, sessionId)
|
hooks?.onSessionCleared?.(instanceId, sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -814,8 +886,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
getSessionRevision: getSessionRevisionValue,
|
getSessionRevision: getSessionRevisionValue,
|
||||||
getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [],
|
getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [],
|
||||||
getMessage: (messageId: string) => state.messages[messageId],
|
getMessage: (messageId: string) => state.messages[messageId],
|
||||||
|
getLatestTodoSnapshot: (sessionId: string) => state.latestTodos[sessionId],
|
||||||
clearSession,
|
clearSession,
|
||||||
clearInstance,
|
clearInstance,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,12 @@ export interface SessionUsageState {
|
|||||||
latestMessageId?: string
|
latestMessageId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LatestTodoSnapshot {
|
||||||
|
messageId: string
|
||||||
|
partId: string
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface InstanceMessageState {
|
export interface InstanceMessageState {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
sessions: Record<string, SessionRecord>
|
sessions: Record<string, SessionRecord>
|
||||||
@@ -99,6 +105,7 @@ export interface InstanceMessageState {
|
|||||||
permissions: InstancePermissionState
|
permissions: InstancePermissionState
|
||||||
usage: Record<string, SessionUsageState>
|
usage: Record<string, SessionUsageState>
|
||||||
scrollState: Record<string, ScrollSnapshot>
|
scrollState: Record<string, ScrollSnapshot>
|
||||||
|
latestTodos: Record<string, LatestTodoSnapshot | undefined>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionUpsertInput {
|
export interface SessionUpsertInput {
|
||||||
|
|||||||
@@ -178,8 +178,8 @@ async function sendMessage(
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
log.info("session.prompt", { instanceId, sessionId, requestBody })
|
log.info("session.promptAsync", { instanceId, sessionId, requestBody })
|
||||||
const response = await instance.client.session.prompt({
|
const response = await instance.client.session.promptAsync({
|
||||||
path: { id: sessionId },
|
path: { id: sessionId },
|
||||||
body: requestBody,
|
body: requestBody,
|
||||||
})
|
})
|
||||||
@@ -334,9 +334,39 @@ async function updateSessionModel(
|
|||||||
updateSessionInfo(instanceId, sessionId)
|
updateSessionInfo(instanceId, sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function renameSession(instanceId: string, sessionId: string, nextTitle: string): Promise<void> {
|
||||||
|
const instance = instances().get(instanceId)
|
||||||
|
if (!instance || !instance.client) {
|
||||||
|
throw new Error("Instance not ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = sessions().get(instanceId)?.get(sessionId)
|
||||||
|
if (!session) {
|
||||||
|
throw new Error("Session not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedTitle = nextTitle.trim()
|
||||||
|
if (!trimmedTitle) {
|
||||||
|
throw new Error("Session title is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
await instance.client.session.update({
|
||||||
|
path: { id: sessionId },
|
||||||
|
body: { title: trimmedTitle },
|
||||||
|
})
|
||||||
|
|
||||||
|
withSession(instanceId, sessionId, (current) => {
|
||||||
|
current.title = trimmedTitle
|
||||||
|
const time = { ...(current.time ?? {}) }
|
||||||
|
time.updated = Date.now()
|
||||||
|
current.time = time
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
abortSession,
|
abortSession,
|
||||||
executeCustomCommand,
|
executeCustomCommand,
|
||||||
|
renameSession,
|
||||||
runShellCommand,
|
runShellCommand,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
updateSessionAgent,
|
updateSessionAgent,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
abortSession,
|
abortSession,
|
||||||
executeCustomCommand,
|
executeCustomCommand,
|
||||||
|
renameSession,
|
||||||
runShellCommand,
|
runShellCommand,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
updateSessionAgent,
|
updateSessionAgent,
|
||||||
@@ -82,6 +83,7 @@ export {
|
|||||||
createSession,
|
createSession,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
executeCustomCommand,
|
executeCustomCommand,
|
||||||
|
renameSession,
|
||||||
runShellCommand,
|
runShellCommand,
|
||||||
fetchAgents,
|
fetchAgents,
|
||||||
fetchProviders,
|
fetchProviders,
|
||||||
|
|||||||
@@ -47,9 +47,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.selector-popover {
|
.selector-popover {
|
||||||
@apply rounded-md shadow-lg overflow-hidden z-50 min-w-[300px];
|
@apply rounded-md shadow-lg overflow-hidden min-w-[300px];
|
||||||
background-color: var(--surface-base);
|
background-color: var(--surface-base);
|
||||||
border: 1px solid var(--border-base);
|
border: 1px solid var(--border-base);
|
||||||
|
z-index: 2200;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selector-search-container {
|
.selector-search-container {
|
||||||
|
|||||||
@@ -5,16 +5,32 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-layout--with-timeline {
|
.message-layout--with-timeline {
|
||||||
grid-template-columns: minmax(0, 1fr) 64px;
|
grid-template-columns: minmax(0, 1fr) 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-layout--with-timeline::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 64px;
|
||||||
|
width: 1px;
|
||||||
|
background-color: var(--border-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.message-layout--with-timeline {
|
.message-layout--with-timeline {
|
||||||
grid-template-columns: minmax(0, 1fr) 40px;
|
grid-template-columns: minmax(0, 1fr) 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-layout--with-timeline::after {
|
||||||
|
right: 40px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-stream-shell {
|
.message-stream-shell {
|
||||||
@@ -22,19 +38,21 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-stream-shell .message-stream {
|
|
||||||
|
.message-stream-shell .message-stream {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-timeline-sidebar {
|
.message-timeline-sidebar {
|
||||||
width: 64px;
|
width: 64px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.message-timeline-sidebar {
|
.message-timeline-sidebar {
|
||||||
|
|||||||
@@ -6,29 +6,47 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.prompt-input-wrapper {
|
.prompt-input-wrapper {
|
||||||
@apply flex items-stretch gap-2 p-2;
|
@apply grid items-stretch;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 64px;
|
||||||
|
gap: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-input-actions {
|
.prompt-input-actions {
|
||||||
@apply flex flex-col items-center justify-between;
|
@apply flex flex-col items-center justify-between;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 0.25rem 0;
|
padding: 0.5rem 0.25rem;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-input-field {
|
.prompt-input-field-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-height: 56px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-input-field {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-input {
|
.prompt-input {
|
||||||
@apply flex-1 w-full min-h-[56px] max-h-[96px] pl-3 pr-10 pt-2.5 pb-4 border rounded-md text-sm resize-none outline-none transition-colors;
|
@apply w-full pl-3 pr-10 pt-2.5 border text-sm resize-none outline-none transition-colors;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
background-color: var(--surface-base);
|
background-color: var(--surface-base);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
border-color: var(--border-base);
|
border-color: var(--border-base);
|
||||||
line-height: var(--line-height-normal);
|
line-height: var(--line-height-normal);
|
||||||
|
border-radius: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -264,6 +282,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.prompt-input-wrapper {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.prompt-input {
|
.prompt-input {
|
||||||
min-height: 64px;
|
min-height: 64px;
|
||||||
@@ -272,7 +296,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.prompt-input-wrapper {
|
.prompt-input-wrapper {
|
||||||
gap: 0.75rem;
|
gap: 0;
|
||||||
padding: 0.75rem;
|
padding: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,77 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tool-call-task-summary {
|
.tool-call-task-summary {
|
||||||
@apply my-2 flex flex-col gap-1.5;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-call-task-item {
|
.tool-call-task-item {
|
||||||
font-size: var(--font-size-xs);
|
display: flex;
|
||||||
line-height: var(--line-height-normal);
|
align-items: center;
|
||||||
padding-left: 8px;
|
gap: 0.4rem;
|
||||||
|
padding: 0.35rem 0.5rem 0.35rem 0.75rem;
|
||||||
border-left: 2px solid var(--border-base);
|
border-left: 2px solid var(--border-base);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-family: var(--font-family-mono);
|
||||||
|
line-height: 1.35;
|
||||||
|
background-color: var(--surface-code);
|
||||||
|
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-call-task-item::before {
|
.tool-call-task-item + .tool-call-task-item {
|
||||||
content: "∟ ";
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-task-item:hover {
|
||||||
|
background-color: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-task-item[data-task-status="completed"] {
|
||||||
|
border-left-color: var(--status-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-task-item[data-task-status="running"] {
|
||||||
|
border-left-color: var(--status-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-task-item[data-task-status="pending"] {
|
||||||
|
border-left-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-task-item[data-task-status="error"] {
|
||||||
|
border-left-color: var(--status-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-task-icon {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tool-call-task-label {
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-task-separator {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-task-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-task-status {
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
background-color: var(--surface-secondary);
|
background-color: var(--surface-secondary);
|
||||||
|
min-height: 42px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-call-todo-item-completed {
|
.tool-call-todo-item-completed {
|
||||||
@@ -82,9 +83,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tool-call-todo-heading {
|
.tool-call-todo-heading {
|
||||||
@apply flex items-start justify-between gap-3;
|
@apply flex items-start gap-3;
|
||||||
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tool-call-todo-status {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
border-radius: 9999px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background-color: var(--surface-hover);
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-todo-text {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: var(--line-height-tight);
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.tool-call-todo-status {
|
.tool-call-todo-status {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|||||||
@@ -362,7 +362,7 @@
|
|||||||
|
|
||||||
/* Panel component utilities */
|
/* Panel component utilities */
|
||||||
.panel {
|
.panel {
|
||||||
@apply rounded-lg shadow-sm border overflow-hidden;
|
@apply rounded-lg shadow-sm border overflow-hidden min-w-0;
|
||||||
background-color: var(--surface-base);
|
background-color: var(--surface-base);
|
||||||
border-color: var(--border-base);
|
border-color: var(--border-base);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -415,7 +415,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel-list {
|
.panel-list {
|
||||||
@apply max-h-[400px] overflow-y-auto;
|
@apply max-h-[400px] overflow-y-auto w-full min-w-0;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-list--fill {
|
.panel-list--fill {
|
||||||
@@ -438,7 +439,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel-list-item-content {
|
.panel-list-item-content {
|
||||||
@apply flex-1 text-left px-4 py-3 flex items-center justify-between gap-3 outline-none transition-colors w-full;
|
@apply flex-1 text-left px-4 py-3 flex items-center justify-between gap-3 outline-none transition-colors w-full min-w-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-list-item-content:hover {
|
.panel-list-item-content:hover {
|
||||||
@@ -487,6 +488,7 @@
|
|||||||
@apply flex flex-1 min-h-0 flex-col;
|
@apply flex flex-1 min-h-0 flex-col;
|
||||||
background-color: var(--surface-base);
|
background-color: var(--surface-base);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Session list component */
|
/* Session list component */
|
||||||
@@ -534,17 +536,35 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.session-resize-handle {
|
.session-resize-handle {
|
||||||
@apply absolute top-0 right-0 w-1 h-full cursor-col-resize bg-transparent transition-colors;
|
@apply absolute top-0 w-1 h-full cursor-col-resize bg-transparent transition-colors;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-resize-handle--left {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-resize-handle--right {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.session-resize-handle:hover {
|
.session-resize-handle:hover {
|
||||||
background-color: var(--accent-primary);
|
background-color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-resize-handle::before {
|
.session-resize-handle::before {
|
||||||
content: "";
|
content: "";
|
||||||
@apply absolute top-0 left-0 w-2 h-full -translate-x-1/2;
|
@apply absolute top-0 h-full w-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-resize-handle--left::before {
|
||||||
|
right: 0;
|
||||||
|
transform: translateX(50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-resize-handle--right::before {
|
||||||
|
left: 0;
|
||||||
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-list-header {
|
.session-list-header {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* Panel component shells */
|
/* Panel component shells */
|
||||||
.panel {
|
.panel {
|
||||||
@apply rounded-lg shadow-sm border overflow-hidden;
|
@apply rounded-lg shadow-sm border overflow-hidden min-w-0;
|
||||||
background-color: var(--surface-base);
|
background-color: var(--surface-base);
|
||||||
border-color: var(--border-base);
|
border-color: var(--border-base);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -53,7 +53,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel-list {
|
.panel-list {
|
||||||
@apply max-h-[400px] overflow-y-auto;
|
@apply max-h-[400px] overflow-y-auto w-full min-w-0;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-list--fill {
|
.panel-list--fill {
|
||||||
@@ -76,7 +77,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel-list-item-content {
|
.panel-list-item-content {
|
||||||
@apply flex-1 text-left px-4 py-3 flex items-center justify-between gap-3 outline-none transition-colors w-full;
|
@apply flex-1 text-left px-4 py-3 flex items-center justify-between gap-3 outline-none transition-colors w-full min-w-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-list-item-content:hover {
|
.panel-list-item-content:hover {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
@apply flex flex-1 min-h-0 flex-col;
|
@apply flex flex-1 min-h-0 flex-col;
|
||||||
background-color: var(--surface-base);
|
background-color: var(--surface-base);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-list-container {
|
.session-list-container {
|
||||||
@@ -148,17 +149,35 @@ session-sidebar-controls .selector-trigger-primary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.session-resize-handle {
|
.session-resize-handle {
|
||||||
@apply absolute top-0 right-0 w-1 h-full cursor-col-resize bg-transparent transition-colors;
|
@apply absolute top-0 w-1 h-full cursor-col-resize bg-transparent transition-colors;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-resize-handle--left {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-resize-handle--right {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.session-resize-handle:hover {
|
.session-resize-handle:hover {
|
||||||
background-color: var(--accent-primary);
|
background-color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-resize-handle::before {
|
.session-resize-handle::before {
|
||||||
content: "";
|
content: "";
|
||||||
@apply absolute top-0 left-0 w-2 h-full -translate-x-1/2;
|
@apply absolute top-0 h-full w-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-resize-handle--left::before {
|
||||||
|
right: 0;
|
||||||
|
transform: translateX(50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-resize-handle--right::before {
|
||||||
|
left: 0;
|
||||||
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-list-header {
|
.session-list-header {
|
||||||
|
|||||||
@@ -22,12 +22,13 @@ export type RawMcpStatus = Record<string, {
|
|||||||
}>
|
}>
|
||||||
|
|
||||||
export interface InstanceMetadata {
|
export interface InstanceMetadata {
|
||||||
project?: ProjectInfo
|
project?: ProjectInfo | null
|
||||||
mcpStatus?: RawMcpStatus
|
mcpStatus?: RawMcpStatus | null
|
||||||
lspStatus?: LspStatus[]
|
lspStatus?: LspStatus[] | null
|
||||||
version?: string
|
version?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface Instance {
|
export interface Instance {
|
||||||
id: string
|
id: string
|
||||||
folder: string
|
folder: string
|
||||||
|
|||||||
Reference in New Issue
Block a user