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: {
|
||||
optimizeDeps: {
|
||||
include: ['@codemirror/language-data']
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@renderer': resolve(__dirname, 'src/renderer'),
|
||||
|
|
|
|||
12
package.json
12
package.json
|
|
@ -61,12 +61,24 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.10.2",
|
||||
"@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-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-rust": "^6.0.2",
|
||||
"@codemirror/lang-sass": "^6.0.2",
|
||||
"@codemirror/lang-sql": "^6.10.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/state": "^6.5.4",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
|
|
|
|||
308
pnpm-lock.yaml
308
pnpm-lock.yaml
|
|
@ -11,24 +11,60 @@ importers:
|
|||
'@codemirror/commands':
|
||||
specifier: ^6.10.2
|
||||
version: 6.10.2
|
||||
'@codemirror/lang-cpp':
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.3
|
||||
'@codemirror/lang-css':
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1
|
||||
'@codemirror/lang-go':
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.1
|
||||
'@codemirror/lang-html':
|
||||
specifier: ^6.4.11
|
||||
version: 6.4.11
|
||||
'@codemirror/lang-java':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2
|
||||
'@codemirror/lang-javascript':
|
||||
specifier: ^6.2.4
|
||||
version: 6.2.4
|
||||
'@codemirror/lang-json':
|
||||
specifier: ^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':
|
||||
specifier: ^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':
|
||||
specifier: ^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':
|
||||
specifier: ^6.12.0
|
||||
version: 6.12.0
|
||||
|
|
@ -402,27 +438,78 @@ packages:
|
|||
'@codemirror/commands@6.10.2':
|
||||
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':
|
||||
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':
|
||||
resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==}
|
||||
|
||||
'@codemirror/lang-java@6.0.2':
|
||||
resolution: {integrity: sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==}
|
||||
|
||||
'@codemirror/lang-javascript@6.2.4':
|
||||
resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==}
|
||||
|
||||
'@codemirror/lang-jinja@6.0.0':
|
||||
resolution: {integrity: sha512-47MFmRcR8UAxd8DReVgj7WJN1WSAMT7OJnewwugZM4XiHWkOjgJQqvEM1NpMj9ALMPyxmlziEI1opH9IaEvmaw==}
|
||||
|
||||
'@codemirror/lang-json@6.0.2':
|
||||
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':
|
||||
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':
|
||||
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':
|
||||
resolution: {integrity: sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==}
|
||||
|
||||
'@codemirror/legacy-modes@6.5.2':
|
||||
resolution: {integrity: sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==}
|
||||
|
||||
'@codemirror/lint@6.9.4':
|
||||
resolution: {integrity: sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==}
|
||||
|
||||
|
|
@ -953,15 +1040,24 @@ packages:
|
|||
'@lezer/common@1.5.1':
|
||||
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':
|
||||
resolution: {integrity: sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==}
|
||||
|
||||
'@lezer/go@1.0.1':
|
||||
resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==}
|
||||
|
||||
'@lezer/highlight@1.2.3':
|
||||
resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
|
||||
|
||||
'@lezer/html@1.3.13':
|
||||
resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==}
|
||||
|
||||
'@lezer/java@1.1.3':
|
||||
resolution: {integrity: sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==}
|
||||
|
||||
'@lezer/javascript@1.5.4':
|
||||
resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==}
|
||||
|
||||
|
|
@ -971,12 +1067,27 @@ packages:
|
|||
'@lezer/lr@1.4.8':
|
||||
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':
|
||||
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':
|
||||
resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==}
|
||||
|
||||
'@lezer/yaml@1.0.4':
|
||||
resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==}
|
||||
|
||||
'@lukeed/ms@2.0.2':
|
||||
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
@ -5575,6 +5686,20 @@ snapshots:
|
|||
'@codemirror/view': 6.39.15
|
||||
'@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':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.0
|
||||
|
|
@ -5583,6 +5708,14 @@ snapshots:
|
|||
'@lezer/common': 1.5.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':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.0
|
||||
|
|
@ -5595,6 +5728,11 @@ snapshots:
|
|||
'@lezer/css': 1.3.1
|
||||
'@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':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.0
|
||||
|
|
@ -5605,11 +5743,56 @@ snapshots:
|
|||
'@lezer/common': 1.5.1
|
||||
'@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':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.1
|
||||
'@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':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.0
|
||||
|
|
@ -5618,6 +5801,44 @@ snapshots:
|
|||
'@lezer/common': 1.5.1
|
||||
'@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':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.0
|
||||
|
|
@ -5627,6 +5848,42 @@ snapshots:
|
|||
'@lezer/common': 1.5.1
|
||||
'@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':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.4
|
||||
|
|
@ -5636,6 +5893,10 @@ snapshots:
|
|||
'@lezer/lr': 1.4.8
|
||||
style-mod: 4.1.3
|
||||
|
||||
'@codemirror/legacy-modes@6.5.2':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.1
|
||||
|
||||
'@codemirror/lint@6.9.4':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.4
|
||||
|
|
@ -6136,12 +6397,24 @@ snapshots:
|
|||
|
||||
'@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':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.1
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@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':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.1
|
||||
|
|
@ -6152,6 +6425,12 @@ snapshots:
|
|||
'@lezer/highlight': 1.2.3
|
||||
'@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':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.1
|
||||
|
|
@ -6168,18 +6447,47 @@ snapshots:
|
|||
dependencies:
|
||||
'@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':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.1
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@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':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.1
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@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': {}
|
||||
|
||||
'@malept/cross-spawn-promise@1.1.1':
|
||||
|
|
|
|||
|
|
@ -169,6 +169,15 @@ function getPaths(flags, teamName) {
|
|||
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) {
|
||||
const taskPath = path.join(paths.tasksDir, String(taskId) + '.json');
|
||||
const task = readJson(taskPath, null);
|
||||
|
|
@ -340,7 +349,7 @@ function sendInboxMessage(paths, teamName, flags) {
|
|||
const text = typeof flags.text === 'string' ? flags.text : '';
|
||||
if (!text) die('Missing --text');
|
||||
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');
|
||||
ensureDir(path.dirname(inboxPath));
|
||||
|
|
@ -374,7 +383,7 @@ function reviewApprove(paths, teamName, taskId, flags) {
|
|||
if (!notify) return;
|
||||
const { task } = readTask(paths, taskId);
|
||||
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 text = note
|
||||
? 'Task #' + String(taskId) + ' approved.\n\n' + note
|
||||
|
|
@ -396,7 +405,7 @@ function reviewRequestChanges(paths, teamName, taskId, flags) {
|
|||
task.status = 'in_progress';
|
||||
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 =
|
||||
'Task #' +
|
||||
String(taskId) +
|
||||
|
|
@ -481,7 +490,7 @@ async function main() {
|
|||
const notify = args.flags.notify === true || args.flags['notify-owner'] === true;
|
||||
if (notify && task.owner) {
|
||||
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 rawDesc = typeof args.flags.description === 'string' ? args.flags.description.trim()
|
||||
: typeof args.flags.desc === 'string' ? args.flags.desc.trim() : '';
|
||||
|
|
|
|||
|
|
@ -431,8 +431,10 @@ export class TeamDataService {
|
|||
AGENT_BLOCK_CLOSE
|
||||
);
|
||||
|
||||
const leadName = await this.resolveLeadName(teamName);
|
||||
await this.sendMessage(teamName, {
|
||||
member: request.owner,
|
||||
from: leadName,
|
||||
text: parts.join('\n'),
|
||||
summary: `New task #${task.id} assigned`,
|
||||
});
|
||||
|
|
@ -469,8 +471,10 @@ export class TeamDataService {
|
|||
`node "${toolPath}" --team ${teamName} task complete ${task.id}`,
|
||||
AGENT_BLOCK_CLOSE
|
||||
);
|
||||
const leadName = await this.resolveLeadName(teamName);
|
||||
await this.sendMessage(teamName, {
|
||||
member: task.owner,
|
||||
from: leadName,
|
||||
text: parts.join('\n'),
|
||||
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>"`,
|
||||
AGENT_BLOCK_CLOSE,
|
||||
];
|
||||
const leadName = await this.resolveLeadName(teamName);
|
||||
await this.sendMessage(teamName, {
|
||||
member: task.owner,
|
||||
from: leadName,
|
||||
text: parts.join('\n'),
|
||||
summary: `Comment on #${taskId}`,
|
||||
});
|
||||
|
|
@ -524,6 +530,17 @@ export class TeamDataService {
|
|||
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(
|
||||
teamName: string,
|
||||
leadName: string,
|
||||
|
|
@ -582,9 +599,13 @@ export class TeamDataService {
|
|||
}
|
||||
|
||||
try {
|
||||
const toolPath = await this.toolsInstaller.ensureInstalled();
|
||||
const [toolPath, leadName] = await Promise.all([
|
||||
this.toolsInstaller.ensureInstalled(),
|
||||
this.resolveLeadName(teamName),
|
||||
]);
|
||||
await this.sendMessage(teamName, {
|
||||
member: reviewer,
|
||||
from: leadName,
|
||||
text:
|
||||
`Please review task #${taskId}.\n\n` +
|
||||
`${AGENT_BLOCK_OPEN}\n` +
|
||||
|
|
@ -786,8 +807,10 @@ export class TeamDataService {
|
|||
|
||||
try {
|
||||
await this.taskWriter.updateStatus(teamName, taskId, 'in_progress');
|
||||
const leadName = await this.resolveLeadName(teamName);
|
||||
await this.sendMessage(teamName, {
|
||||
member: task.owner,
|
||||
from: leadName,
|
||||
text:
|
||||
`Task #${taskId} needs fixes.\n\n` +
|
||||
`${patch.comment?.trim() || 'Reviewer requested changes.'}\n\n` +
|
||||
|
|
|
|||
|
|
@ -131,6 +131,8 @@ interface ProvisioningRun {
|
|||
directReplyParts: string[];
|
||||
/** Accumulates assistant text during provisioning phase for live UI preview. */
|
||||
provisioningOutputParts: string[];
|
||||
/** Session ID detected from stream-json output (result.session_id or message.session_id). */
|
||||
detectedSessionId: string | null;
|
||||
}
|
||||
|
||||
type ProvisioningAuthSource =
|
||||
|
|
@ -765,6 +767,7 @@ export class TeamProvisioningService {
|
|||
leadRelayCapture: null,
|
||||
directReplyParts: [],
|
||||
provisioningOutputParts: [],
|
||||
detectedSessionId: null,
|
||||
progress: {
|
||||
runId,
|
||||
teamName: request.teamName,
|
||||
|
|
@ -1036,6 +1039,7 @@ export class TeamProvisioningService {
|
|||
leadRelayCapture: null,
|
||||
directReplyParts: [],
|
||||
provisioningOutputParts: [],
|
||||
detectedSessionId: null,
|
||||
progress: {
|
||||
runId,
|
||||
teamName: request.teamName,
|
||||
|
|
@ -1522,7 +1526,8 @@ export class TeamProvisioningService {
|
|||
const aliveTeams = this.getAliveTeams();
|
||||
if (aliveTeams.length === 0) return;
|
||||
|
||||
const newResolved = resolveLanguageName(newLangCode);
|
||||
const systemLocale = getSystemLocale();
|
||||
const newResolved = resolveLanguageName(newLangCode, systemLocale);
|
||||
|
||||
for (const teamName of aliveTeams) {
|
||||
try {
|
||||
|
|
@ -1532,7 +1537,15 @@ export class TeamProvisioningService {
|
|||
const oldCode = config.language || 'system';
|
||||
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 =
|
||||
`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 ` +
|
||||
|
|
@ -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') {
|
||||
const subtype =
|
||||
typeof msg.subtype === 'string'
|
||||
|
|
@ -1750,7 +1774,7 @@ export class TeamProvisioningService {
|
|||
});
|
||||
}
|
||||
}
|
||||
if (!run.provisioningComplete) {
|
||||
if (!run.provisioningComplete && !run.cancelRequested) {
|
||||
void this.handleProvisioningTurnComplete(run);
|
||||
}
|
||||
} else if (subtype === 'error') {
|
||||
|
|
@ -1760,7 +1784,7 @@ export class TeamProvisioningService {
|
|||
if (run.leadRelayCapture) {
|
||||
run.leadRelayCapture.rejectOnce(errorMsg);
|
||||
}
|
||||
if (!run.provisioningComplete) {
|
||||
if (!run.provisioningComplete && !run.cancelRequested) {
|
||||
const progress = updateProgress(
|
||||
run,
|
||||
'failed',
|
||||
|
|
@ -1787,6 +1811,7 @@ export class TeamProvisioningService {
|
|||
* Process stays alive for subsequent tasks.
|
||||
*/
|
||||
private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise<void> {
|
||||
if (run.cancelRequested) return;
|
||||
run.provisioningComplete = true;
|
||||
|
||||
// Clear provisioning timeout — no longer needed
|
||||
|
|
@ -1797,7 +1822,7 @@ export class TeamProvisioningService {
|
|||
this.stopFilesystemMonitor(run);
|
||||
|
||||
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);
|
||||
const readyMessage = 'Team launched — process alive and ready';
|
||||
const progress = updateProgress(run, 'ready', readyMessage, {
|
||||
|
|
@ -1838,7 +1863,7 @@ export class TeamProvisioningService {
|
|||
|
||||
// Persist teammates metadata separately from config.json.
|
||||
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', {
|
||||
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
|
||||
|
|
@ -2388,24 +2413,50 @@ export class TeamProvisioningService {
|
|||
* Combines session history append and projectPath update to avoid
|
||||
* 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');
|
||||
try {
|
||||
const raw = await fs.promises.readFile(configPath, 'utf8');
|
||||
const config = JSON.parse(raw) as Record<string, unknown>;
|
||||
|
||||
// Append session to history
|
||||
const leadSessionId = config.leadSessionId;
|
||||
if (typeof leadSessionId === 'string' && leadSessionId.trim().length > 0) {
|
||||
const sessionHistory = Array.isArray(config.sessionHistory)
|
||||
? (config.sessionHistory as string[])
|
||||
: [];
|
||||
if (!sessionHistory.includes(leadSessionId)) {
|
||||
sessionHistory.push(leadSessionId);
|
||||
config.sessionHistory = sessionHistory;
|
||||
const sessionHistory = Array.isArray(config.sessionHistory)
|
||||
? (config.sessionHistory as string[])
|
||||
: [];
|
||||
|
||||
// Preserve old leadSessionId in history before overwriting
|
||||
const oldLeadSessionId = config.leadSessionId;
|
||||
if (typeof oldLeadSessionId === 'string' && oldLeadSessionId.trim().length > 0) {
|
||||
if (!sessionHistory.includes(oldLeadSessionId)) {
|
||||
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
|
||||
const langCode = ConfigManager.getInstance().getConfig().general.agentLanguage || 'system';
|
||||
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> {
|
||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||
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 = ({
|
||||
group,
|
||||
|
|
@ -36,37 +82,49 @@ const StreamGroup = ({
|
|||
onToggle: () => void;
|
||||
expandedItemIds: Set<string>;
|
||||
onItemClick: (itemId: string) => void;
|
||||
}): React.JSX.Element => (
|
||||
<div className="rounded border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-raised)]"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className={cn(
|
||||
'shrink-0 text-[var(--color-text-muted)] transition-transform duration-150',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
<Bot size={13} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<span className="min-w-0 truncate text-[11px] text-[var(--color-text-secondary)]">
|
||||
{group.summary}
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="border-t border-[var(--color-border)] p-2">
|
||||
<DisplayItemList
|
||||
items={group.items}
|
||||
onItemClick={onItemClick}
|
||||
expandedItemIds={expandedItemIds}
|
||||
aiGroupId={group.id}
|
||||
}): React.JSX.Element => {
|
||||
// Scope item IDs to this group to avoid cross-group collisions
|
||||
const groupItemIds = useMemo(
|
||||
() => scopedItemIds(expandedItemIds, group.id),
|
||||
[expandedItemIds, group.id]
|
||||
);
|
||||
const handleItemClick = useCallback(
|
||||
(itemId: string) => onItemClick(`${group.id}::${itemId}`),
|
||||
[group.id, onItemClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-raised)]"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className={cn(
|
||||
'shrink-0 text-[var(--color-text-muted)] transition-transform duration-150',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</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)]">
|
||||
{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 = ({
|
||||
cliLogsTail,
|
||||
|
|
@ -126,13 +184,14 @@ export const CliLogsRichView = ({
|
|||
const hasContent = cliLogsTail.trim().length > 0;
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
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
|
||||
)}
|
||||
>
|
||||
{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}
|
||||
</pre>
|
||||
) : (
|
||||
|
|
@ -146,16 +205,26 @@ export const CliLogsRichView = ({
|
|||
|
||||
return (
|
||||
<div ref={scrollRef} className={cn('max-h-[400px] space-y-1.5 overflow-y-auto', className)}>
|
||||
{groups.map((group) => (
|
||||
<StreamGroup
|
||||
key={group.id}
|
||||
group={group}
|
||||
isExpanded={expandedGroupIds.has(group.id)}
|
||||
onToggle={() => handleGroupToggle(group.id)}
|
||||
expandedItemIds={expandedItemIds}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
))}
|
||||
{groups.map((group) =>
|
||||
group.items.length === 1 ? (
|
||||
// Single item — render flat without collapsible group wrapper
|
||||
<FlatGroupItem
|
||||
key={group.id}
|
||||
group={group}
|
||||
expandedItemIds={expandedItemIds}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
) : (
|
||||
<StreamGroup
|
||||
key={group.id}
|
||||
group={group}
|
||||
isExpanded={expandedGroupIds.has(group.id)}
|
||||
onToggle={() => handleGroupToggle(group.id)}
|
||||
expandedItemIds={expandedItemIds}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -200,20 +200,34 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
);
|
||||
}
|
||||
|
||||
if (currentProjectPath) {
|
||||
const matches = (t: TeamSummary): boolean => {
|
||||
if (t.projectPath && normalizePath(t.projectPath) === currentProjectPath) return true;
|
||||
return t.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ?? false;
|
||||
};
|
||||
result = [...result].sort((a, b) => {
|
||||
const aMatch = matches(a) ? 0 : 1;
|
||||
const bMatch = matches(b) ? 0 : 1;
|
||||
return aMatch - bMatch;
|
||||
});
|
||||
}
|
||||
const aliveSet = new Set(aliveTeams);
|
||||
const matchesProject = currentProjectPath
|
||||
? (t: TeamSummary): boolean => {
|
||||
if (t.projectPath && normalizePath(t.projectPath) === currentProjectPath) return true;
|
||||
return (
|
||||
t.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ?? false
|
||||
);
|
||||
}
|
||||
: 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;
|
||||
}, [teams, searchQuery, currentProjectPath]);
|
||||
}, [teams, searchQuery, currentProjectPath, aliveTeams]);
|
||||
|
||||
// Live branch/worktree for team project paths (poll so it updates during process)
|
||||
const projectPathsToPoll = useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
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 { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
||||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
ArrowLeftFromLine,
|
||||
ArrowRightFromLine,
|
||||
|
|
@ -148,6 +149,19 @@ export const KanbanTaskCard = ({
|
|||
const hasBlockedBy = blockedByIds.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 (
|
||||
<div
|
||||
data-task-id={task.id}
|
||||
|
|
@ -341,9 +355,10 @@ export const KanbanTaskCard = ({
|
|||
Move back to DONE
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{(columnId === 'done' || columnId === 'review' || columnId === 'approved') &&
|
||||
onViewChanges ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{showChangesColumn && taskHasChanges === true ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
|
|
@ -356,9 +371,8 @@ export const KanbanTaskCard = ({
|
|||
Changes
|
||||
</button>
|
||||
) : null}
|
||||
<UnreadCommentsBadge unreadCount={unreadCount} totalCount={task.comments?.length ?? 0} />
|
||||
</div>
|
||||
|
||||
<UnreadCommentsBadge unreadCount={unreadCount} totalCount={task.comments?.length ?? 0} />
|
||||
</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 { isElectronMode } from '@renderer/api';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useDiffNavigation } from '@renderer/hooks/useDiffNavigation';
|
||||
import { useViewedFiles } from '@renderer/hooks/useViewedFiles';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
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 { ConfidenceBadge } from './ConfidenceBadge';
|
||||
import { DiffErrorBoundary } from './DiffErrorBoundary';
|
||||
|
|
@ -18,6 +21,7 @@ import { ReviewToolbar } from './ReviewToolbar';
|
|||
import { ScopeWarningBanner } from './ScopeWarningBanner';
|
||||
import { ViewedProgressBar } from './ViewedProgressBar';
|
||||
|
||||
import type { EditorState } from '@codemirror/state';
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type { HunkDecision, TaskChangeSetV2 } from '@shared/types';
|
||||
|
||||
|
|
@ -49,7 +53,7 @@ export const ChangeReviewDialog = ({
|
|||
mode,
|
||||
memberName,
|
||||
taskId,
|
||||
}: ChangeReviewDialogProps) => {
|
||||
}: ChangeReviewDialogProps): React.ReactElement | null => {
|
||||
const {
|
||||
activeChangeSet,
|
||||
changeSetLoading,
|
||||
|
|
@ -64,12 +68,10 @@ export const ChangeReviewDialog = ({
|
|||
fileDecisions,
|
||||
fileContents,
|
||||
fileContentsLoading,
|
||||
diffViewMode,
|
||||
collapseUnchanged,
|
||||
applying,
|
||||
applyError,
|
||||
setHunkDecision,
|
||||
setDiffViewMode,
|
||||
setCollapseUnchanged,
|
||||
fetchFileContent,
|
||||
acceptAll,
|
||||
|
|
@ -87,6 +89,10 @@ export const ChangeReviewDialog = ({
|
|||
const [timelineOpen, setTimelineOpen] = useState(false);
|
||||
// Counter to force editor rebuild on discard
|
||||
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
|
||||
const scopeKey = mode === 'task' ? `task:${taskId ?? ''}` : `agent:${memberName ?? ''}`;
|
||||
|
|
@ -107,18 +113,54 @@ export const ChangeReviewDialog = ({
|
|||
progress: viewedProgress,
|
||||
} = 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
|
||||
const editedCount = Object.keys(editedContents).length;
|
||||
const hasCurrentFileEdits = !!(
|
||||
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(() => {
|
||||
if (selectedReviewFilePath) void saveEditedFile(selectedReviewFilePath);
|
||||
}, [selectedReviewFilePath, saveEditedFile]);
|
||||
|
||||
const handleDiscardCurrentFile = useCallback(() => {
|
||||
if (selectedReviewFilePath) {
|
||||
editorStateCache.current.delete(selectedReviewFilePath);
|
||||
setCachedInitialState(undefined);
|
||||
discardFileEdits(selectedReviewFilePath);
|
||||
setDiscardCounter((c) => c + 1);
|
||||
}
|
||||
|
|
@ -127,7 +169,7 @@ export const ChangeReviewDialog = ({
|
|||
const diffNav = useDiffNavigation(
|
||||
activeChangeSet?.files ?? [],
|
||||
selectedReviewFilePath,
|
||||
selectReviewFile,
|
||||
handleSelectFile,
|
||||
editorViewRef,
|
||||
open,
|
||||
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'accepted'),
|
||||
|
|
@ -166,7 +208,7 @@ export const ChangeReviewDialog = ({
|
|||
// Escape to close
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
const handler = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') onOpenChange(false);
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
|
|
@ -250,12 +292,25 @@ export const ChangeReviewDialog = ({
|
|||
? `Changes by ${memberName ?? 'unknown'}`
|
||||
: `Changes for task #${taskId ?? '?'}`;
|
||||
|
||||
const isMacElectron =
|
||||
isElectronMode() && window.navigator.userAgent.toLowerCase().includes('mac');
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-surface">
|
||||
{/* 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">
|
||||
<h2 className="text-sm font-medium text-text">{title}</h2>
|
||||
{activeChangeSet && (
|
||||
|
|
@ -278,6 +333,7 @@ export const ChangeReviewDialog = ({
|
|||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
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" />
|
||||
</button>
|
||||
|
|
@ -297,29 +353,23 @@ export const ChangeReviewDialog = ({
|
|||
<ReviewToolbar
|
||||
stats={reviewStats}
|
||||
changeStats={changeStats}
|
||||
diffViewMode={diffViewMode}
|
||||
collapseUnchanged={collapseUnchanged}
|
||||
applying={applying}
|
||||
autoViewed={autoViewed}
|
||||
onAutoViewedChange={setAutoViewed}
|
||||
onAcceptAll={acceptAll}
|
||||
onRejectAll={rejectAll}
|
||||
onAcceptAll={handleAcceptAll}
|
||||
onRejectAll={handleRejectAll}
|
||||
onApply={handleApply}
|
||||
onDiffViewModeChange={setDiffViewMode}
|
||||
onCollapseUnchangedChange={setCollapseUnchanged}
|
||||
editedCount={editedCount}
|
||||
hasCurrentFileEdits={hasCurrentFileEdits}
|
||||
saving={applying}
|
||||
onSaveCurrentFile={handleSaveCurrentFile}
|
||||
onDiscardCurrentFile={handleDiscardCurrentFile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Scope warnings */}
|
||||
{/* Scope info / warnings */}
|
||||
{mode === 'task' &&
|
||||
activeChangeSet &&
|
||||
isTaskChangeSetV2(activeChangeSet) &&
|
||||
activeChangeSet.warnings.length > 0 && (
|
||||
(activeChangeSet.warnings.length > 0 || activeChangeSet.scope.confidence.tier >= 2) && (
|
||||
<ScopeWarningBanner
|
||||
warnings={activeChangeSet.warnings}
|
||||
confidence={activeChangeSet.scope.confidence}
|
||||
|
|
@ -354,7 +404,7 @@ export const ChangeReviewDialog = ({
|
|||
<ReviewFileTree
|
||||
files={activeChangeSet.files}
|
||||
selectedFilePath={selectedReviewFilePath}
|
||||
onSelectFile={selectReviewFile}
|
||||
onSelectFile={handleSelectFile}
|
||||
viewedSet={viewedSet}
|
||||
onMarkViewed={markViewed}
|
||||
onUnmarkViewed={unmarkViewed}
|
||||
|
|
@ -391,7 +441,7 @@ export const ChangeReviewDialog = ({
|
|||
<div className="flex-1 overflow-y-auto">
|
||||
{selectedFile ? (
|
||||
<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">
|
||||
<span className="text-xs font-medium text-text">
|
||||
{selectedFile.relativePath}
|
||||
|
|
@ -410,7 +460,7 @@ export const ChangeReviewDialog = ({
|
|||
{/* File-level decision indicator */}
|
||||
{fileDecisions[selectedFile.filePath] && (
|
||||
<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'
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: fileDecisions[selectedFile.filePath] === 'rejected'
|
||||
|
|
@ -421,6 +471,49 @@ export const ChangeReviewDialog = ({
|
|||
{fileDecisions[selectedFile.filePath]}
|
||||
</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>
|
||||
|
||||
{/* Loading state */}
|
||||
|
|
@ -450,6 +543,7 @@ export const ChangeReviewDialog = ({
|
|||
readOnly={false}
|
||||
showMergeControls={true}
|
||||
collapseUnchanged={collapseUnchanged}
|
||||
initialState={cachedInitialState}
|
||||
onHunkAccepted={(idx) =>
|
||||
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 { go } from '@codemirror/lang-go';
|
||||
import { html } from '@codemirror/lang-html';
|
||||
import { java } from '@codemirror/lang-java';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
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 { rust } from '@codemirror/lang-rust';
|
||||
import { sass } from '@codemirror/lang-sass';
|
||||
import { sql } from '@codemirror/lang-sql';
|
||||
import { xml } from '@codemirror/lang-xml';
|
||||
import {
|
||||
acceptChunk,
|
||||
getChunks,
|
||||
goToNextChunk,
|
||||
goToPreviousChunk,
|
||||
rejectChunk,
|
||||
unifiedMergeView,
|
||||
} from '@codemirror/merge';
|
||||
import { EditorState, type Extension } from '@codemirror/state';
|
||||
import { yaml } from '@codemirror/lang-yaml';
|
||||
import { indentUnit, LanguageDescription, syntaxHighlighting } from '@codemirror/language';
|
||||
import { languages } from '@codemirror/language-data';
|
||||
import { goToNextChunk, goToPreviousChunk, unifiedMergeView } from '@codemirror/merge';
|
||||
import { Compartment, EditorState, type Extension } from '@codemirror/state';
|
||||
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||
import { EditorView, keymap } from '@codemirror/view';
|
||||
|
||||
import { acceptChunk, getChunks, mergeUndoSupport, rejectChunk } from './CodeMirrorDiffUtils';
|
||||
|
||||
interface CodeMirrorDiffViewProps {
|
||||
original: string;
|
||||
modified: string;
|
||||
|
|
@ -35,10 +43,12 @@ interface CodeMirrorDiffViewProps {
|
|||
editorViewRef?: React.RefObject<EditorView | null>;
|
||||
/** Called when editor content changes (debounced, only when readOnly=false) */
|
||||
onContentChanged?: (content: string) => void;
|
||||
/** Cached EditorState to restore (preserves undo history between file switches) */
|
||||
initialState?: EditorState;
|
||||
}
|
||||
|
||||
/** Detect language extension from file name */
|
||||
function getLanguageExtension(fileName: string): Extension | null {
|
||||
/** Synchronous language extension for common file types (bundled by Vite) */
|
||||
function getSyncLanguageExtension(fileName: string): Extension | null {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'ts':
|
||||
|
|
@ -57,19 +67,53 @@ function getLanguageExtension(fileName: string): Extension | null {
|
|||
case 'jsonl':
|
||||
return json();
|
||||
case 'css':
|
||||
case 'scss':
|
||||
return css();
|
||||
case 'scss':
|
||||
return sass({ indented: false });
|
||||
case 'sass':
|
||||
return sass({ indented: true });
|
||||
case 'less':
|
||||
return less();
|
||||
case 'html':
|
||||
case 'htm':
|
||||
return html();
|
||||
case 'xml':
|
||||
case 'svg':
|
||||
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:
|
||||
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 */
|
||||
function computeHunkIndexAtPos(state: EditorState, pos: number): number {
|
||||
const chunks = getChunks(state);
|
||||
|
|
@ -123,15 +167,15 @@ const diffTheme = EditorView.theme({
|
|||
'.cm-selectionBackground': {
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.3) !important',
|
||||
},
|
||||
// Diff-specific styles
|
||||
// Diff-specific styles — line-level backgrounds (no per-character underlines)
|
||||
'.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': {
|
||||
backgroundColor: 'var(--diff-removed-bg, rgba(248, 81, 73, 0.15))',
|
||||
},
|
||||
'.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': {
|
||||
backgroundColor: 'var(--diff-removed-bg, rgba(248, 81, 73, 0.15))',
|
||||
|
|
@ -195,7 +239,8 @@ export const CodeMirrorDiffView = ({
|
|||
onFullyViewed,
|
||||
editorViewRef: externalViewRef,
|
||||
onContentChanged,
|
||||
}: CodeMirrorDiffViewProps) => {
|
||||
initialState,
|
||||
}: CodeMirrorDiffViewProps): React.ReactElement => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(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 extensions: Extension[] = [
|
||||
diffTheme,
|
||||
syntaxHighlighting(oneDarkHighlightStyle),
|
||||
EditorView.editable.of(!readOnly),
|
||||
EditorState.readOnly.of(readOnly),
|
||||
];
|
||||
|
|
@ -233,12 +280,13 @@ export const CodeMirrorDiffView = ({
|
|||
// Undo/redo support and standard editing keybindings
|
||||
if (!readOnly) {
|
||||
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) {
|
||||
extensions.push(langExtension);
|
||||
}
|
||||
// Language placeholder — actual language injected async via compartment reconfigure
|
||||
extensions.push(langCompartment.current.of([]));
|
||||
|
||||
// Keyboard shortcuts for chunk navigation and accept/reject
|
||||
extensions.push(
|
||||
|
|
@ -294,7 +342,7 @@ export const CodeMirrorDiffView = ({
|
|||
// Unified merge view
|
||||
const mergeConfig: Parameters<typeof unifiedMergeView>[0] = {
|
||||
original,
|
||||
highlightChanges: true,
|
||||
highlightChanges: false,
|
||||
gutter: true,
|
||||
syntaxHighlightDeletions: true,
|
||||
};
|
||||
|
|
@ -307,7 +355,12 @@ export const CodeMirrorDiffView = ({
|
|||
}
|
||||
|
||||
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');
|
||||
|
||||
if (type === 'accept') {
|
||||
|
|
@ -320,7 +373,7 @@ export const CodeMirrorDiffView = ({
|
|||
if (view) {
|
||||
const pos = view.posAtDOM(btn);
|
||||
const hunkIndex = computeHunkIndexAtPos(view.state, pos);
|
||||
action(e);
|
||||
acceptChunk(view, pos);
|
||||
onAcceptRef.current?.(hunkIndex);
|
||||
scrollToNextChunk();
|
||||
}
|
||||
|
|
@ -335,7 +388,7 @@ export const CodeMirrorDiffView = ({
|
|||
if (view) {
|
||||
const pos = view.posAtDOM(btn);
|
||||
const hunkIndex = computeHunkIndexAtPos(view.state, pos);
|
||||
action(e);
|
||||
rejectChunk(view, pos);
|
||||
onRejectRef.current?.(hunkIndex);
|
||||
scrollToNextChunk();
|
||||
}
|
||||
|
|
@ -352,7 +405,6 @@ export const CodeMirrorDiffView = ({
|
|||
}, [
|
||||
original,
|
||||
readOnly,
|
||||
langExtension,
|
||||
showMergeControls,
|
||||
collapseUnchangedProp,
|
||||
collapseMargin,
|
||||
|
|
@ -368,11 +420,13 @@ export const CodeMirrorDiffView = ({
|
|||
viewRef.current = null;
|
||||
}
|
||||
|
||||
const view = new EditorView({
|
||||
doc: modified,
|
||||
extensions: buildExtensions(),
|
||||
parent: containerRef.current,
|
||||
});
|
||||
const view = initialState
|
||||
? new EditorView({ state: initialState, parent: containerRef.current })
|
||||
: new EditorView({
|
||||
doc: modified,
|
||||
extensions: buildExtensions(),
|
||||
parent: containerRef.current,
|
||||
});
|
||||
|
||||
viewRef.current = view;
|
||||
// Sync to external ref via holder
|
||||
|
|
@ -389,7 +443,39 @@ export const CodeMirrorDiffView = ({
|
|||
}
|
||||
};
|
||||
// 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
|
||||
useEffect(() => {
|
||||
|
|
@ -418,6 +504,3 @@ export const CodeMirrorDiffView = ({
|
|||
</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';
|
||||
|
||||
interface KeyboardShortcutsHelpProps {
|
||||
|
|
@ -10,10 +12,15 @@ const shortcuts = [
|
|||
{ keys: ['\u2318+Y'], action: 'Accept change' },
|
||||
{ keys: ['\u2318+N'], action: 'Reject change' },
|
||||
{ keys: ['\u2318+\u21A9'], action: 'Save file' },
|
||||
{ keys: ['\u2318+Z'], action: 'Undo' },
|
||||
{ keys: ['\u2318+\u21E7+Z'], action: 'Redo' },
|
||||
{ keys: ['Esc'], action: 'Close dialog' },
|
||||
];
|
||||
|
||||
export const KeyboardShortcutsHelp = ({ open, onOpenChange }: KeyboardShortcutsHelpProps) => {
|
||||
export const KeyboardShortcutsHelp = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: KeyboardShortcutsHelpProps): React.ReactElement | null => {
|
||||
if (!open) return null;
|
||||
|
||||
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 {
|
||||
Check,
|
||||
Columns2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FoldVertical,
|
||||
GitMerge,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Rows2,
|
||||
Save,
|
||||
Undo2,
|
||||
UnfoldVertical,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
|
|
@ -18,7 +19,6 @@ import type { ChangeStats } from '@shared/types';
|
|||
interface ReviewToolbarProps {
|
||||
stats: { pending: number; accepted: number; rejected: number };
|
||||
changeStats: ChangeStats;
|
||||
diffViewMode: 'unified' | 'split';
|
||||
collapseUnchanged: boolean;
|
||||
applying: boolean;
|
||||
autoViewed: boolean;
|
||||
|
|
@ -26,20 +26,13 @@ interface ReviewToolbarProps {
|
|||
onAcceptAll: () => void;
|
||||
onRejectAll: () => void;
|
||||
onApply: () => void;
|
||||
onDiffViewModeChange: (mode: 'unified' | 'split') => void;
|
||||
onCollapseUnchangedChange: (collapse: boolean) => void;
|
||||
// Editable diff props
|
||||
editedCount?: number;
|
||||
hasCurrentFileEdits?: boolean;
|
||||
saving?: boolean;
|
||||
onSaveCurrentFile?: () => void;
|
||||
onDiscardCurrentFile?: () => void;
|
||||
}
|
||||
|
||||
export const ReviewToolbar = ({
|
||||
stats,
|
||||
changeStats,
|
||||
diffViewMode,
|
||||
collapseUnchanged,
|
||||
applying,
|
||||
autoViewed,
|
||||
|
|
@ -47,14 +40,9 @@ export const ReviewToolbar = ({
|
|||
onAcceptAll,
|
||||
onRejectAll,
|
||||
onApply,
|
||||
onDiffViewModeChange,
|
||||
onCollapseUnchangedChange,
|
||||
editedCount = 0,
|
||||
hasCurrentFileEdits = false,
|
||||
saving = false,
|
||||
onSaveCurrentFile,
|
||||
onDiscardCurrentFile,
|
||||
}: ReviewToolbarProps) => {
|
||||
}: ReviewToolbarProps): React.ReactElement => {
|
||||
const hasRejected = stats.rejected > 0;
|
||||
const canApply = hasRejected && !applying;
|
||||
|
||||
|
|
@ -90,118 +78,116 @@ export const ReviewToolbar = ({
|
|||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* View toggles */}
|
||||
<div className="flex items-center gap-1 rounded-md border border-border bg-surface p-0.5">
|
||||
<button
|
||||
onClick={() => onDiffViewModeChange('unified')}
|
||||
className={cn(
|
||||
'rounded px-2 py-1 text-xs transition-colors',
|
||||
diffViewMode === 'unified'
|
||||
? 'bg-surface-raised text-text'
|
||||
: 'text-text-muted hover:text-text'
|
||||
)}
|
||||
title="Unified view"
|
||||
>
|
||||
<Rows2 className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDiffViewModeChange('split')}
|
||||
className={cn(
|
||||
'rounded px-2 py-1 text-xs transition-colors',
|
||||
diffViewMode === 'split'
|
||||
? 'bg-surface-raised text-text'
|
||||
: 'text-text-muted hover:text-text'
|
||||
)}
|
||||
title="Split view"
|
||||
>
|
||||
<Columns2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onCollapseUnchangedChange(!collapseUnchanged)}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors',
|
||||
collapseUnchanged ? 'bg-surface-raised text-text' : 'text-text-muted hover:text-text'
|
||||
)}
|
||||
>
|
||||
{collapseUnchanged ? (
|
||||
<FoldVertical className="size-3.5" />
|
||||
) : (
|
||||
<UnfoldVertical className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{collapseUnchanged ? 'Show all lines' : 'Collapse unchanged regions'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<button
|
||||
onClick={() => onCollapseUnchangedChange(!collapseUnchanged)}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors',
|
||||
collapseUnchanged ? 'bg-surface-raised text-text' : 'text-text-muted hover:text-text'
|
||||
)}
|
||||
title={collapseUnchanged ? 'Show all lines' : 'Collapse unchanged'}
|
||||
>
|
||||
{collapseUnchanged ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onAutoViewedChange(!autoViewed)}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors',
|
||||
autoViewed ? 'bg-surface-raised text-text' : 'text-text-muted hover:text-text'
|
||||
)}
|
||||
title={autoViewed ? 'Auto-mark viewed: ON' : 'Auto-mark viewed: OFF'}
|
||||
>
|
||||
{autoViewed ? <Eye className="size-3.5" /> : <EyeOff className="size-3.5" />}
|
||||
<span className="text-[10px]">Auto</span>
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onAutoViewedChange(!autoViewed)}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors',
|
||||
autoViewed ? 'bg-surface-raised text-text' : 'text-text-muted hover:text-text'
|
||||
)}
|
||||
>
|
||||
{autoViewed ? <Eye className="size-3.5" /> : <EyeOff className="size-3.5" />}
|
||||
<span className="text-[10px]">Auto</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{autoViewed
|
||||
? 'Auto-mark files as viewed when scrolled to end (ON)'
|
||||
: 'Auto-mark files as viewed when scrolled to end (OFF)'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<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 && (
|
||||
<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
|
||||
</span>
|
||||
)}
|
||||
|
||||
{(hasCurrentFileEdits || editedCount > 0) && <div className="h-4 w-px bg-border" />}
|
||||
{editedCount > 0 && <div className="h-4 w-px bg-border" />}
|
||||
|
||||
{/* Actions */}
|
||||
<button
|
||||
onClick={onAcceptAll}
|
||||
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"
|
||||
>
|
||||
<Check className="size-3" />
|
||||
Accept All
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onAcceptAll}
|
||||
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"
|
||||
>
|
||||
<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
|
||||
onClick={onRejectAll}
|
||||
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"
|
||||
>
|
||||
<X className="size-3" />
|
||||
Reject All
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onRejectAll}
|
||||
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"
|
||||
>
|
||||
<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
|
||||
onClick={onApply}
|
||||
disabled={!canApply}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded px-3 py-1 text-xs font-medium transition-colors',
|
||||
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>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onApply}
|
||||
disabled={!canApply}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded px-3 py-1 text-xs font-medium transition-colors',
|
||||
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 All Changes'}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Apply review decisions across all files</TooltipContent>
|
||||
</Tooltip>
|
||||
</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 { FC } from 'react';
|
||||
|
||||
interface ScopeWarningBannerProps {
|
||||
warnings: string[];
|
||||
|
|
@ -8,33 +12,93 @@ interface ScopeWarningBannerProps {
|
|||
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 = ({
|
||||
warnings,
|
||||
confidence,
|
||||
onDismiss,
|
||||
}: ScopeWarningBannerProps) => {
|
||||
if (warnings.length === 0 && confidence.tier <= 2) return null;
|
||||
}: ScopeWarningBannerProps): JSX.Element => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const config = TIER_CONFIGS[confidence.tier] ?? TIER_CONFIGS[4];
|
||||
const { Icon } = config;
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-yellow-500/20 bg-yellow-500/10 p-3 text-sm">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-yellow-400" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-yellow-300">
|
||||
{confidence.tier >= 3
|
||||
? 'Task boundary detection is approximate'
|
||||
: 'Note about these changes'}
|
||||
</p>
|
||||
{warnings.map((w, i) => (
|
||||
<p key={i} className="mt-1 text-text-secondary">
|
||||
{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" />
|
||||
<div className={cn('border-b px-4 py-2', config.border, config.bg)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={cn('size-3.5 shrink-0', config.accentColor)} />
|
||||
<span className={cn('text-xs font-medium', config.accentColor)}>{config.title}</span>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-0.5 text-xs text-text-muted transition-colors hover:text-text-secondary"
|
||||
>
|
||||
Read more
|
||||
<ChevronRight className={cn('size-3 transition-transform', expanded && 'rotate-90')} />
|
||||
</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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { api } from '@renderer/api';
|
||||
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 {
|
||||
AgentChangeSet,
|
||||
|
|
@ -37,7 +43,6 @@ export interface ChangeReviewSlice {
|
|||
fileDecisions: Record<string, HunkDecision>;
|
||||
fileContents: Record<string, FileChangeWithContent>;
|
||||
fileContentsLoading: Record<string, boolean>;
|
||||
diffViewMode: 'unified' | 'split';
|
||||
collapseUnchanged: boolean;
|
||||
applyError: string | null;
|
||||
applying: boolean;
|
||||
|
|
@ -45,6 +50,9 @@ export interface ChangeReviewSlice {
|
|||
// Editable diff state
|
||||
editedContents: Record<string, string>;
|
||||
|
||||
/** Cache: "teamName:taskId" → true/false (has file changes) */
|
||||
taskHasChanges: Record<string, boolean>;
|
||||
|
||||
// Phase 1 actions
|
||||
fetchAgentChanges: (teamName: string, memberName: string) => Promise<void>;
|
||||
fetchTaskChanges: (teamName: string, taskId: string) => Promise<void>;
|
||||
|
|
@ -59,7 +67,6 @@ export interface ChangeReviewSlice {
|
|||
rejectAllFile: (filePath: string) => void;
|
||||
acceptAll: () => void;
|
||||
rejectAll: () => void;
|
||||
setDiffViewMode: (mode: 'unified' | 'split') => void;
|
||||
setCollapseUnchanged: (collapse: boolean) => void;
|
||||
fetchFileContent: (
|
||||
teamName: string,
|
||||
|
|
@ -74,6 +81,9 @@ export interface ChangeReviewSlice {
|
|||
discardFileEdits: (filePath: string) => void;
|
||||
discardAllEdits: () => void;
|
||||
saveEditedFile: (filePath: string) => Promise<void>;
|
||||
|
||||
// Task change availability
|
||||
checkTaskHasChanges: (teamName: string, taskId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeReviewSlice> = (
|
||||
|
|
@ -92,7 +102,6 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
fileDecisions: {},
|
||||
fileContents: {},
|
||||
fileContentsLoading: {},
|
||||
diffViewMode: 'unified',
|
||||
collapseUnchanged: true,
|
||||
applyError: null,
|
||||
applying: false,
|
||||
|
|
@ -100,6 +109,8 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
// Editable diff initial state
|
||||
editedContents: {},
|
||||
|
||||
taskHasChanges: {},
|
||||
|
||||
fetchAgentChanges: async (teamName: string, memberName: string) => {
|
||||
set({ changeSetLoading: true, changeSetError: null });
|
||||
try {
|
||||
|
|
@ -120,11 +131,13 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
set({ changeSetLoading: true, changeSetError: null });
|
||||
try {
|
||||
const data = await api.review.getTaskChanges(teamName, taskId);
|
||||
set({
|
||||
const cacheKey = `${teamName}:${taskId}`;
|
||||
set((s) => ({
|
||||
activeChangeSet: data,
|
||||
changeSetLoading: false,
|
||||
selectedReviewFilePath: data.files[0]?.filePath ?? null,
|
||||
});
|
||||
taskHasChanges: { ...s.taskHasChanges, [cacheKey]: data.files.length > 0 },
|
||||
}));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch task changes';
|
||||
logger.error('fetchTaskChanges error:', message);
|
||||
|
|
@ -241,10 +254,6 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
set({ hunkDecisions: newHunkDecisions, fileDecisions: newFileDecisions });
|
||||
},
|
||||
|
||||
setDiffViewMode: (mode: 'unified' | 'split') => {
|
||||
set({ diffViewMode: mode });
|
||||
},
|
||||
|
||||
setCollapseUnchanged: (collapse: boolean) => {
|
||||
set({ collapseUnchanged: collapse });
|
||||
},
|
||||
|
|
@ -284,7 +293,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
totalLinesAdded: number;
|
||||
totalLinesRemoved: number;
|
||||
files: { filePath: string }[];
|
||||
}) =>
|
||||
}): string =>
|
||||
`${cs.totalFiles}:${cs.totalLinesAdded}:${cs.totalLinesRemoved}:${cs.files.map((f) => f.filePath).join(',')}`;
|
||||
|
||||
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) => {
|
||||
set((state) => {
|
||||
const newCache = { ...state.changeStatsCache };
|
||||
|
|
|
|||
|
|
@ -34,35 +34,31 @@ interface ContentBlock {
|
|||
|
||||
/**
|
||||
* Attempts to extract the content array from a parsed stream-json line.
|
||||
* Handles both `{ type: "assistant", content: [...] }` and
|
||||
* `{ message: { type: "assistant", content: [...] } }` formats.
|
||||
* Handles both `{ type: "assistant", content: [...] }` (direct) and
|
||||
* `{ type: "assistant", message: { type: "message", content: [...] } }` (wrapped) formats.
|
||||
*/
|
||||
function extractContentBlocks(parsed: unknown): ContentBlock[] | null {
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
|
||||
// Only process assistant messages
|
||||
if (obj.type !== 'assistant') return null;
|
||||
|
||||
// Direct format: { type: "assistant", content: [...] }
|
||||
if (obj.type === 'assistant' && Array.isArray(obj.content)) {
|
||||
if (Array.isArray(obj.content)) {
|
||||
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') {
|
||||
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[];
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue