Compare commits

...

12 Commits

Author SHA1 Message Date
Shantur Rathore
0af79002ed Min version 0.13.3 2026-03-31 20:16:35 +01:00
Shantur Rathore
f3981a1cce Bump version to 0.13.3 2026-03-31 20:15:25 +01:00
Shantur Rathore
031e8d5717 Fix bumpVersion script for both npm and tauri 2026-03-31 20:15:16 +01:00
Shantur
995fb3b6a3 Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-03-31 19:35:28 +01:00
Shantur
aeb0ff11b3 fix(ui): stop conversation speech when voice input starts 2026-03-31 18:59:52 +01:00
Shantur
b61cfbd9f9 fix(ui): refine GitHub stars display 2026-03-31 18:51:53 +01:00
Shantur
481dd1a88a fix(ui): wrap long toast messages
Constrain toast titles and bodies so long retry and error messages wrap inside the notification card instead of overflowing past the container.
2026-03-31 18:41:32 +01:00
Shantur
3f6cdd36f3 feat(ui): surface retrying session status
Preserve retry metadata from session.status events so the session list and header can show a live retry countdown with context. Notify users when a session enters retry and reuse the existing error styling so retrying feels actionable without losing the current badge layout.
2026-03-31 18:38:54 +01:00
Shantur
fe932c8307 fix(ui): avoid caching incomplete code highlighting
Only cache markdown HTML after Shiki has the required fence languages loaded so virtualized assistant messages can re-render with syntax highlighting when remounted.
2026-03-31 15:18:44 +01:00
Pascal André
64ac885157 feat(ui): add session yolo mode controls (#256)
## Summary
- add a per-session Yolo mode toggle for permission prompts and persist
its state
- move the control into the Status tab with clearer copy, an info
tooltip, and a visible header badge when it is enabled
- auto-accept queued permissions for any yolo-enabled session in the
instance, not only the currently focused session

## Why
- keeps this risky mode explicit and easy to audit from the session
status area
- matches the expected multi-session desktop behavior when several
sessions stay active in parallel

## Testing
- npm run typecheck --workspace @codenomad/ui
- npm run build --workspace @codenomad/ui

Closes #18
2026-03-31 14:46:20 +01:00
Shantur
1d953dfe64 feat(ui): add session reload action
Let users refresh a session transcript from the sidebar without reopening it. Reuse the existing forced message loading path so the reload behavior stays aligned with normal session hydration.
2026-03-31 14:32:45 +01:00
Shantur
42589464e5 feat(voice): support per-client conversation mode state 2026-03-31 12:39:29 +01:00
56 changed files with 1233 additions and 233 deletions

81
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.13.1", "version": "0.13.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.13.1", "version": "0.13.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
@@ -64,7 +64,6 @@
"version": "7.28.5", "version": "7.28.5",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@@ -3381,7 +3380,6 @@
"version": "7.20.5", "version": "7.20.5",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/parser": "^7.20.7", "@babel/parser": "^7.20.7",
"@babel/types": "^7.20.7", "@babel/types": "^7.20.7",
@@ -3483,7 +3481,6 @@
"version": "22.19.0", "version": "22.19.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -3558,7 +3555,6 @@
"integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==", "integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cac": "^6.7.14", "cac": "^6.7.14",
"colorette": "^2.0.20", "colorette": "^2.0.20",
@@ -3641,7 +3637,6 @@
"version": "6.12.6", "version": "6.12.6",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0", "fast-json-stable-stringify": "^2.0.0",
@@ -3844,6 +3839,7 @@
"version": "5.3.2", "version": "5.3.2",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"archiver-utils": "^2.1.0", "archiver-utils": "^2.1.0",
"async": "^3.2.4", "async": "^3.2.4",
@@ -3861,6 +3857,7 @@
"version": "2.1.0", "version": "2.1.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"glob": "^7.1.4", "glob": "^7.1.4",
"graceful-fs": "^4.2.0", "graceful-fs": "^4.2.0",
@@ -3881,6 +3878,7 @@
"version": "2.3.8", "version": "2.3.8",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"core-util-is": "~1.0.0", "core-util-is": "~1.0.0",
"inherits": "~2.0.3", "inherits": "~2.0.3",
@@ -3894,12 +3892,14 @@
"node_modules/archiver-utils/node_modules/safe-buffer": { "node_modules/archiver-utils/node_modules/safe-buffer": {
"version": "5.1.2", "version": "5.1.2",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/archiver-utils/node_modules/string_decoder": { "node_modules/archiver-utils/node_modules/string_decoder": {
"version": "1.1.1", "version": "1.1.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"safe-buffer": "~5.1.0" "safe-buffer": "~5.1.0"
} }
@@ -4213,6 +4213,7 @@
"version": "4.1.0", "version": "4.1.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"buffer": "^5.5.0", "buffer": "^5.5.0",
"inherits": "^2.0.4", "inherits": "^2.0.4",
@@ -4276,7 +4277,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -4767,6 +4767,7 @@
"version": "4.1.2", "version": "4.1.2",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"buffer-crc32": "^0.2.13", "buffer-crc32": "^0.2.13",
"crc32-stream": "^4.0.2", "crc32-stream": "^4.0.2",
@@ -4896,6 +4897,7 @@
"version": "1.2.2", "version": "1.2.2",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"crc32": "bin/crc32.njs" "crc32": "bin/crc32.njs"
}, },
@@ -4907,6 +4909,7 @@
"version": "4.0.3", "version": "4.0.3",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"crc-32": "^1.2.0", "crc-32": "^1.2.0",
"readable-stream": "^3.4.0" "readable-stream": "^3.4.0"
@@ -5272,7 +5275,6 @@
"version": "24.13.3", "version": "24.13.3",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"app-builder-lib": "24.13.3", "app-builder-lib": "24.13.3",
"builder-util": "24.13.1", "builder-util": "24.13.1",
@@ -5439,6 +5441,7 @@
"version": "24.13.3", "version": "24.13.3",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"app-builder-lib": "24.13.3", "app-builder-lib": "24.13.3",
"archiver": "^5.3.1", "archiver": "^5.3.1",
@@ -5450,6 +5453,7 @@
"version": "10.1.0", "version": "10.1.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"graceful-fs": "^4.2.0", "graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1", "jsonfile": "^6.0.1",
@@ -5463,6 +5467,7 @@
"version": "6.2.0", "version": "6.2.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"universalify": "^2.0.0" "universalify": "^2.0.0"
}, },
@@ -5474,6 +5479,7 @@
"version": "2.0.1", "version": "2.0.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
@@ -6191,7 +6197,8 @@
"node_modules/fs-constants": { "node_modules/fs-constants": {
"version": "1.0.0", "version": "1.0.0",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/fs-extra": { "node_modules/fs-extra": {
"version": "8.1.0", "version": "8.1.0",
@@ -7408,7 +7415,8 @@
"node_modules/isarray": { "node_modules/isarray": {
"version": "1.0.0", "version": "1.0.0",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/isbinaryfile": { "node_modules/isbinaryfile": {
"version": "5.0.6", "version": "5.0.6",
@@ -7458,7 +7466,6 @@
"version": "1.21.7", "version": "1.21.7",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
@@ -7590,6 +7597,7 @@
"version": "1.0.1", "version": "1.0.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"readable-stream": "^2.0.5" "readable-stream": "^2.0.5"
}, },
@@ -7601,6 +7609,7 @@
"version": "2.3.8", "version": "2.3.8",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"core-util-is": "~1.0.0", "core-util-is": "~1.0.0",
"inherits": "~2.0.3", "inherits": "~2.0.3",
@@ -7614,12 +7623,14 @@
"node_modules/lazystream/node_modules/safe-buffer": { "node_modules/lazystream/node_modules/safe-buffer": {
"version": "5.1.2", "version": "5.1.2",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lazystream/node_modules/string_decoder": { "node_modules/lazystream/node_modules/string_decoder": {
"version": "1.1.1", "version": "1.1.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"safe-buffer": "~5.1.0" "safe-buffer": "~5.1.0"
} }
@@ -7684,22 +7695,26 @@
"node_modules/lodash.defaults": { "node_modules/lodash.defaults": {
"version": "4.2.0", "version": "4.2.0",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lodash.difference": { "node_modules/lodash.difference": {
"version": "4.5.0", "version": "4.5.0",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lodash.flatten": { "node_modules/lodash.flatten": {
"version": "4.4.0", "version": "4.4.0",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lodash.isplainobject": { "node_modules/lodash.isplainobject": {
"version": "4.0.6", "version": "4.0.6",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lodash.sortby": { "node_modules/lodash.sortby": {
"version": "4.7.0", "version": "4.7.0",
@@ -7711,7 +7726,8 @@
"node_modules/lodash.union": { "node_modules/lodash.union": {
"version": "4.6.0", "version": "4.6.0",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lowercase-keys": { "node_modules/lowercase-keys": {
"version": "2.0.0", "version": "2.0.0",
@@ -8515,7 +8531,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -8663,7 +8678,8 @@
"node_modules/process-nextick-args": { "node_modules/process-nextick-args": {
"version": "2.0.1", "version": "2.0.1",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/process-warning": { "node_modules/process-warning": {
"version": "3.0.0", "version": "3.0.0",
@@ -8912,6 +8928,7 @@
"version": "3.6.2", "version": "3.6.2",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"inherits": "^2.0.3", "inherits": "^2.0.3",
"string_decoder": "^1.1.1", "string_decoder": "^1.1.1",
@@ -8925,6 +8942,7 @@
"version": "1.1.3", "version": "1.1.3",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"minimatch": "^5.1.0" "minimatch": "^5.1.0"
} }
@@ -9227,7 +9245,6 @@
"version": "4.52.5", "version": "4.52.5",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
}, },
@@ -9451,7 +9468,6 @@
"node_modules/seroval": { "node_modules/seroval": {
"version": "1.3.2", "version": "1.3.2",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
} }
@@ -9775,7 +9791,6 @@
"node_modules/solid-js": { "node_modules/solid-js": {
"version": "1.9.10", "version": "1.9.10",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.1.0", "csstype": "^3.1.0",
"seroval": "~1.3.0", "seroval": "~1.3.0",
@@ -9916,6 +9931,7 @@
"version": "1.3.0", "version": "1.3.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"safe-buffer": "~5.2.0" "safe-buffer": "~5.2.0"
} }
@@ -10249,6 +10265,7 @@
"version": "2.2.0", "version": "2.2.0",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"bl": "^4.0.3", "bl": "^4.0.3",
"end-of-stream": "^1.4.1", "end-of-stream": "^1.4.1",
@@ -10441,7 +10458,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -10691,7 +10707,6 @@
"version": "5.9.3", "version": "5.9.3",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -11039,7 +11054,6 @@
"version": "5.4.21", "version": "5.4.21",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",
@@ -11524,7 +11538,6 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -11719,7 +11732,6 @@
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"rollup": "dist/bin/rollup" "rollup": "dist/bin/rollup"
}, },
@@ -12008,6 +12020,7 @@
"version": "4.1.1", "version": "4.1.1",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"archiver-utils": "^3.0.4", "archiver-utils": "^3.0.4",
"compress-commons": "^4.1.2", "compress-commons": "^4.1.2",
@@ -12021,6 +12034,7 @@
"version": "3.0.4", "version": "3.0.4",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"glob": "^7.2.3", "glob": "^7.2.3",
"graceful-fs": "^4.2.0", "graceful-fs": "^4.2.0",
@@ -12040,7 +12054,6 @@
"node_modules/zod": { "node_modules/zod": {
"version": "3.25.76", "version": "3.25.76",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
@@ -12055,7 +12068,7 @@
}, },
"packages/electron-app": { "packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.13.1", "version": "0.13.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codenomad/ui": "file:../ui", "@codenomad/ui": "file:../ui",
@@ -12092,7 +12105,7 @@
}, },
"packages/server": { "packages/server": {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.13.1", "version": "0.13.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
@@ -12134,7 +12147,7 @@
}, },
"packages/tauri-app": { "packages/tauri-app": {
"name": "@codenomad/tauri-app", "name": "@codenomad/tauri-app",
"version": "0.13.1", "version": "0.13.3",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.9.4" "@tauri-apps/cli": "^2.9.4"
@@ -12142,7 +12155,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.13.1", "version": "0.13.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@git-diff-view/solid": "^0.0.8", "@git-diff-view/solid": "^0.0.8",

View File

@@ -1,6 +1,6 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.13.1", "version": "0.13.3",
"private": true, "private": true,
"description": "CodeNomad monorepo workspace", "description": "CodeNomad monorepo workspace",
"license": "MIT", "license": "MIT",
@@ -22,7 +22,7 @@
"build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app", "build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app",
"build:binaries": "npm run build:binaries --workspace @neuralnomads/codenomad-electron-app", "build:binaries": "npm run build:binaries --workspace @neuralnomads/codenomad-electron-app",
"typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app", "typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app",
"bumpVersion": "npm version --workspaces --include-workspace-root --no-git-tag-version && npm run sync:version --workspace @codenomad/tauri-app" "bumpVersion": "node ./scripts/bump-version.js"
}, },
"dependencies": { "dependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",

View File

@@ -1,4 +1,4 @@
{ {
"minServerVersion": "0.13.1", "minServerVersion": "0.13.3",
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest" "latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.13.1", "version": "0.13.3",
"description": "CodeNomad - AI coding assistant", "description": "CodeNomad - AI coding assistant",
"license": "MIT", "license": "MIT",
"author": { "author": {

View File

@@ -4,6 +4,6 @@
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@opencode-ai/plugin": "1.3.2" "@opencode-ai/plugin": "1.3.7"
} }
} }

View File

@@ -1,12 +1,12 @@
{ {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.13.1", "version": "0.13.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.13.1", "version": "0.13.3",
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0", "@fastify/reply-from": "^9.8.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.13.1", "version": "0.13.3",
"description": "CodeNomad Server", "description": "CodeNomad Server",
"license": "MIT", "license": "MIT",
"author": { "author": {

View File

@@ -0,0 +1,128 @@
import type { Logger } from "../logger"
const STALE_CONNECTION_TIMEOUT_MS = 45000
const STALE_SWEEP_INTERVAL_MS = 5000
export interface ClientConnectionRef {
clientId: string
connectionId: string
}
export interface ClientConnectionRecord extends ClientConnectionRef {
key: string
connectedAt: number
lastSeenAt: number
}
type ConnectionChangeEvent = {
type: "connected" | "disconnected"
connection: ClientConnectionRecord
reason?: string
}
interface RegisteredConnection extends ClientConnectionRecord {
close: () => void
}
export class ClientConnectionManager {
private readonly connections = new Map<string, RegisteredConnection>()
private readonly subscribers = new Set<(event: ConnectionChangeEvent) => void>()
private readonly sweepTimer: NodeJS.Timeout
constructor(private readonly logger: Logger) {
this.sweepTimer = setInterval(() => this.sweepStaleConnections(), STALE_SWEEP_INTERVAL_MS)
this.sweepTimer.unref?.()
}
shutdown(): void {
clearInterval(this.sweepTimer)
for (const connection of Array.from(this.connections.values())) {
this.disconnect(connection.key, "shutdown", false)
}
}
subscribe(listener: (event: ConnectionChangeEvent) => void): () => void {
this.subscribers.add(listener)
return () => this.subscribers.delete(listener)
}
register(input: ClientConnectionRef & { close: () => void }): () => void {
const key = getConnectionKey(input)
const now = Date.now()
const existing = this.connections.get(key)
if (existing) {
this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Replacing existing client connection")
this.disconnect(key, "replaced")
}
const connection: RegisteredConnection = {
key,
clientId: input.clientId,
connectionId: input.connectionId,
connectedAt: now,
lastSeenAt: now,
close: input.close,
}
this.connections.set(key, connection)
this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Client connected")
this.notify({ type: "connected", connection })
return () => this.disconnect(key, "closed")
}
pong(input: ClientConnectionRef): boolean {
const key = getConnectionKey(input)
const connection = this.connections.get(key)
if (!connection) {
this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Ignoring pong for unknown client connection")
return false
}
connection.lastSeenAt = Date.now()
return true
}
isConnected(input: ClientConnectionRef): boolean {
return this.connections.has(getConnectionKey(input))
}
private sweepStaleConnections(): void {
const cutoff = Date.now() - STALE_CONNECTION_TIMEOUT_MS
for (const connection of Array.from(this.connections.values())) {
if (connection.lastSeenAt > cutoff) continue
this.logger.debug({ clientId: connection.clientId, connectionId: connection.connectionId }, "Client connection timed out")
this.disconnect(connection.key, "timeout")
}
}
private disconnect(key: string, reason: string, invokeClose = true): void {
const connection = this.connections.get(key)
if (!connection) return
this.connections.delete(key)
this.logger.debug({ clientId: connection.clientId, connectionId: connection.connectionId, reason }, "Client disconnected")
if (invokeClose) {
try {
connection.close()
} catch (error) {
this.logger.warn({ err: error, clientId: connection.clientId, connectionId: connection.connectionId }, "Failed to close stale client connection")
}
}
this.notify({ type: "disconnected", connection, reason })
}
private notify(event: ConnectionChangeEvent): void {
for (const subscriber of this.subscribers) {
try {
subscriber(event)
} catch (error) {
this.logger.warn({ err: error, eventType: event.type }, "Client connection subscriber failed")
}
}
}
}
function getConnectionKey(input: ClientConnectionRef): string {
return `${input.clientId}:${input.connectionId}`
}

View File

@@ -0,0 +1,96 @@
import type { Logger } from "../logger"
import type { ClientConnectionManager, ClientConnectionRef } from "../clients/connection-manager"
import type { PluginChannelManager } from "./channel"
interface VoiceModeManagerOptions {
connections: ClientConnectionManager
channel: PluginChannelManager
logger: Logger
}
export class VoiceModeManager {
private readonly enabledConnectionsByInstance = new Map<string, Set<string>>()
private readonly aggregateByInstance = new Map<string, boolean>()
constructor(private readonly options: VoiceModeManagerOptions) {
this.options.connections.subscribe((event) => {
if (event.type !== "disconnected") return
this.clearConnection(event.connection)
})
}
setEnabled(instanceId: string, connection: ClientConnectionRef, enabled: boolean): void {
if (enabled && !this.options.connections.isConnected(connection)) {
this.options.logger.debug(
{ instanceId, clientId: connection.clientId, connectionId: connection.connectionId },
"Ignoring voice mode enable for disconnected client connection",
)
return
}
const key = getConnectionKey(connection)
const current = this.enabledConnectionsByInstance.get(instanceId) ?? new Set<string>()
if (enabled) {
current.add(key)
this.enabledConnectionsByInstance.set(instanceId, current)
} else if (current.delete(key)) {
if (current.size === 0) {
this.enabledConnectionsByInstance.delete(instanceId)
} else {
this.enabledConnectionsByInstance.set(instanceId, current)
}
}
this.options.logger.debug({ instanceId, clientId: connection.clientId, connectionId: connection.connectionId, enabled }, "Voice mode updated for client connection")
this.publishIfChanged(instanceId)
}
syncInstance(instanceId: string): void {
this.options.channel.send(instanceId, buildVoiceModeEvent(this.isEnabled(instanceId)))
}
isEnabled(instanceId: string): boolean {
return this.aggregateByInstance.get(instanceId) === true
}
private clearConnection(connection: ClientConnectionRef): void {
const key = getConnectionKey(connection)
for (const [instanceId, enabledConnections] of Array.from(this.enabledConnectionsByInstance.entries())) {
if (!enabledConnections.delete(key)) continue
if (enabledConnections.size === 0) {
this.enabledConnectionsByInstance.delete(instanceId)
}
this.publishIfChanged(instanceId)
}
}
private publishIfChanged(instanceId: string): void {
const enabled = (this.enabledConnectionsByInstance.get(instanceId)?.size ?? 0) > 0
const previous = this.aggregateByInstance.get(instanceId) === true
if (enabled === previous) return
if (enabled) {
this.aggregateByInstance.set(instanceId, true)
} else {
this.aggregateByInstance.delete(instanceId)
}
this.options.logger.debug({ instanceId, enabled }, "Broadcasting aggregate voice mode")
this.options.channel.send(instanceId, buildVoiceModeEvent(enabled))
}
}
function buildVoiceModeEvent(enabled: boolean) {
return {
type: "codenomad.voiceMode",
properties: {
enabled,
formatVersion: "v1",
},
}
}
function getConnectionKey(connection: ClientConnectionRef): string {
return `${connection.clientId}:${connection.connectionId}`
}

View File

@@ -29,7 +29,9 @@ import type { AuthManager } from "../auth/manager"
import { registerAuthRoutes } from "./routes/auth" import { registerAuthRoutes } from "./routes/auth"
import { sendUnauthorized, wantsHtml } from "../auth/http-auth" import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
import type { SpeechService } from "../speech/service" import type { SpeechService } from "../speech/service"
import { ClientConnectionManager } from "../clients/connection-manager"
import { PluginChannelManager } from "../plugins/channel" import { PluginChannelManager } from "../plugins/channel"
import { VoiceModeManager } from "../plugins/voice-mode"
interface HttpServerDeps { interface HttpServerDeps {
bindHost: string bindHost: string
@@ -174,7 +176,13 @@ export function createHttpServer(deps: HttpServerDeps) {
eventBus: deps.eventBus, eventBus: deps.eventBus,
logger: deps.logger.child({ component: "background-processes" }), logger: deps.logger.child({ component: "background-processes" }),
}) })
const clientConnectionManager = new ClientConnectionManager(deps.logger.child({ component: "client-connections" }))
const pluginChannel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" })) const pluginChannel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
const voiceModeManager = new VoiceModeManager({
connections: clientConnectionManager,
channel: pluginChannel,
logger: deps.logger.child({ component: "voice-mode" }),
})
registerAuthRoutes(app, { authManager: deps.authManager }) registerAuthRoutes(app, { authManager: deps.authManager })
@@ -250,7 +258,12 @@ export function createHttpServer(deps: HttpServerDeps) {
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger }) registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger })
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser }) registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
registerMetaRoutes(app, { serverMeta: deps.serverMeta }) registerMetaRoutes(app, { serverMeta: deps.serverMeta })
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger }) registerEventRoutes(app, {
eventBus: deps.eventBus,
registerClient: registerSseClient,
logger: sseLogger,
connectionManager: clientConnectionManager,
})
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager }) registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
registerStorageRoutes(app, { registerStorageRoutes(app, {
instanceStore: deps.instanceStore, instanceStore: deps.instanceStore,
@@ -263,6 +276,7 @@ export function createHttpServer(deps: HttpServerDeps) {
eventBus: deps.eventBus, eventBus: deps.eventBus,
logger: proxyLogger, logger: proxyLogger,
channel: pluginChannel, channel: pluginChannel,
voiceModeManager,
}) })
registerBackgroundProcessRoutes(app, { backgroundProcessManager }) registerBackgroundProcessRoutes(app, { backgroundProcessManager })
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger }) registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
@@ -328,6 +342,7 @@ export function createHttpServer(deps: HttpServerDeps) {
}, },
stop: () => { stop: () => {
closeSseClients() closeSseClients()
clientConnectionManager.shutdown()
return app.close() return app.close()
}, },
} }

View File

@@ -1,19 +1,32 @@
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import { z } from "zod"
import { EventBus } from "../../events/bus" import { EventBus } from "../../events/bus"
import { WorkspaceEventPayload } from "../../api-types" import { WorkspaceEventPayload } from "../../api-types"
import type { ClientConnectionManager } from "../../clients/connection-manager"
import { Logger } from "../../logger" import { Logger } from "../../logger"
interface RouteDeps { interface RouteDeps {
eventBus: EventBus eventBus: EventBus
registerClient: (cleanup: () => void) => () => void registerClient: (cleanup: () => void) => () => void
logger: Logger logger: Logger
connectionManager: ClientConnectionManager
} }
let nextClientId = 0 let nextClientId = 0
const ConnectionQuerySchema = z.object({
clientId: z.string().trim().min(1),
connectionId: z.string().trim().min(1),
})
const PongBodySchema = ConnectionQuerySchema.extend({
pingTs: z.number().optional(),
})
export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) { export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/events", (request, reply) => { app.get("/api/events", (request, reply) => {
const clientId = ++nextClientId const clientId = ++nextClientId
const connection = ConnectionQuerySchema.parse(request.query ?? {})
deps.logger.debug({ clientId }, "SSE client connected") deps.logger.debug({ clientId }, "SSE client connected")
const origin = request.headers.origin ?? "*" const origin = request.headers.origin ?? "*"
@@ -35,7 +48,8 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
const unsubscribe = deps.eventBus.onEvent(send) const unsubscribe = deps.eventBus.onEvent(send)
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
reply.raw.write(`:hb ${Date.now()}\n\n`) const ping = { ts: Date.now() }
reply.raw.write(`event: codenomad.client.ping\ndata: ${JSON.stringify(ping)}\n\n`)
}, 15000) }, 15000)
let closed = false let closed = false
@@ -49,13 +63,27 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
} }
const unregister = deps.registerClient(close) const unregister = deps.registerClient(close)
const unregisterConnection = deps.connectionManager.register({
...connection,
close,
})
const handleClose = () => { const handleClose = () => {
close() close()
unregister() unregister()
unregisterConnection()
} }
request.raw.on("close", handleClose) request.raw.on("close", handleClose)
request.raw.on("error", handleClose) request.raw.on("error", handleClose)
}) })
app.post("/api/client-connections/pong", (request, reply) => {
const body = PongBodySchema.parse(request.body ?? {})
if (!deps.connectionManager.pong(body)) {
reply.code(404).send({ error: "Client connection not found" })
return
}
reply.code(204).send()
})
} }

View File

@@ -6,12 +6,14 @@ import type { EventBus } from "../../events/bus"
import type { Logger } from "../../logger" import type { Logger } from "../../logger"
import { PluginChannelManager } from "../../plugins/channel" import { PluginChannelManager } from "../../plugins/channel"
import { buildPingEvent, handlePluginEvent } from "../../plugins/handlers" import { buildPingEvent, handlePluginEvent } from "../../plugins/handlers"
import { VoiceModeManager } from "../../plugins/voice-mode"
interface RouteDeps { interface RouteDeps {
workspaceManager: WorkspaceManager workspaceManager: WorkspaceManager
eventBus: EventBus eventBus: EventBus
logger: Logger logger: Logger
channel: PluginChannelManager channel: PluginChannelManager
voiceModeManager: VoiceModeManager
} }
const PluginEventSchema = z.object({ const PluginEventSchema = z.object({
@@ -21,6 +23,8 @@ const PluginEventSchema = z.object({
const VoiceModeStateSchema = z.object({ const VoiceModeStateSchema = z.object({
enabled: z.boolean(), enabled: z.boolean(),
clientId: z.string().trim().min(1),
connectionId: z.string().trim().min(1),
}) })
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) { export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
@@ -38,6 +42,7 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
reply.hijack() reply.hijack()
const registration = deps.channel.register(request.params.id, reply) const registration = deps.channel.register(request.params.id, reply)
deps.voiceModeManager.syncInstance(request.params.id)
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
deps.channel.send(request.params.id, buildPingEvent()) deps.channel.send(request.params.id, buildPingEvent())
@@ -61,13 +66,11 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
} }
const payload = VoiceModeStateSchema.parse(request.body ?? {}) const payload = VoiceModeStateSchema.parse(request.body ?? {})
deps.channel.send(request.params.id, { deps.voiceModeManager.setEnabled(
type: "codenomad.voiceMode", request.params.id,
properties: { { clientId: payload.clientId, connectionId: payload.connectionId },
enabled: payload.enabled, payload.enabled,
formatVersion: "v1", )
},
})
return { enabled: payload.enabled } return { enabled: payload.enabled }
}) })

View File

@@ -458,7 +458,7 @@ dependencies = [
[[package]] [[package]]
name = "codenomad-tauri" name = "codenomad-tauri"
version = "0.12.3" version = "0.13.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dirs 5.0.1", "dirs 5.0.1",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@codenomad/tauri-app", "name": "@codenomad/tauri-app",
"version": "0.13.1", "version": "0.13.3",
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "codenomad-tauri" name = "codenomad-tauri"
version = "0.12.3" version = "0.13.3"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"

View File

@@ -1,16 +1,13 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "CodeNomad", "productName": "CodeNomad",
"version": "0.12.3", "version": "0.13.3",
"identifier": "ai.neuralnomads.codenomad.client", "identifier": "ai.neuralnomads.codenomad.client",
"build": { "build": {
"beforeDevCommand": "npm run dev:bootstrap", "beforeDevCommand": "npm run dev:bootstrap",
"beforeBuildCommand": "npm run bundle:server", "beforeBuildCommand": "npm run bundle:server",
"frontendDist": "resources/ui-loading" "frontendDist": "resources/ui-loading"
}, },
"app": { "app": {
"withGlobalTauri": true, "withGlobalTauri": true,
"windows": [ "windows": [
@@ -33,9 +30,13 @@
], ],
"security": { "security": {
"assetProtocol": { "assetProtocol": {
"scope": ["**"] "scope": [
"**"
]
}, },
"capabilities": ["main-window-native-dialogs"] "capabilities": [
"main-window-native-dialogs"
]
} }
}, },
"bundle": { "bundle": {
@@ -44,7 +45,17 @@
"resources/server", "resources/server",
"resources/ui-loading" "resources/ui-loading"
], ],
"icon": ["icon.icns", "icon.ico", "icon.png"], "icon": [
"targets": ["app", "appimage", "deb", "rpm", "nsis"] "icon.icns",
"icon.ico",
"icon.png"
],
"targets": [
"app",
"appimage",
"deb",
"rpm",
"nsis"
]
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.13.1", "version": "0.13.3",
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",

View File

@@ -443,7 +443,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
rel="noreferrer" rel="noreferrer"
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5" class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
aria-label={t("folderSelection.links.githubStars")} aria-label={t("folderSelection.links.githubStars")}
title={t("folderSelection.links.githubStars")} title={githubStars() !== null ? `${t("folderSelection.links.githubStars")}: ${githubStars()!.toLocaleString()}` : t("folderSelection.links.githubStars")}
onClick={(event) => { onClick={(event) => {
event.preventDefault() event.preventDefault()
void openExternalUrl(GITHUB_URL, "folder-selection") void openExternalUrl(GITHUB_URL, "folder-selection")

View File

@@ -36,12 +36,12 @@ import { serverApi } from "../../lib/api-client"
import { loadBackgroundProcesses } from "../../stores/background-processes" import { loadBackgroundProcesses } from "../../stores/background-processes"
import { BackgroundProcessOutputDialog } from "../background-process-output-dialog" import { BackgroundProcessOutputDialog } from "../background-process-output-dialog"
import { useI18n } from "../../lib/i18n" import { useI18n } from "../../lib/i18n"
import { getPermissionQueueLength, getQuestionQueueLength } from "../../stores/instances" import { getPermissionQueue, getPermissionQueueLength, getQuestionQueueLength, sendPermissionResponse } from "../../stores/instances"
import SessionSidebar from "./shell/SessionSidebar" import SessionSidebar from "./shell/SessionSidebar"
import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests" import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests"
import RightPanel from "./shell/right-panel/RightPanel" import RightPanel from "./shell/right-panel/RightPanel"
import { useDrawerChrome } from "./shell/useDrawerChrome" import { useDrawerChrome } from "./shell/useDrawerChrome"
import { getSessionStatus } from "../../stores/session-status" import { getRetrySeconds, getSessionRetry, getSessionStatus } from "../../stores/session-status"
import { Maximize2, ShieldAlert } from "lucide-solid" import { Maximize2, ShieldAlert } from "lucide-solid"
import type { LayoutMode } from "./shell/types" import type { LayoutMode } from "./shell/types"
@@ -57,6 +57,13 @@ import { useDrawerHostMeasure } from "./shell/useDrawerHostMeasure"
import { useDrawerResize } from "./shell/useDrawerResize" import { useDrawerResize } from "./shell/useDrawerResize"
import { useSessionCache } from "./shell/useSessionCache" import { useSessionCache } from "./shell/useSessionCache"
import { useInstanceSessionContext } from "./shell/useInstanceSessionContext" import { useInstanceSessionContext } from "./shell/useInstanceSessionContext"
import { getPermissionSessionId } from "../../types/permission"
import {
canAutoRespondPermission,
finishAutoRespondPermission,
getPermissionAutoAcceptInFlightVersion,
isPermissionAutoAcceptEnabled,
} from "../../stores/permission-auto-accept"
const log = getLogger("session") const log = getLogger("session")
@@ -97,6 +104,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal<BackgroundProcess | null>(null) const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal<BackgroundProcess | null>(null)
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false) const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false) const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
const [now, setNow] = createSignal(Date.now())
// Worktree selector manages its own dialogs. // Worktree selector manages its own dialogs.
const [showSessionSearch, setShowSessionSearch] = createSignal(false) const [showSessionSearch, setShowSessionSearch] = createSignal(false)
@@ -230,6 +238,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
window.localStorage.setItem(RIGHT_DRAWER_STORAGE_KEY, rightDrawerWidth().toString()) window.localStorage.setItem(RIGHT_DRAWER_STORAGE_KEY, rightDrawerWidth().toString())
}) })
createEffect(() => {
if (typeof window === "undefined") return
const timer = window.setInterval(() => setNow(Date.now()), 1000)
onCleanup(() => window.clearInterval(timer))
})
const connectionStatus = () => sseManager.getStatus(props.instance.id) const connectionStatus = () => sseManager.getStatus(props.instance.id)
const connectionStatusClass = () => { const connectionStatusClass = () => {
const status = connectionStatus() const status = connectionStatus()
@@ -252,6 +266,33 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
return permissions + questions > 0 return permissions + questions > 0
}) })
const permissionQueue = createMemo(() => getPermissionQueue(props.instance.id))
createEffect(() => {
getPermissionAutoAcceptInFlightVersion()
for (const permission of permissionQueue()) {
const sessionId = getPermissionSessionId(permission)
if (!sessionId) continue
if (!permission?.id) continue
if (!canAutoRespondPermission(props.instance.id, sessionId, permission.id)) continue
void sendPermissionResponse(props.instance.id, sessionId, permission.id, "once")
.catch((error) => {
log.error("Failed to auto-accept permission", error)
})
.finally(() => {
finishAutoRespondPermission(props.instance.id, sessionId, permission.id)
})
}
})
const yoloModeEnabled = createMemo(() => {
const session = activeSessionForInstance()
if (!session) return false
return isPermissionAutoAcceptEnabled(props.instance.id, session.id)
})
const activeSessionStatusPill = createMemo(() => { const activeSessionStatusPill = createMemo(() => {
const activeSessionId = activeSessionIdForInstance() const activeSessionId = activeSessionIdForInstance()
if (!activeSessionId || activeSessionId === "info") return null if (!activeSessionId || activeSessionId === "info") return null
@@ -272,17 +313,28 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
} }
const status = getSessionStatus(props.instance.id, activeSessionId) const status = getSessionStatus(props.instance.id, activeSessionId)
const text = const retry = getSessionRetry(props.instance.id, activeSessionId)
status === "working" const text = retry
? (() => {
const seconds = getRetrySeconds(retry.next, now())
return seconds > 0 ? t("sessionList.status.retryingIn", { seconds: String(seconds) }) : t("sessionList.status.retrying")
})()
: status === "working"
? t("sessionList.status.working") ? t("sessionList.status.working")
: status === "compacting" : status === "compacting"
? t("sessionList.status.compacting") ? t("sessionList.status.compacting")
: t("sessionList.status.idle") : t("sessionList.status.idle")
return { return {
className: `session-${status}`, className: `session-${retry ? "retrying" : status}`,
text, text,
showAlertIcon: false, showAlertIcon: false,
title: retry
? t("sessionList.status.retryTooltip", {
message: retry.message,
attempt: String(retry.attempt),
})
: undefined,
} }
}) })
@@ -290,13 +342,39 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const pill = activeSessionStatusPill() const pill = activeSessionStatusPill()
if (!pill) return null if (!pill) return null
return ( return (
<span class={`status-indicator session-status session-status-list ${pill.className}`}> <span class={`status-indicator session-status session-status-list ${pill.className}`} title={pill.title}>
{pill.showAlertIcon ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />} {pill.showAlertIcon ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
{pill.text} {pill.text}
</span> </span>
) )
} }
const renderYoloModePill = () => {
if (!yoloModeEnabled()) return null
return (
<span
class="status-indicator session-status session-status-list session-yolo-mode"
aria-label={t("instanceShell.yoloMode.badgeAriaLabel")}
title={t("instanceShell.yoloMode.badgeAriaLabel")}
>
<span class="status-dot" />
{t("instanceShell.yoloMode.badge")}
</span>
)
}
const renderSessionHeaderIndicators = () => (
<div class="flex items-center flex-wrap justify-center gap-2">
{renderYoloModePill()}
<Show when={hasPendingRequests()} fallback={renderActiveSessionStatusPill()}>
<PermissionNotificationBanner
instanceId={props.instance.id}
onClick={() => setPermissionModalOpen(true)}
/>
</Show>
</div>
)
const handleCommandPaletteClick = () => { const handleCommandPaletteClick = () => {
showCommandPalette(props.instance.id) showCommandPalette(props.instance.id)
} }
@@ -622,12 +700,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</Show> </Show>
<div class="flex-1 flex items-center justify-center min-w-0"> <div class="flex-1 flex items-center justify-center min-w-0">
<Show when={hasPendingRequests()} fallback={renderActiveSessionStatusPill()}> {renderSessionHeaderIndicators()}
<PermissionNotificationBanner
instanceId={props.instance.id}
onClick={() => setPermissionModalOpen(true)}
/>
</Show>
</div> </div>
<div class="flex flex-wrap items-center justify-center gap-1"> <div class="flex flex-wrap items-center justify-center gap-1">
@@ -719,12 +792,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</Show> </Show>
<div class="ml-auto flex items-center session-header-hints"> <div class="ml-auto flex items-center session-header-hints">
<Show when={hasPendingRequests()} fallback={renderActiveSessionStatusPill()}> {renderSessionHeaderIndicators()}
<PermissionNotificationBanner
instanceId={props.instance.id}
onClick={() => setPermissionModalOpen(true)}
/>
</Show>
</div> </div>
</div> </div>

View File

@@ -48,104 +48,103 @@ interface SessionSidebarProps {
} }
const SessionSidebar: Component<SessionSidebarProps> = (props) => ( const SessionSidebar: Component<SessionSidebarProps> = (props) => (
<div class="flex flex-col h-full min-h-0" ref={props.setContentEl}> <div class="flex flex-col h-full min-h-0" ref={props.setContentEl}>
<div class="flex flex-col gap-2 px-4 py-3 border-b border-base"> <div class="flex flex-col gap-2 px-4 py-3 border-b border-base">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary"> <span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
{props.t("instanceShell.leftPanel.sessionsTitle")} {props.t("instanceShell.leftPanel.sessionsTitle")}
</span> </span>
<div class="flex items-center gap-2 text-primary"> <div class="flex items-center gap-2 text-primary">
<IconButton
size="small"
color="inherit"
aria-label={props.t("sessionList.actions.newSession.ariaLabel")}
title={props.t("sessionList.actions.newSession.title")}
onClick={() => {
const result = props.onNewSession()
if (result instanceof Promise) {
void result.catch((error) => log.error("Failed to create session:", error))
}
}}
>
<PlusSquare class="w-5 h-5" />
</IconButton>
<IconButton
size="small"
color="inherit"
aria-label={props.t("sessionList.filter.ariaLabel")}
title={props.t("sessionList.filter.ariaLabel")}
aria-pressed={props.showSearch()}
onClick={props.onToggleSearch}
sx={{
color: props.showSearch() ? "var(--text-primary)" : "inherit",
backgroundColor: props.showSearch() ? "var(--surface-hover)" : "transparent",
"&:hover": {
backgroundColor: "var(--surface-hover)",
},
}}
>
<Search class="w-5 h-5" />
</IconButton>
<IconButton
size="small"
color="inherit"
aria-label={props.t("instanceShell.leftPanel.instanceInfo")}
title={props.t("instanceShell.leftPanel.instanceInfo")}
onClick={() => props.onSelectSession("info")}
>
<InfoOutlinedIcon fontSize="small" />
</IconButton>
<Show when={!props.isPhoneLayout()}>
<IconButton <IconButton
size="small" size="small"
color="inherit" color="inherit"
aria-label={props.leftPinned() ? props.t("instanceShell.leftDrawer.unpin") : props.t("instanceShell.leftDrawer.pin")} aria-label={props.t("sessionList.actions.newSession.ariaLabel")}
onClick={() => (props.leftPinned() ? props.onUnpinLeftDrawer() : props.onPinLeftDrawer())} title={props.t("sessionList.actions.newSession.title")}
onClick={() => {
const result = props.onNewSession()
if (result instanceof Promise) {
void result.catch((error) => log.error("Failed to create session:", error))
}
}}
> >
{props.leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />} <PlusSquare class="w-5 h-5" />
</IconButton> </IconButton>
</Show>
<Show when={props.drawerState() === "floating-open"}>
<IconButton <IconButton
size="small" size="small"
color="inherit" color="inherit"
aria-label={props.t("instanceShell.leftDrawer.toggle.close")} aria-label={props.t("sessionList.filter.ariaLabel")}
title={props.t("instanceShell.leftDrawer.toggle.close")} title={props.t("sessionList.filter.ariaLabel")}
onClick={props.onCloseLeftDrawer} aria-pressed={props.showSearch()}
onClick={props.onToggleSearch}
sx={{
color: props.showSearch() ? "var(--text-primary)" : "inherit",
backgroundColor: props.showSearch() ? "var(--surface-hover)" : "transparent",
"&:hover": {
backgroundColor: "var(--surface-hover)",
},
}}
> >
<MenuOpenIcon fontSize="small" /> <Search class="w-5 h-5" />
</IconButton> </IconButton>
<IconButton
size="small"
color="inherit"
aria-label={props.t("instanceShell.leftPanel.instanceInfo")}
title={props.t("instanceShell.leftPanel.instanceInfo")}
onClick={() => props.onSelectSession("info")}
>
<InfoOutlinedIcon fontSize="small" />
</IconButton>
<Show when={!props.isPhoneLayout()}>
<IconButton
size="small"
color="inherit"
aria-label={props.leftPinned() ? props.t("instanceShell.leftDrawer.unpin") : props.t("instanceShell.leftDrawer.pin")}
onClick={() => (props.leftPinned() ? props.onUnpinLeftDrawer() : props.onPinLeftDrawer())}
>
{props.leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</IconButton>
</Show>
<Show when={props.drawerState() === "floating-open"}>
<IconButton
size="small"
color="inherit"
aria-label={props.t("instanceShell.leftDrawer.toggle.close")}
title={props.t("instanceShell.leftDrawer.toggle.close")}
onClick={props.onCloseLeftDrawer}
>
<MenuOpenIcon fontSize="small" />
</IconButton>
</Show>
</div>
</div>
<div class="session-sidebar-shortcuts">
<Show when={props.keyboardShortcuts().length}>
<KeyboardHint shortcuts={props.keyboardShortcuts()} separator=" " showDescription={false} />
</Show> </Show>
</div> </div>
</div> </div>
<div class="session-sidebar-shortcuts">
<Show when={props.keyboardShortcuts().length}>
<KeyboardHint shortcuts={props.keyboardShortcuts()} separator=" " showDescription={false} />
</Show>
</div>
</div>
<div class="session-sidebar flex flex-col flex-1 min-h-0"> <div class="session-sidebar flex flex-col flex-1 min-h-0">
<SessionList <SessionList
instanceId={props.instanceId} instanceId={props.instanceId}
threads={props.threads()} threads={props.threads()}
activeSessionId={props.activeSessionId()} activeSessionId={props.activeSessionId()}
onSelect={props.onSelectSession} onSelect={props.onSelectSession}
onNew={() => { onNew={() => {
const result = props.onNewSession() const result = props.onNewSession()
if (result instanceof Promise) { if (result instanceof Promise) {
void result.catch((error) => log.error("Failed to create session:", error)) void result.catch((error) => log.error("Failed to create session:", error))
} }
}} }}
enableFilterBar={props.showSearch()} enableFilterBar={props.showSearch()}
showHeader={false} showHeader={false}
showFooter={false} showFooter={false}
/> />
<div class="session-sidebar-separator" /> <div class="session-sidebar-separator" />
<Show when={props.activeSession()}> <Show when={props.activeSession()}>
{(activeSession) => ( {(activeSession) => (
<>
<div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3"> <div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3">
<WorktreeSelector instanceId={props.instanceId} sessionId={activeSession().id} /> <WorktreeSelector instanceId={props.instanceId} sessionId={activeSession().id} />
@@ -177,11 +176,10 @@ const SessionSidebar: Component<SessionSidebarProps> = (props) => (
showDescription={false} showDescription={false}
/> />
</div> </div>
</> )}
)} </Show>
</Show> </div>
</div> </div>
</div> )
)
export default SessionSidebar export default SessionSidebar

View File

@@ -89,6 +89,7 @@ interface RightPanelProps {
const RightPanel: Component<RightPanelProps> = (props) => { const RightPanel: Component<RightPanelProps> = (props) => {
const [rightPanelTab, setRightPanelTab] = createSignal<RightPanelTab>(readStoredRightPanelTab("changes")) const [rightPanelTab, setRightPanelTab] = createSignal<RightPanelTab>(readStoredRightPanelTab("changes"))
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>([ const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>([
"yolo-mode",
"plan", "plan",
"background-processes", "background-processes",
"mcp", "mcp",
@@ -787,7 +788,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
setRightPanelTab("changes") setRightPanelTab("changes")
} }
const statusSectionIds = ["session-changes", "plan", "background-processes", "mcp", "lsp", "plugins"] const statusSectionIds = ["yolo-mode", "session-changes", "plan", "background-processes", "mcp", "lsp", "plugins"]
createEffect(() => { createEffect(() => {
const currentExpanded = new Set(rightPanelExpandedItems()) const currentExpanded = new Set(rightPanelExpandedItems())

View File

@@ -2,6 +2,7 @@ import { For, Show, type Accessor, type Component } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2" import type { ToolState } from "@opencode-ai/sdk/v2"
import { Accordion } from "@kobalte/core" import { Accordion } from "@kobalte/core"
import { Tooltip } from "@kobalte/core/tooltip" import { Tooltip } from "@kobalte/core/tooltip"
import Switch from "@suid/material/Switch"
import { ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid" import { ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
@@ -12,6 +13,7 @@ import type { Session } from "../../../../../types/session"
import ContextUsagePanel from "../../../../session/context-usage-panel" import ContextUsagePanel from "../../../../session/context-usage-panel"
import { TodoListView } from "../../../../tool-call/renderers/todo" import { TodoListView } from "../../../../tool-call/renderers/todo"
import InstanceServiceStatus from "../../../../instance-service-status" import InstanceServiceStatus from "../../../../instance-service-status"
import { isPermissionAutoAcceptEnabled, togglePermissionAutoAccept } from "../../../../../stores/permission-auto-accept"
interface StatusTabProps { interface StatusTabProps {
t: (key: string, vars?: Record<string, any>) => string t: (key: string, vars?: Record<string, any>) => string
@@ -39,6 +41,35 @@ interface StatusTabProps {
const StatusTab: Component<StatusTabProps> = (props) => { const StatusTab: Component<StatusTabProps> = (props) => {
const isSectionExpanded = (id: string) => props.expandedItems().includes(id) const isSectionExpanded = (id: string) => props.expandedItems().includes(id)
const renderYoloModeSection = () => {
const session = props.activeSession()
if (!session) {
return (
<div class="right-panel-empty right-panel-empty--left">
<span class="text-xs">{props.t("instanceShell.yoloMode.noSessionSelected")}</span>
</div>
)
}
return (
<div class="rounded-md border border-base bg-surface-secondary px-3 py-2">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-sm font-medium text-primary">{props.t("instanceShell.yoloMode.title")}</div>
<p class="mt-1 text-xs text-secondary">{props.t("instanceShell.yoloMode.description")}</p>
</div>
<Switch
checked={isPermissionAutoAcceptEnabled(props.instanceId, session.id)}
color="warning"
size="small"
inputProps={{ "aria-label": props.t("instanceShell.yoloMode.title") }}
onChange={() => togglePermissionAutoAccept(props.instanceId, session.id)}
/>
</div>
</div>
)
}
const renderStatusSessionChanges = () => { const renderStatusSessionChanges = () => {
const sessionId = props.activeSessionId() const sessionId = props.activeSessionId()
if (!sessionId || sessionId === "info") { if (!sessionId || sessionId === "info") {
@@ -204,6 +235,12 @@ const StatusTab: Component<StatusTabProps> = (props) => {
} }
const statusSections = [ const statusSections = [
{
id: "yolo-mode",
labelKey: "instanceShell.rightPanel.sections.yoloMode",
tooltipKey: "instanceShell.rightPanel.sections.yoloMode.tooltip",
render: renderYoloModeSection,
},
{ {
id: "session-changes", id: "session-changes",
labelKey: "instanceShell.rightPanel.sections.sessionChanges", labelKey: "instanceShell.rightPanel.sections.sessionChanges",
@@ -281,29 +318,23 @@ const StatusTab: Component<StatusTabProps> = (props) => {
<For each={statusSections}> <For each={statusSections}>
{(section) => ( {(section) => (
<Accordion.Item value={section.id} class="right-panel-accordion-item"> <Accordion.Item value={section.id} class="right-panel-accordion-item">
<Accordion.Header> <Accordion.Header class="right-panel-accordion-header-row">
<Accordion.Trigger class="right-panel-accordion-trigger"> <Accordion.Trigger class="right-panel-accordion-trigger">
<span class="section-left"> <span class="section-left">
<Tooltip openDelay={200} gutter={4} placement="top">
<Tooltip.Trigger
class="section-info-trigger"
aria-label={props.t(section.tooltipKey)}
onClick={(e) => e.stopPropagation()}
>
<Info class="section-info-icon" />
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content class="section-info-tooltip">
{props.t(section.tooltipKey)}
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip>
<span class="section-label">{props.t(section.labelKey)}</span> <span class="section-label">{props.t(section.labelKey)}</span>
</span> </span>
<ChevronDown <ChevronDown
class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`} class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`}
/> />
</Accordion.Trigger> </Accordion.Trigger>
<Tooltip openDelay={200} gutter={4} placement="top">
<Tooltip.Trigger as="button" type="button" class="section-info-trigger" aria-label={props.t(section.tooltipKey)}>
<Info class="section-info-icon" />
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content class="section-info-tooltip">{props.t(section.tooltipKey)}</Tooltip.Content>
</Tooltip.Portal>
</Tooltip>
</Accordion.Header> </Accordion.Header>
<Accordion.Content class="right-panel-accordion-content">{section.render()}</Accordion.Content> <Accordion.Content class="right-panel-accordion-content">{section.render()}</Accordion.Content>
</Accordion.Item> </Accordion.Item>

View File

@@ -123,7 +123,11 @@ export function Markdown(props: MarkdownProps) {
version: () => resolved().version, version: () => resolved().version,
}) })
const commitCacheEntry = (snapshot: ReturnType<typeof resolved>, renderedHtml: string) => { const commitCacheEntry = (
snapshot: ReturnType<typeof resolved>,
renderedHtml: string,
options?: { cache?: boolean },
) => {
const cacheEntry: RenderCache = { const cacheEntry: RenderCache = {
text: snapshot.text, text: snapshot.text,
html: renderedHtml, html: renderedHtml,
@@ -131,7 +135,9 @@ export function Markdown(props: MarkdownProps) {
mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`, mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`,
} }
setHtml(renderedHtml) setHtml(renderedHtml)
cacheHandle.set(cacheEntry) if (options?.cache ?? true) {
cacheHandle.set(cacheEntry)
}
notifyRendered() notifyRendered()
} }
@@ -142,9 +148,10 @@ export function Markdown(props: MarkdownProps) {
suppressHighlight: !snapshot.highlightEnabled, suppressHighlight: !snapshot.highlightEnabled,
escapeRawHtml: snapshot.escapeRawHtml, escapeRawHtml: snapshot.escapeRawHtml,
}) })
const shouldCache = !snapshot.highlightEnabled || !markdown.hasPendingCodeHighlight(snapshot.text)
if (latestRequestKey === snapshot.requestKey) { if (latestRequestKey === snapshot.requestKey) {
commitCacheEntry(snapshot, rendered) commitCacheEntry(snapshot, rendered, { cache: shouldCache })
} }
} }

View File

@@ -19,7 +19,12 @@ import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
import { usePromptPicker } from "./prompt-input/usePromptPicker" import { usePromptPicker } from "./prompt-input/usePromptPicker"
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown" import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
import { usePromptVoiceInput } from "./prompt-input/usePromptVoiceInput" import { usePromptVoiceInput } from "./prompt-input/usePromptVoiceInput"
import { canUseConversationMode, isConversationModeEnabled, toggleConversationMode } from "../stores/conversation-speech" import {
canUseConversationMode,
clearConversationPlaybackForInstance,
isConversationModeEnabled,
toggleConversationMode,
} from "../stores/conversation-speech"
const log = getLogger("actions") const log = getLogger("actions")
const LazyUnifiedPicker = lazy(() => import("./unified-picker")) const LazyUnifiedPicker = lazy(() => import("./unified-picker"))
@@ -492,6 +497,8 @@ export default function PromptInput(props: PromptInputProps) {
const beginVoicePress = (event?: PointerEvent | KeyboardEvent) => { const beginVoicePress = (event?: PointerEvent | KeyboardEvent) => {
if (voiceButtonPressed || props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput()) return if (voiceButtonPressed || props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput()) return
voiceButtonPressed = true voiceButtonPressed = true
// Treat a mic press as barge-in: stop any active assistant speech before listening.
clearConversationPlaybackForInstance(props.instanceId)
if (event instanceof PointerEvent) { if (event instanceof PointerEvent) {
const target = event.currentTarget const target = event.currentTarget

View File

@@ -1,8 +1,8 @@
import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCleanup } from "solid-js" import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCleanup } from "solid-js"
import type { SessionStatus } from "../types/session" import type { SessionStatus } from "../types/session"
import type { SessionThread } from "../stores/session-state" import type { SessionThread } from "../stores/session-state"
import { getSessionStatus } from "../stores/session-status" import { getRetrySeconds, getSessionRetry, getSessionStatus } from "../stores/session-status"
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown, Search, Square, CheckSquare, MinusSquare, Split } from "lucide-solid" import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown, Search, Square, CheckSquare, MinusSquare, Split, RotateCw } from "lucide-solid"
import KeyboardHint from "./keyboard-hint" import KeyboardHint from "./keyboard-hint"
import SessionRenameDialog from "./session-rename-dialog" import SessionRenameDialog from "./session-rename-dialog"
import { keyboardRegistry } from "../lib/keyboard-registry" import { keyboardRegistry } from "../lib/keyboard-registry"
@@ -14,6 +14,7 @@ import {
ensureSessionParentExpanded, ensureSessionParentExpanded,
getVisibleSessionIds, getVisibleSessionIds,
isSessionParentExpanded, isSessionParentExpanded,
loadMessages,
loading, loading,
renameSession, renameSession,
sessions as sessionStateSessions, sessions as sessionStateSessions,
@@ -53,6 +54,14 @@ const SessionList: Component<SessionListProps> = (props) => {
const normalizedQuery = createMemo(() => (props.enableFilterBar ? filterQuery().trim().toLowerCase() : "")) const normalizedQuery = createMemo(() => (props.enableFilterBar ? filterQuery().trim().toLowerCase() : ""))
const [selectedSessionIds, setSelectedSessionIds] = createSignal<Set<string>>(new Set()) const [selectedSessionIds, setSelectedSessionIds] = createSignal<Set<string>>(new Set())
const [reloadingSessionIds, setReloadingSessionIds] = createSignal<Set<string>>(new Set())
const [now, setNow] = createSignal(Date.now())
createEffect(() => {
if (typeof window === "undefined") return
const timer = window.setInterval(() => setNow(Date.now()), 1000)
onCleanup(() => window.clearInterval(timer))
})
const normalizeSessionLabel = (sessionId: string) => { const normalizeSessionLabel = (sessionId: string) => {
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId) const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
@@ -213,6 +222,32 @@ const SessionList: Component<SessionListProps> = (props) => {
setRenameTarget({ id: sessionId, title: session.title ?? "", label }) setRenameTarget({ id: sessionId, title: session.title ?? "", label })
} }
const isSessionReloading = (sessionId: string) => reloadingSessionIds().has(sessionId)
const handleReloadSession = async (event: MouseEvent, sessionId: string) => {
event.stopPropagation()
if (isSessionReloading(sessionId)) return
setReloadingSessionIds((prev) => {
const next = new Set(prev)
next.add(sessionId)
return next
})
try {
await loadMessages(props.instanceId, sessionId, true)
} catch (error) {
log.error(`Failed to reload session ${sessionId}:`, error)
showToastNotification({ message: t("sessionList.reload.error"), variant: "error" })
} finally {
setReloadingSessionIds((prev) => {
const next = new Set(prev)
next.delete(sessionId)
return next
})
}
}
const closeRenameDialog = () => { const closeRenameDialog = () => {
setRenameTarget(null) setRenameTarget(null)
} }
@@ -372,7 +407,13 @@ const SessionList: Component<SessionListProps> = (props) => {
const isActive = () => props.activeSessionId === rowProps.sessionId const isActive = () => props.activeSessionId === rowProps.sessionId
const title = () => session()?.title || t("sessionList.session.untitled") const title = () => session()?.title || t("sessionList.session.untitled")
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId) const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
const retry = () => getSessionRetry(props.instanceId, rowProps.sessionId)
const statusLabel = () => { const statusLabel = () => {
const retryState = retry()
if (retryState) {
const seconds = getRetrySeconds(retryState.next, now())
return seconds > 0 ? t("sessionList.status.retryingIn", { seconds: String(seconds) }) : t("sessionList.status.retrying")
}
switch (formatSessionStatus(status())) { switch (formatSessionStatus(status())) {
case "working": case "working":
return t("sessionList.status.working") return t("sessionList.status.working")
@@ -385,13 +426,21 @@ const SessionList: Component<SessionListProps> = (props) => {
const needsPermission = () => Boolean(session()?.pendingPermission) const needsPermission = () => Boolean(session()?.pendingPermission)
const needsQuestion = () => Boolean((session() as any)?.pendingQuestion) const needsQuestion = () => Boolean((session() as any)?.pendingQuestion)
const needsInput = () => needsPermission() || needsQuestion() const needsInput = () => needsPermission() || needsQuestion()
const statusClassName = () => (needsInput() ? "session-permission" : `session-${status()}`) const statusClassName = () => (needsInput() ? "session-permission" : `session-${retry() ? "retrying" : status()}`)
const statusText = () => const statusText = () =>
needsPermission() needsPermission()
? t("sessionList.status.needsPermission") ? t("sessionList.status.needsPermission")
: needsQuestion() : needsQuestion()
? t("sessionList.status.needsInput") ? t("sessionList.status.needsInput")
: statusLabel() : statusLabel()
const statusTooltip = () => {
const retryState = retry()
if (!retryState) return undefined
return t("sessionList.status.retryTooltip", {
message: retryState.message,
attempt: String(retryState.attempt),
})
}
const isSelected = () => selectedSessionIds().has(rowProps.sessionId) const isSelected = () => selectedSessionIds().has(rowProps.sessionId)
@@ -471,7 +520,7 @@ const SessionList: Component<SessionListProps> = (props) => {
<ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} /> <ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} />
</span> </span>
</Show> </Show>
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}> <span class={`status-indicator session-status session-status-list ${statusClassName()}`} title={statusTooltip()}>
{needsInput() ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />} {needsInput() ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
{statusText()} {statusText()}
</span> </span>
@@ -493,6 +542,21 @@ 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) => handleReloadSession(event, rowProps.sessionId)}
role="button"
tabIndex={0}
aria-label={t("sessionList.actions.reload.ariaLabel")}
title={t("sessionList.actions.reload.title")}
>
<Show
when={!isSessionReloading(rowProps.sessionId)}
fallback={<RotateCw class="w-3 h-3 animate-spin" />}
>
<RotateCw class="w-3 h-3" />
</Show>
</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) => { onClick={(event) => {

View File

@@ -24,6 +24,7 @@ import type {
WorktreeMap, WorktreeMap,
WorktreeCreateRequest, WorktreeCreateRequest,
} from "../../../server/src/api-types" } from "../../../server/src/api-types"
import { getClientIdentity } from "./client-identity"
import { getLogger } from "./logger" import { getLogger } from "./logger"
const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined
@@ -350,9 +351,16 @@ export const serverApi = {
) )
}, },
updateVoiceMode(instanceId: string, enabled: boolean): Promise<VoiceModeStateResponse> { updateVoiceMode(instanceId: string, enabled: boolean): Promise<VoiceModeStateResponse> {
const identity = getClientIdentity()
return request<VoiceModeStateResponse>(`/workspaces/${encodeURIComponent(instanceId)}/plugin/voice-mode`, { return request<VoiceModeStateResponse>(`/workspaces/${encodeURIComponent(instanceId)}/plugin/voice-mode`, {
method: "POST", method: "POST",
body: JSON.stringify({ enabled }), body: JSON.stringify({ ...identity, enabled }),
})
},
sendClientConnectionPong(payload: { clientId: string; connectionId: string; pingTs?: number }): Promise<void> {
return request<void>("/api/client-connections/pong", {
method: "POST",
body: JSON.stringify(payload),
}) })
}, },
fetchBackgroundProcessOutput( fetchBackgroundProcessOutput(
@@ -379,9 +387,15 @@ export const serverApi = {
`/workspaces/${encodeURIComponent(instanceId)}/plugin/background-processes/${encodeURIComponent(processId)}/output${suffix}`, `/workspaces/${encodeURIComponent(instanceId)}/plugin/background-processes/${encodeURIComponent(processId)}/output${suffix}`,
) )
}, },
connectEvents(onEvent: (event: WorkspaceEventPayload) => void, onError?: () => void) { connectEvents(
sseLogger.info(`Connecting to ${EVENTS_URL}`) onEvent: (event: WorkspaceEventPayload) => void,
const source = new EventSource(EVENTS_URL, { withCredentials: true } as any) onError?: () => void,
onPing?: (payload: { ts?: number }) => void,
) {
const identity = getClientIdentity()
const url = buildClientEventsUrl(identity)
sseLogger.info(`Connecting to ${url}`)
const source = new EventSource(url, { withCredentials: true } as any)
source.onmessage = (event) => { source.onmessage = (event) => {
try { try {
const payload = JSON.parse(event.data) as WorkspaceEventPayload const payload = JSON.parse(event.data) as WorkspaceEventPayload
@@ -394,8 +408,26 @@ export const serverApi = {
sseLogger.warn("EventSource error, closing stream") sseLogger.warn("EventSource error, closing stream")
onError?.() onError?.()
} }
source.addEventListener("codenomad.client.ping", (event: MessageEvent) => {
try {
const payload = event.data ? (JSON.parse(event.data) as { ts?: number }) : {}
onPing?.(payload)
} catch (error) {
sseLogger.error("Failed to parse ping event", error)
}
})
return source return source
}, },
} }
function buildClientEventsUrl(identity: { clientId: string; connectionId: string }): string {
const url = new URL(EVENTS_URL, typeof window !== "undefined" ? window.location.origin : "http://localhost")
url.searchParams.set("clientId", identity.clientId)
url.searchParams.set("connectionId", identity.connectionId)
if (EVENTS_URL.startsWith("http://") || EVENTS_URL.startsWith("https://")) {
return url.toString()
}
return `${url.pathname}${url.search}`
}
export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType } export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType }

View File

@@ -0,0 +1,58 @@
const CLIENT_ID_STORAGE_KEY = "codenomad.client-id"
const CONNECTION_ID_STORAGE_KEY = "codenomad.connection-id"
let cachedClientId: string | null = null
let cachedConnectionId: string | null = null
export function getClientIdentity(): { clientId: string; connectionId: string } {
return {
clientId: getOrCreateClientId(),
connectionId: getOrCreateConnectionId(),
}
}
function getOrCreateClientId(): string {
if (cachedClientId) return cachedClientId
cachedClientId = getOrCreateStoredValue(CLIENT_ID_STORAGE_KEY, window.localStorage)
return cachedClientId
}
function getOrCreateConnectionId(): string {
if (cachedConnectionId) return cachedConnectionId
cachedConnectionId = getOrCreateStoredValue(CONNECTION_ID_STORAGE_KEY, window.sessionStorage)
return cachedConnectionId
}
function getOrCreateStoredValue(key: string, storage: Storage): string {
if (typeof window === "undefined") {
return generateUUID()
}
try {
const existing = storage.getItem(key)
if (existing && existing.trim()) {
return existing.trim()
}
} catch {
return generateUUID()
}
const next = generateUUID()
try {
storage.setItem(key, next)
} catch {
// Ignore storage failures and fall back to the in-memory value.
}
return next
}
function generateUUID(): string {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID()
}
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (char) => {
const random = (Math.random() * 16) | 0
const value = char === "x" ? random : (random & 0x3) | 0x8
return value.toString(16)
})
}

View File

@@ -19,9 +19,6 @@ export function formatCompactCount(value: number): string {
return `${(value / 1_000_000).toFixed(1)}M` return `${(value / 1_000_000).toFixed(1)}M`
} }
if (value >= 10_000) { if (value >= 10_000) {
return `${Math.round(value / 1_000)}K`
}
if (value >= 1_000) {
const label = `${(value / 1_000).toFixed(1)}K` const label = `${(value / 1_000).toFixed(1)}K`
return label.replace(/\.0K$/, "K") return label.replace(/\.0K$/, "K")
} }

View File

@@ -26,7 +26,6 @@ export const instanceMessages = {
"instanceShell.leftPanel.sessionsTitle": "Sessions", "instanceShell.leftPanel.sessionsTitle": "Sessions",
"instanceShell.leftPanel.instanceInfo": "Instance Info", "instanceShell.leftPanel.instanceInfo": "Instance Info",
"instanceShell.leftDrawer.pin": "Pin left drawer", "instanceShell.leftDrawer.pin": "Pin left drawer",
"instanceShell.leftDrawer.unpin": "Unpin left drawer", "instanceShell.leftDrawer.unpin": "Unpin left drawer",
"instanceShell.leftDrawer.toggle.pinned": "Left drawer pinned", "instanceShell.leftDrawer.toggle.pinned": "Left drawer pinned",
@@ -107,6 +106,8 @@ export const instanceMessages = {
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Cancel", "instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Cancel",
"instanceShell.rightPanel.toast.saveSuccess": "File saved successfully", "instanceShell.rightPanel.toast.saveSuccess": "File saved successfully",
"instanceShell.rightPanel.toast.saveError": "Failed to save file", "instanceShell.rightPanel.toast.saveError": "Failed to save file",
"instanceShell.rightPanel.sections.yoloMode": "Yolo Mode",
"instanceShell.rightPanel.sections.yoloMode.tooltip": "Automatically approves permission requests for the current session. Use it only when you trust the tools being run.",
"instanceShell.rightPanel.sections.sessionChanges": "Session Changes", "instanceShell.rightPanel.sections.sessionChanges": "Session Changes",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Files modified in the current session. Shows additions and deletions for each file.", "instanceShell.rightPanel.sections.sessionChanges.tooltip": "Files modified in the current session. Shows additions and deletions for each file.",
"instanceShell.rightPanel.sections.plan": "Plan", "instanceShell.rightPanel.sections.plan": "Plan",
@@ -150,6 +151,12 @@ export const instanceMessages = {
"instanceShell.plan.noSessionSelected": "Select a session to view plan.", "instanceShell.plan.noSessionSelected": "Select a session to view plan.",
"instanceShell.plan.empty": "Nothing planned yet.", "instanceShell.plan.empty": "Nothing planned yet.",
"instanceShell.yoloMode.noSessionSelected": "Select a session to configure Yolo mode.",
"instanceShell.yoloMode.title": "Yolo mode",
"instanceShell.yoloMode.description": "Automatically approve permission requests for this session. Disabled by default.",
"instanceShell.yoloMode.badge": "Yolo mode",
"instanceShell.yoloMode.badgeAriaLabel": "Yolo mode enabled",
"instanceShell.backgroundProcesses.empty": "No background processes.", "instanceShell.backgroundProcesses.empty": "No background processes.",
"instanceShell.backgroundProcesses.status": "Status: {status}", "instanceShell.backgroundProcesses.status": "Status: {status}",
"instanceShell.backgroundProcesses.output": "Output: {sizeKb}KB", "instanceShell.backgroundProcesses.output": "Output: {sizeKb}KB",

View File

@@ -15,6 +15,10 @@ export const sessionMessages = {
"sessionList.status.working": "Working", "sessionList.status.working": "Working",
"sessionList.status.compacting": "Compacting", "sessionList.status.compacting": "Compacting",
"sessionList.status.idle": "Idle", "sessionList.status.idle": "Idle",
"sessionList.status.retrying": "Retrying",
"sessionList.status.retryingIn": "Retrying in {seconds}s",
"sessionList.status.retryTooltip": "{message} (Attempt {attempt})",
"sessionList.status.retryToast": "{countdown}: {message} (Attempt {attempt})",
"sessionList.status.needsPermission": "Needs Permission", "sessionList.status.needsPermission": "Needs Permission",
"sessionList.status.needsInput": "Needs Input", "sessionList.status.needsInput": "Needs Input",
"sessionList.expand.collapseAriaLabel": "Collapse session", "sessionList.expand.collapseAriaLabel": "Collapse session",
@@ -25,12 +29,15 @@ export const sessionMessages = {
"sessionList.actions.newSession.title": "New session", "sessionList.actions.newSession.title": "New session",
"sessionList.actions.copyId.ariaLabel": "Copy session ID", "sessionList.actions.copyId.ariaLabel": "Copy session ID",
"sessionList.actions.copyId.title": "Copy session ID", "sessionList.actions.copyId.title": "Copy session ID",
"sessionList.actions.reload.ariaLabel": "Reload session",
"sessionList.actions.reload.title": "Reload session",
"sessionList.actions.rename.ariaLabel": "Rename session", "sessionList.actions.rename.ariaLabel": "Rename session",
"sessionList.actions.rename.title": "Rename session", "sessionList.actions.rename.title": "Rename session",
"sessionList.actions.delete.ariaLabel": "Delete session", "sessionList.actions.delete.ariaLabel": "Delete session",
"sessionList.actions.delete.title": "Delete session", "sessionList.actions.delete.title": "Delete session",
"sessionList.copyId.success": "Session ID copied", "sessionList.copyId.success": "Session ID copied",
"sessionList.copyId.error": "Unable to copy session ID", "sessionList.copyId.error": "Unable to copy session ID",
"sessionList.reload.error": "Unable to reload session",
"sessionList.delete.error": "Unable to delete session", "sessionList.delete.error": "Unable to delete session",
"sessionList.delete.title": "Delete session", "sessionList.delete.title": "Delete session",
"sessionList.delete.confirmMessage": "Delete \"{label}\"? This cannot be undone.", "sessionList.delete.confirmMessage": "Delete \"{label}\"? This cannot be undone.",

View File

@@ -26,7 +26,6 @@ export const instanceMessages = {
"instanceShell.leftPanel.sessionsTitle": "Sesiones", "instanceShell.leftPanel.sessionsTitle": "Sesiones",
"instanceShell.leftPanel.instanceInfo": "Info de la instancia", "instanceShell.leftPanel.instanceInfo": "Info de la instancia",
"instanceShell.leftDrawer.pin": "Fijar panel izquierdo", "instanceShell.leftDrawer.pin": "Fijar panel izquierdo",
"instanceShell.leftDrawer.unpin": "Desfijar panel izquierdo", "instanceShell.leftDrawer.unpin": "Desfijar panel izquierdo",
"instanceShell.leftDrawer.toggle.pinned": "Panel izquierdo fijado", "instanceShell.leftDrawer.toggle.pinned": "Panel izquierdo fijado",
@@ -107,6 +106,8 @@ export const instanceMessages = {
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Cancelar", "instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Cancelar",
"instanceShell.rightPanel.toast.saveSuccess": "Archivo guardado exitosamente", "instanceShell.rightPanel.toast.saveSuccess": "Archivo guardado exitosamente",
"instanceShell.rightPanel.toast.saveError": "Error al guardar el archivo", "instanceShell.rightPanel.toast.saveError": "Error al guardar el archivo",
"instanceShell.rightPanel.sections.yoloMode": "Modo yolo",
"instanceShell.rightPanel.sections.yoloMode.tooltip": "Aprueba automaticamente las solicitudes de permiso de la sesion actual. Usalo solo si confias en las herramientas que se estan ejecutando.",
"instanceShell.rightPanel.sections.sessionChanges": "Cambios de sesión", "instanceShell.rightPanel.sections.sessionChanges": "Cambios de sesión",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Archivos modificados en la sesión actual. Muestra las adiciones y eliminaciones de cada archivo.", "instanceShell.rightPanel.sections.sessionChanges.tooltip": "Archivos modificados en la sesión actual. Muestra las adiciones y eliminaciones de cada archivo.",
"instanceShell.rightPanel.sections.plan": "Plan", "instanceShell.rightPanel.sections.plan": "Plan",
@@ -140,6 +141,12 @@ export const instanceMessages = {
"instanceShell.plan.noSessionSelected": "Selecciona una sesión para ver el plan.", "instanceShell.plan.noSessionSelected": "Selecciona una sesión para ver el plan.",
"instanceShell.plan.empty": "Aún no hay nada planificado.", "instanceShell.plan.empty": "Aún no hay nada planificado.",
"instanceShell.yoloMode.noSessionSelected": "Selecciona una sesion para configurar el modo yolo.",
"instanceShell.yoloMode.title": "Modo yolo",
"instanceShell.yoloMode.description": "Aprueba automaticamente las solicitudes de permiso de esta sesion. Esta desactivado por defecto.",
"instanceShell.yoloMode.badge": "Modo yolo",
"instanceShell.yoloMode.badgeAriaLabel": "Modo yolo activado",
"instanceShell.backgroundProcesses.empty": "No hay procesos en segundo plano.", "instanceShell.backgroundProcesses.empty": "No hay procesos en segundo plano.",
"instanceShell.backgroundProcesses.status": "Estado: {status}", "instanceShell.backgroundProcesses.status": "Estado: {status}",
"instanceShell.backgroundProcesses.output": "Salida: {sizeKb} KB", "instanceShell.backgroundProcesses.output": "Salida: {sizeKb} KB",

View File

@@ -15,6 +15,10 @@ export const sessionMessages = {
"sessionList.status.working": "Trabajando", "sessionList.status.working": "Trabajando",
"sessionList.status.compacting": "Compactando", "sessionList.status.compacting": "Compactando",
"sessionList.status.idle": "Inactiva", "sessionList.status.idle": "Inactiva",
"sessionList.status.retrying": "Reintentando",
"sessionList.status.retryingIn": "Reintentando en {seconds}s",
"sessionList.status.retryTooltip": "{message} (Intento {attempt})",
"sessionList.status.retryToast": "{countdown}: {message} (Intento {attempt})",
"sessionList.status.needsPermission": "Requiere permiso", "sessionList.status.needsPermission": "Requiere permiso",
"sessionList.status.needsInput": "Requiere entrada", "sessionList.status.needsInput": "Requiere entrada",
"sessionList.expand.collapseAriaLabel": "Colapsar sesión", "sessionList.expand.collapseAriaLabel": "Colapsar sesión",
@@ -25,12 +29,15 @@ export const sessionMessages = {
"sessionList.actions.newSession.title": "Nueva sesión", "sessionList.actions.newSession.title": "Nueva sesión",
"sessionList.actions.copyId.ariaLabel": "Copiar ID de sesión", "sessionList.actions.copyId.ariaLabel": "Copiar ID de sesión",
"sessionList.actions.copyId.title": "Copiar ID de sesión", "sessionList.actions.copyId.title": "Copiar ID de sesión",
"sessionList.actions.reload.ariaLabel": "Recargar sesión",
"sessionList.actions.reload.title": "Recargar sesión",
"sessionList.actions.rename.ariaLabel": "Renombrar sesión", "sessionList.actions.rename.ariaLabel": "Renombrar sesión",
"sessionList.actions.rename.title": "Renombrar sesión", "sessionList.actions.rename.title": "Renombrar sesión",
"sessionList.actions.delete.ariaLabel": "Eliminar sesión", "sessionList.actions.delete.ariaLabel": "Eliminar sesión",
"sessionList.actions.delete.title": "Eliminar sesión", "sessionList.actions.delete.title": "Eliminar sesión",
"sessionList.copyId.success": "ID de sesión copiado", "sessionList.copyId.success": "ID de sesión copiado",
"sessionList.copyId.error": "No se pudo copiar el ID de sesión", "sessionList.copyId.error": "No se pudo copiar el ID de sesión",
"sessionList.reload.error": "No se pudo recargar la sesión",
"sessionList.delete.error": "No se pudo eliminar la sesión", "sessionList.delete.error": "No se pudo eliminar la sesión",
"sessionList.delete.title": "Eliminar sesión", "sessionList.delete.title": "Eliminar sesión",
"sessionList.delete.confirmMessage": "¿Eliminar \"{label}\"? Esto no se puede deshacer.", "sessionList.delete.confirmMessage": "¿Eliminar \"{label}\"? Esto no se puede deshacer.",

View File

@@ -26,7 +26,6 @@ export const instanceMessages = {
"instanceShell.leftPanel.sessionsTitle": "Sessions", "instanceShell.leftPanel.sessionsTitle": "Sessions",
"instanceShell.leftPanel.instanceInfo": "Infos de l'instance", "instanceShell.leftPanel.instanceInfo": "Infos de l'instance",
"instanceShell.leftDrawer.pin": "Épingler le tiroir gauche", "instanceShell.leftDrawer.pin": "Épingler le tiroir gauche",
"instanceShell.leftDrawer.unpin": "Désépingler le tiroir gauche", "instanceShell.leftDrawer.unpin": "Désépingler le tiroir gauche",
"instanceShell.leftDrawer.toggle.pinned": "Tiroir gauche épinglé", "instanceShell.leftDrawer.toggle.pinned": "Tiroir gauche épinglé",
@@ -107,6 +106,8 @@ export const instanceMessages = {
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Annuler", "instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Annuler",
"instanceShell.rightPanel.toast.saveSuccess": "Fichier enregistré avec succès", "instanceShell.rightPanel.toast.saveSuccess": "Fichier enregistré avec succès",
"instanceShell.rightPanel.toast.saveError": "Échec de l'enregistrement du fichier", "instanceShell.rightPanel.toast.saveError": "Échec de l'enregistrement du fichier",
"instanceShell.rightPanel.sections.yoloMode": "Mode yolo",
"instanceShell.rightPanel.sections.yoloMode.tooltip": "Approuve automatiquement les demandes d'autorisation pour la session actuelle. A utiliser seulement si vous faites confiance aux outils executes.",
"instanceShell.rightPanel.sections.sessionChanges": "Changements de session", "instanceShell.rightPanel.sections.sessionChanges": "Changements de session",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Fichiers modifiés dans la session actuelle. Affiche les ajouts et suppressions pour chaque fichier.", "instanceShell.rightPanel.sections.sessionChanges.tooltip": "Fichiers modifiés dans la session actuelle. Affiche les ajouts et suppressions pour chaque fichier.",
"instanceShell.rightPanel.sections.plan": "Plan", "instanceShell.rightPanel.sections.plan": "Plan",
@@ -140,6 +141,12 @@ export const instanceMessages = {
"instanceShell.plan.noSessionSelected": "Sélectionnez une session pour voir le plan.", "instanceShell.plan.noSessionSelected": "Sélectionnez une session pour voir le plan.",
"instanceShell.plan.empty": "Aucun plan pour l'instant.", "instanceShell.plan.empty": "Aucun plan pour l'instant.",
"instanceShell.yoloMode.noSessionSelected": "Selectionnez une session pour configurer le mode yolo.",
"instanceShell.yoloMode.title": "Mode yolo",
"instanceShell.yoloMode.description": "Approuve automatiquement les demandes d'autorisation pour cette session. Desactive par defaut.",
"instanceShell.yoloMode.badge": "Mode yolo",
"instanceShell.yoloMode.badgeAriaLabel": "Mode yolo active",
"instanceShell.backgroundProcesses.empty": "Aucun processus en arrière-plan.", "instanceShell.backgroundProcesses.empty": "Aucun processus en arrière-plan.",
"instanceShell.backgroundProcesses.status": "Statut : {status}", "instanceShell.backgroundProcesses.status": "Statut : {status}",
"instanceShell.backgroundProcesses.output": "Sortie : {sizeKb}KB", "instanceShell.backgroundProcesses.output": "Sortie : {sizeKb}KB",

View File

@@ -15,6 +15,10 @@ export const sessionMessages = {
"sessionList.status.working": "En cours", "sessionList.status.working": "En cours",
"sessionList.status.compacting": "Compactage", "sessionList.status.compacting": "Compactage",
"sessionList.status.idle": "Inactif", "sessionList.status.idle": "Inactif",
"sessionList.status.retrying": "Nouvelle tentative",
"sessionList.status.retryingIn": "Nouvelle tentative dans {seconds}s",
"sessionList.status.retryTooltip": "{message} (Tentative {attempt})",
"sessionList.status.retryToast": "{countdown} : {message} (Tentative {attempt})",
"sessionList.status.needsPermission": "Autorisation requise", "sessionList.status.needsPermission": "Autorisation requise",
"sessionList.status.needsInput": "Entrée requise", "sessionList.status.needsInput": "Entrée requise",
"sessionList.expand.collapseAriaLabel": "Réduire la session", "sessionList.expand.collapseAriaLabel": "Réduire la session",
@@ -25,12 +29,15 @@ export const sessionMessages = {
"sessionList.actions.newSession.title": "Nouvelle session", "sessionList.actions.newSession.title": "Nouvelle session",
"sessionList.actions.copyId.ariaLabel": "Copier l'ID de session", "sessionList.actions.copyId.ariaLabel": "Copier l'ID de session",
"sessionList.actions.copyId.title": "Copier l'ID de session", "sessionList.actions.copyId.title": "Copier l'ID de session",
"sessionList.actions.reload.ariaLabel": "Recharger la session",
"sessionList.actions.reload.title": "Recharger la session",
"sessionList.actions.rename.ariaLabel": "Renommer la session", "sessionList.actions.rename.ariaLabel": "Renommer la session",
"sessionList.actions.rename.title": "Renommer la session", "sessionList.actions.rename.title": "Renommer la session",
"sessionList.actions.delete.ariaLabel": "Supprimer la session", "sessionList.actions.delete.ariaLabel": "Supprimer la session",
"sessionList.actions.delete.title": "Supprimer la session", "sessionList.actions.delete.title": "Supprimer la session",
"sessionList.copyId.success": "ID de session copié", "sessionList.copyId.success": "ID de session copié",
"sessionList.copyId.error": "Impossible de copier l'ID de session", "sessionList.copyId.error": "Impossible de copier l'ID de session",
"sessionList.reload.error": "Impossible de recharger la session",
"sessionList.delete.error": "Impossible de supprimer la session", "sessionList.delete.error": "Impossible de supprimer la session",
"sessionList.delete.title": "Supprimer la session", "sessionList.delete.title": "Supprimer la session",
"sessionList.delete.confirmMessage": "Supprimer \"{label}\" ? Cette action est irréversible.", "sessionList.delete.confirmMessage": "Supprimer \"{label}\" ? Cette action est irréversible.",

View File

@@ -26,7 +26,6 @@ export const instanceMessages = {
"instanceShell.leftPanel.sessionsTitle": "סשנים", "instanceShell.leftPanel.sessionsTitle": "סשנים",
"instanceShell.leftPanel.instanceInfo": "מידע על המופע", "instanceShell.leftPanel.instanceInfo": "מידע על המופע",
"instanceShell.leftDrawer.pin": "נעץ מגירה שמאלית", "instanceShell.leftDrawer.pin": "נעץ מגירה שמאלית",
"instanceShell.leftDrawer.unpin": "שחרר נעיצת מגירה שמאלית", "instanceShell.leftDrawer.unpin": "שחרר נעיצת מגירה שמאלית",
"instanceShell.leftDrawer.toggle.pinned": "המגירה השמאלית נעוצה", "instanceShell.leftDrawer.toggle.pinned": "המגירה השמאלית נעוצה",
@@ -107,6 +106,8 @@ export const instanceMessages = {
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "בטל", "instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "בטל",
"instanceShell.rightPanel.toast.saveSuccess": "הקובץ נשמר בהצלחה", "instanceShell.rightPanel.toast.saveSuccess": "הקובץ נשמר בהצלחה",
"instanceShell.rightPanel.toast.saveError": "כשלון בשמירת הקובץ", "instanceShell.rightPanel.toast.saveError": "כשלון בשמירת הקובץ",
"instanceShell.rightPanel.sections.yoloMode": "מצב Yolo",
"instanceShell.rightPanel.sections.yoloMode.tooltip": "מאשר אוטומטית בקשות הרשאה עבור הסשן הנוכחי. השתמשו בזה רק אם אתם סומכים על הכלים שרצים.",
"instanceShell.rightPanel.sections.sessionChanges": "שינויי סשן", "instanceShell.rightPanel.sections.sessionChanges": "שינויי סשן",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "קבצים שהשתנו בסשן הנוכחי. מציג הוספות ומחיקות לכל קובץ.", "instanceShell.rightPanel.sections.sessionChanges.tooltip": "קבצים שהשתנו בסשן הנוכחי. מציג הוספות ומחיקות לכל קובץ.",
"instanceShell.rightPanel.sections.plan": "תוכנית", "instanceShell.rightPanel.sections.plan": "תוכנית",
@@ -148,6 +149,12 @@ export const instanceMessages = {
"instanceShell.plan.noSessionSelected": "בחר סשן לצפייה בתוכנית.", "instanceShell.plan.noSessionSelected": "בחר סשן לצפייה בתוכנית.",
"instanceShell.plan.empty": "עדיין לא תוכנן דבר.", "instanceShell.plan.empty": "עדיין לא תוכנן דבר.",
"instanceShell.yoloMode.noSessionSelected": "בחרו סשן כדי להגדיר מצב Yolo.",
"instanceShell.yoloMode.title": "מצב Yolo",
"instanceShell.yoloMode.description": "מאשר אוטומטית בקשות הרשאה עבור הסשן הזה. כבוי כברירת מחדל.",
"instanceShell.yoloMode.badge": "Yolo",
"instanceShell.yoloMode.badgeAriaLabel": "מצב Yolo פעיל",
"instanceShell.backgroundProcesses.empty": "אין תהליכי רקע.", "instanceShell.backgroundProcesses.empty": "אין תהליכי רקע.",
"instanceShell.backgroundProcesses.status": "סטטוס: {status}", "instanceShell.backgroundProcesses.status": "סטטוס: {status}",
"instanceShell.backgroundProcesses.output": "פלט: {sizeKb}KB", "instanceShell.backgroundProcesses.output": "פלט: {sizeKb}KB",

View File

@@ -15,6 +15,10 @@ export const sessionMessages = {
"sessionList.status.working": "עובד", "sessionList.status.working": "עובד",
"sessionList.status.compacting": "מסכם", "sessionList.status.compacting": "מסכם",
"sessionList.status.idle": "מוכן", "sessionList.status.idle": "מוכן",
"sessionList.status.retrying": "מנסה שוב",
"sessionList.status.retryingIn": "מנסה שוב בעוד {seconds}ש׳",
"sessionList.status.retryTooltip": "{message} (ניסיון {attempt})",
"sessionList.status.retryToast": "{countdown}: {message} (ניסיון {attempt})",
"sessionList.status.needsPermission": "נדרש אישור", "sessionList.status.needsPermission": "נדרש אישור",
"sessionList.status.needsInput": "נדרש קלט", "sessionList.status.needsInput": "נדרש קלט",
"sessionList.expand.collapseAriaLabel": "כווץ סשן", "sessionList.expand.collapseAriaLabel": "כווץ סשן",
@@ -25,12 +29,15 @@ export const sessionMessages = {
"sessionList.actions.newSession.title": "סשן חדש", "sessionList.actions.newSession.title": "סשן חדש",
"sessionList.actions.copyId.ariaLabel": "העתק מזהה סשן", "sessionList.actions.copyId.ariaLabel": "העתק מזהה סשן",
"sessionList.actions.copyId.title": "העתק מזהה סשן", "sessionList.actions.copyId.title": "העתק מזהה סשן",
"sessionList.actions.reload.ariaLabel": "טען מחדש סשן",
"sessionList.actions.reload.title": "טען מחדש סשן",
"sessionList.actions.rename.ariaLabel": "שנה שם סשן", "sessionList.actions.rename.ariaLabel": "שנה שם סשן",
"sessionList.actions.rename.title": "שנה שם סשן", "sessionList.actions.rename.title": "שנה שם סשן",
"sessionList.actions.delete.ariaLabel": "מחק סשן", "sessionList.actions.delete.ariaLabel": "מחק סשן",
"sessionList.actions.delete.title": "מחק סשן", "sessionList.actions.delete.title": "מחק סשן",
"sessionList.copyId.success": "מזהה סשן הועתק", "sessionList.copyId.success": "מזהה סשן הועתק",
"sessionList.copyId.error": "לא ניתן להעתיק מזהה סשן", "sessionList.copyId.error": "לא ניתן להעתיק מזהה סשן",
"sessionList.reload.error": "לא ניתן לטעון מחדש את הסשן",
"sessionList.delete.error": "לא ניתן למחוק סשן", "sessionList.delete.error": "לא ניתן למחוק סשן",
"sessionList.delete.title": "מחק סשן", "sessionList.delete.title": "מחק סשן",
"sessionList.delete.confirmMessage": "למחוק את \"{label}\"? לא ניתן לבטל פעולה זו.", "sessionList.delete.confirmMessage": "למחוק את \"{label}\"? לא ניתן לבטל פעולה זו.",

View File

@@ -26,7 +26,6 @@ export const instanceMessages = {
"instanceShell.leftPanel.sessionsTitle": "セッション", "instanceShell.leftPanel.sessionsTitle": "セッション",
"instanceShell.leftPanel.instanceInfo": "インスタンス情報", "instanceShell.leftPanel.instanceInfo": "インスタンス情報",
"instanceShell.leftDrawer.pin": "左ドロワーを固定", "instanceShell.leftDrawer.pin": "左ドロワーを固定",
"instanceShell.leftDrawer.unpin": "左ドロワーの固定を解除", "instanceShell.leftDrawer.unpin": "左ドロワーの固定を解除",
"instanceShell.leftDrawer.toggle.pinned": "左ドロワーを固定しました", "instanceShell.leftDrawer.toggle.pinned": "左ドロワーを固定しました",
@@ -107,6 +106,8 @@ export const instanceMessages = {
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "キャンセル", "instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "キャンセル",
"instanceShell.rightPanel.toast.saveSuccess": "ファイルを保存しました", "instanceShell.rightPanel.toast.saveSuccess": "ファイルを保存しました",
"instanceShell.rightPanel.toast.saveError": "ファイルの保存に失敗しました", "instanceShell.rightPanel.toast.saveError": "ファイルの保存に失敗しました",
"instanceShell.rightPanel.sections.yoloMode": "Yoloモード",
"instanceShell.rightPanel.sections.yoloMode.tooltip": "現在のセッションの権限リクエストを自動承認します。実行中のツールを信頼できる場合にのみ使用してください。",
"instanceShell.rightPanel.sections.sessionChanges": "セッション変更", "instanceShell.rightPanel.sections.sessionChanges": "セッション変更",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "現在のセッションで変更されたファイル。各ファイルの追加と削除を表示します。", "instanceShell.rightPanel.sections.sessionChanges.tooltip": "現在のセッションで変更されたファイル。各ファイルの追加と削除を表示します。",
"instanceShell.rightPanel.sections.plan": "計画", "instanceShell.rightPanel.sections.plan": "計画",
@@ -140,6 +141,12 @@ export const instanceMessages = {
"instanceShell.plan.noSessionSelected": "計画を表示するにはセッションを選択してください。", "instanceShell.plan.noSessionSelected": "計画を表示するにはセッションを選択してください。",
"instanceShell.plan.empty": "まだ計画はありません。", "instanceShell.plan.empty": "まだ計画はありません。",
"instanceShell.yoloMode.noSessionSelected": "Yoloモードを設定するにはセッションを選択してください。",
"instanceShell.yoloMode.title": "Yoloモード",
"instanceShell.yoloMode.description": "このセッションの権限リクエストを自動承認します。デフォルトでは無効です。",
"instanceShell.yoloMode.badge": "Yolo",
"instanceShell.yoloMode.badgeAriaLabel": "Yoloモードが有効",
"instanceShell.backgroundProcesses.empty": "バックグラウンドプロセスはありません。", "instanceShell.backgroundProcesses.empty": "バックグラウンドプロセスはありません。",
"instanceShell.backgroundProcesses.status": "状態: {status}", "instanceShell.backgroundProcesses.status": "状態: {status}",
"instanceShell.backgroundProcesses.output": "出力: {sizeKb}KB", "instanceShell.backgroundProcesses.output": "出力: {sizeKb}KB",

View File

@@ -15,6 +15,10 @@ export const sessionMessages = {
"sessionList.status.working": "作業中", "sessionList.status.working": "作業中",
"sessionList.status.compacting": "圧縮中", "sessionList.status.compacting": "圧縮中",
"sessionList.status.idle": "待機中", "sessionList.status.idle": "待機中",
"sessionList.status.retrying": "再試行中",
"sessionList.status.retryingIn": "{seconds}秒後に再試行",
"sessionList.status.retryTooltip": "{message}{attempt}回目)",
"sessionList.status.retryToast": "{countdown}: {message}{attempt}回目)",
"sessionList.status.needsPermission": "許可待ち", "sessionList.status.needsPermission": "許可待ち",
"sessionList.status.needsInput": "入力待ち", "sessionList.status.needsInput": "入力待ち",
"sessionList.expand.collapseAriaLabel": "セッションを折りたたむ", "sessionList.expand.collapseAriaLabel": "セッションを折りたたむ",
@@ -25,12 +29,15 @@ export const sessionMessages = {
"sessionList.actions.newSession.title": "新しいセッション", "sessionList.actions.newSession.title": "新しいセッション",
"sessionList.actions.copyId.ariaLabel": "セッション ID をコピー", "sessionList.actions.copyId.ariaLabel": "セッション ID をコピー",
"sessionList.actions.copyId.title": "セッション ID をコピー", "sessionList.actions.copyId.title": "セッション ID をコピー",
"sessionList.actions.reload.ariaLabel": "セッションを再読み込み",
"sessionList.actions.reload.title": "セッションを再読み込み",
"sessionList.actions.rename.ariaLabel": "セッション名を変更", "sessionList.actions.rename.ariaLabel": "セッション名を変更",
"sessionList.actions.rename.title": "セッション名を変更", "sessionList.actions.rename.title": "セッション名を変更",
"sessionList.actions.delete.ariaLabel": "セッションを削除", "sessionList.actions.delete.ariaLabel": "セッションを削除",
"sessionList.actions.delete.title": "セッションを削除", "sessionList.actions.delete.title": "セッションを削除",
"sessionList.copyId.success": "セッション ID をコピーしました", "sessionList.copyId.success": "セッション ID をコピーしました",
"sessionList.copyId.error": "セッション ID をコピーできません", "sessionList.copyId.error": "セッション ID をコピーできません",
"sessionList.reload.error": "セッションを再読み込みできません",
"sessionList.delete.error": "セッションを削除できません", "sessionList.delete.error": "セッションを削除できません",
"sessionList.delete.title": "セッションを削除", "sessionList.delete.title": "セッションを削除",
"sessionList.delete.confirmMessage": "\"{label}\" を削除しますか?この操作は元に戻せません。", "sessionList.delete.confirmMessage": "\"{label}\" を削除しますか?この操作は元に戻せません。",

View File

@@ -26,7 +26,6 @@ export const instanceMessages = {
"instanceShell.leftPanel.sessionsTitle": "Сессии", "instanceShell.leftPanel.sessionsTitle": "Сессии",
"instanceShell.leftPanel.instanceInfo": "Информация об экземпляре", "instanceShell.leftPanel.instanceInfo": "Информация об экземпляре",
"instanceShell.leftDrawer.pin": "Закрепить левую панель", "instanceShell.leftDrawer.pin": "Закрепить левую панель",
"instanceShell.leftDrawer.unpin": "Открепить левую панель", "instanceShell.leftDrawer.unpin": "Открепить левую панель",
"instanceShell.leftDrawer.toggle.pinned": "Левая панель закреплена", "instanceShell.leftDrawer.toggle.pinned": "Левая панель закреплена",
@@ -107,6 +106,8 @@ export const instanceMessages = {
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Отмена", "instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Отмена",
"instanceShell.rightPanel.toast.saveSuccess": "Файл успешно сохранён", "instanceShell.rightPanel.toast.saveSuccess": "Файл успешно сохранён",
"instanceShell.rightPanel.toast.saveError": "Не удалось сохранить файл", "instanceShell.rightPanel.toast.saveError": "Не удалось сохранить файл",
"instanceShell.rightPanel.sections.yoloMode": "Режим Yolo",
"instanceShell.rightPanel.sections.yoloMode.tooltip": "Автоматически одобряет запросы разрешений для текущей сессии. Включайте только если доверяете запускаемым инструментам.",
"instanceShell.rightPanel.sections.sessionChanges": "Изменения сессии", "instanceShell.rightPanel.sections.sessionChanges": "Изменения сессии",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Файлы, измененные в текущей сессии. Показывает добавления и удаления для каждого файла.", "instanceShell.rightPanel.sections.sessionChanges.tooltip": "Файлы, измененные в текущей сессии. Показывает добавления и удаления для каждого файла.",
"instanceShell.rightPanel.sections.plan": "План", "instanceShell.rightPanel.sections.plan": "План",
@@ -140,6 +141,12 @@ export const instanceMessages = {
"instanceShell.plan.noSessionSelected": "Выберите сессию, чтобы просмотреть план.", "instanceShell.plan.noSessionSelected": "Выберите сессию, чтобы просмотреть план.",
"instanceShell.plan.empty": "Пока ничего не запланировано.", "instanceShell.plan.empty": "Пока ничего не запланировано.",
"instanceShell.yoloMode.noSessionSelected": "Выберите сессию, чтобы настроить режим Yolo.",
"instanceShell.yoloMode.title": "Режим Yolo",
"instanceShell.yoloMode.description": "Автоматически одобряет запросы разрешений для этой сессии. По умолчанию выключен.",
"instanceShell.yoloMode.badge": "Yolo",
"instanceShell.yoloMode.badgeAriaLabel": "Режим Yolo включен",
"instanceShell.backgroundProcesses.empty": "Нет фоновых процессов.", "instanceShell.backgroundProcesses.empty": "Нет фоновых процессов.",
"instanceShell.backgroundProcesses.status": "Статус: {status}", "instanceShell.backgroundProcesses.status": "Статус: {status}",
"instanceShell.backgroundProcesses.output": "Вывод: {sizeKb}KB", "instanceShell.backgroundProcesses.output": "Вывод: {sizeKb}KB",

View File

@@ -15,6 +15,10 @@ export const sessionMessages = {
"sessionList.status.working": "Работает", "sessionList.status.working": "Работает",
"sessionList.status.compacting": "Компактация", "sessionList.status.compacting": "Компактация",
"sessionList.status.idle": "Простой", "sessionList.status.idle": "Простой",
"sessionList.status.retrying": "Повтор",
"sessionList.status.retryingIn": "Повтор через {seconds}с",
"sessionList.status.retryTooltip": "{message} (Попытка {attempt})",
"sessionList.status.retryToast": "{countdown}: {message} (Попытка {attempt})",
"sessionList.status.needsPermission": "Требуется разрешение", "sessionList.status.needsPermission": "Требуется разрешение",
"sessionList.status.needsInput": "Требуется ввод", "sessionList.status.needsInput": "Требуется ввод",
"sessionList.expand.collapseAriaLabel": "Свернуть сессию", "sessionList.expand.collapseAriaLabel": "Свернуть сессию",
@@ -25,12 +29,15 @@ export const sessionMessages = {
"sessionList.actions.newSession.title": "Новая сессия", "sessionList.actions.newSession.title": "Новая сессия",
"sessionList.actions.copyId.ariaLabel": "Скопировать ID сессии", "sessionList.actions.copyId.ariaLabel": "Скопировать ID сессии",
"sessionList.actions.copyId.title": "Скопировать ID сессии", "sessionList.actions.copyId.title": "Скопировать ID сессии",
"sessionList.actions.reload.ariaLabel": "Обновить сессию",
"sessionList.actions.reload.title": "Обновить сессию",
"sessionList.actions.rename.ariaLabel": "Переименовать сессию", "sessionList.actions.rename.ariaLabel": "Переименовать сессию",
"sessionList.actions.rename.title": "Переименовать сессию", "sessionList.actions.rename.title": "Переименовать сессию",
"sessionList.actions.delete.ariaLabel": "Удалить сессию", "sessionList.actions.delete.ariaLabel": "Удалить сессию",
"sessionList.actions.delete.title": "Удалить сессию", "sessionList.actions.delete.title": "Удалить сессию",
"sessionList.copyId.success": "ID сессии скопирован", "sessionList.copyId.success": "ID сессии скопирован",
"sessionList.copyId.error": "Не удалось скопировать ID сессии", "sessionList.copyId.error": "Не удалось скопировать ID сессии",
"sessionList.reload.error": "Не удалось обновить сессию",
"sessionList.delete.error": "Не удалось удалить сессию", "sessionList.delete.error": "Не удалось удалить сессию",
"sessionList.delete.title": "Удалить сессию", "sessionList.delete.title": "Удалить сессию",
"sessionList.delete.confirmMessage": "Удалить \"{label}\"? Это действие нельзя отменить.", "sessionList.delete.confirmMessage": "Удалить \"{label}\"? Это действие нельзя отменить.",

View File

@@ -26,7 +26,6 @@ export const instanceMessages = {
"instanceShell.leftPanel.sessionsTitle": "会话", "instanceShell.leftPanel.sessionsTitle": "会话",
"instanceShell.leftPanel.instanceInfo": "实例信息", "instanceShell.leftPanel.instanceInfo": "实例信息",
"instanceShell.leftDrawer.pin": "固定左侧抽屉", "instanceShell.leftDrawer.pin": "固定左侧抽屉",
"instanceShell.leftDrawer.unpin": "取消固定左侧抽屉", "instanceShell.leftDrawer.unpin": "取消固定左侧抽屉",
"instanceShell.leftDrawer.toggle.pinned": "左侧抽屉已固定", "instanceShell.leftDrawer.toggle.pinned": "左侧抽屉已固定",
@@ -107,6 +106,8 @@ export const instanceMessages = {
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "取消", "instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "取消",
"instanceShell.rightPanel.toast.saveSuccess": "文件保存成功", "instanceShell.rightPanel.toast.saveSuccess": "文件保存成功",
"instanceShell.rightPanel.toast.saveError": "保存文件失败", "instanceShell.rightPanel.toast.saveError": "保存文件失败",
"instanceShell.rightPanel.sections.yoloMode": "Yolo 模式",
"instanceShell.rightPanel.sections.yoloMode.tooltip": "自动批准当前会话的权限请求。仅在你信任正在运行的工具时启用。",
"instanceShell.rightPanel.sections.sessionChanges": "会话更改", "instanceShell.rightPanel.sections.sessionChanges": "会话更改",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "当前会话中修改的文件。显示每个文件的添加和删除。", "instanceShell.rightPanel.sections.sessionChanges.tooltip": "当前会话中修改的文件。显示每个文件的添加和删除。",
"instanceShell.rightPanel.sections.plan": "计划", "instanceShell.rightPanel.sections.plan": "计划",
@@ -140,6 +141,12 @@ export const instanceMessages = {
"instanceShell.plan.noSessionSelected": "选择会话以查看计划。", "instanceShell.plan.noSessionSelected": "选择会话以查看计划。",
"instanceShell.plan.empty": "暂无计划。", "instanceShell.plan.empty": "暂无计划。",
"instanceShell.yoloMode.noSessionSelected": "请选择一个会话来配置 Yolo 模式。",
"instanceShell.yoloMode.title": "Yolo 模式",
"instanceShell.yoloMode.description": "自动批准此会话的权限请求。默认关闭。",
"instanceShell.yoloMode.badge": "Yolo",
"instanceShell.yoloMode.badgeAriaLabel": "Yolo 模式已启用",
"instanceShell.backgroundProcesses.empty": "没有后台进程。", "instanceShell.backgroundProcesses.empty": "没有后台进程。",
"instanceShell.backgroundProcesses.status": "状态:{status}", "instanceShell.backgroundProcesses.status": "状态:{status}",
"instanceShell.backgroundProcesses.output": "输出:{sizeKb}KB", "instanceShell.backgroundProcesses.output": "输出:{sizeKb}KB",

View File

@@ -15,6 +15,10 @@ export const sessionMessages = {
"sessionList.status.working": "工作中", "sessionList.status.working": "工作中",
"sessionList.status.compacting": "压缩中", "sessionList.status.compacting": "压缩中",
"sessionList.status.idle": "空闲", "sessionList.status.idle": "空闲",
"sessionList.status.retrying": "重试中",
"sessionList.status.retryingIn": "{seconds} 秒后重试",
"sessionList.status.retryTooltip": "{message}(第 {attempt} 次尝试)",
"sessionList.status.retryToast": "{countdown}: {message}(第 {attempt} 次尝试)",
"sessionList.status.needsPermission": "需要权限", "sessionList.status.needsPermission": "需要权限",
"sessionList.status.needsInput": "需要输入", "sessionList.status.needsInput": "需要输入",
"sessionList.expand.collapseAriaLabel": "折叠会话", "sessionList.expand.collapseAriaLabel": "折叠会话",
@@ -25,12 +29,15 @@ export const sessionMessages = {
"sessionList.actions.newSession.title": "新建会话", "sessionList.actions.newSession.title": "新建会话",
"sessionList.actions.copyId.ariaLabel": "复制会话 ID", "sessionList.actions.copyId.ariaLabel": "复制会话 ID",
"sessionList.actions.copyId.title": "复制会话 ID", "sessionList.actions.copyId.title": "复制会话 ID",
"sessionList.actions.reload.ariaLabel": "重新加载会话",
"sessionList.actions.reload.title": "重新加载会话",
"sessionList.actions.rename.ariaLabel": "重命名会话", "sessionList.actions.rename.ariaLabel": "重命名会话",
"sessionList.actions.rename.title": "重命名会话", "sessionList.actions.rename.title": "重命名会话",
"sessionList.actions.delete.ariaLabel": "删除会话", "sessionList.actions.delete.ariaLabel": "删除会话",
"sessionList.actions.delete.title": "删除会话", "sessionList.actions.delete.title": "删除会话",
"sessionList.copyId.success": "已复制会话 ID", "sessionList.copyId.success": "已复制会话 ID",
"sessionList.copyId.error": "无法复制会话 ID", "sessionList.copyId.error": "无法复制会话 ID",
"sessionList.reload.error": "无法重新加载会话",
"sessionList.delete.error": "无法删除会话", "sessionList.delete.error": "无法删除会话",
"sessionList.delete.title": "删除会话", "sessionList.delete.title": "删除会话",
"sessionList.delete.confirmMessage": "删除“{label}”?此操作无法撤销。", "sessionList.delete.confirmMessage": "删除“{label}”?此操作无法撤销。",

View File

@@ -120,14 +120,7 @@ function resolveLanguage(token: string): { canonical: string | null; raw: string
return { canonical: null, raw: normalized } return { canonical: null, raw: normalized }
} }
async function ensureLanguages(content: string) { function collectCodeFenceLanguages(content: string): string[] {
if (highlightSuppressed) {
return
}
// Extract code-fence language tokens via `marked` so we correctly handle code blocks
// that contain backticks (e.g. JS template literals). Regex-based fence scans tend
// to miss these and prevent languages from loading.
const foundLanguages = new Set<string>() const foundLanguages = new Set<string>()
try { try {
const tokens = marked.lexer(content) as any const tokens = marked.lexer(content) as any
@@ -139,10 +132,44 @@ async function ensureLanguages(content: string) {
} }
}) })
} catch { } catch {
// If tokenization fails for any reason, skip language preloading. return []
}
return [...foundLanguages]
}
export function hasPendingCodeHighlight(content: string): boolean {
const languages = collectCodeFenceLanguages(content)
for (const token of languages) {
const rawToken = normalizeLanguageToken(token)
if (!rawToken || rawToken === "text") {
continue
}
const { canonical, raw } = resolveLanguage(token)
const langKey = canonical || raw
if (langKey === "text" || raw === "text") {
continue
}
if (!highlighter || !loadedLanguages.has(langKey)) {
return true
}
}
return false
}
async function ensureLanguages(content: string) {
if (highlightSuppressed) {
return return
} }
// Extract code-fence language tokens via `marked` so we correctly handle code blocks
// that contain backticks (e.g. JS template literals). Regex-based fence scans tend
// to miss these and prevent languages from loading.
const foundLanguages = collectCodeFenceLanguages(content)
// Queue language loading tasks // Queue language loading tasks
for (const token of foundLanguages) { for (const token of foundLanguages) {
const rawToken = normalizeLanguageToken(token) const rawToken = normalizeLanguageToken(token)

View File

@@ -102,9 +102,11 @@ export function showToastNotification(payload: ToastPayload): ToastHandle {
</button> </button>
<div class="flex items-start gap-3 pr-6"> <div class="flex items-start gap-3 pr-6">
<span class={`mt-1 inline-block h-2.5 w-2.5 rounded-full ${accent.badge}`} /> <span class={`mt-1 inline-block h-2.5 w-2.5 rounded-full ${accent.badge}`} />
<div class="flex-1 text-sm leading-snug"> <div class="min-w-0 flex-1 text-sm leading-snug">
{payload.title && <p class={`font-semibold ${accent.headline}`}>{payload.title}</p>} {payload.title && <p class={`break-words ${accent.headline} font-semibold`}>{payload.title}</p>}
<p class={`${accent.body} ${payload.title ? "mt-1" : ""}`}>{payload.message}</p> <p class={`${accent.body} ${payload.title ? "mt-1" : ""} whitespace-pre-wrap break-words [overflow-wrap:anywhere]`}>
{payload.message}
</p>
{payload.action && ( {payload.action && (
<button <button
type="button" type="button"

View File

@@ -1,5 +1,6 @@
import type { WorkspaceEventPayload, WorkspaceEventType } from "../../../server/src/api-types" import type { WorkspaceEventPayload, WorkspaceEventType } from "../../../server/src/api-types"
import { serverApi } from "./api-client" import { serverApi } from "./api-client"
import { getClientIdentity } from "./client-identity"
import { getLogger } from "./logger" import { getLogger } from "./logger"
const RETRY_BASE_DELAY = 1000 const RETRY_BASE_DELAY = 1000
@@ -16,6 +17,7 @@ function logSse(message: string, context?: Record<string, unknown>) {
class ServerEvents { class ServerEvents {
private handlers = new Map<WorkspaceEventType | "*", Set<(event: WorkspaceEventPayload) => void>>() private handlers = new Map<WorkspaceEventType | "*", Set<(event: WorkspaceEventPayload) => void>>()
private openHandlers = new Set<() => void>()
private source: EventSource | null = null private source: EventSource | null = null
private retryDelay = RETRY_BASE_DELAY private retryDelay = RETRY_BASE_DELAY
@@ -28,10 +30,24 @@ class ServerEvents {
this.source.close() this.source.close()
} }
logSse("Connecting to backend events stream") logSse("Connecting to backend events stream")
this.source = serverApi.connectEvents((event) => this.dispatch(event), () => this.scheduleReconnect()) this.source = serverApi.connectEvents(
(event) => this.dispatch(event),
() => this.scheduleReconnect(),
(payload) => {
void serverApi
.sendClientConnectionPong({
...getClientIdentity(),
pingTs: payload.ts,
})
.catch((error) => {
log.error("Failed to send client connection pong", error)
})
},
)
this.source.onopen = () => { this.source.onopen = () => {
logSse("Events stream connected") logSse("Events stream connected")
this.retryDelay = RETRY_BASE_DELAY this.retryDelay = RETRY_BASE_DELAY
this.openHandlers.forEach((handler) => handler())
} }
} }
@@ -61,6 +77,11 @@ class ServerEvents {
bucket.add(handler) bucket.add(handler)
return () => bucket.delete(handler) return () => bucket.delete(handler)
} }
onOpen(handler: () => void): () => void {
this.openHandlers.add(handler)
return () => this.openHandlers.delete(handler)
}
} }
export const serverEvents = new ServerEvents() export const serverEvents = new ServerEvents()

View File

@@ -4,6 +4,7 @@ import { showToastNotification } from "../lib/notifications"
import { serverApi } from "../lib/api-client" import { serverApi } from "../lib/api-client"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { formatToMimeType, getSpeechPlaybackSupport } from "../lib/speech-playback-support" import { formatToMimeType, getSpeechPlaybackSupport } from "../lib/speech-playback-support"
import { serverEvents } from "../lib/server-events"
import { serverSettings } from "./preferences" import { serverSettings } from "./preferences"
import { loadSpeechCapabilities, speechCapabilities } from "./speech" import { loadSpeechCapabilities, speechCapabilities } from "./speech"
import { getActiveSession, sessions } from "./session-state" import { getActiveSession, sessions } from "./session-state"
@@ -44,6 +45,10 @@ let currentPlayback:
let queueRunner: Promise<void> | null = null let queueRunner: Promise<void> | null = null
let playbackErrorShown = false let playbackErrorShown = false
serverEvents.onOpen(() => {
void syncConversationModesToServer()
})
function getEntryKey(instanceId: string, sessionId: string, messageId: string, partId: string): string { function getEntryKey(instanceId: string, sessionId: string, messageId: string, partId: string): string {
return `${instanceId}:${sessionId}:${messageId}:${partId}` return `${instanceId}:${sessionId}:${messageId}:${partId}`
} }
@@ -532,3 +537,12 @@ function extractLeadingSpokenBlock(text: string): string {
if (!match?.[1]) return "" if (!match?.[1]) return ""
return match[1].trim() return match[1].trim()
} }
async function syncConversationModesToServer(): Promise<void> {
const updates: Promise<unknown>[] = []
for (const [instanceId, enabled] of conversationModeInstances()) {
if (!enabled) continue
updates.push(serverApi.updateVoiceMode(instanceId, true))
}
await Promise.allSettled(updates)
}

View File

@@ -0,0 +1,81 @@
import { createSignal } from "solid-js"
const STORAGE_KEY = "codenomad:permission-auto-accept:v1"
function makeKey(instanceId: string, sessionId: string) {
return `${instanceId}:${sessionId}`
}
function readInitialState() {
if (typeof window === "undefined" || !window.localStorage) {
return new Map<string, boolean>()
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return new Map<string, boolean>()
const parsed = JSON.parse(raw) as Record<string, boolean>
return new Map(Object.entries(parsed).filter((entry): entry is [string, boolean] => entry[1] === true))
} catch {
return new Map<string, boolean>()
}
}
function persist(next: Map<string, boolean>) {
if (typeof window === "undefined" || !window.localStorage) {
return
}
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(Object.fromEntries(next)))
} catch {
// ignore persistence failures
}
}
const [autoAcceptState, setAutoAcceptState] = createSignal(readInitialState())
const [inFlightVersion, setInFlightVersion] = createSignal(0)
const inFlight = new Set<string>()
export function isPermissionAutoAcceptEnabled(instanceId: string, sessionId: string) {
return autoAcceptState().get(makeKey(instanceId, sessionId)) ?? false
}
export function setPermissionAutoAcceptEnabled(instanceId: string, sessionId: string, enabled: boolean) {
const key = makeKey(instanceId, sessionId)
setAutoAcceptState((prev) => {
const next = new Map(prev)
if (enabled) {
next.set(key, true)
} else {
next.delete(key)
}
persist(next)
return next
})
}
export function togglePermissionAutoAccept(instanceId: string, sessionId: string) {
setPermissionAutoAcceptEnabled(instanceId, sessionId, !isPermissionAutoAcceptEnabled(instanceId, sessionId))
}
export function canAutoRespondPermission(instanceId: string, sessionId: string, requestId: string) {
const key = makeKey(instanceId, sessionId)
if (!autoAcceptState().get(key)) return false
const requestKey = `${key}:${requestId}`
if (inFlight.has(requestKey)) return false
inFlight.add(requestKey)
return true
}
export function getPermissionAutoAcceptInFlightVersion() {
return inFlightVersion()
}
export function finishAutoRespondPermission(instanceId: string, sessionId: string, requestId: string) {
if (!inFlight.delete(`${makeKey(instanceId, sessionId)}:${requestId}`)) {
return
}
setInFlightVersion((value) => value + 1)
}

View File

@@ -1,4 +1,4 @@
import { mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session" import { mapSdkSessionRetry, mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session"
import type { Message } from "../types/message" import type { Message } from "../types/message"
import type { FileDiff } from "@opencode-ai/sdk/v2/client" import type { FileDiff } from "@opencode-ai/sdk/v2/client"
@@ -149,12 +149,15 @@ async function fetchSessions(instanceId: string): Promise<void> {
const existingStatus = existingSession?.status const existingStatus = existingSession?.status
let status: SessionStatus let status: SessionStatus
let retry = existingSession?.retry ?? null
if (existingStatus === "compacting") { if (existingStatus === "compacting") {
status = "compacting" status = "compacting"
retry = null
} else { } else {
const rawStatus = (apiSession as any)?.status ?? statusById[apiSession.id] const rawStatus = (apiSession as any)?.status ?? statusById[apiSession.id]
const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string" const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string"
status = hasType ? mapSdkSessionStatus(rawStatus) : existingStatus ?? "idle" status = hasType ? mapSdkSessionStatus(rawStatus) : existingStatus ?? "idle"
retry = hasType ? mapSdkSessionRetry(rawStatus) : retry
} }
sessionMap.set(apiSession.id, { sessionMap.set(apiSession.id, {
@@ -165,6 +168,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
agent: existingSession?.agent ?? "", agent: existingSession?.agent ?? "",
model: existingSession?.model ?? { providerId: "", modelId: "" }, model: existingSession?.model ?? { providerId: "", modelId: "" },
status, status,
retry,
version: apiSession.version, version: apiSession.version,
time: { time: {
...apiSession.time, ...apiSession.time,

View File

@@ -28,7 +28,7 @@ import type { PermissionReplyEventPropertiesLike, PermissionRequestLike } from "
import { getQuestionId, getQuestionSessionId, getRequestIdFromQuestionReply } from "../types/question" import { getQuestionId, getQuestionSessionId, getRequestIdFromQuestionReply } from "../types/question"
import type { QuestionRequest } from "../types/question" import type { QuestionRequest } from "../types/question"
import type { EventQuestionReplied, EventQuestionRejected } from "@opencode-ai/sdk/v2" import type { EventQuestionReplied, EventQuestionRejected } from "@opencode-ai/sdk/v2"
import { showToastNotification, ToastVariant } from "../lib/notifications" import { showToastNotification, type ToastHandle, ToastVariant } from "../lib/notifications"
import { sendOsNotification } from "../lib/os-notifications" import { sendOsNotification } from "../lib/os-notifications"
import { preferences } from "./preferences" import { preferences } from "./preferences"
import { import {
@@ -39,7 +39,14 @@ import {
removeQuestionFromQueue, removeQuestionFromQueue,
} from "./instances" } from "./instances"
import { showAlertDialog } from "./alerts" import { showAlertDialog } from "./alerts"
import { createClientSession, mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session" import {
createClientSession,
mapSdkSessionRetry,
mapSdkSessionStatus,
type Session,
type SessionRetryState,
type SessionStatus,
} from "../types/session"
import { ensureSessionParentExpanded, sessions, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state" import { ensureSessionParentExpanded, sessions, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state"
import { normalizeMessagePart } from "./message-v2/normalizers" import { normalizeMessagePart } from "./message-v2/normalizers"
import { updateSessionInfo } from "./message-v2/session-info" import { updateSessionInfo } from "./message-v2/session-info"
@@ -67,6 +74,15 @@ import { handleConversationAssistantPartUpdated } from "./conversation-speech"
const log = getLogger("sse") const log = getLogger("sse")
const pendingSessionFetches = new Map<string, Promise<void>>() const pendingSessionFetches = new Map<string, Promise<void>>()
let activeRetryToast: ToastHandle | null = null
function isSameRetryState(left: SessionRetryState | null | undefined, right: SessionRetryState | null | undefined): boolean {
const a = left ?? null
const b = right ?? null
if (a === b) return true
if (!a || !b) return false
return a.attempt === b.attempt && a.message === b.message && a.next === b.next
}
function shouldSendOsNotification(kind: "needsInput" | "idle"): boolean { function shouldSendOsNotification(kind: "needsInput" | "idle"): boolean {
if (typeof document === "undefined") return false if (typeof document === "undefined") return false
@@ -131,18 +147,20 @@ interface TuiToastEvent {
const ALLOWED_TOAST_VARIANTS = new Set<ToastVariant>(["info", "success", "warning", "error"]) const ALLOWED_TOAST_VARIANTS = new Set<ToastVariant>(["info", "success", "warning", "error"])
function applySessionStatus(instanceId: string, sessionId: string, status: SessionStatus) { function applySessionStatus(instanceId: string, sessionId: string, status: SessionStatus, retry?: SessionRetryState | null) {
let parentToExpand: string | null = null let parentToExpand: string | null = null
withSession(instanceId, sessionId, (session) => { withSession(instanceId, sessionId, (session) => {
const current = session.status ?? "idle" const current = session.status ?? "idle"
if (current === status) return false const nextRetry = retry ?? null
if (current === status && isSameRetryState(session.retry, nextRetry)) return false
if (current === "compacting" && status !== "compacting") { if (current === "compacting" && status !== "compacting") {
return false return false
} }
session.status = status session.status = status
session.retry = status === "working" ? nextRetry : null
// Auto-expand the parent thread when a child session starts working. // Auto-expand the parent thread when a child session starts working.
// Users can still collapse it; we only expand on the transition. // Users can still collapse it; we only expand on the transition.
@@ -172,6 +190,7 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory
) )
let fetchedStatus: SessionStatus = "idle" let fetchedStatus: SessionStatus = "idle"
let fetchedRetry: SessionRetryState | null = null
try { try {
let statuses: Record<string, any> = {} let statuses: Record<string, any> = {}
try { try {
@@ -187,11 +206,13 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory
const rawStatus = (info as any)?.status ?? statuses?.[sessionId] const rawStatus = (info as any)?.status ?? statuses?.[sessionId]
const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string" const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string"
fetchedStatus = hasType ? mapSdkSessionStatus(rawStatus) : "idle" fetchedStatus = hasType ? mapSdkSessionStatus(rawStatus) : "idle"
fetchedRetry = hasType ? mapSdkSessionRetry(rawStatus) : null
} catch (error) { } catch (error) {
log.error("Failed to fetch session status", error) log.error("Failed to fetch session status", error)
} }
const fetched = createClientSession(info, instanceId, "", { providerId: "", modelId: "" }, fetchedStatus) const fetched = createClientSession(info, instanceId, "", { providerId: "", modelId: "" }, fetchedStatus)
fetched.retry = fetchedRetry
let updatedInstanceSessions: Map<string, Session> | undefined let updatedInstanceSessions: Map<string, Session> | undefined
let shouldExpandParent: string | null = null let shouldExpandParent: string | null = null
@@ -205,6 +226,7 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory
agent: existing?.agent ?? fetched.agent, agent: existing?.agent ?? fetched.agent,
model: existing?.model ?? fetched.model, model: existing?.model ?? fetched.model,
status: existing?.status === "compacting" ? "compacting" : fetched.status, status: existing?.status === "compacting" ? "compacting" : fetched.status,
retry: existing?.status === "compacting" ? null : fetched.retry,
pendingPermission: existing?.pendingPermission ?? fetched.pendingPermission, pendingPermission: existing?.pendingPermission ?? fetched.pendingPermission,
pendingQuestion: existing?.pendingQuestion ?? false, pendingQuestion: existing?.pendingQuestion ?? false,
} }
@@ -231,14 +253,20 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory
} }
} }
function ensureSessionStatus(instanceId: string, sessionId: string, status: SessionStatus, directory?: string) { function ensureSessionStatus(
instanceId: string,
sessionId: string,
status: SessionStatus,
directory?: string,
retry?: SessionRetryState | null,
) {
const instanceSessions = sessions().get(instanceId) const instanceSessions = sessions().get(instanceId)
const existing = instanceSessions?.get(sessionId) const existing = instanceSessions?.get(sessionId)
if (existing) { if (existing) {
if ((existing.status ?? "idle") === status) { if ((existing.status ?? "idle") === status && isSameRetryState(existing.retry, retry)) {
return return
} }
applySessionStatus(instanceId, sessionId, status) applySessionStatus(instanceId, sessionId, status, retry)
return return
} }
@@ -250,7 +278,7 @@ function ensureSessionStatus(instanceId: string, sessionId: string, status: Sess
const pending = (async () => { const pending = (async () => {
const fetched = await fetchSessionInfo(instanceId, sessionId, directory) const fetched = await fetchSessionInfo(instanceId, sessionId, directory)
if (!fetched) return if (!fetched) return
applySessionStatus(instanceId, sessionId, status) applySessionStatus(instanceId, sessionId, status, retry)
})() })()
pendingSessionFetches.set(key, pending) pendingSessionFetches.set(key, pending)
@@ -428,6 +456,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
modelId: "", modelId: "",
}, },
status: "idle", status: "idle",
retry: null,
version: info.version || "0", version: info.version || "0",
time: info.time time: info.time
? { ...info.time } ? { ...info.time }
@@ -461,6 +490,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
...existingSession, ...existingSession,
title: info.title || existingSession.title, title: info.title || existingSession.title,
status: existingSession.status ?? "idle", status: existingSession.status ?? "idle",
retry: existingSession.retry ?? null,
time: mergedTime, time: mergedTime,
revert: info.revert revert: info.revert
? { ? {
@@ -532,8 +562,29 @@ function handleSessionStatus(instanceId: string, event: EventSessionStatus): voi
const sessionId = event.properties?.sessionID const sessionId = event.properties?.sessionID
if (!sessionId) return if (!sessionId) return
const status = mapSdkSessionStatus(event.properties.status) const rawStatus = event.properties.status
ensureSessionStatus(instanceId, sessionId, status, (event as any)?.directory) const status = mapSdkSessionStatus(rawStatus)
const retry = mapSdkSessionRetry(rawStatus)
ensureSessionStatus(instanceId, sessionId, status, (event as any)?.directory, retry)
if (retry) {
const remainingSeconds = Math.max(0, Math.round((retry.next - Date.now()) / 1000))
const countdown =
remainingSeconds > 0
? tGlobal("sessionList.status.retryingIn", { seconds: String(remainingSeconds) })
: tGlobal("sessionList.status.retrying")
const label = getSessionTitle(instanceId, sessionId)
activeRetryToast?.dismiss()
activeRetryToast = showToastNotification({
title: label || getInstanceDisplayName(instanceId),
message: tGlobal("sessionList.status.retryToast", {
countdown,
message: retry.message,
attempt: String(retry.attempt),
}),
variant: "error",
duration: 7000,
})
}
log.info(`[SSE] Session status updated: ${sessionId}`, { status }) log.info(`[SSE] Session status updated: ${sessionId}`, { status })
} }
@@ -547,6 +598,7 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted
if (existing) { if (existing) {
withSession(instanceId, sessionID, (session) => { withSession(instanceId, sessionID, (session) => {
session.status = "working" session.status = "working"
session.retry = null
}) })
} else { } else {
ensureSessionStatus(instanceId, sessionID, "working", (event as any)?.directory) ensureSessionStatus(instanceId, sessionID, "working", (event as any)?.directory)

View File

@@ -353,6 +353,9 @@ function setSessionStatus(instanceId: string, sessionId: string, status: Session
if (session.status === status) return false if (session.status === status) return false
const previous = session.status const previous = session.status
session.status = status session.status = status
if (status !== "working") {
session.retry = null
}
// If a child session starts working, auto-expand its parent thread once. // If a child session starts working, auto-expand its parent thread once.
// Users can still collapse it afterwards; we only expand on the transition. // Users can still collapse it afterwards; we only expand on the transition.

View File

@@ -1,4 +1,4 @@
import type { Session, SessionStatus } from "../types/session" import type { Session, SessionRetryState, SessionStatus } from "../types/session"
import { getInstanceSessionIndicatorStatusCached, sessions } from "./session-state" import { getInstanceSessionIndicatorStatusCached, sessions } from "./session-state"
function getSession(instanceId: string, sessionId: string): Session | null { function getSession(instanceId: string, sessionId: string): Session | null {
@@ -14,6 +14,15 @@ export function getSessionStatus(instanceId: string, sessionId: string): Session
return session.status ?? "idle" return session.status ?? "idle"
} }
export function getSessionRetry(instanceId: string, sessionId: string): SessionRetryState | null {
const session = getSession(instanceId, sessionId)
return session?.retry ?? null
}
export function getRetrySeconds(next: number, now = Date.now()): number {
return Math.max(0, Math.round((next - now) / 1000))
}
export type InstanceSessionIndicatorStatus = "permission" | SessionStatus export type InstanceSessionIndicatorStatus = "permission" | SessionStatus
export function getInstanceSessionIndicatorStatus(instanceId: string): InstanceSessionIndicatorStatus { export function getInstanceSessionIndicatorStatus(instanceId: string): InstanceSessionIndicatorStatus {

View File

@@ -184,6 +184,7 @@
} }
.status-indicator.session-status.session-working, .status-indicator.session-status.session-working,
.status-indicator.session-status.session-retrying,
.status-indicator.session-status.session-compacting, .status-indicator.session-status.session-compacting,
.status-indicator.session-status.session-idle { .status-indicator.session-status.session-idle {
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
@@ -194,6 +195,11 @@
--session-status-dot: var(--session-status-working-fg); --session-status-dot: var(--session-status-working-fg);
} }
.status-indicator.session-status.session-retrying {
color: var(--status-error);
--session-status-dot: var(--status-error);
}
.status-indicator.session-status.session-compacting { .status-indicator.session-status.session-compacting {
color: var(--session-status-compacting-fg); color: var(--session-status-compacting-fg);
--session-status-dot: var(--session-status-compacting-fg); --session-status-dot: var(--session-status-compacting-fg);
@@ -222,6 +228,10 @@
background-color: var(--session-status-working-bg); background-color: var(--session-status-working-bg);
} }
.status-indicator.session-status.session-retrying.session-status-list {
background-color: var(--status-error-bg);
}
.status-indicator.session-status.session-compacting.session-status-list { .status-indicator.session-status.session-compacting.session-status-list {
background-color: var(--session-status-compacting-bg); background-color: var(--session-status-compacting-bg);
} }

View File

@@ -412,6 +412,19 @@
background-color: var(--surface-secondary); background-color: var(--surface-secondary);
} }
.right-panel-accordion-header-row {
@apply flex items-center gap-2;
}
.right-panel-accordion-header-row .right-panel-accordion-trigger {
flex: 1 1 auto;
}
.right-panel-accordion-header-row .section-info-trigger {
flex: 0 0 auto;
margin-inline-end: 0.75rem;
}
.right-panel-accordion-trigger { .right-panel-accordion-trigger {
@apply w-full flex items-center justify-between px-3 py-2.5 text-[11px] font-semibold uppercase tracking-wide transition-colors duration-150; @apply w-full flex items-center justify-between px-3 py-2.5 text-[11px] font-semibold uppercase tracking-wide transition-colors duration-150;
color: var(--text-secondary); color: var(--text-secondary);
@@ -452,6 +465,8 @@
@apply inline-flex items-center justify-center p-0.5 rounded transition-all duration-150; @apply inline-flex items-center justify-center p-0.5 rounded transition-all duration-150;
color: var(--text-muted); color: var(--text-muted);
flex-shrink: 0; flex-shrink: 0;
border: none;
background-color: transparent;
} }
.section-info-trigger:hover { .section-info-trigger:hover {
@@ -459,6 +474,12 @@
background-color: var(--surface-hover); background-color: var(--surface-hover);
} }
.section-info-trigger:focus-visible {
@apply ring-2 ring-offset-1;
ring-color: var(--accent-primary);
ring-offset-color: var(--surface-secondary);
}
.section-label { .section-label {
margin-inline-start: 2px; margin-inline-start: 2px;
} }

View File

@@ -107,6 +107,28 @@
@apply w-full; @apply w-full;
} }
.session-sidebar-toggle {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.4rem 0.65rem;
border: 1px solid var(--border-base);
border-radius: 0.75rem;
background: var(--surface-base);
min-height: 2rem;
width: fit-content;
max-width: 100%;
margin-left: auto;
}
.session-sidebar-toggle-title {
font-size: 0.8rem;
font-weight: 500;
color: var(--text-primary);
line-height: 1.2;
}
.session-sidebar-controls .selector-trigger, .session-sidebar-controls .selector-trigger,
.session-sidebar-controls [data-model-selector-control], .session-sidebar-controls [data-model-selector-control],
.session-sidebar-controls .selector-trigger-label, .session-sidebar-controls .selector-trigger-label,
@@ -394,6 +416,7 @@ session-sidebar-controls .selector-trigger-primary {
} }
.status-indicator.session-status.session-working, .status-indicator.session-status.session-working,
.status-indicator.session-status.session-retrying,
.status-indicator.session-status.session-compacting, .status-indicator.session-status.session-compacting,
.status-indicator.session-status.session-idle { .status-indicator.session-status.session-idle {
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
@@ -404,6 +427,11 @@ session-sidebar-controls .selector-trigger-primary {
--session-status-dot: var(--session-status-working-fg); --session-status-dot: var(--session-status-working-fg);
} }
.status-indicator.session-status.session-retrying {
color: var(--status-error);
--session-status-dot: var(--status-error);
}
.status-indicator.session-status.session-compacting { .status-indicator.session-status.session-compacting {
color: var(--session-status-compacting-fg); color: var(--session-status-compacting-fg);
--session-status-dot: var(--session-status-compacting-fg); --session-status-dot: var(--session-status-compacting-fg);
@@ -432,6 +460,10 @@ session-sidebar-controls .selector-trigger-primary {
background-color: var(--session-status-working-bg); background-color: var(--session-status-working-bg);
} }
.status-indicator.session-status.session-retrying.session-status-list {
background-color: var(--status-error-bg);
}
.status-indicator.session-status.session-compacting.session-status-list { .status-indicator.session-status.session-compacting.session-status-list {
background-color: var(--session-status-compacting-bg); background-color: var(--session-status-compacting-bg);
} }
@@ -458,6 +490,16 @@ session-sidebar-controls .selector-trigger-primary {
border: 1px solid transparent; border: 1px solid transparent;
} }
.status-indicator.session-yolo-mode {
color: var(--accent-primary);
background-color: color-mix(in oklab, var(--accent-primary) 14%, transparent);
border-color: color-mix(in oklab, var(--accent-primary) 28%, transparent);
}
.status-indicator.session-yolo-mode .status-dot {
background-color: var(--accent-primary);
}
@media (max-width: 768px) { @media (max-width: 768px) {
.session-list-container { .session-list-container {
min-width: 200px; min-width: 200px;

View File

@@ -17,6 +17,12 @@ export type {
export type SessionStatus = "idle" | "working" | "compacting" export type SessionStatus = "idle" | "working" | "compacting"
export interface SessionRetryState {
attempt: number
message: string
next: number
}
export function mapSdkSessionStatus(status: SDKSessionStatus | null | undefined): SessionStatus { export function mapSdkSessionStatus(status: SDKSessionStatus | null | undefined): SessionStatus {
if (!status || status.type === "idle") { if (!status || status.type === "idle") {
return "idle" return "idle"
@@ -26,6 +32,18 @@ export function mapSdkSessionStatus(status: SDKSessionStatus | null | undefined)
return "working" return "working"
} }
export function mapSdkSessionRetry(status: SDKSessionStatus | null | undefined): SessionRetryState | null {
if (!status || status.type !== "retry") {
return null
}
return {
attempt: typeof status.attempt === "number" ? status.attempt : 1,
message: typeof status.message === "string" ? status.message : "",
next: typeof status.next === "number" ? status.next : Date.now(),
}
}
// Our client-specific Session interface extending SDK Session // Our client-specific Session interface extending SDK Session
export interface Session export interface Session
extends Omit<import("@opencode-ai/sdk").Session, "projectID" | "directory" | "parentID"> { extends Omit<import("@opencode-ai/sdk").Session, "projectID" | "directory" | "parentID"> {
@@ -40,6 +58,7 @@ export interface Session
pendingPermission?: boolean // Indicates if session is waiting on user permission pendingPermission?: boolean // Indicates if session is waiting on user permission
pendingQuestion?: boolean // Indicates if session is waiting on user input pendingQuestion?: boolean // Indicates if session is waiting on user input
status: SessionStatus // Single source of truth for session status status: SessionStatus // Single source of truth for session status
retry?: SessionRetryState | null // Retry metadata for transient backoff states
diff?: FileDiff[] // Session-level file diffs (hydrated via session.diff) diff?: FileDiff[] // Session-level file diffs (hydrated via session.diff)
} }

40
scripts/bump-version.js Normal file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env node
const { spawnSync } = require("child_process")
const versionArgs = process.argv.slice(2)
if (versionArgs.length === 0) {
console.error("[bumpVersion] missing version argument (example: npm run bumpVersion -- patch)")
process.exit(1)
}
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"
function runStep(args, label) {
const result = spawnSync(npmCommand, args, {
stdio: "inherit",
})
if (result.error) {
console.error(`[bumpVersion] failed during ${label}: ${result.error.message}`)
process.exit(1)
}
if (result.status !== 0) {
process.exit(result.status ?? 1)
}
}
runStep(
[
"version",
...versionArgs,
"--workspaces",
"--include-workspace-root",
"--no-git-tag-version",
],
"npm version"
)
runStep(["run", "sync:version", "--workspace", "@codenomad/tauri-app"], "tauri version sync")