feat: enhance team management and review processes with new functionalities
- Added support for inferring team lead names from configuration, improving message attribution in task communications. - Introduced new dependencies for CodeMirror language support, enhancing the editing experience for various programming languages. - Implemented scoped item IDs in the CLI logs view to prevent cross-group collisions, improving UI clarity. - Enhanced sorting logic in the team list view to prioritize alive teams and match current project paths. - Added lazy-check functionality for task changes in Kanban cards, optimizing performance and user experience. - Updated diff view components to support new language features and improve overall editing capabilities.
This commit is contained in:
parent
1d7e55e89a
commit
fd3176716b
17 changed files with 1181 additions and 300 deletions
|
|
@ -81,6 +81,9 @@ export default defineConfig({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
renderer: {
|
renderer: {
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['@codemirror/language-data']
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@renderer': resolve(__dirname, 'src/renderer'),
|
'@renderer': resolve(__dirname, 'src/renderer'),
|
||||||
|
|
|
||||||
12
package.json
12
package.json
|
|
@ -61,12 +61,24 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "^6.10.2",
|
"@codemirror/commands": "^6.10.2",
|
||||||
|
"@codemirror/lang-cpp": "^6.0.3",
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
|
"@codemirror/lang-go": "^6.0.1",
|
||||||
"@codemirror/lang-html": "^6.4.11",
|
"@codemirror/lang-html": "^6.4.11",
|
||||||
|
"@codemirror/lang-java": "^6.0.2",
|
||||||
"@codemirror/lang-javascript": "^6.2.4",
|
"@codemirror/lang-javascript": "^6.2.4",
|
||||||
"@codemirror/lang-json": "^6.0.2",
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
|
"@codemirror/lang-less": "^6.0.2",
|
||||||
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
|
"@codemirror/lang-php": "^6.0.2",
|
||||||
"@codemirror/lang-python": "^6.2.1",
|
"@codemirror/lang-python": "^6.2.1",
|
||||||
|
"@codemirror/lang-rust": "^6.0.2",
|
||||||
|
"@codemirror/lang-sass": "^6.0.2",
|
||||||
|
"@codemirror/lang-sql": "^6.10.0",
|
||||||
"@codemirror/lang-xml": "^6.1.0",
|
"@codemirror/lang-xml": "^6.1.0",
|
||||||
|
"@codemirror/lang-yaml": "^6.1.2",
|
||||||
|
"@codemirror/language": "^6.12.1",
|
||||||
|
"@codemirror/language-data": "^6.5.2",
|
||||||
"@codemirror/merge": "^6.12.0",
|
"@codemirror/merge": "^6.12.0",
|
||||||
"@codemirror/state": "^6.5.4",
|
"@codemirror/state": "^6.5.4",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
|
|
|
||||||
308
pnpm-lock.yaml
308
pnpm-lock.yaml
|
|
@ -11,24 +11,60 @@ importers:
|
||||||
'@codemirror/commands':
|
'@codemirror/commands':
|
||||||
specifier: ^6.10.2
|
specifier: ^6.10.2
|
||||||
version: 6.10.2
|
version: 6.10.2
|
||||||
|
'@codemirror/lang-cpp':
|
||||||
|
specifier: ^6.0.3
|
||||||
|
version: 6.0.3
|
||||||
'@codemirror/lang-css':
|
'@codemirror/lang-css':
|
||||||
specifier: ^6.3.1
|
specifier: ^6.3.1
|
||||||
version: 6.3.1
|
version: 6.3.1
|
||||||
|
'@codemirror/lang-go':
|
||||||
|
specifier: ^6.0.1
|
||||||
|
version: 6.0.1
|
||||||
'@codemirror/lang-html':
|
'@codemirror/lang-html':
|
||||||
specifier: ^6.4.11
|
specifier: ^6.4.11
|
||||||
version: 6.4.11
|
version: 6.4.11
|
||||||
|
'@codemirror/lang-java':
|
||||||
|
specifier: ^6.0.2
|
||||||
|
version: 6.0.2
|
||||||
'@codemirror/lang-javascript':
|
'@codemirror/lang-javascript':
|
||||||
specifier: ^6.2.4
|
specifier: ^6.2.4
|
||||||
version: 6.2.4
|
version: 6.2.4
|
||||||
'@codemirror/lang-json':
|
'@codemirror/lang-json':
|
||||||
specifier: ^6.0.2
|
specifier: ^6.0.2
|
||||||
version: 6.0.2
|
version: 6.0.2
|
||||||
|
'@codemirror/lang-less':
|
||||||
|
specifier: ^6.0.2
|
||||||
|
version: 6.0.2
|
||||||
|
'@codemirror/lang-markdown':
|
||||||
|
specifier: ^6.5.0
|
||||||
|
version: 6.5.0
|
||||||
|
'@codemirror/lang-php':
|
||||||
|
specifier: ^6.0.2
|
||||||
|
version: 6.0.2
|
||||||
'@codemirror/lang-python':
|
'@codemirror/lang-python':
|
||||||
specifier: ^6.2.1
|
specifier: ^6.2.1
|
||||||
version: 6.2.1
|
version: 6.2.1
|
||||||
|
'@codemirror/lang-rust':
|
||||||
|
specifier: ^6.0.2
|
||||||
|
version: 6.0.2
|
||||||
|
'@codemirror/lang-sass':
|
||||||
|
specifier: ^6.0.2
|
||||||
|
version: 6.0.2
|
||||||
|
'@codemirror/lang-sql':
|
||||||
|
specifier: ^6.10.0
|
||||||
|
version: 6.10.0
|
||||||
'@codemirror/lang-xml':
|
'@codemirror/lang-xml':
|
||||||
specifier: ^6.1.0
|
specifier: ^6.1.0
|
||||||
version: 6.1.0
|
version: 6.1.0
|
||||||
|
'@codemirror/lang-yaml':
|
||||||
|
specifier: ^6.1.2
|
||||||
|
version: 6.1.2
|
||||||
|
'@codemirror/language':
|
||||||
|
specifier: ^6.12.1
|
||||||
|
version: 6.12.1
|
||||||
|
'@codemirror/language-data':
|
||||||
|
specifier: ^6.5.2
|
||||||
|
version: 6.5.2
|
||||||
'@codemirror/merge':
|
'@codemirror/merge':
|
||||||
specifier: ^6.12.0
|
specifier: ^6.12.0
|
||||||
version: 6.12.0
|
version: 6.12.0
|
||||||
|
|
@ -402,27 +438,78 @@ packages:
|
||||||
'@codemirror/commands@6.10.2':
|
'@codemirror/commands@6.10.2':
|
||||||
resolution: {integrity: sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==}
|
resolution: {integrity: sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==}
|
||||||
|
|
||||||
|
'@codemirror/lang-angular@0.1.4':
|
||||||
|
resolution: {integrity: sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==}
|
||||||
|
|
||||||
|
'@codemirror/lang-cpp@6.0.3':
|
||||||
|
resolution: {integrity: sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==}
|
||||||
|
|
||||||
'@codemirror/lang-css@6.3.1':
|
'@codemirror/lang-css@6.3.1':
|
||||||
resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
|
resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
|
||||||
|
|
||||||
|
'@codemirror/lang-go@6.0.1':
|
||||||
|
resolution: {integrity: sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==}
|
||||||
|
|
||||||
'@codemirror/lang-html@6.4.11':
|
'@codemirror/lang-html@6.4.11':
|
||||||
resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==}
|
resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==}
|
||||||
|
|
||||||
|
'@codemirror/lang-java@6.0.2':
|
||||||
|
resolution: {integrity: sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==}
|
||||||
|
|
||||||
'@codemirror/lang-javascript@6.2.4':
|
'@codemirror/lang-javascript@6.2.4':
|
||||||
resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==}
|
resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==}
|
||||||
|
|
||||||
|
'@codemirror/lang-jinja@6.0.0':
|
||||||
|
resolution: {integrity: sha512-47MFmRcR8UAxd8DReVgj7WJN1WSAMT7OJnewwugZM4XiHWkOjgJQqvEM1NpMj9ALMPyxmlziEI1opH9IaEvmaw==}
|
||||||
|
|
||||||
'@codemirror/lang-json@6.0.2':
|
'@codemirror/lang-json@6.0.2':
|
||||||
resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
|
resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
|
||||||
|
|
||||||
|
'@codemirror/lang-less@6.0.2':
|
||||||
|
resolution: {integrity: sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==}
|
||||||
|
|
||||||
|
'@codemirror/lang-liquid@6.3.2':
|
||||||
|
resolution: {integrity: sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw==}
|
||||||
|
|
||||||
|
'@codemirror/lang-markdown@6.5.0':
|
||||||
|
resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==}
|
||||||
|
|
||||||
|
'@codemirror/lang-php@6.0.2':
|
||||||
|
resolution: {integrity: sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==}
|
||||||
|
|
||||||
'@codemirror/lang-python@6.2.1':
|
'@codemirror/lang-python@6.2.1':
|
||||||
resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==}
|
resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==}
|
||||||
|
|
||||||
|
'@codemirror/lang-rust@6.0.2':
|
||||||
|
resolution: {integrity: sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==}
|
||||||
|
|
||||||
|
'@codemirror/lang-sass@6.0.2':
|
||||||
|
resolution: {integrity: sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==}
|
||||||
|
|
||||||
|
'@codemirror/lang-sql@6.10.0':
|
||||||
|
resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==}
|
||||||
|
|
||||||
|
'@codemirror/lang-vue@0.1.3':
|
||||||
|
resolution: {integrity: sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==}
|
||||||
|
|
||||||
|
'@codemirror/lang-wast@6.0.2':
|
||||||
|
resolution: {integrity: sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==}
|
||||||
|
|
||||||
'@codemirror/lang-xml@6.1.0':
|
'@codemirror/lang-xml@6.1.0':
|
||||||
resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==}
|
resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==}
|
||||||
|
|
||||||
|
'@codemirror/lang-yaml@6.1.2':
|
||||||
|
resolution: {integrity: sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==}
|
||||||
|
|
||||||
|
'@codemirror/language-data@6.5.2':
|
||||||
|
resolution: {integrity: sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==}
|
||||||
|
|
||||||
'@codemirror/language@6.12.1':
|
'@codemirror/language@6.12.1':
|
||||||
resolution: {integrity: sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==}
|
resolution: {integrity: sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==}
|
||||||
|
|
||||||
|
'@codemirror/legacy-modes@6.5.2':
|
||||||
|
resolution: {integrity: sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==}
|
||||||
|
|
||||||
'@codemirror/lint@6.9.4':
|
'@codemirror/lint@6.9.4':
|
||||||
resolution: {integrity: sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==}
|
resolution: {integrity: sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==}
|
||||||
|
|
||||||
|
|
@ -953,15 +1040,24 @@ packages:
|
||||||
'@lezer/common@1.5.1':
|
'@lezer/common@1.5.1':
|
||||||
resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==}
|
resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==}
|
||||||
|
|
||||||
|
'@lezer/cpp@1.1.5':
|
||||||
|
resolution: {integrity: sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw==}
|
||||||
|
|
||||||
'@lezer/css@1.3.1':
|
'@lezer/css@1.3.1':
|
||||||
resolution: {integrity: sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==}
|
resolution: {integrity: sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==}
|
||||||
|
|
||||||
|
'@lezer/go@1.0.1':
|
||||||
|
resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==}
|
||||||
|
|
||||||
'@lezer/highlight@1.2.3':
|
'@lezer/highlight@1.2.3':
|
||||||
resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
|
resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
|
||||||
|
|
||||||
'@lezer/html@1.3.13':
|
'@lezer/html@1.3.13':
|
||||||
resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==}
|
resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==}
|
||||||
|
|
||||||
|
'@lezer/java@1.1.3':
|
||||||
|
resolution: {integrity: sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==}
|
||||||
|
|
||||||
'@lezer/javascript@1.5.4':
|
'@lezer/javascript@1.5.4':
|
||||||
resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==}
|
resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==}
|
||||||
|
|
||||||
|
|
@ -971,12 +1067,27 @@ packages:
|
||||||
'@lezer/lr@1.4.8':
|
'@lezer/lr@1.4.8':
|
||||||
resolution: {integrity: sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==}
|
resolution: {integrity: sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==}
|
||||||
|
|
||||||
|
'@lezer/markdown@1.6.3':
|
||||||
|
resolution: {integrity: sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==}
|
||||||
|
|
||||||
|
'@lezer/php@1.0.5':
|
||||||
|
resolution: {integrity: sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==}
|
||||||
|
|
||||||
'@lezer/python@1.1.18':
|
'@lezer/python@1.1.18':
|
||||||
resolution: {integrity: sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==}
|
resolution: {integrity: sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==}
|
||||||
|
|
||||||
|
'@lezer/rust@1.0.2':
|
||||||
|
resolution: {integrity: sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==}
|
||||||
|
|
||||||
|
'@lezer/sass@1.1.0':
|
||||||
|
resolution: {integrity: sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==}
|
||||||
|
|
||||||
'@lezer/xml@1.0.6':
|
'@lezer/xml@1.0.6':
|
||||||
resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==}
|
resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==}
|
||||||
|
|
||||||
|
'@lezer/yaml@1.0.4':
|
||||||
|
resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==}
|
||||||
|
|
||||||
'@lukeed/ms@2.0.2':
|
'@lukeed/ms@2.0.2':
|
||||||
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
|
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
@ -5575,6 +5686,20 @@ snapshots:
|
||||||
'@codemirror/view': 6.39.15
|
'@codemirror/view': 6.39.15
|
||||||
'@lezer/common': 1.5.1
|
'@lezer/common': 1.5.1
|
||||||
|
|
||||||
|
'@codemirror/lang-angular@0.1.4':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/lang-html': 6.4.11
|
||||||
|
'@codemirror/lang-javascript': 6.2.4
|
||||||
|
'@codemirror/language': 6.12.1
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
'@lezer/highlight': 1.2.3
|
||||||
|
'@lezer/lr': 1.4.8
|
||||||
|
|
||||||
|
'@codemirror/lang-cpp@6.0.3':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/language': 6.12.1
|
||||||
|
'@lezer/cpp': 1.1.5
|
||||||
|
|
||||||
'@codemirror/lang-css@6.3.1':
|
'@codemirror/lang-css@6.3.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/autocomplete': 6.20.0
|
'@codemirror/autocomplete': 6.20.0
|
||||||
|
|
@ -5583,6 +5708,14 @@ snapshots:
|
||||||
'@lezer/common': 1.5.1
|
'@lezer/common': 1.5.1
|
||||||
'@lezer/css': 1.3.1
|
'@lezer/css': 1.3.1
|
||||||
|
|
||||||
|
'@codemirror/lang-go@6.0.1':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/autocomplete': 6.20.0
|
||||||
|
'@codemirror/language': 6.12.1
|
||||||
|
'@codemirror/state': 6.5.4
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
'@lezer/go': 1.0.1
|
||||||
|
|
||||||
'@codemirror/lang-html@6.4.11':
|
'@codemirror/lang-html@6.4.11':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/autocomplete': 6.20.0
|
'@codemirror/autocomplete': 6.20.0
|
||||||
|
|
@ -5595,6 +5728,11 @@ snapshots:
|
||||||
'@lezer/css': 1.3.1
|
'@lezer/css': 1.3.1
|
||||||
'@lezer/html': 1.3.13
|
'@lezer/html': 1.3.13
|
||||||
|
|
||||||
|
'@codemirror/lang-java@6.0.2':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/language': 6.12.1
|
||||||
|
'@lezer/java': 1.1.3
|
||||||
|
|
||||||
'@codemirror/lang-javascript@6.2.4':
|
'@codemirror/lang-javascript@6.2.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/autocomplete': 6.20.0
|
'@codemirror/autocomplete': 6.20.0
|
||||||
|
|
@ -5605,11 +5743,56 @@ snapshots:
|
||||||
'@lezer/common': 1.5.1
|
'@lezer/common': 1.5.1
|
||||||
'@lezer/javascript': 1.5.4
|
'@lezer/javascript': 1.5.4
|
||||||
|
|
||||||
|
'@codemirror/lang-jinja@6.0.0':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/lang-html': 6.4.11
|
||||||
|
'@codemirror/language': 6.12.1
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
'@lezer/highlight': 1.2.3
|
||||||
|
'@lezer/lr': 1.4.8
|
||||||
|
|
||||||
'@codemirror/lang-json@6.0.2':
|
'@codemirror/lang-json@6.0.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/language': 6.12.1
|
'@codemirror/language': 6.12.1
|
||||||
'@lezer/json': 1.0.3
|
'@lezer/json': 1.0.3
|
||||||
|
|
||||||
|
'@codemirror/lang-less@6.0.2':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/lang-css': 6.3.1
|
||||||
|
'@codemirror/language': 6.12.1
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
'@lezer/highlight': 1.2.3
|
||||||
|
'@lezer/lr': 1.4.8
|
||||||
|
|
||||||
|
'@codemirror/lang-liquid@6.3.2':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/autocomplete': 6.20.0
|
||||||
|
'@codemirror/lang-html': 6.4.11
|
||||||
|
'@codemirror/language': 6.12.1
|
||||||
|
'@codemirror/state': 6.5.4
|
||||||
|
'@codemirror/view': 6.39.15
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
'@lezer/highlight': 1.2.3
|
||||||
|
'@lezer/lr': 1.4.8
|
||||||
|
|
||||||
|
'@codemirror/lang-markdown@6.5.0':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/autocomplete': 6.20.0
|
||||||
|
'@codemirror/lang-html': 6.4.11
|
||||||
|
'@codemirror/language': 6.12.1
|
||||||
|
'@codemirror/state': 6.5.4
|
||||||
|
'@codemirror/view': 6.39.15
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
'@lezer/markdown': 1.6.3
|
||||||
|
|
||||||
|
'@codemirror/lang-php@6.0.2':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/lang-html': 6.4.11
|
||||||
|
'@codemirror/language': 6.12.1
|
||||||
|
'@codemirror/state': 6.5.4
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
'@lezer/php': 1.0.5
|
||||||
|
|
||||||
'@codemirror/lang-python@6.2.1':
|
'@codemirror/lang-python@6.2.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/autocomplete': 6.20.0
|
'@codemirror/autocomplete': 6.20.0
|
||||||
|
|
@ -5618,6 +5801,44 @@ snapshots:
|
||||||
'@lezer/common': 1.5.1
|
'@lezer/common': 1.5.1
|
||||||
'@lezer/python': 1.1.18
|
'@lezer/python': 1.1.18
|
||||||
|
|
||||||
|
'@codemirror/lang-rust@6.0.2':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/language': 6.12.1
|
||||||
|
'@lezer/rust': 1.0.2
|
||||||
|
|
||||||
|
'@codemirror/lang-sass@6.0.2':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/lang-css': 6.3.1
|
||||||
|
'@codemirror/language': 6.12.1
|
||||||
|
'@codemirror/state': 6.5.4
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
'@lezer/sass': 1.1.0
|
||||||
|
|
||||||
|
'@codemirror/lang-sql@6.10.0':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/autocomplete': 6.20.0
|
||||||
|
'@codemirror/language': 6.12.1
|
||||||
|
'@codemirror/state': 6.5.4
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
'@lezer/highlight': 1.2.3
|
||||||
|
'@lezer/lr': 1.4.8
|
||||||
|
|
||||||
|
'@codemirror/lang-vue@0.1.3':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/lang-html': 6.4.11
|
||||||
|
'@codemirror/lang-javascript': 6.2.4
|
||||||
|
'@codemirror/language': 6.12.1
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
'@lezer/highlight': 1.2.3
|
||||||
|
'@lezer/lr': 1.4.8
|
||||||
|
|
||||||
|
'@codemirror/lang-wast@6.0.2':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/language': 6.12.1
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
'@lezer/highlight': 1.2.3
|
||||||
|
'@lezer/lr': 1.4.8
|
||||||
|
|
||||||
'@codemirror/lang-xml@6.1.0':
|
'@codemirror/lang-xml@6.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/autocomplete': 6.20.0
|
'@codemirror/autocomplete': 6.20.0
|
||||||
|
|
@ -5627,6 +5848,42 @@ snapshots:
|
||||||
'@lezer/common': 1.5.1
|
'@lezer/common': 1.5.1
|
||||||
'@lezer/xml': 1.0.6
|
'@lezer/xml': 1.0.6
|
||||||
|
|
||||||
|
'@codemirror/lang-yaml@6.1.2':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/autocomplete': 6.20.0
|
||||||
|
'@codemirror/language': 6.12.1
|
||||||
|
'@codemirror/state': 6.5.4
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
'@lezer/highlight': 1.2.3
|
||||||
|
'@lezer/lr': 1.4.8
|
||||||
|
'@lezer/yaml': 1.0.4
|
||||||
|
|
||||||
|
'@codemirror/language-data@6.5.2':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/lang-angular': 0.1.4
|
||||||
|
'@codemirror/lang-cpp': 6.0.3
|
||||||
|
'@codemirror/lang-css': 6.3.1
|
||||||
|
'@codemirror/lang-go': 6.0.1
|
||||||
|
'@codemirror/lang-html': 6.4.11
|
||||||
|
'@codemirror/lang-java': 6.0.2
|
||||||
|
'@codemirror/lang-javascript': 6.2.4
|
||||||
|
'@codemirror/lang-jinja': 6.0.0
|
||||||
|
'@codemirror/lang-json': 6.0.2
|
||||||
|
'@codemirror/lang-less': 6.0.2
|
||||||
|
'@codemirror/lang-liquid': 6.3.2
|
||||||
|
'@codemirror/lang-markdown': 6.5.0
|
||||||
|
'@codemirror/lang-php': 6.0.2
|
||||||
|
'@codemirror/lang-python': 6.2.1
|
||||||
|
'@codemirror/lang-rust': 6.0.2
|
||||||
|
'@codemirror/lang-sass': 6.0.2
|
||||||
|
'@codemirror/lang-sql': 6.10.0
|
||||||
|
'@codemirror/lang-vue': 0.1.3
|
||||||
|
'@codemirror/lang-wast': 6.0.2
|
||||||
|
'@codemirror/lang-xml': 6.1.0
|
||||||
|
'@codemirror/lang-yaml': 6.1.2
|
||||||
|
'@codemirror/language': 6.12.1
|
||||||
|
'@codemirror/legacy-modes': 6.5.2
|
||||||
|
|
||||||
'@codemirror/language@6.12.1':
|
'@codemirror/language@6.12.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/state': 6.5.4
|
'@codemirror/state': 6.5.4
|
||||||
|
|
@ -5636,6 +5893,10 @@ snapshots:
|
||||||
'@lezer/lr': 1.4.8
|
'@lezer/lr': 1.4.8
|
||||||
style-mod: 4.1.3
|
style-mod: 4.1.3
|
||||||
|
|
||||||
|
'@codemirror/legacy-modes@6.5.2':
|
||||||
|
dependencies:
|
||||||
|
'@codemirror/language': 6.12.1
|
||||||
|
|
||||||
'@codemirror/lint@6.9.4':
|
'@codemirror/lint@6.9.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/state': 6.5.4
|
'@codemirror/state': 6.5.4
|
||||||
|
|
@ -6136,12 +6397,24 @@ snapshots:
|
||||||
|
|
||||||
'@lezer/common@1.5.1': {}
|
'@lezer/common@1.5.1': {}
|
||||||
|
|
||||||
|
'@lezer/cpp@1.1.5':
|
||||||
|
dependencies:
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
'@lezer/highlight': 1.2.3
|
||||||
|
'@lezer/lr': 1.4.8
|
||||||
|
|
||||||
'@lezer/css@1.3.1':
|
'@lezer/css@1.3.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@lezer/common': 1.5.1
|
'@lezer/common': 1.5.1
|
||||||
'@lezer/highlight': 1.2.3
|
'@lezer/highlight': 1.2.3
|
||||||
'@lezer/lr': 1.4.8
|
'@lezer/lr': 1.4.8
|
||||||
|
|
||||||
|
'@lezer/go@1.0.1':
|
||||||
|
dependencies:
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
'@lezer/highlight': 1.2.3
|
||||||
|
'@lezer/lr': 1.4.8
|
||||||
|
|
||||||
'@lezer/highlight@1.2.3':
|
'@lezer/highlight@1.2.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@lezer/common': 1.5.1
|
'@lezer/common': 1.5.1
|
||||||
|
|
@ -6152,6 +6425,12 @@ snapshots:
|
||||||
'@lezer/highlight': 1.2.3
|
'@lezer/highlight': 1.2.3
|
||||||
'@lezer/lr': 1.4.8
|
'@lezer/lr': 1.4.8
|
||||||
|
|
||||||
|
'@lezer/java@1.1.3':
|
||||||
|
dependencies:
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
'@lezer/highlight': 1.2.3
|
||||||
|
'@lezer/lr': 1.4.8
|
||||||
|
|
||||||
'@lezer/javascript@1.5.4':
|
'@lezer/javascript@1.5.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@lezer/common': 1.5.1
|
'@lezer/common': 1.5.1
|
||||||
|
|
@ -6168,18 +6447,47 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@lezer/common': 1.5.1
|
'@lezer/common': 1.5.1
|
||||||
|
|
||||||
|
'@lezer/markdown@1.6.3':
|
||||||
|
dependencies:
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
'@lezer/highlight': 1.2.3
|
||||||
|
|
||||||
|
'@lezer/php@1.0.5':
|
||||||
|
dependencies:
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
'@lezer/highlight': 1.2.3
|
||||||
|
'@lezer/lr': 1.4.8
|
||||||
|
|
||||||
'@lezer/python@1.1.18':
|
'@lezer/python@1.1.18':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@lezer/common': 1.5.1
|
'@lezer/common': 1.5.1
|
||||||
'@lezer/highlight': 1.2.3
|
'@lezer/highlight': 1.2.3
|
||||||
'@lezer/lr': 1.4.8
|
'@lezer/lr': 1.4.8
|
||||||
|
|
||||||
|
'@lezer/rust@1.0.2':
|
||||||
|
dependencies:
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
'@lezer/highlight': 1.2.3
|
||||||
|
'@lezer/lr': 1.4.8
|
||||||
|
|
||||||
|
'@lezer/sass@1.1.0':
|
||||||
|
dependencies:
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
'@lezer/highlight': 1.2.3
|
||||||
|
'@lezer/lr': 1.4.8
|
||||||
|
|
||||||
'@lezer/xml@1.0.6':
|
'@lezer/xml@1.0.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@lezer/common': 1.5.1
|
'@lezer/common': 1.5.1
|
||||||
'@lezer/highlight': 1.2.3
|
'@lezer/highlight': 1.2.3
|
||||||
'@lezer/lr': 1.4.8
|
'@lezer/lr': 1.4.8
|
||||||
|
|
||||||
|
'@lezer/yaml@1.0.4':
|
||||||
|
dependencies:
|
||||||
|
'@lezer/common': 1.5.1
|
||||||
|
'@lezer/highlight': 1.2.3
|
||||||
|
'@lezer/lr': 1.4.8
|
||||||
|
|
||||||
'@lukeed/ms@2.0.2': {}
|
'@lukeed/ms@2.0.2': {}
|
||||||
|
|
||||||
'@malept/cross-spawn-promise@1.1.1':
|
'@malept/cross-spawn-promise@1.1.1':
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,15 @@ function getPaths(flags, teamName) {
|
||||||
return { claudeDir, teamDir, tasksDir, kanbanPath };
|
return { claudeDir, teamDir, tasksDir, kanbanPath };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function inferLeadName(paths) {
|
||||||
|
const config = readJson(path.join(paths.teamDir, 'config.json'), null);
|
||||||
|
if (!config || !Array.isArray(config.members)) return 'team-lead';
|
||||||
|
const lead = config.members.find(function (m) {
|
||||||
|
return m.role && String(m.role).toLowerCase().includes('lead');
|
||||||
|
});
|
||||||
|
return lead ? String(lead.name) : (config.members[0] ? String(config.members[0].name) : 'team-lead');
|
||||||
|
}
|
||||||
|
|
||||||
function readTask(paths, taskId) {
|
function readTask(paths, taskId) {
|
||||||
const taskPath = path.join(paths.tasksDir, String(taskId) + '.json');
|
const taskPath = path.join(paths.tasksDir, String(taskId) + '.json');
|
||||||
const task = readJson(taskPath, null);
|
const task = readJson(taskPath, null);
|
||||||
|
|
@ -340,7 +349,7 @@ function sendInboxMessage(paths, teamName, flags) {
|
||||||
const text = typeof flags.text === 'string' ? flags.text : '';
|
const text = typeof flags.text === 'string' ? flags.text : '';
|
||||||
if (!text) die('Missing --text');
|
if (!text) die('Missing --text');
|
||||||
const summary = typeof flags.summary === 'string' ? flags.summary : undefined;
|
const summary = typeof flags.summary === 'string' ? flags.summary : undefined;
|
||||||
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'user';
|
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths);
|
||||||
|
|
||||||
const inboxPath = path.join(paths.teamDir, 'inboxes', String(to) + '.json');
|
const inboxPath = path.join(paths.teamDir, 'inboxes', String(to) + '.json');
|
||||||
ensureDir(path.dirname(inboxPath));
|
ensureDir(path.dirname(inboxPath));
|
||||||
|
|
@ -374,7 +383,7 @@ function reviewApprove(paths, teamName, taskId, flags) {
|
||||||
if (!notify) return;
|
if (!notify) return;
|
||||||
const { task } = readTask(paths, taskId);
|
const { task } = readTask(paths, taskId);
|
||||||
if (!task.owner) return;
|
if (!task.owner) return;
|
||||||
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'user';
|
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths);
|
||||||
const note = typeof flags.note === 'string' ? flags.note.trim() : '';
|
const note = typeof flags.note === 'string' ? flags.note.trim() : '';
|
||||||
const text = note
|
const text = note
|
||||||
? 'Task #' + String(taskId) + ' approved.\n\n' + note
|
? 'Task #' + String(taskId) + ' approved.\n\n' + note
|
||||||
|
|
@ -396,7 +405,7 @@ function reviewRequestChanges(paths, teamName, taskId, flags) {
|
||||||
task.status = 'in_progress';
|
task.status = 'in_progress';
|
||||||
writeTask(taskPath, task);
|
writeTask(taskPath, task);
|
||||||
|
|
||||||
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'user';
|
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths);
|
||||||
const text =
|
const text =
|
||||||
'Task #' +
|
'Task #' +
|
||||||
String(taskId) +
|
String(taskId) +
|
||||||
|
|
@ -481,7 +490,7 @@ async function main() {
|
||||||
const notify = args.flags.notify === true || args.flags['notify-owner'] === true;
|
const notify = args.flags.notify === true || args.flags['notify-owner'] === true;
|
||||||
if (notify && task.owner) {
|
if (notify && task.owner) {
|
||||||
const from =
|
const from =
|
||||||
typeof args.flags.from === 'string' && args.flags.from.trim() ? args.flags.from.trim() : 'user';
|
typeof args.flags.from === 'string' && args.flags.from.trim() ? args.flags.from.trim() : inferLeadName(paths);
|
||||||
const parts = ['New task assigned to you: #' + String(task.id) + ' "' + String(task.subject) + '".'];
|
const parts = ['New task assigned to you: #' + String(task.id) + ' "' + String(task.subject) + '".'];
|
||||||
const rawDesc = typeof args.flags.description === 'string' ? args.flags.description.trim()
|
const rawDesc = typeof args.flags.description === 'string' ? args.flags.description.trim()
|
||||||
: typeof args.flags.desc === 'string' ? args.flags.desc.trim() : '';
|
: typeof args.flags.desc === 'string' ? args.flags.desc.trim() : '';
|
||||||
|
|
|
||||||
|
|
@ -431,8 +431,10 @@ export class TeamDataService {
|
||||||
AGENT_BLOCK_CLOSE
|
AGENT_BLOCK_CLOSE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const leadName = await this.resolveLeadName(teamName);
|
||||||
await this.sendMessage(teamName, {
|
await this.sendMessage(teamName, {
|
||||||
member: request.owner,
|
member: request.owner,
|
||||||
|
from: leadName,
|
||||||
text: parts.join('\n'),
|
text: parts.join('\n'),
|
||||||
summary: `New task #${task.id} assigned`,
|
summary: `New task #${task.id} assigned`,
|
||||||
});
|
});
|
||||||
|
|
@ -469,8 +471,10 @@ export class TeamDataService {
|
||||||
`node "${toolPath}" --team ${teamName} task complete ${task.id}`,
|
`node "${toolPath}" --team ${teamName} task complete ${task.id}`,
|
||||||
AGENT_BLOCK_CLOSE
|
AGENT_BLOCK_CLOSE
|
||||||
);
|
);
|
||||||
|
const leadName = await this.resolveLeadName(teamName);
|
||||||
await this.sendMessage(teamName, {
|
await this.sendMessage(teamName, {
|
||||||
member: task.owner,
|
member: task.owner,
|
||||||
|
from: leadName,
|
||||||
text: parts.join('\n'),
|
text: parts.join('\n'),
|
||||||
summary: `Task #${task.id} started`,
|
summary: `Task #${task.id} started`,
|
||||||
});
|
});
|
||||||
|
|
@ -507,8 +511,10 @@ export class TeamDataService {
|
||||||
`node "${toolPath}" --team ${teamName} task comment ${taskId} --text "<your reply>" --from "<your-name>"`,
|
`node "${toolPath}" --team ${teamName} task comment ${taskId} --text "<your reply>" --from "<your-name>"`,
|
||||||
AGENT_BLOCK_CLOSE,
|
AGENT_BLOCK_CLOSE,
|
||||||
];
|
];
|
||||||
|
const leadName = await this.resolveLeadName(teamName);
|
||||||
await this.sendMessage(teamName, {
|
await this.sendMessage(teamName, {
|
||||||
member: task.owner,
|
member: task.owner,
|
||||||
|
from: leadName,
|
||||||
text: parts.join('\n'),
|
text: parts.join('\n'),
|
||||||
summary: `Comment on #${taskId}`,
|
summary: `Comment on #${taskId}`,
|
||||||
});
|
});
|
||||||
|
|
@ -524,6 +530,17 @@ export class TeamDataService {
|
||||||
return this.inboxWriter.sendMessage(teamName, request);
|
return this.inboxWriter.sendMessage(teamName, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveLeadName(teamName: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const config = await this.configReader.getConfig(teamName);
|
||||||
|
if (!config) return 'team-lead';
|
||||||
|
const lead = config.members?.find((m) => m.role?.toLowerCase().includes('lead'));
|
||||||
|
return lead?.name ?? config.members?.[0]?.name ?? 'team-lead';
|
||||||
|
} catch {
|
||||||
|
return 'team-lead';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async sendDirectToLead(
|
async sendDirectToLead(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
leadName: string,
|
leadName: string,
|
||||||
|
|
@ -582,9 +599,13 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const toolPath = await this.toolsInstaller.ensureInstalled();
|
const [toolPath, leadName] = await Promise.all([
|
||||||
|
this.toolsInstaller.ensureInstalled(),
|
||||||
|
this.resolveLeadName(teamName),
|
||||||
|
]);
|
||||||
await this.sendMessage(teamName, {
|
await this.sendMessage(teamName, {
|
||||||
member: reviewer,
|
member: reviewer,
|
||||||
|
from: leadName,
|
||||||
text:
|
text:
|
||||||
`Please review task #${taskId}.\n\n` +
|
`Please review task #${taskId}.\n\n` +
|
||||||
`${AGENT_BLOCK_OPEN}\n` +
|
`${AGENT_BLOCK_OPEN}\n` +
|
||||||
|
|
@ -786,8 +807,10 @@ export class TeamDataService {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.taskWriter.updateStatus(teamName, taskId, 'in_progress');
|
await this.taskWriter.updateStatus(teamName, taskId, 'in_progress');
|
||||||
|
const leadName = await this.resolveLeadName(teamName);
|
||||||
await this.sendMessage(teamName, {
|
await this.sendMessage(teamName, {
|
||||||
member: task.owner,
|
member: task.owner,
|
||||||
|
from: leadName,
|
||||||
text:
|
text:
|
||||||
`Task #${taskId} needs fixes.\n\n` +
|
`Task #${taskId} needs fixes.\n\n` +
|
||||||
`${patch.comment?.trim() || 'Reviewer requested changes.'}\n\n` +
|
`${patch.comment?.trim() || 'Reviewer requested changes.'}\n\n` +
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,8 @@ interface ProvisioningRun {
|
||||||
directReplyParts: string[];
|
directReplyParts: string[];
|
||||||
/** Accumulates assistant text during provisioning phase for live UI preview. */
|
/** Accumulates assistant text during provisioning phase for live UI preview. */
|
||||||
provisioningOutputParts: string[];
|
provisioningOutputParts: string[];
|
||||||
|
/** Session ID detected from stream-json output (result.session_id or message.session_id). */
|
||||||
|
detectedSessionId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProvisioningAuthSource =
|
type ProvisioningAuthSource =
|
||||||
|
|
@ -765,6 +767,7 @@ export class TeamProvisioningService {
|
||||||
leadRelayCapture: null,
|
leadRelayCapture: null,
|
||||||
directReplyParts: [],
|
directReplyParts: [],
|
||||||
provisioningOutputParts: [],
|
provisioningOutputParts: [],
|
||||||
|
detectedSessionId: null,
|
||||||
progress: {
|
progress: {
|
||||||
runId,
|
runId,
|
||||||
teamName: request.teamName,
|
teamName: request.teamName,
|
||||||
|
|
@ -1036,6 +1039,7 @@ export class TeamProvisioningService {
|
||||||
leadRelayCapture: null,
|
leadRelayCapture: null,
|
||||||
directReplyParts: [],
|
directReplyParts: [],
|
||||||
provisioningOutputParts: [],
|
provisioningOutputParts: [],
|
||||||
|
detectedSessionId: null,
|
||||||
progress: {
|
progress: {
|
||||||
runId,
|
runId,
|
||||||
teamName: request.teamName,
|
teamName: request.teamName,
|
||||||
|
|
@ -1522,7 +1526,8 @@ export class TeamProvisioningService {
|
||||||
const aliveTeams = this.getAliveTeams();
|
const aliveTeams = this.getAliveTeams();
|
||||||
if (aliveTeams.length === 0) return;
|
if (aliveTeams.length === 0) return;
|
||||||
|
|
||||||
const newResolved = resolveLanguageName(newLangCode);
|
const systemLocale = getSystemLocale();
|
||||||
|
const newResolved = resolveLanguageName(newLangCode, systemLocale);
|
||||||
|
|
||||||
for (const teamName of aliveTeams) {
|
for (const teamName of aliveTeams) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -1532,7 +1537,15 @@ export class TeamProvisioningService {
|
||||||
const oldCode = config.language || 'system';
|
const oldCode = config.language || 'system';
|
||||||
if (oldCode === newLangCode) continue;
|
if (oldCode === newLangCode) continue;
|
||||||
|
|
||||||
const oldResolved = resolveLanguageName(oldCode);
|
// Compare resolved names to avoid spurious notifications
|
||||||
|
// e.g. switching from 'ru' to 'system' when system locale is Russian
|
||||||
|
const oldResolved = resolveLanguageName(oldCode, systemLocale);
|
||||||
|
if (oldResolved === newResolved) {
|
||||||
|
// Effective language unchanged — just update stored code silently
|
||||||
|
await this.configReader.updateConfig(teamName, { language: newLangCode });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const message =
|
const message =
|
||||||
`The user has changed the preferred communication language from "${oldResolved}" to "${newResolved}". ` +
|
`The user has changed the preferred communication language from "${oldResolved}" to "${newResolved}". ` +
|
||||||
`Please switch to ${newResolved} for all future responses and broadcast this change to all teammates ` +
|
`Please switch to ${newResolved} for all future responses and broadcast this change to all teammates ` +
|
||||||
|
|
@ -1704,6 +1717,17 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture session_id from any message type (first occurrence wins)
|
||||||
|
if (!run.detectedSessionId) {
|
||||||
|
const sid = typeof msg.session_id === 'string' ? msg.session_id : undefined;
|
||||||
|
if (sid && sid.trim().length > 0) {
|
||||||
|
run.detectedSessionId = sid.trim();
|
||||||
|
logger.info(
|
||||||
|
`[${run.teamName}] Detected session ID from stream-json: ${run.detectedSessionId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.type === 'result') {
|
if (msg.type === 'result') {
|
||||||
const subtype =
|
const subtype =
|
||||||
typeof msg.subtype === 'string'
|
typeof msg.subtype === 'string'
|
||||||
|
|
@ -1750,7 +1774,7 @@ export class TeamProvisioningService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!run.provisioningComplete) {
|
if (!run.provisioningComplete && !run.cancelRequested) {
|
||||||
void this.handleProvisioningTurnComplete(run);
|
void this.handleProvisioningTurnComplete(run);
|
||||||
}
|
}
|
||||||
} else if (subtype === 'error') {
|
} else if (subtype === 'error') {
|
||||||
|
|
@ -1760,7 +1784,7 @@ export class TeamProvisioningService {
|
||||||
if (run.leadRelayCapture) {
|
if (run.leadRelayCapture) {
|
||||||
run.leadRelayCapture.rejectOnce(errorMsg);
|
run.leadRelayCapture.rejectOnce(errorMsg);
|
||||||
}
|
}
|
||||||
if (!run.provisioningComplete) {
|
if (!run.provisioningComplete && !run.cancelRequested) {
|
||||||
const progress = updateProgress(
|
const progress = updateProgress(
|
||||||
run,
|
run,
|
||||||
'failed',
|
'failed',
|
||||||
|
|
@ -1787,6 +1811,7 @@ export class TeamProvisioningService {
|
||||||
* Process stays alive for subsequent tasks.
|
* Process stays alive for subsequent tasks.
|
||||||
*/
|
*/
|
||||||
private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise<void> {
|
private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise<void> {
|
||||||
|
if (run.cancelRequested) return;
|
||||||
run.provisioningComplete = true;
|
run.provisioningComplete = true;
|
||||||
|
|
||||||
// Clear provisioning timeout — no longer needed
|
// Clear provisioning timeout — no longer needed
|
||||||
|
|
@ -1797,7 +1822,7 @@ export class TeamProvisioningService {
|
||||||
this.stopFilesystemMonitor(run);
|
this.stopFilesystemMonitor(run);
|
||||||
|
|
||||||
if (run.isLaunch) {
|
if (run.isLaunch) {
|
||||||
await this.updateConfigPostLaunch(run.teamName, run.request.cwd);
|
await this.updateConfigPostLaunch(run.teamName, run.request.cwd, run.detectedSessionId);
|
||||||
await this.cleanupPrelaunchBackup(run.teamName);
|
await this.cleanupPrelaunchBackup(run.teamName);
|
||||||
const readyMessage = 'Team launched — process alive and ready';
|
const readyMessage = 'Team launched — process alive and ready';
|
||||||
const progress = updateProgress(run, 'ready', readyMessage, {
|
const progress = updateProgress(run, 'ready', readyMessage, {
|
||||||
|
|
@ -1838,7 +1863,7 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
// Persist teammates metadata separately from config.json.
|
// Persist teammates metadata separately from config.json.
|
||||||
await this.persistMembersMeta(run.teamName, run.request);
|
await this.persistMembersMeta(run.teamName, run.request);
|
||||||
await this.updateConfigPostLaunch(run.teamName, run.request.cwd);
|
await this.updateConfigPostLaunch(run.teamName, run.request.cwd, run.detectedSessionId);
|
||||||
|
|
||||||
const progress = updateProgress(run, 'ready', 'Team provisioned — process alive and ready', {
|
const progress = updateProgress(run, 'ready', 'Team provisioned — process alive and ready', {
|
||||||
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
|
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
|
||||||
|
|
@ -2388,24 +2413,50 @@ export class TeamProvisioningService {
|
||||||
* Combines session history append and projectPath update to avoid
|
* Combines session history append and projectPath update to avoid
|
||||||
* race conditions with the CLI writing to the same file.
|
* race conditions with the CLI writing to the same file.
|
||||||
*/
|
*/
|
||||||
private async updateConfigPostLaunch(teamName: string, projectPath: string): Promise<void> {
|
private async updateConfigPostLaunch(
|
||||||
|
teamName: string,
|
||||||
|
projectPath: string,
|
||||||
|
detectedSessionId: string | null
|
||||||
|
): Promise<void> {
|
||||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||||
try {
|
try {
|
||||||
const raw = await fs.promises.readFile(configPath, 'utf8');
|
const raw = await fs.promises.readFile(configPath, 'utf8');
|
||||||
const config = JSON.parse(raw) as Record<string, unknown>;
|
const config = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
|
||||||
// Append session to history
|
const sessionHistory = Array.isArray(config.sessionHistory)
|
||||||
const leadSessionId = config.leadSessionId;
|
? (config.sessionHistory as string[])
|
||||||
if (typeof leadSessionId === 'string' && leadSessionId.trim().length > 0) {
|
: [];
|
||||||
const sessionHistory = Array.isArray(config.sessionHistory)
|
|
||||||
? (config.sessionHistory as string[])
|
// Preserve old leadSessionId in history before overwriting
|
||||||
: [];
|
const oldLeadSessionId = config.leadSessionId;
|
||||||
if (!sessionHistory.includes(leadSessionId)) {
|
if (typeof oldLeadSessionId === 'string' && oldLeadSessionId.trim().length > 0) {
|
||||||
sessionHistory.push(leadSessionId);
|
if (!sessionHistory.includes(oldLeadSessionId)) {
|
||||||
config.sessionHistory = sessionHistory;
|
sessionHistory.push(oldLeadSessionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update leadSessionId to the new session detected from stream-json
|
||||||
|
let newSessionId = detectedSessionId;
|
||||||
|
|
||||||
|
// Fallback: if stream-json didn't provide session_id, scan project dir for newest JSONL
|
||||||
|
if (!newSessionId && projectPath.trim()) {
|
||||||
|
const scannedId = await this.scanForNewestSession(projectPath, sessionHistory);
|
||||||
|
if (scannedId) {
|
||||||
|
newSessionId = scannedId;
|
||||||
|
logger.info(`[${teamName}] Detected new session via project dir scan: ${scannedId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newSessionId) {
|
||||||
|
config.leadSessionId = newSessionId;
|
||||||
|
if (!sessionHistory.includes(newSessionId)) {
|
||||||
|
sessionHistory.push(newSessionId);
|
||||||
|
}
|
||||||
|
logger.info(`[${teamName}] Updated leadSessionId: ${newSessionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
config.sessionHistory = sessionHistory;
|
||||||
|
|
||||||
// Save current language setting
|
// Save current language setting
|
||||||
const langCode = ConfigManager.getInstance().getConfig().general.agentLanguage || 'system';
|
const langCode = ConfigManager.getInstance().getConfig().general.agentLanguage || 'system';
|
||||||
config.language = langCode;
|
config.language = langCode;
|
||||||
|
|
@ -2432,6 +2483,41 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback: scan the project directory for the newest JSONL file
|
||||||
|
* that isn't already in sessionHistory. Returns the session ID or null.
|
||||||
|
*/
|
||||||
|
private async scanForNewestSession(
|
||||||
|
projectPath: string,
|
||||||
|
knownSessions: string[]
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const projectId = encodePath(projectPath);
|
||||||
|
const baseDir = extractBaseDir(projectId);
|
||||||
|
const projectDir = path.join(getProjectsBasePath(), baseDir);
|
||||||
|
const entries = await fs.promises.readdir(projectDir);
|
||||||
|
|
||||||
|
const knownSet = new Set(knownSessions);
|
||||||
|
let newest: { id: string; mtime: number } | null = null;
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.endsWith('.jsonl')) continue;
|
||||||
|
const sessionId = entry.replace('.jsonl', '');
|
||||||
|
if (knownSet.has(sessionId)) continue;
|
||||||
|
|
||||||
|
const filePath = path.join(projectDir, entry);
|
||||||
|
const stat = await fs.promises.stat(filePath);
|
||||||
|
if (!newest || stat.mtimeMs > newest.mtime) {
|
||||||
|
newest = { id: sessionId, mtime: stat.mtimeMs };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newest?.id ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async normalizeTeamConfigForLaunch(teamName: string, configRaw: string): Promise<void> {
|
private async normalizeTeamConfigForLaunch(teamName: string, configRaw: string): Promise<void> {
|
||||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||||
const backupPath = `${configPath}.prelaunch.bak`;
|
const backupPath = `${configPath}.prelaunch.bak`;
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,53 @@ interface CliLogsRichViewProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single collapsible group of assistant items.
|
* Derives a scoped Set for a single group from the global prefixed Set.
|
||||||
|
* Global keys are stored as `groupId::itemId`; this strips the prefix.
|
||||||
|
*/
|
||||||
|
function scopedItemIds(globalIds: Set<string>, groupId: string): Set<string> {
|
||||||
|
const prefix = `${groupId}::`;
|
||||||
|
const scoped = new Set<string>();
|
||||||
|
for (const key of globalIds) {
|
||||||
|
if (key.startsWith(prefix)) {
|
||||||
|
scoped.add(key.slice(prefix.length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scoped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-item group rendered flat (no collapsible wrapper).
|
||||||
|
*/
|
||||||
|
const FlatGroupItem = ({
|
||||||
|
group,
|
||||||
|
expandedItemIds,
|
||||||
|
onItemClick,
|
||||||
|
}: {
|
||||||
|
group: StreamJsonGroup;
|
||||||
|
expandedItemIds: Set<string>;
|
||||||
|
onItemClick: (itemId: string) => void;
|
||||||
|
}): React.JSX.Element => {
|
||||||
|
const groupItemIds = useMemo(
|
||||||
|
() => scopedItemIds(expandedItemIds, group.id),
|
||||||
|
[expandedItemIds, group.id]
|
||||||
|
);
|
||||||
|
const handleItemClick = useCallback(
|
||||||
|
(itemId: string) => onItemClick(`${group.id}::${itemId}`),
|
||||||
|
[group.id, onItemClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DisplayItemList
|
||||||
|
items={group.items}
|
||||||
|
onItemClick={handleItemClick}
|
||||||
|
expandedItemIds={groupItemIds}
|
||||||
|
aiGroupId={group.id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single collapsible group of assistant items (2+ items).
|
||||||
*/
|
*/
|
||||||
const StreamGroup = ({
|
const StreamGroup = ({
|
||||||
group,
|
group,
|
||||||
|
|
@ -36,37 +82,49 @@ const StreamGroup = ({
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
expandedItemIds: Set<string>;
|
expandedItemIds: Set<string>;
|
||||||
onItemClick: (itemId: string) => void;
|
onItemClick: (itemId: string) => void;
|
||||||
}): React.JSX.Element => (
|
}): React.JSX.Element => {
|
||||||
<div className="rounded border border-[var(--color-border)] bg-[var(--color-surface)]">
|
// Scope item IDs to this group to avoid cross-group collisions
|
||||||
<button
|
const groupItemIds = useMemo(
|
||||||
type="button"
|
() => scopedItemIds(expandedItemIds, group.id),
|
||||||
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-raised)]"
|
[expandedItemIds, group.id]
|
||||||
onClick={onToggle}
|
);
|
||||||
>
|
const handleItemClick = useCallback(
|
||||||
<ChevronRight
|
(itemId: string) => onItemClick(`${group.id}::${itemId}`),
|
||||||
size={12}
|
[group.id, onItemClick]
|
||||||
className={cn(
|
);
|
||||||
'shrink-0 text-[var(--color-text-muted)] transition-transform duration-150',
|
|
||||||
isExpanded && 'rotate-90'
|
return (
|
||||||
)}
|
<div className="rounded border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||||
/>
|
<button
|
||||||
<Bot size={13} className="shrink-0 text-[var(--color-text-muted)]" />
|
type="button"
|
||||||
<span className="min-w-0 truncate text-[11px] text-[var(--color-text-secondary)]">
|
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-raised)]"
|
||||||
{group.summary}
|
onClick={onToggle}
|
||||||
</span>
|
>
|
||||||
</button>
|
<ChevronRight
|
||||||
{isExpanded && (
|
size={12}
|
||||||
<div className="border-t border-[var(--color-border)] p-2">
|
className={cn(
|
||||||
<DisplayItemList
|
'shrink-0 text-[var(--color-text-muted)] transition-transform duration-150',
|
||||||
items={group.items}
|
isExpanded && 'rotate-90'
|
||||||
onItemClick={onItemClick}
|
)}
|
||||||
expandedItemIds={expandedItemIds}
|
|
||||||
aiGroupId={group.id}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<Bot size={13} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||||
)}
|
<span className="min-w-0 truncate text-[11px] text-[var(--color-text-secondary)]">
|
||||||
</div>
|
{group.summary}
|
||||||
);
|
</span>
|
||||||
|
</button>
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t border-[var(--color-border)] p-2">
|
||||||
|
<DisplayItemList
|
||||||
|
items={group.items}
|
||||||
|
onItemClick={handleItemClick}
|
||||||
|
expandedItemIds={groupItemIds}
|
||||||
|
aiGroupId={group.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const CliLogsRichView = ({
|
export const CliLogsRichView = ({
|
||||||
cliLogsTail,
|
cliLogsTail,
|
||||||
|
|
@ -126,13 +184,14 @@ export const CliLogsRichView = ({
|
||||||
const hasContent = cliLogsTail.trim().length > 0;
|
const hasContent = cliLogsTail.trim().length > 0;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded border border-[var(--color-border)] bg-[var(--color-surface)]',
|
'max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)]',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{hasContent ? (
|
{hasContent ? (
|
||||||
<pre className="max-h-[400px] overflow-y-auto p-2 font-mono text-[11px] leading-relaxed text-[var(--color-text-secondary)]">
|
<pre className="p-2 font-mono text-[11px] leading-relaxed text-[var(--color-text-secondary)]">
|
||||||
{cliLogsTail}
|
{cliLogsTail}
|
||||||
</pre>
|
</pre>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -146,16 +205,26 @@ export const CliLogsRichView = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={scrollRef} className={cn('max-h-[400px] space-y-1.5 overflow-y-auto', className)}>
|
<div ref={scrollRef} className={cn('max-h-[400px] space-y-1.5 overflow-y-auto', className)}>
|
||||||
{groups.map((group) => (
|
{groups.map((group) =>
|
||||||
<StreamGroup
|
group.items.length === 1 ? (
|
||||||
key={group.id}
|
// Single item — render flat without collapsible group wrapper
|
||||||
group={group}
|
<FlatGroupItem
|
||||||
isExpanded={expandedGroupIds.has(group.id)}
|
key={group.id}
|
||||||
onToggle={() => handleGroupToggle(group.id)}
|
group={group}
|
||||||
expandedItemIds={expandedItemIds}
|
expandedItemIds={expandedItemIds}
|
||||||
onItemClick={handleItemClick}
|
onItemClick={handleItemClick}
|
||||||
/>
|
/>
|
||||||
))}
|
) : (
|
||||||
|
<StreamGroup
|
||||||
|
key={group.id}
|
||||||
|
group={group}
|
||||||
|
isExpanded={expandedGroupIds.has(group.id)}
|
||||||
|
onToggle={() => handleGroupToggle(group.id)}
|
||||||
|
expandedItemIds={expandedItemIds}
|
||||||
|
onItemClick={handleItemClick}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -200,20 +200,34 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentProjectPath) {
|
const aliveSet = new Set(aliveTeams);
|
||||||
const matches = (t: TeamSummary): boolean => {
|
const matchesProject = currentProjectPath
|
||||||
if (t.projectPath && normalizePath(t.projectPath) === currentProjectPath) return true;
|
? (t: TeamSummary): boolean => {
|
||||||
return t.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ?? false;
|
if (t.projectPath && normalizePath(t.projectPath) === currentProjectPath) return true;
|
||||||
};
|
return (
|
||||||
result = [...result].sort((a, b) => {
|
t.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ?? false
|
||||||
const aMatch = matches(a) ? 0 : 1;
|
);
|
||||||
const bMatch = matches(b) ? 0 : 1;
|
}
|
||||||
return aMatch - bMatch;
|
: null;
|
||||||
});
|
|
||||||
}
|
result = [...result].sort((a, b) => {
|
||||||
|
// 1. Alive (running) teams first
|
||||||
|
const aliveA = aliveSet.has(a.teamName) ? 0 : 1;
|
||||||
|
const aliveB = aliveSet.has(b.teamName) ? 0 : 1;
|
||||||
|
if (aliveA !== aliveB) return aliveA - aliveB;
|
||||||
|
|
||||||
|
// 2. Matching current project second
|
||||||
|
if (matchesProject) {
|
||||||
|
const projA = matchesProject(a) ? 0 : 1;
|
||||||
|
const projB = matchesProject(b) ? 0 : 1;
|
||||||
|
if (projA !== projB) return projA - projB;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [teams, searchQuery, currentProjectPath]);
|
}, [teams, searchQuery, currentProjectPath, aliveTeams]);
|
||||||
|
|
||||||
// Live branch/worktree for team project paths (poll so it updates during process)
|
// Live branch/worktree for team project paths (poll so it updates during process)
|
||||||
const projectPathsToPoll = useMemo(() => {
|
const projectPathsToPoll = useMemo(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
|
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
|
||||||
|
|
@ -6,6 +6,7 @@ import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||||
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
import {
|
import {
|
||||||
ArrowLeftFromLine,
|
ArrowLeftFromLine,
|
||||||
ArrowRightFromLine,
|
ArrowRightFromLine,
|
||||||
|
|
@ -148,6 +149,19 @@ export const KanbanTaskCard = ({
|
||||||
const hasBlockedBy = blockedByIds.length > 0;
|
const hasBlockedBy = blockedByIds.length > 0;
|
||||||
const hasBlocks = blocksIds.length > 0;
|
const hasBlocks = blocksIds.length > 0;
|
||||||
|
|
||||||
|
// Lazy-check if task has file changes (only for done/review/approved columns)
|
||||||
|
const showChangesColumn =
|
||||||
|
(columnId === 'done' || columnId === 'review' || columnId === 'approved') && !!onViewChanges;
|
||||||
|
const cacheKey = `${teamName}:${task.id}`;
|
||||||
|
const taskHasChanges = useStore((s) => s.taskHasChanges[cacheKey]);
|
||||||
|
const checkTaskHasChanges = useStore((s) => s.checkTaskHasChanges);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showChangesColumn && task.status === 'completed' && taskHasChanges == null) {
|
||||||
|
void checkTaskHasChanges(teamName, task.id);
|
||||||
|
}
|
||||||
|
}, [showChangesColumn, task.status, task.id, teamName, taskHasChanges, checkTaskHasChanges]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-task-id={task.id}
|
data-task-id={task.id}
|
||||||
|
|
@ -341,9 +355,10 @@ export const KanbanTaskCard = ({
|
||||||
Move back to DONE
|
Move back to DONE
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
{(columnId === 'done' || columnId === 'review' || columnId === 'approved') &&
|
<div className="flex items-center gap-1.5">
|
||||||
onViewChanges ? (
|
{showChangesColumn && taskHasChanges === true ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|
@ -356,9 +371,8 @@ export const KanbanTaskCard = ({
|
||||||
Changes
|
Changes
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
<UnreadCommentsBadge unreadCount={unreadCount} totalCount={task.comments?.length ?? 0} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UnreadCommentsBadge unreadCount={unreadCount} totalCount={task.comments?.length ?? 0} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { goToNextChunk, rejectChunk } from '@codemirror/merge';
|
import { goToNextChunk, rejectChunk } from '@codemirror/merge';
|
||||||
|
import { isElectronMode } from '@renderer/api';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useDiffNavigation } from '@renderer/hooks/useDiffNavigation';
|
import { useDiffNavigation } from '@renderer/hooks/useDiffNavigation';
|
||||||
import { useViewedFiles } from '@renderer/hooks/useViewedFiles';
|
import { useViewedFiles } from '@renderer/hooks/useViewedFiles';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { ChevronDown, Clock, Loader2, X } from 'lucide-react';
|
import { ChevronDown, Clock, Loader2, Save, Undo2, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { acceptAllChunks, rejectAllChunks } from './CodeMirrorDiffUtils';
|
||||||
import { CodeMirrorDiffView } from './CodeMirrorDiffView';
|
import { CodeMirrorDiffView } from './CodeMirrorDiffView';
|
||||||
import { ConfidenceBadge } from './ConfidenceBadge';
|
import { ConfidenceBadge } from './ConfidenceBadge';
|
||||||
import { DiffErrorBoundary } from './DiffErrorBoundary';
|
import { DiffErrorBoundary } from './DiffErrorBoundary';
|
||||||
|
|
@ -18,6 +21,7 @@ import { ReviewToolbar } from './ReviewToolbar';
|
||||||
import { ScopeWarningBanner } from './ScopeWarningBanner';
|
import { ScopeWarningBanner } from './ScopeWarningBanner';
|
||||||
import { ViewedProgressBar } from './ViewedProgressBar';
|
import { ViewedProgressBar } from './ViewedProgressBar';
|
||||||
|
|
||||||
|
import type { EditorState } from '@codemirror/state';
|
||||||
import type { EditorView } from '@codemirror/view';
|
import type { EditorView } from '@codemirror/view';
|
||||||
import type { HunkDecision, TaskChangeSetV2 } from '@shared/types';
|
import type { HunkDecision, TaskChangeSetV2 } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -49,7 +53,7 @@ export const ChangeReviewDialog = ({
|
||||||
mode,
|
mode,
|
||||||
memberName,
|
memberName,
|
||||||
taskId,
|
taskId,
|
||||||
}: ChangeReviewDialogProps) => {
|
}: ChangeReviewDialogProps): React.ReactElement | null => {
|
||||||
const {
|
const {
|
||||||
activeChangeSet,
|
activeChangeSet,
|
||||||
changeSetLoading,
|
changeSetLoading,
|
||||||
|
|
@ -64,12 +68,10 @@ export const ChangeReviewDialog = ({
|
||||||
fileDecisions,
|
fileDecisions,
|
||||||
fileContents,
|
fileContents,
|
||||||
fileContentsLoading,
|
fileContentsLoading,
|
||||||
diffViewMode,
|
|
||||||
collapseUnchanged,
|
collapseUnchanged,
|
||||||
applying,
|
applying,
|
||||||
applyError,
|
applyError,
|
||||||
setHunkDecision,
|
setHunkDecision,
|
||||||
setDiffViewMode,
|
|
||||||
setCollapseUnchanged,
|
setCollapseUnchanged,
|
||||||
fetchFileContent,
|
fetchFileContent,
|
||||||
acceptAll,
|
acceptAll,
|
||||||
|
|
@ -87,6 +89,10 @@ export const ChangeReviewDialog = ({
|
||||||
const [timelineOpen, setTimelineOpen] = useState(false);
|
const [timelineOpen, setTimelineOpen] = useState(false);
|
||||||
// Counter to force editor rebuild on discard
|
// Counter to force editor rebuild on discard
|
||||||
const [discardCounter, setDiscardCounter] = useState(0);
|
const [discardCounter, setDiscardCounter] = useState(0);
|
||||||
|
// Cache EditorState per file to preserve undo history between file switches
|
||||||
|
const editorStateCache = useRef(new Map<string, EditorState>());
|
||||||
|
// Current file's cached initial state (derived outside render to avoid ref access during render)
|
||||||
|
const [cachedInitialState, setCachedInitialState] = useState<EditorState | undefined>(undefined);
|
||||||
|
|
||||||
// Build scope key for viewed storage
|
// Build scope key for viewed storage
|
||||||
const scopeKey = mode === 'task' ? `task:${taskId ?? ''}` : `agent:${memberName ?? ''}`;
|
const scopeKey = mode === 'task' ? `task:${taskId ?? ''}` : `agent:${memberName ?? ''}`;
|
||||||
|
|
@ -107,18 +113,54 @@ export const ChangeReviewDialog = ({
|
||||||
progress: viewedProgress,
|
progress: viewedProgress,
|
||||||
} = useViewedFiles(teamName, scopeKey, allFilePaths);
|
} = useViewedFiles(teamName, scopeKey, allFilePaths);
|
||||||
|
|
||||||
|
// When collapseUnchanged changes, invalidate cached state for current file
|
||||||
|
// so the editor is recreated with the new extension config
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedReviewFilePath) {
|
||||||
|
editorStateCache.current.delete(selectedReviewFilePath);
|
||||||
|
}
|
||||||
|
queueMicrotask(() => setCachedInitialState(undefined));
|
||||||
|
}, [collapseUnchanged]); // eslint-disable-line react-hooks/exhaustive-deps -- only collapseUnchanged triggers cache invalidation
|
||||||
|
|
||||||
// Editable diff computed values
|
// Editable diff computed values
|
||||||
const editedCount = Object.keys(editedContents).length;
|
const editedCount = Object.keys(editedContents).length;
|
||||||
const hasCurrentFileEdits = !!(
|
const hasCurrentFileEdits = !!(
|
||||||
selectedReviewFilePath && selectedReviewFilePath in editedContents
|
selectedReviewFilePath && selectedReviewFilePath in editedContents
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Save current editor state to cache before switching files
|
||||||
|
const handleSelectFile = useCallback(
|
||||||
|
(filePath: string | null) => {
|
||||||
|
const view = editorViewRef.current;
|
||||||
|
if (view && selectedReviewFilePath) {
|
||||||
|
editorStateCache.current.set(selectedReviewFilePath, view.state);
|
||||||
|
}
|
||||||
|
setCachedInitialState(filePath ? editorStateCache.current.get(filePath) : undefined);
|
||||||
|
selectReviewFile(filePath);
|
||||||
|
},
|
||||||
|
[selectedReviewFilePath, selectReviewFile]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAcceptAll = useCallback(() => {
|
||||||
|
const view = editorViewRef.current;
|
||||||
|
if (view) acceptAllChunks(view);
|
||||||
|
acceptAll();
|
||||||
|
}, [acceptAll]);
|
||||||
|
|
||||||
|
const handleRejectAll = useCallback(() => {
|
||||||
|
const view = editorViewRef.current;
|
||||||
|
if (view) rejectAllChunks(view);
|
||||||
|
rejectAll();
|
||||||
|
}, [rejectAll]);
|
||||||
|
|
||||||
const handleSaveCurrentFile = useCallback(() => {
|
const handleSaveCurrentFile = useCallback(() => {
|
||||||
if (selectedReviewFilePath) void saveEditedFile(selectedReviewFilePath);
|
if (selectedReviewFilePath) void saveEditedFile(selectedReviewFilePath);
|
||||||
}, [selectedReviewFilePath, saveEditedFile]);
|
}, [selectedReviewFilePath, saveEditedFile]);
|
||||||
|
|
||||||
const handleDiscardCurrentFile = useCallback(() => {
|
const handleDiscardCurrentFile = useCallback(() => {
|
||||||
if (selectedReviewFilePath) {
|
if (selectedReviewFilePath) {
|
||||||
|
editorStateCache.current.delete(selectedReviewFilePath);
|
||||||
|
setCachedInitialState(undefined);
|
||||||
discardFileEdits(selectedReviewFilePath);
|
discardFileEdits(selectedReviewFilePath);
|
||||||
setDiscardCounter((c) => c + 1);
|
setDiscardCounter((c) => c + 1);
|
||||||
}
|
}
|
||||||
|
|
@ -127,7 +169,7 @@ export const ChangeReviewDialog = ({
|
||||||
const diffNav = useDiffNavigation(
|
const diffNav = useDiffNavigation(
|
||||||
activeChangeSet?.files ?? [],
|
activeChangeSet?.files ?? [],
|
||||||
selectedReviewFilePath,
|
selectedReviewFilePath,
|
||||||
selectReviewFile,
|
handleSelectFile,
|
||||||
editorViewRef,
|
editorViewRef,
|
||||||
open,
|
open,
|
||||||
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'accepted'),
|
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'accepted'),
|
||||||
|
|
@ -166,7 +208,7 @@ export const ChangeReviewDialog = ({
|
||||||
// Escape to close
|
// Escape to close
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent): void => {
|
||||||
if (e.key === 'Escape') onOpenChange(false);
|
if (e.key === 'Escape') onOpenChange(false);
|
||||||
};
|
};
|
||||||
document.addEventListener('keydown', handler);
|
document.addEventListener('keydown', handler);
|
||||||
|
|
@ -250,12 +292,25 @@ export const ChangeReviewDialog = ({
|
||||||
? `Changes by ${memberName ?? 'unknown'}`
|
? `Changes by ${memberName ?? 'unknown'}`
|
||||||
: `Changes for task #${taskId ?? '?'}`;
|
: `Changes for task #${taskId ?? '?'}`;
|
||||||
|
|
||||||
|
const isMacElectron =
|
||||||
|
isElectronMode() && window.navigator.userAgent.toLowerCase().includes('mac');
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex flex-col bg-surface">
|
<div className="fixed inset-0 z-50 flex flex-col bg-surface">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between border-b border-border bg-surface-sidebar px-4 py-3">
|
<div
|
||||||
|
className="flex items-center justify-between border-b border-border bg-surface-sidebar px-4 py-3"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
paddingLeft: isMacElectron
|
||||||
|
? 'var(--macos-traffic-light-padding-left, 72px)'
|
||||||
|
: undefined,
|
||||||
|
WebkitAppRegion: isMacElectron ? 'drag' : undefined,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h2 className="text-sm font-medium text-text">{title}</h2>
|
<h2 className="text-sm font-medium text-text">{title}</h2>
|
||||||
{activeChangeSet && (
|
{activeChangeSet && (
|
||||||
|
|
@ -278,6 +333,7 @@ export const ChangeReviewDialog = ({
|
||||||
<button
|
<button
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||||
|
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<X className="size-4" />
|
<X className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -297,29 +353,23 @@ export const ChangeReviewDialog = ({
|
||||||
<ReviewToolbar
|
<ReviewToolbar
|
||||||
stats={reviewStats}
|
stats={reviewStats}
|
||||||
changeStats={changeStats}
|
changeStats={changeStats}
|
||||||
diffViewMode={diffViewMode}
|
|
||||||
collapseUnchanged={collapseUnchanged}
|
collapseUnchanged={collapseUnchanged}
|
||||||
applying={applying}
|
applying={applying}
|
||||||
autoViewed={autoViewed}
|
autoViewed={autoViewed}
|
||||||
onAutoViewedChange={setAutoViewed}
|
onAutoViewedChange={setAutoViewed}
|
||||||
onAcceptAll={acceptAll}
|
onAcceptAll={handleAcceptAll}
|
||||||
onRejectAll={rejectAll}
|
onRejectAll={handleRejectAll}
|
||||||
onApply={handleApply}
|
onApply={handleApply}
|
||||||
onDiffViewModeChange={setDiffViewMode}
|
|
||||||
onCollapseUnchangedChange={setCollapseUnchanged}
|
onCollapseUnchangedChange={setCollapseUnchanged}
|
||||||
editedCount={editedCount}
|
editedCount={editedCount}
|
||||||
hasCurrentFileEdits={hasCurrentFileEdits}
|
|
||||||
saving={applying}
|
|
||||||
onSaveCurrentFile={handleSaveCurrentFile}
|
|
||||||
onDiscardCurrentFile={handleDiscardCurrentFile}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Scope warnings */}
|
{/* Scope info / warnings */}
|
||||||
{mode === 'task' &&
|
{mode === 'task' &&
|
||||||
activeChangeSet &&
|
activeChangeSet &&
|
||||||
isTaskChangeSetV2(activeChangeSet) &&
|
isTaskChangeSetV2(activeChangeSet) &&
|
||||||
activeChangeSet.warnings.length > 0 && (
|
(activeChangeSet.warnings.length > 0 || activeChangeSet.scope.confidence.tier >= 2) && (
|
||||||
<ScopeWarningBanner
|
<ScopeWarningBanner
|
||||||
warnings={activeChangeSet.warnings}
|
warnings={activeChangeSet.warnings}
|
||||||
confidence={activeChangeSet.scope.confidence}
|
confidence={activeChangeSet.scope.confidence}
|
||||||
|
|
@ -354,7 +404,7 @@ export const ChangeReviewDialog = ({
|
||||||
<ReviewFileTree
|
<ReviewFileTree
|
||||||
files={activeChangeSet.files}
|
files={activeChangeSet.files}
|
||||||
selectedFilePath={selectedReviewFilePath}
|
selectedFilePath={selectedReviewFilePath}
|
||||||
onSelectFile={selectReviewFile}
|
onSelectFile={handleSelectFile}
|
||||||
viewedSet={viewedSet}
|
viewedSet={viewedSet}
|
||||||
onMarkViewed={markViewed}
|
onMarkViewed={markViewed}
|
||||||
onUnmarkViewed={unmarkViewed}
|
onUnmarkViewed={unmarkViewed}
|
||||||
|
|
@ -391,7 +441,7 @@ export const ChangeReviewDialog = ({
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{selectedFile ? (
|
{selectedFile ? (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* File header with content source badge */}
|
{/* File header with content source badge and save/discard */}
|
||||||
<div className="flex items-center gap-2 border-b border-border px-4 py-2">
|
<div className="flex items-center gap-2 border-b border-border px-4 py-2">
|
||||||
<span className="text-xs font-medium text-text">
|
<span className="text-xs font-medium text-text">
|
||||||
{selectedFile.relativePath}
|
{selectedFile.relativePath}
|
||||||
|
|
@ -410,7 +460,7 @@ export const ChangeReviewDialog = ({
|
||||||
{/* File-level decision indicator */}
|
{/* File-level decision indicator */}
|
||||||
{fileDecisions[selectedFile.filePath] && (
|
{fileDecisions[selectedFile.filePath] && (
|
||||||
<span
|
<span
|
||||||
className={`ml-auto rounded px-1.5 py-0.5 text-[10px] ${
|
className={`rounded px-1.5 py-0.5 text-[10px] ${
|
||||||
fileDecisions[selectedFile.filePath] === 'accepted'
|
fileDecisions[selectedFile.filePath] === 'accepted'
|
||||||
? 'bg-green-500/20 text-green-400'
|
? 'bg-green-500/20 text-green-400'
|
||||||
: fileDecisions[selectedFile.filePath] === 'rejected'
|
: fileDecisions[selectedFile.filePath] === 'rejected'
|
||||||
|
|
@ -421,6 +471,49 @@ export const ChangeReviewDialog = ({
|
||||||
{fileDecisions[selectedFile.filePath]}
|
{fileDecisions[selectedFile.filePath]}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-1.5">
|
||||||
|
{hasCurrentFileEdits && (
|
||||||
|
<>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={handleDiscardCurrentFile}
|
||||||
|
className="flex items-center gap-1 rounded bg-orange-500/15 px-2 py-1 text-xs text-orange-400 transition-colors hover:bg-orange-500/25"
|
||||||
|
>
|
||||||
|
<Undo2 className="size-3" />
|
||||||
|
Discard
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
Discard all edits for this file
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveCurrentFile}
|
||||||
|
disabled={applying}
|
||||||
|
className="flex items-center gap-1 rounded bg-green-500/15 px-2 py-1 text-xs text-green-400 transition-colors hover:bg-green-500/25 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{applying ? (
|
||||||
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="size-3" />
|
||||||
|
)}
|
||||||
|
Save File
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
<span>Save file to disk</span>
|
||||||
|
<kbd className="ml-2 rounded border border-border bg-surface-raised px-1 py-0.5 font-mono text-[10px] text-text-muted">
|
||||||
|
⌘↵
|
||||||
|
</kbd>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Loading state */}
|
{/* Loading state */}
|
||||||
|
|
@ -450,6 +543,7 @@ export const ChangeReviewDialog = ({
|
||||||
readOnly={false}
|
readOnly={false}
|
||||||
showMergeControls={true}
|
showMergeControls={true}
|
||||||
collapseUnchanged={collapseUnchanged}
|
collapseUnchanged={collapseUnchanged}
|
||||||
|
initialState={cachedInitialState}
|
||||||
onHunkAccepted={(idx) =>
|
onHunkAccepted={(idx) =>
|
||||||
setHunkDecision(selectedFile.filePath, idx, 'accepted')
|
setHunkDecision(selectedFile.filePath, idx, 'accepted')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
75
src/renderer/components/team/review/CodeMirrorDiffUtils.ts
Normal file
75
src/renderer/components/team/review/CodeMirrorDiffUtils.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { invertedEffects } from '@codemirror/commands';
|
||||||
|
import {
|
||||||
|
acceptChunk,
|
||||||
|
getChunks,
|
||||||
|
getOriginalDoc,
|
||||||
|
rejectChunk,
|
||||||
|
updateOriginalDoc,
|
||||||
|
} from '@codemirror/merge';
|
||||||
|
import { ChangeSet, type ChangeSpec, type StateEffect } from '@codemirror/state';
|
||||||
|
import { type EditorView } from '@codemirror/view';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teaches CM history to undo acceptChunk operations (updateOriginalDoc effects).
|
||||||
|
* Without this, Cmd+Z only works for rejectChunk (document changes) but not acceptChunk.
|
||||||
|
*/
|
||||||
|
export const mergeUndoSupport = invertedEffects.of((tr) => {
|
||||||
|
const effects: StateEffect<unknown>[] = [];
|
||||||
|
for (const effect of tr.effects) {
|
||||||
|
if (effect.is(updateOriginalDoc)) {
|
||||||
|
const prevOriginal = getOriginalDoc(tr.startState);
|
||||||
|
const inverseSpecs: ChangeSpec[] = [];
|
||||||
|
effect.value.changes.iterChanges((fromA: number, toA: number, fromB: number, toB: number) => {
|
||||||
|
inverseSpecs.push({
|
||||||
|
from: fromB,
|
||||||
|
to: toB,
|
||||||
|
insert: prevOriginal.sliceString(fromA, toA),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const inverseChanges = ChangeSet.of(inverseSpecs, effect.value.doc.length);
|
||||||
|
effects.push(updateOriginalDoc.of({ doc: prevOriginal, changes: inverseChanges }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return effects;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Accept all remaining chunks in one transaction (single Cmd+Z to undo) */
|
||||||
|
export function acceptAllChunks(view: EditorView): boolean {
|
||||||
|
const result = getChunks(view.state);
|
||||||
|
if (!result || result.chunks.length === 0) return false;
|
||||||
|
|
||||||
|
const orig = getOriginalDoc(view.state);
|
||||||
|
const specs: ChangeSpec[] = [];
|
||||||
|
for (const chunk of result.chunks) {
|
||||||
|
specs.push({
|
||||||
|
from: chunk.fromA,
|
||||||
|
to: chunk.toA,
|
||||||
|
insert: view.state.doc.sliceString(chunk.fromB, chunk.toB),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const changes = ChangeSet.of(specs, orig.length);
|
||||||
|
view.dispatch({
|
||||||
|
effects: updateOriginalDoc.of({ doc: changes.apply(orig), changes }),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reject all remaining chunks in one transaction (single Cmd+Z to undo) */
|
||||||
|
export function rejectAllChunks(view: EditorView): boolean {
|
||||||
|
const result = getChunks(view.state);
|
||||||
|
if (!result || result.chunks.length === 0) return false;
|
||||||
|
|
||||||
|
const orig = getOriginalDoc(view.state);
|
||||||
|
const specs: ChangeSpec[] = [];
|
||||||
|
for (const chunk of result.chunks) {
|
||||||
|
specs.push({
|
||||||
|
from: chunk.fromB,
|
||||||
|
to: chunk.toB,
|
||||||
|
insert: orig.sliceString(chunk.fromA, chunk.toA),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
view.dispatch({ changes: specs });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { acceptChunk, getChunks, rejectChunk };
|
||||||
|
|
@ -1,23 +1,31 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
import React, { useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
||||||
|
import { cpp } from '@codemirror/lang-cpp';
|
||||||
import { css } from '@codemirror/lang-css';
|
import { css } from '@codemirror/lang-css';
|
||||||
|
import { go } from '@codemirror/lang-go';
|
||||||
import { html } from '@codemirror/lang-html';
|
import { html } from '@codemirror/lang-html';
|
||||||
|
import { java } from '@codemirror/lang-java';
|
||||||
import { javascript } from '@codemirror/lang-javascript';
|
import { javascript } from '@codemirror/lang-javascript';
|
||||||
import { json } from '@codemirror/lang-json';
|
import { json } from '@codemirror/lang-json';
|
||||||
|
import { less } from '@codemirror/lang-less';
|
||||||
|
import { markdown } from '@codemirror/lang-markdown';
|
||||||
|
import { php } from '@codemirror/lang-php';
|
||||||
import { python } from '@codemirror/lang-python';
|
import { python } from '@codemirror/lang-python';
|
||||||
|
import { rust } from '@codemirror/lang-rust';
|
||||||
|
import { sass } from '@codemirror/lang-sass';
|
||||||
|
import { sql } from '@codemirror/lang-sql';
|
||||||
import { xml } from '@codemirror/lang-xml';
|
import { xml } from '@codemirror/lang-xml';
|
||||||
import {
|
import { yaml } from '@codemirror/lang-yaml';
|
||||||
acceptChunk,
|
import { indentUnit, LanguageDescription, syntaxHighlighting } from '@codemirror/language';
|
||||||
getChunks,
|
import { languages } from '@codemirror/language-data';
|
||||||
goToNextChunk,
|
import { goToNextChunk, goToPreviousChunk, unifiedMergeView } from '@codemirror/merge';
|
||||||
goToPreviousChunk,
|
import { Compartment, EditorState, type Extension } from '@codemirror/state';
|
||||||
rejectChunk,
|
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||||
unifiedMergeView,
|
|
||||||
} from '@codemirror/merge';
|
|
||||||
import { EditorState, type Extension } from '@codemirror/state';
|
|
||||||
import { EditorView, keymap } from '@codemirror/view';
|
import { EditorView, keymap } from '@codemirror/view';
|
||||||
|
|
||||||
|
import { acceptChunk, getChunks, mergeUndoSupport, rejectChunk } from './CodeMirrorDiffUtils';
|
||||||
|
|
||||||
interface CodeMirrorDiffViewProps {
|
interface CodeMirrorDiffViewProps {
|
||||||
original: string;
|
original: string;
|
||||||
modified: string;
|
modified: string;
|
||||||
|
|
@ -35,10 +43,12 @@ interface CodeMirrorDiffViewProps {
|
||||||
editorViewRef?: React.RefObject<EditorView | null>;
|
editorViewRef?: React.RefObject<EditorView | null>;
|
||||||
/** Called when editor content changes (debounced, only when readOnly=false) */
|
/** Called when editor content changes (debounced, only when readOnly=false) */
|
||||||
onContentChanged?: (content: string) => void;
|
onContentChanged?: (content: string) => void;
|
||||||
|
/** Cached EditorState to restore (preserves undo history between file switches) */
|
||||||
|
initialState?: EditorState;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Detect language extension from file name */
|
/** Synchronous language extension for common file types (bundled by Vite) */
|
||||||
function getLanguageExtension(fileName: string): Extension | null {
|
function getSyncLanguageExtension(fileName: string): Extension | null {
|
||||||
const ext = fileName.split('.').pop()?.toLowerCase();
|
const ext = fileName.split('.').pop()?.toLowerCase();
|
||||||
switch (ext) {
|
switch (ext) {
|
||||||
case 'ts':
|
case 'ts':
|
||||||
|
|
@ -57,19 +67,53 @@ function getLanguageExtension(fileName: string): Extension | null {
|
||||||
case 'jsonl':
|
case 'jsonl':
|
||||||
return json();
|
return json();
|
||||||
case 'css':
|
case 'css':
|
||||||
case 'scss':
|
|
||||||
return css();
|
return css();
|
||||||
|
case 'scss':
|
||||||
|
return sass({ indented: false });
|
||||||
|
case 'sass':
|
||||||
|
return sass({ indented: true });
|
||||||
|
case 'less':
|
||||||
|
return less();
|
||||||
case 'html':
|
case 'html':
|
||||||
case 'htm':
|
case 'htm':
|
||||||
return html();
|
return html();
|
||||||
case 'xml':
|
case 'xml':
|
||||||
case 'svg':
|
case 'svg':
|
||||||
return xml();
|
return xml();
|
||||||
|
case 'md':
|
||||||
|
case 'mdx':
|
||||||
|
case 'markdown':
|
||||||
|
return markdown();
|
||||||
|
case 'yaml':
|
||||||
|
case 'yml':
|
||||||
|
return yaml();
|
||||||
|
case 'rs':
|
||||||
|
return rust();
|
||||||
|
case 'go':
|
||||||
|
return go();
|
||||||
|
case 'java':
|
||||||
|
return java();
|
||||||
|
case 'c':
|
||||||
|
case 'h':
|
||||||
|
case 'cpp':
|
||||||
|
case 'cxx':
|
||||||
|
case 'cc':
|
||||||
|
case 'hpp':
|
||||||
|
return cpp();
|
||||||
|
case 'php':
|
||||||
|
return php();
|
||||||
|
case 'sql':
|
||||||
|
return sql();
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Async fallback: match by filename via @codemirror/language-data for rare languages */
|
||||||
|
function getAsyncLanguageDesc(fileName: string): LanguageDescription | null {
|
||||||
|
return LanguageDescription.matchFilename(languages, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
/** Compute hunk index for the chunk at a given position */
|
/** Compute hunk index for the chunk at a given position */
|
||||||
function computeHunkIndexAtPos(state: EditorState, pos: number): number {
|
function computeHunkIndexAtPos(state: EditorState, pos: number): number {
|
||||||
const chunks = getChunks(state);
|
const chunks = getChunks(state);
|
||||||
|
|
@ -123,15 +167,15 @@ const diffTheme = EditorView.theme({
|
||||||
'.cm-selectionBackground': {
|
'.cm-selectionBackground': {
|
||||||
backgroundColor: 'rgba(59, 130, 246, 0.3) !important',
|
backgroundColor: 'rgba(59, 130, 246, 0.3) !important',
|
||||||
},
|
},
|
||||||
// Diff-specific styles
|
// Diff-specific styles — line-level backgrounds (no per-character underlines)
|
||||||
'.cm-changedLine': {
|
'.cm-changedLine': {
|
||||||
backgroundColor: 'var(--diff-added-bg, rgba(46, 160, 67, 0.15))',
|
backgroundColor: 'var(--diff-added-bg, rgba(46, 160, 67, 0.22))',
|
||||||
},
|
},
|
||||||
'.cm-deletedChunk': {
|
'.cm-deletedChunk': {
|
||||||
backgroundColor: 'var(--diff-removed-bg, rgba(248, 81, 73, 0.15))',
|
backgroundColor: 'var(--diff-removed-bg, rgba(248, 81, 73, 0.15))',
|
||||||
},
|
},
|
||||||
'.cm-insertedLine': {
|
'.cm-insertedLine': {
|
||||||
backgroundColor: 'var(--diff-added-bg, rgba(46, 160, 67, 0.15))',
|
backgroundColor: 'var(--diff-added-bg, rgba(46, 160, 67, 0.22))',
|
||||||
},
|
},
|
||||||
'.cm-deletedLine': {
|
'.cm-deletedLine': {
|
||||||
backgroundColor: 'var(--diff-removed-bg, rgba(248, 81, 73, 0.15))',
|
backgroundColor: 'var(--diff-removed-bg, rgba(248, 81, 73, 0.15))',
|
||||||
|
|
@ -195,7 +239,8 @@ export const CodeMirrorDiffView = ({
|
||||||
onFullyViewed,
|
onFullyViewed,
|
||||||
editorViewRef: externalViewRef,
|
editorViewRef: externalViewRef,
|
||||||
onContentChanged,
|
onContentChanged,
|
||||||
}: CodeMirrorDiffViewProps) => {
|
initialState,
|
||||||
|
}: CodeMirrorDiffViewProps): React.ReactElement => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const viewRef = useRef<EditorView | null>(null);
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
const endSentinelRef = useRef<HTMLDivElement>(null);
|
const endSentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -221,11 +266,13 @@ export const CodeMirrorDiffView = ({
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const langExtension = useMemo(() => getLanguageExtension(fileName), [fileName]);
|
// Compartment for lazy-injected language support
|
||||||
|
const langCompartment = useRef(new Compartment());
|
||||||
|
|
||||||
const buildExtensions = useCallback(() => {
|
const buildExtensions = useCallback(() => {
|
||||||
const extensions: Extension[] = [
|
const extensions: Extension[] = [
|
||||||
diffTheme,
|
diffTheme,
|
||||||
|
syntaxHighlighting(oneDarkHighlightStyle),
|
||||||
EditorView.editable.of(!readOnly),
|
EditorView.editable.of(!readOnly),
|
||||||
EditorState.readOnly.of(readOnly),
|
EditorState.readOnly.of(readOnly),
|
||||||
];
|
];
|
||||||
|
|
@ -233,12 +280,13 @@ export const CodeMirrorDiffView = ({
|
||||||
// Undo/redo support and standard editing keybindings
|
// Undo/redo support and standard editing keybindings
|
||||||
if (!readOnly) {
|
if (!readOnly) {
|
||||||
extensions.push(history());
|
extensions.push(history());
|
||||||
extensions.push(keymap.of([...defaultKeymap, ...historyKeymap]));
|
extensions.push(mergeUndoSupport);
|
||||||
|
extensions.push(indentUnit.of(' '));
|
||||||
|
extensions.push(keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (langExtension) {
|
// Language placeholder — actual language injected async via compartment reconfigure
|
||||||
extensions.push(langExtension);
|
extensions.push(langCompartment.current.of([]));
|
||||||
}
|
|
||||||
|
|
||||||
// Keyboard shortcuts for chunk navigation and accept/reject
|
// Keyboard shortcuts for chunk navigation and accept/reject
|
||||||
extensions.push(
|
extensions.push(
|
||||||
|
|
@ -294,7 +342,7 @@ export const CodeMirrorDiffView = ({
|
||||||
// Unified merge view
|
// Unified merge view
|
||||||
const mergeConfig: Parameters<typeof unifiedMergeView>[0] = {
|
const mergeConfig: Parameters<typeof unifiedMergeView>[0] = {
|
||||||
original,
|
original,
|
||||||
highlightChanges: true,
|
highlightChanges: false,
|
||||||
gutter: true,
|
gutter: true,
|
||||||
syntaxHighlightDeletions: true,
|
syntaxHighlightDeletions: true,
|
||||||
};
|
};
|
||||||
|
|
@ -307,7 +355,12 @@ export const CodeMirrorDiffView = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showMergeControls) {
|
if (showMergeControls) {
|
||||||
mergeConfig.mergeControls = (type, action) => {
|
// NOTE: We intentionally do NOT use the `action` callback from @codemirror/merge.
|
||||||
|
// CM's DeletionWidget caches DOM via a global WeakMap keyed by chunk.changes.
|
||||||
|
// When EditorView is recreated (e.g. from cached initialState), toDOM() returns
|
||||||
|
// the OLD cached DOM whose `action` closure references the DESTROYED view.
|
||||||
|
// Instead, we call acceptChunk/rejectChunk directly with viewRef.current.
|
||||||
|
mergeConfig.mergeControls = (type, _action) => {
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
|
|
||||||
if (type === 'accept') {
|
if (type === 'accept') {
|
||||||
|
|
@ -320,7 +373,7 @@ export const CodeMirrorDiffView = ({
|
||||||
if (view) {
|
if (view) {
|
||||||
const pos = view.posAtDOM(btn);
|
const pos = view.posAtDOM(btn);
|
||||||
const hunkIndex = computeHunkIndexAtPos(view.state, pos);
|
const hunkIndex = computeHunkIndexAtPos(view.state, pos);
|
||||||
action(e);
|
acceptChunk(view, pos);
|
||||||
onAcceptRef.current?.(hunkIndex);
|
onAcceptRef.current?.(hunkIndex);
|
||||||
scrollToNextChunk();
|
scrollToNextChunk();
|
||||||
}
|
}
|
||||||
|
|
@ -335,7 +388,7 @@ export const CodeMirrorDiffView = ({
|
||||||
if (view) {
|
if (view) {
|
||||||
const pos = view.posAtDOM(btn);
|
const pos = view.posAtDOM(btn);
|
||||||
const hunkIndex = computeHunkIndexAtPos(view.state, pos);
|
const hunkIndex = computeHunkIndexAtPos(view.state, pos);
|
||||||
action(e);
|
rejectChunk(view, pos);
|
||||||
onRejectRef.current?.(hunkIndex);
|
onRejectRef.current?.(hunkIndex);
|
||||||
scrollToNextChunk();
|
scrollToNextChunk();
|
||||||
}
|
}
|
||||||
|
|
@ -352,7 +405,6 @@ export const CodeMirrorDiffView = ({
|
||||||
}, [
|
}, [
|
||||||
original,
|
original,
|
||||||
readOnly,
|
readOnly,
|
||||||
langExtension,
|
|
||||||
showMergeControls,
|
showMergeControls,
|
||||||
collapseUnchangedProp,
|
collapseUnchangedProp,
|
||||||
collapseMargin,
|
collapseMargin,
|
||||||
|
|
@ -368,11 +420,13 @@ export const CodeMirrorDiffView = ({
|
||||||
viewRef.current = null;
|
viewRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const view = new EditorView({
|
const view = initialState
|
||||||
doc: modified,
|
? new EditorView({ state: initialState, parent: containerRef.current })
|
||||||
extensions: buildExtensions(),
|
: new EditorView({
|
||||||
parent: containerRef.current,
|
doc: modified,
|
||||||
});
|
extensions: buildExtensions(),
|
||||||
|
parent: containerRef.current,
|
||||||
|
});
|
||||||
|
|
||||||
viewRef.current = view;
|
viewRef.current = view;
|
||||||
// Sync to external ref via holder
|
// Sync to external ref via holder
|
||||||
|
|
@ -389,7 +443,39 @@ export const CodeMirrorDiffView = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// We intentionally rebuild the entire editor when key props change
|
// We intentionally rebuild the entire editor when key props change
|
||||||
}, [original, modified, buildExtensions]);
|
}, [original, modified, buildExtensions, initialState]);
|
||||||
|
|
||||||
|
// Inject language extension via compartment after editor creation
|
||||||
|
useEffect(() => {
|
||||||
|
const view = viewRef.current;
|
||||||
|
if (!view) return;
|
||||||
|
|
||||||
|
// Try synchronous (bundled) language first
|
||||||
|
const syncLang = getSyncLanguageExtension(fileName);
|
||||||
|
if (syncLang) {
|
||||||
|
view.dispatch({ effects: langCompartment.current.reconfigure(syncLang) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async fallback for rare languages via @codemirror/language-data
|
||||||
|
const desc = getAsyncLanguageDesc(fileName);
|
||||||
|
if (!desc) return;
|
||||||
|
|
||||||
|
if (desc.support) {
|
||||||
|
view.dispatch({ effects: langCompartment.current.reconfigure(desc.support) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
void desc.load().then((support: Extension) => {
|
||||||
|
if (!cancelled && viewRef.current === view) {
|
||||||
|
view.dispatch({ effects: langCompartment.current.reconfigure(support) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [fileName, buildExtensions, initialState]);
|
||||||
|
|
||||||
// Auto-viewed detection via IntersectionObserver
|
// Auto-viewed detection via IntersectionObserver
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -418,6 +504,3 @@ export const CodeMirrorDiffView = ({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Re-export merge utils for external use
|
|
||||||
export { acceptChunk, getChunks, rejectChunk };
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
interface KeyboardShortcutsHelpProps {
|
interface KeyboardShortcutsHelpProps {
|
||||||
|
|
@ -10,10 +12,15 @@ const shortcuts = [
|
||||||
{ keys: ['\u2318+Y'], action: 'Accept change' },
|
{ keys: ['\u2318+Y'], action: 'Accept change' },
|
||||||
{ keys: ['\u2318+N'], action: 'Reject change' },
|
{ keys: ['\u2318+N'], action: 'Reject change' },
|
||||||
{ keys: ['\u2318+\u21A9'], action: 'Save file' },
|
{ keys: ['\u2318+\u21A9'], action: 'Save file' },
|
||||||
|
{ keys: ['\u2318+Z'], action: 'Undo' },
|
||||||
|
{ keys: ['\u2318+\u21E7+Z'], action: 'Redo' },
|
||||||
{ keys: ['Esc'], action: 'Close dialog' },
|
{ keys: ['Esc'], action: 'Close dialog' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const KeyboardShortcutsHelp = ({ open, onOpenChange }: KeyboardShortcutsHelpProps) => {
|
export const KeyboardShortcutsHelp = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: KeyboardShortcutsHelpProps): React.ReactElement | null => {
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
Columns2,
|
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
FoldVertical,
|
||||||
GitMerge,
|
GitMerge,
|
||||||
Loader2,
|
Loader2,
|
||||||
Pencil,
|
Pencil,
|
||||||
Rows2,
|
UnfoldVertical,
|
||||||
Save,
|
|
||||||
Undo2,
|
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -18,7 +19,6 @@ import type { ChangeStats } from '@shared/types';
|
||||||
interface ReviewToolbarProps {
|
interface ReviewToolbarProps {
|
||||||
stats: { pending: number; accepted: number; rejected: number };
|
stats: { pending: number; accepted: number; rejected: number };
|
||||||
changeStats: ChangeStats;
|
changeStats: ChangeStats;
|
||||||
diffViewMode: 'unified' | 'split';
|
|
||||||
collapseUnchanged: boolean;
|
collapseUnchanged: boolean;
|
||||||
applying: boolean;
|
applying: boolean;
|
||||||
autoViewed: boolean;
|
autoViewed: boolean;
|
||||||
|
|
@ -26,20 +26,13 @@ interface ReviewToolbarProps {
|
||||||
onAcceptAll: () => void;
|
onAcceptAll: () => void;
|
||||||
onRejectAll: () => void;
|
onRejectAll: () => void;
|
||||||
onApply: () => void;
|
onApply: () => void;
|
||||||
onDiffViewModeChange: (mode: 'unified' | 'split') => void;
|
|
||||||
onCollapseUnchangedChange: (collapse: boolean) => void;
|
onCollapseUnchangedChange: (collapse: boolean) => void;
|
||||||
// Editable diff props
|
|
||||||
editedCount?: number;
|
editedCount?: number;
|
||||||
hasCurrentFileEdits?: boolean;
|
|
||||||
saving?: boolean;
|
|
||||||
onSaveCurrentFile?: () => void;
|
|
||||||
onDiscardCurrentFile?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ReviewToolbar = ({
|
export const ReviewToolbar = ({
|
||||||
stats,
|
stats,
|
||||||
changeStats,
|
changeStats,
|
||||||
diffViewMode,
|
|
||||||
collapseUnchanged,
|
collapseUnchanged,
|
||||||
applying,
|
applying,
|
||||||
autoViewed,
|
autoViewed,
|
||||||
|
|
@ -47,14 +40,9 @@ export const ReviewToolbar = ({
|
||||||
onAcceptAll,
|
onAcceptAll,
|
||||||
onRejectAll,
|
onRejectAll,
|
||||||
onApply,
|
onApply,
|
||||||
onDiffViewModeChange,
|
|
||||||
onCollapseUnchangedChange,
|
onCollapseUnchangedChange,
|
||||||
editedCount = 0,
|
editedCount = 0,
|
||||||
hasCurrentFileEdits = false,
|
}: ReviewToolbarProps): React.ReactElement => {
|
||||||
saving = false,
|
|
||||||
onSaveCurrentFile,
|
|
||||||
onDiscardCurrentFile,
|
|
||||||
}: ReviewToolbarProps) => {
|
|
||||||
const hasRejected = stats.rejected > 0;
|
const hasRejected = stats.rejected > 0;
|
||||||
const canApply = hasRejected && !applying;
|
const canApply = hasRejected && !applying;
|
||||||
|
|
||||||
|
|
@ -90,118 +78,116 @@ export const ReviewToolbar = ({
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{/* View toggles */}
|
<Tooltip>
|
||||||
<div className="flex items-center gap-1 rounded-md border border-border bg-surface p-0.5">
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
onClick={() => onDiffViewModeChange('unified')}
|
onClick={() => onCollapseUnchangedChange(!collapseUnchanged)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded px-2 py-1 text-xs transition-colors',
|
'flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors',
|
||||||
diffViewMode === 'unified'
|
collapseUnchanged ? 'bg-surface-raised text-text' : 'text-text-muted hover:text-text'
|
||||||
? 'bg-surface-raised text-text'
|
)}
|
||||||
: 'text-text-muted hover:text-text'
|
>
|
||||||
)}
|
{collapseUnchanged ? (
|
||||||
title="Unified view"
|
<FoldVertical className="size-3.5" />
|
||||||
>
|
) : (
|
||||||
<Rows2 className="size-3.5" />
|
<UnfoldVertical className="size-3.5" />
|
||||||
</button>
|
)}
|
||||||
<button
|
</button>
|
||||||
onClick={() => onDiffViewModeChange('split')}
|
</TooltipTrigger>
|
||||||
className={cn(
|
<TooltipContent side="bottom">
|
||||||
'rounded px-2 py-1 text-xs transition-colors',
|
{collapseUnchanged ? 'Show all lines' : 'Collapse unchanged regions'}
|
||||||
diffViewMode === 'split'
|
</TooltipContent>
|
||||||
? 'bg-surface-raised text-text'
|
</Tooltip>
|
||||||
: 'text-text-muted hover:text-text'
|
|
||||||
)}
|
|
||||||
title="Split view"
|
|
||||||
>
|
|
||||||
<Columns2 className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<Tooltip>
|
||||||
onClick={() => onCollapseUnchangedChange(!collapseUnchanged)}
|
<TooltipTrigger asChild>
|
||||||
className={cn(
|
<button
|
||||||
'flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors',
|
onClick={() => onAutoViewedChange(!autoViewed)}
|
||||||
collapseUnchanged ? 'bg-surface-raised text-text' : 'text-text-muted hover:text-text'
|
className={cn(
|
||||||
)}
|
'flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors',
|
||||||
title={collapseUnchanged ? 'Show all lines' : 'Collapse unchanged'}
|
autoViewed ? 'bg-surface-raised text-text' : 'text-text-muted hover:text-text'
|
||||||
>
|
)}
|
||||||
{collapseUnchanged ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
|
>
|
||||||
</button>
|
{autoViewed ? <Eye className="size-3.5" /> : <EyeOff className="size-3.5" />}
|
||||||
|
<span className="text-[10px]">Auto</span>
|
||||||
<button
|
</button>
|
||||||
onClick={() => onAutoViewedChange(!autoViewed)}
|
</TooltipTrigger>
|
||||||
className={cn(
|
<TooltipContent side="bottom">
|
||||||
'flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors',
|
{autoViewed
|
||||||
autoViewed ? 'bg-surface-raised text-text' : 'text-text-muted hover:text-text'
|
? 'Auto-mark files as viewed when scrolled to end (ON)'
|
||||||
)}
|
: 'Auto-mark files as viewed when scrolled to end (OFF)'}
|
||||||
title={autoViewed ? 'Auto-mark viewed: ON' : 'Auto-mark viewed: OFF'}
|
</TooltipContent>
|
||||||
>
|
</Tooltip>
|
||||||
{autoViewed ? <Eye className="size-3.5" /> : <EyeOff className="size-3.5" />}
|
|
||||||
<span className="text-[10px]">Auto</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="h-4 w-px bg-border" />
|
<div className="h-4 w-px bg-border" />
|
||||||
|
|
||||||
{/* Edited files indicator + actions */}
|
|
||||||
{hasCurrentFileEdits && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={onSaveCurrentFile}
|
|
||||||
disabled={saving}
|
|
||||||
className="flex items-center gap-1 rounded bg-green-500/15 px-2 py-1 text-xs text-green-400 transition-colors hover:bg-green-500/25 disabled:opacity-50"
|
|
||||||
title="Save file to disk (Cmd+Enter)"
|
|
||||||
>
|
|
||||||
{saving ? <Loader2 className="size-3 animate-spin" /> : <Save className="size-3" />}
|
|
||||||
Save File
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onDiscardCurrentFile}
|
|
||||||
className="flex items-center gap-1 rounded bg-orange-500/15 px-2 py-1 text-xs text-orange-400 transition-colors hover:bg-orange-500/25"
|
|
||||||
title="Discard edits for this file"
|
|
||||||
>
|
|
||||||
<Undo2 className="size-3" /> Discard
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{editedCount > 0 && (
|
{editedCount > 0 && (
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/20 px-2 py-0.5 text-xs text-amber-400">
|
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/20 px-2 py-0.5 text-xs text-amber-400">
|
||||||
<Pencil className="size-3" /> {editedCount} edited
|
<Pencil className="size-3" /> {editedCount} edited
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(hasCurrentFileEdits || editedCount > 0) && <div className="h-4 w-px bg-border" />}
|
{editedCount > 0 && <div className="h-4 w-px bg-border" />}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<button
|
<Tooltip>
|
||||||
onClick={onAcceptAll}
|
<TooltipTrigger asChild>
|
||||||
className="flex items-center gap-1 rounded bg-green-500/15 px-2.5 py-1 text-xs text-green-400 transition-colors hover:bg-green-500/25"
|
<button
|
||||||
>
|
onClick={onAcceptAll}
|
||||||
<Check className="size-3" />
|
className="flex items-center gap-1 rounded bg-green-500/15 px-2.5 py-1 text-xs text-green-400 transition-colors hover:bg-green-500/25"
|
||||||
Accept All
|
>
|
||||||
</button>
|
<Check className="size-3" />
|
||||||
|
Accept All
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
<span>Accept all changes in current file</span>
|
||||||
|
<kbd className="ml-2 rounded border border-border bg-surface-raised px-1 py-0.5 font-mono text-[10px] text-text-muted">
|
||||||
|
⌘Y
|
||||||
|
</kbd>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<button
|
<Tooltip>
|
||||||
onClick={onRejectAll}
|
<TooltipTrigger asChild>
|
||||||
className="flex items-center gap-1 rounded bg-red-500/15 px-2.5 py-1 text-xs text-red-400 transition-colors hover:bg-red-500/25"
|
<button
|
||||||
>
|
onClick={onRejectAll}
|
||||||
<X className="size-3" />
|
className="flex items-center gap-1 rounded bg-red-500/15 px-2.5 py-1 text-xs text-red-400 transition-colors hover:bg-red-500/25"
|
||||||
Reject All
|
>
|
||||||
</button>
|
<X className="size-3" />
|
||||||
|
Reject All
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
<span>Reject all changes in current file</span>
|
||||||
|
<kbd className="ml-2 rounded border border-border bg-surface-raised px-1 py-0.5 font-mono text-[10px] text-text-muted">
|
||||||
|
⌘N
|
||||||
|
</kbd>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<button
|
<Tooltip>
|
||||||
onClick={onApply}
|
<TooltipTrigger asChild>
|
||||||
disabled={!canApply}
|
<button
|
||||||
className={cn(
|
onClick={onApply}
|
||||||
'flex items-center gap-1 rounded px-3 py-1 text-xs font-medium transition-colors',
|
disabled={!canApply}
|
||||||
canApply
|
className={cn(
|
||||||
? 'bg-blue-500/20 text-blue-400 hover:bg-blue-500/30'
|
'flex items-center gap-1 rounded px-3 py-1 text-xs font-medium transition-colors',
|
||||||
: 'cursor-not-allowed bg-zinc-500/10 text-zinc-600'
|
canApply
|
||||||
)}
|
? 'bg-blue-500/20 text-blue-400 hover:bg-blue-500/30'
|
||||||
>
|
: 'cursor-not-allowed bg-zinc-500/10 text-zinc-600'
|
||||||
{applying ? <Loader2 className="size-3 animate-spin" /> : <GitMerge className="size-3" />}
|
)}
|
||||||
{applying ? 'Applying...' : 'Apply Changes'}
|
>
|
||||||
</button>
|
{applying ? (
|
||||||
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<GitMerge className="size-3" />
|
||||||
|
)}
|
||||||
|
{applying ? 'Applying...' : 'Apply All Changes'}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">Apply review decisions across all files</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { AlertTriangle, X } from 'lucide-react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@renderer/lib/utils';
|
||||||
|
import { AlertTriangle, ChevronRight, Info, ShieldCheck, X } from 'lucide-react';
|
||||||
|
|
||||||
import type { TaskScopeConfidence } from '@shared/types';
|
import type { TaskScopeConfidence } from '@shared/types';
|
||||||
|
import type { FC } from 'react';
|
||||||
|
|
||||||
interface ScopeWarningBannerProps {
|
interface ScopeWarningBannerProps {
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
|
|
@ -8,33 +12,93 @@ interface ScopeWarningBannerProps {
|
||||||
onDismiss?: () => void;
|
onDismiss?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TierConfig {
|
||||||
|
Icon: FC<{ className?: string }>;
|
||||||
|
border: string;
|
||||||
|
bg: string;
|
||||||
|
accentColor: string;
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIER_CONFIGS: Record<number, TierConfig> = {
|
||||||
|
1: {
|
||||||
|
Icon: ShieldCheck,
|
||||||
|
border: 'border-emerald-500/15',
|
||||||
|
bg: 'bg-emerald-500/5',
|
||||||
|
accentColor: 'text-emerald-400',
|
||||||
|
title: 'Scope determined precisely',
|
||||||
|
detail:
|
||||||
|
'Both start (TaskUpdate → in_progress) and completion (TaskUpdate → completed) markers found in the session log. The diff includes only file modifications (Edit, Write) between these two boundaries.',
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
Icon: Info,
|
||||||
|
border: 'border-blue-500/15',
|
||||||
|
bg: 'bg-blue-500/5',
|
||||||
|
accentColor: 'text-blue-400',
|
||||||
|
title: 'End boundary estimated',
|
||||||
|
detail:
|
||||||
|
'Only the start marker (TaskUpdate → in_progress) was found — the task has no completion marker yet. Changes shown from start marker to end of session log.',
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
Icon: AlertTriangle,
|
||||||
|
border: 'border-orange-500/20',
|
||||||
|
bg: 'bg-orange-500/5',
|
||||||
|
accentColor: 'text-orange-400',
|
||||||
|
title: 'Start boundary estimated',
|
||||||
|
detail:
|
||||||
|
'Only the completion marker (TaskUpdate → completed) was found. The start of work was not captured — this can happen if the task was already in progress when the session began. Changes shown from session start to completion marker.',
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
Icon: AlertTriangle,
|
||||||
|
border: 'border-red-500/20',
|
||||||
|
bg: 'bg-red-500/5',
|
||||||
|
accentColor: 'text-red-400',
|
||||||
|
title: 'Showing all session changes',
|
||||||
|
detail:
|
||||||
|
'No TaskUpdate markers found in the session log. Cannot determine task-specific boundaries — this can happen with older CLI versions or non-standard workflows. All file modifications from the session are included.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const ScopeWarningBanner = ({
|
export const ScopeWarningBanner = ({
|
||||||
warnings,
|
warnings,
|
||||||
confidence,
|
confidence,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
}: ScopeWarningBannerProps) => {
|
}: ScopeWarningBannerProps): JSX.Element => {
|
||||||
if (warnings.length === 0 && confidence.tier <= 2) return null;
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const config = TIER_CONFIGS[confidence.tier] ?? TIER_CONFIGS[4];
|
||||||
|
const { Icon } = config;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-2 rounded-lg border border-yellow-500/20 bg-yellow-500/10 p-3 text-sm">
|
<div className={cn('border-b px-4 py-2', config.border, config.bg)}>
|
||||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-yellow-400" />
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1">
|
<Icon className={cn('size-3.5 shrink-0', config.accentColor)} />
|
||||||
<p className="font-medium text-yellow-300">
|
<span className={cn('text-xs font-medium', config.accentColor)}>{config.title}</span>
|
||||||
{confidence.tier >= 3
|
<button
|
||||||
? 'Task boundary detection is approximate'
|
onClick={() => setExpanded(!expanded)}
|
||||||
: 'Note about these changes'}
|
className="flex items-center gap-0.5 text-xs text-text-muted transition-colors hover:text-text-secondary"
|
||||||
</p>
|
>
|
||||||
{warnings.map((w, i) => (
|
Read more
|
||||||
<p key={i} className="mt-1 text-text-secondary">
|
<ChevronRight className={cn('size-3 transition-transform', expanded && 'rotate-90')} />
|
||||||
{w}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
<p className="mt-1 text-xs text-text-muted">Detection: {confidence.reason}</p>
|
|
||||||
</div>
|
|
||||||
{onDismiss && (
|
|
||||||
<button onClick={onDismiss} className="text-text-muted hover:text-text">
|
|
||||||
<X className="size-4" />
|
|
||||||
</button>
|
</button>
|
||||||
|
{onDismiss && (
|
||||||
|
<button onClick={onDismiss} className="ml-auto text-text-muted hover:text-text">
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="mt-2 space-y-1.5 pl-6 text-xs text-text-secondary">
|
||||||
|
<p>{config.detail}</p>
|
||||||
|
{warnings.length > 0 && (
|
||||||
|
<ul className="list-inside list-disc space-y-0.5 text-text-muted">
|
||||||
|
{warnings.map((w, i) => (
|
||||||
|
<li key={i}>{w}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
|
/** Tracks in-flight checkTaskHasChanges calls to avoid duplicate requests */
|
||||||
|
const taskChangesCheckInFlight = new Set<string>();
|
||||||
|
/** Negative results cached with timestamp — recheck after 30s */
|
||||||
|
const taskChangesNegativeCache = new Map<string, number>();
|
||||||
|
const NEGATIVE_CACHE_TTL = 30_000;
|
||||||
|
|
||||||
import type { AppState } from '../types';
|
import type { AppState } from '../types';
|
||||||
import type {
|
import type {
|
||||||
AgentChangeSet,
|
AgentChangeSet,
|
||||||
|
|
@ -37,7 +43,6 @@ export interface ChangeReviewSlice {
|
||||||
fileDecisions: Record<string, HunkDecision>;
|
fileDecisions: Record<string, HunkDecision>;
|
||||||
fileContents: Record<string, FileChangeWithContent>;
|
fileContents: Record<string, FileChangeWithContent>;
|
||||||
fileContentsLoading: Record<string, boolean>;
|
fileContentsLoading: Record<string, boolean>;
|
||||||
diffViewMode: 'unified' | 'split';
|
|
||||||
collapseUnchanged: boolean;
|
collapseUnchanged: boolean;
|
||||||
applyError: string | null;
|
applyError: string | null;
|
||||||
applying: boolean;
|
applying: boolean;
|
||||||
|
|
@ -45,6 +50,9 @@ export interface ChangeReviewSlice {
|
||||||
// Editable diff state
|
// Editable diff state
|
||||||
editedContents: Record<string, string>;
|
editedContents: Record<string, string>;
|
||||||
|
|
||||||
|
/** Cache: "teamName:taskId" → true/false (has file changes) */
|
||||||
|
taskHasChanges: Record<string, boolean>;
|
||||||
|
|
||||||
// Phase 1 actions
|
// Phase 1 actions
|
||||||
fetchAgentChanges: (teamName: string, memberName: string) => Promise<void>;
|
fetchAgentChanges: (teamName: string, memberName: string) => Promise<void>;
|
||||||
fetchTaskChanges: (teamName: string, taskId: string) => Promise<void>;
|
fetchTaskChanges: (teamName: string, taskId: string) => Promise<void>;
|
||||||
|
|
@ -59,7 +67,6 @@ export interface ChangeReviewSlice {
|
||||||
rejectAllFile: (filePath: string) => void;
|
rejectAllFile: (filePath: string) => void;
|
||||||
acceptAll: () => void;
|
acceptAll: () => void;
|
||||||
rejectAll: () => void;
|
rejectAll: () => void;
|
||||||
setDiffViewMode: (mode: 'unified' | 'split') => void;
|
|
||||||
setCollapseUnchanged: (collapse: boolean) => void;
|
setCollapseUnchanged: (collapse: boolean) => void;
|
||||||
fetchFileContent: (
|
fetchFileContent: (
|
||||||
teamName: string,
|
teamName: string,
|
||||||
|
|
@ -74,6 +81,9 @@ export interface ChangeReviewSlice {
|
||||||
discardFileEdits: (filePath: string) => void;
|
discardFileEdits: (filePath: string) => void;
|
||||||
discardAllEdits: () => void;
|
discardAllEdits: () => void;
|
||||||
saveEditedFile: (filePath: string) => Promise<void>;
|
saveEditedFile: (filePath: string) => Promise<void>;
|
||||||
|
|
||||||
|
// Task change availability
|
||||||
|
checkTaskHasChanges: (teamName: string, taskId: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeReviewSlice> = (
|
export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeReviewSlice> = (
|
||||||
|
|
@ -92,7 +102,6 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
||||||
fileDecisions: {},
|
fileDecisions: {},
|
||||||
fileContents: {},
|
fileContents: {},
|
||||||
fileContentsLoading: {},
|
fileContentsLoading: {},
|
||||||
diffViewMode: 'unified',
|
|
||||||
collapseUnchanged: true,
|
collapseUnchanged: true,
|
||||||
applyError: null,
|
applyError: null,
|
||||||
applying: false,
|
applying: false,
|
||||||
|
|
@ -100,6 +109,8 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
||||||
// Editable diff initial state
|
// Editable diff initial state
|
||||||
editedContents: {},
|
editedContents: {},
|
||||||
|
|
||||||
|
taskHasChanges: {},
|
||||||
|
|
||||||
fetchAgentChanges: async (teamName: string, memberName: string) => {
|
fetchAgentChanges: async (teamName: string, memberName: string) => {
|
||||||
set({ changeSetLoading: true, changeSetError: null });
|
set({ changeSetLoading: true, changeSetError: null });
|
||||||
try {
|
try {
|
||||||
|
|
@ -120,11 +131,13 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
||||||
set({ changeSetLoading: true, changeSetError: null });
|
set({ changeSetLoading: true, changeSetError: null });
|
||||||
try {
|
try {
|
||||||
const data = await api.review.getTaskChanges(teamName, taskId);
|
const data = await api.review.getTaskChanges(teamName, taskId);
|
||||||
set({
|
const cacheKey = `${teamName}:${taskId}`;
|
||||||
|
set((s) => ({
|
||||||
activeChangeSet: data,
|
activeChangeSet: data,
|
||||||
changeSetLoading: false,
|
changeSetLoading: false,
|
||||||
selectedReviewFilePath: data.files[0]?.filePath ?? null,
|
selectedReviewFilePath: data.files[0]?.filePath ?? null,
|
||||||
});
|
taskHasChanges: { ...s.taskHasChanges, [cacheKey]: data.files.length > 0 },
|
||||||
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Failed to fetch task changes';
|
const message = error instanceof Error ? error.message : 'Failed to fetch task changes';
|
||||||
logger.error('fetchTaskChanges error:', message);
|
logger.error('fetchTaskChanges error:', message);
|
||||||
|
|
@ -241,10 +254,6 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
||||||
set({ hunkDecisions: newHunkDecisions, fileDecisions: newFileDecisions });
|
set({ hunkDecisions: newHunkDecisions, fileDecisions: newFileDecisions });
|
||||||
},
|
},
|
||||||
|
|
||||||
setDiffViewMode: (mode: 'unified' | 'split') => {
|
|
||||||
set({ diffViewMode: mode });
|
|
||||||
},
|
|
||||||
|
|
||||||
setCollapseUnchanged: (collapse: boolean) => {
|
setCollapseUnchanged: (collapse: boolean) => {
|
||||||
set({ collapseUnchanged: collapse });
|
set({ collapseUnchanged: collapse });
|
||||||
},
|
},
|
||||||
|
|
@ -284,7 +293,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
||||||
totalLinesAdded: number;
|
totalLinesAdded: number;
|
||||||
totalLinesRemoved: number;
|
totalLinesRemoved: number;
|
||||||
files: { filePath: string }[];
|
files: { filePath: string }[];
|
||||||
}) =>
|
}): string =>
|
||||||
`${cs.totalFiles}:${cs.totalLinesAdded}:${cs.totalLinesRemoved}:${cs.files.map((f) => f.filePath).join(',')}`;
|
`${cs.totalFiles}:${cs.totalLinesAdded}:${cs.totalLinesRemoved}:${cs.files.map((f) => f.filePath).join(',')}`;
|
||||||
|
|
||||||
if (memberName && current) {
|
if (memberName && current) {
|
||||||
|
|
@ -397,6 +406,35 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
checkTaskHasChanges: async (teamName: string, taskId: string) => {
|
||||||
|
const cacheKey = `${teamName}:${taskId}`;
|
||||||
|
// Positive results are final — no need to recheck
|
||||||
|
if (get().taskHasChanges[cacheKey] === true) return;
|
||||||
|
// Prevent duplicate in-flight requests
|
||||||
|
if (taskChangesCheckInFlight.has(cacheKey)) return;
|
||||||
|
// Negative results cached with TTL — avoid API spam for tasks that truly have no changes
|
||||||
|
const negativeTs = taskChangesNegativeCache.get(cacheKey);
|
||||||
|
if (negativeTs && Date.now() - negativeTs < NEGATIVE_CACHE_TTL) return;
|
||||||
|
|
||||||
|
taskChangesCheckInFlight.add(cacheKey);
|
||||||
|
try {
|
||||||
|
const data = await api.review.getTaskChanges(teamName, taskId);
|
||||||
|
if (data.files.length > 0) {
|
||||||
|
set((s) => ({
|
||||||
|
taskHasChanges: { ...s.taskHasChanges, [cacheKey]: true },
|
||||||
|
}));
|
||||||
|
taskChangesNegativeCache.delete(cacheKey);
|
||||||
|
} else {
|
||||||
|
taskChangesNegativeCache.set(cacheKey, Date.now());
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Don't cache errors in store — allow retry when session data appears later
|
||||||
|
taskChangesNegativeCache.set(cacheKey, Date.now());
|
||||||
|
} finally {
|
||||||
|
taskChangesCheckInFlight.delete(cacheKey);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
invalidateChangeStats: (teamName: string) => {
|
invalidateChangeStats: (teamName: string) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const newCache = { ...state.changeStatsCache };
|
const newCache = { ...state.changeStatsCache };
|
||||||
|
|
|
||||||
|
|
@ -34,35 +34,31 @@ interface ContentBlock {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempts to extract the content array from a parsed stream-json line.
|
* Attempts to extract the content array from a parsed stream-json line.
|
||||||
* Handles both `{ type: "assistant", content: [...] }` and
|
* Handles both `{ type: "assistant", content: [...] }` (direct) and
|
||||||
* `{ message: { type: "assistant", content: [...] } }` formats.
|
* `{ type: "assistant", message: { type: "message", content: [...] } }` (wrapped) formats.
|
||||||
*/
|
*/
|
||||||
function extractContentBlocks(parsed: unknown): ContentBlock[] | null {
|
function extractContentBlocks(parsed: unknown): ContentBlock[] | null {
|
||||||
if (!parsed || typeof parsed !== 'object') return null;
|
if (!parsed || typeof parsed !== 'object') return null;
|
||||||
|
|
||||||
const obj = parsed as Record<string, unknown>;
|
const obj = parsed as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Only process assistant messages
|
||||||
|
if (obj.type !== 'assistant') return null;
|
||||||
|
|
||||||
// Direct format: { type: "assistant", content: [...] }
|
// Direct format: { type: "assistant", content: [...] }
|
||||||
if (obj.type === 'assistant' && Array.isArray(obj.content)) {
|
if (Array.isArray(obj.content)) {
|
||||||
return obj.content as ContentBlock[];
|
return obj.content as ContentBlock[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wrapped format: { message: { type: "assistant", content: [...] } }
|
// Wrapped format: { type: "assistant", message: { type: "message", content: [...] } }
|
||||||
|
// The inner message.type is "message" (not "assistant")
|
||||||
if (obj.message && typeof obj.message === 'object') {
|
if (obj.message && typeof obj.message === 'object') {
|
||||||
const msg = obj.message as Record<string, unknown>;
|
const msg = obj.message as Record<string, unknown>;
|
||||||
if (msg.type === 'assistant' && Array.isArray(msg.content)) {
|
if (Array.isArray(msg.content)) {
|
||||||
return msg.content as ContentBlock[];
|
return msg.content as ContentBlock[];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result format: { type: "result", result: { type: "assistant", content: [...] } }
|
|
||||||
if (obj.type === 'result' && obj.result && typeof obj.result === 'object') {
|
|
||||||
const result = obj.result as Record<string, unknown>;
|
|
||||||
if (Array.isArray(result.content)) {
|
|
||||||
return result.content as ContentBlock[];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue