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:
iliya 2026-02-25 11:40:01 +02:00
parent 1d7e55e89a
commit fd3176716b
17 changed files with 1181 additions and 300 deletions

View file

@ -81,6 +81,9 @@ export default defineConfig({
} }
}, },
renderer: { renderer: {
optimizeDeps: {
include: ['@codemirror/language-data']
},
resolve: { resolve: {
alias: { alias: {
'@renderer': resolve(__dirname, 'src/renderer'), '@renderer': resolve(__dirname, 'src/renderer'),

View file

@ -61,12 +61,24 @@
}, },
"dependencies": { "dependencies": {
"@codemirror/commands": "^6.10.2", "@codemirror/commands": "^6.10.2",
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-go": "^6.0.1",
"@codemirror/lang-html": "^6.4.11", "@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2", "@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-less": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-python": "^6.2.1", "@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "^6.0.2",
"@codemirror/lang-sass": "^6.0.2",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-xml": "^6.1.0", "@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.12.1",
"@codemirror/language-data": "^6.5.2",
"@codemirror/merge": "^6.12.0", "@codemirror/merge": "^6.12.0",
"@codemirror/state": "^6.5.4", "@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",

View file

@ -11,24 +11,60 @@ importers:
'@codemirror/commands': '@codemirror/commands':
specifier: ^6.10.2 specifier: ^6.10.2
version: 6.10.2 version: 6.10.2
'@codemirror/lang-cpp':
specifier: ^6.0.3
version: 6.0.3
'@codemirror/lang-css': '@codemirror/lang-css':
specifier: ^6.3.1 specifier: ^6.3.1
version: 6.3.1 version: 6.3.1
'@codemirror/lang-go':
specifier: ^6.0.1
version: 6.0.1
'@codemirror/lang-html': '@codemirror/lang-html':
specifier: ^6.4.11 specifier: ^6.4.11
version: 6.4.11 version: 6.4.11
'@codemirror/lang-java':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lang-javascript': '@codemirror/lang-javascript':
specifier: ^6.2.4 specifier: ^6.2.4
version: 6.2.4 version: 6.2.4
'@codemirror/lang-json': '@codemirror/lang-json':
specifier: ^6.0.2 specifier: ^6.0.2
version: 6.0.2 version: 6.0.2
'@codemirror/lang-less':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lang-markdown':
specifier: ^6.5.0
version: 6.5.0
'@codemirror/lang-php':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lang-python': '@codemirror/lang-python':
specifier: ^6.2.1 specifier: ^6.2.1
version: 6.2.1 version: 6.2.1
'@codemirror/lang-rust':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lang-sass':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lang-sql':
specifier: ^6.10.0
version: 6.10.0
'@codemirror/lang-xml': '@codemirror/lang-xml':
specifier: ^6.1.0 specifier: ^6.1.0
version: 6.1.0 version: 6.1.0
'@codemirror/lang-yaml':
specifier: ^6.1.2
version: 6.1.2
'@codemirror/language':
specifier: ^6.12.1
version: 6.12.1
'@codemirror/language-data':
specifier: ^6.5.2
version: 6.5.2
'@codemirror/merge': '@codemirror/merge':
specifier: ^6.12.0 specifier: ^6.12.0
version: 6.12.0 version: 6.12.0
@ -402,27 +438,78 @@ packages:
'@codemirror/commands@6.10.2': '@codemirror/commands@6.10.2':
resolution: {integrity: sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==} resolution: {integrity: sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==}
'@codemirror/lang-angular@0.1.4':
resolution: {integrity: sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==}
'@codemirror/lang-cpp@6.0.3':
resolution: {integrity: sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==}
'@codemirror/lang-css@6.3.1': '@codemirror/lang-css@6.3.1':
resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==} resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
'@codemirror/lang-go@6.0.1':
resolution: {integrity: sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==}
'@codemirror/lang-html@6.4.11': '@codemirror/lang-html@6.4.11':
resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==} resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==}
'@codemirror/lang-java@6.0.2':
resolution: {integrity: sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==}
'@codemirror/lang-javascript@6.2.4': '@codemirror/lang-javascript@6.2.4':
resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==} resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==}
'@codemirror/lang-jinja@6.0.0':
resolution: {integrity: sha512-47MFmRcR8UAxd8DReVgj7WJN1WSAMT7OJnewwugZM4XiHWkOjgJQqvEM1NpMj9ALMPyxmlziEI1opH9IaEvmaw==}
'@codemirror/lang-json@6.0.2': '@codemirror/lang-json@6.0.2':
resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
'@codemirror/lang-less@6.0.2':
resolution: {integrity: sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==}
'@codemirror/lang-liquid@6.3.2':
resolution: {integrity: sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw==}
'@codemirror/lang-markdown@6.5.0':
resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==}
'@codemirror/lang-php@6.0.2':
resolution: {integrity: sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==}
'@codemirror/lang-python@6.2.1': '@codemirror/lang-python@6.2.1':
resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==} resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==}
'@codemirror/lang-rust@6.0.2':
resolution: {integrity: sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==}
'@codemirror/lang-sass@6.0.2':
resolution: {integrity: sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==}
'@codemirror/lang-sql@6.10.0':
resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==}
'@codemirror/lang-vue@0.1.3':
resolution: {integrity: sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==}
'@codemirror/lang-wast@6.0.2':
resolution: {integrity: sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==}
'@codemirror/lang-xml@6.1.0': '@codemirror/lang-xml@6.1.0':
resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==} resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==}
'@codemirror/lang-yaml@6.1.2':
resolution: {integrity: sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==}
'@codemirror/language-data@6.5.2':
resolution: {integrity: sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==}
'@codemirror/language@6.12.1': '@codemirror/language@6.12.1':
resolution: {integrity: sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==} resolution: {integrity: sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==}
'@codemirror/legacy-modes@6.5.2':
resolution: {integrity: sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==}
'@codemirror/lint@6.9.4': '@codemirror/lint@6.9.4':
resolution: {integrity: sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==} resolution: {integrity: sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==}
@ -953,15 +1040,24 @@ packages:
'@lezer/common@1.5.1': '@lezer/common@1.5.1':
resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==} resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==}
'@lezer/cpp@1.1.5':
resolution: {integrity: sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw==}
'@lezer/css@1.3.1': '@lezer/css@1.3.1':
resolution: {integrity: sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==} resolution: {integrity: sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==}
'@lezer/go@1.0.1':
resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==}
'@lezer/highlight@1.2.3': '@lezer/highlight@1.2.3':
resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
'@lezer/html@1.3.13': '@lezer/html@1.3.13':
resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==} resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==}
'@lezer/java@1.1.3':
resolution: {integrity: sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==}
'@lezer/javascript@1.5.4': '@lezer/javascript@1.5.4':
resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==}
@ -971,12 +1067,27 @@ packages:
'@lezer/lr@1.4.8': '@lezer/lr@1.4.8':
resolution: {integrity: sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==} resolution: {integrity: sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==}
'@lezer/markdown@1.6.3':
resolution: {integrity: sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==}
'@lezer/php@1.0.5':
resolution: {integrity: sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==}
'@lezer/python@1.1.18': '@lezer/python@1.1.18':
resolution: {integrity: sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==} resolution: {integrity: sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==}
'@lezer/rust@1.0.2':
resolution: {integrity: sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==}
'@lezer/sass@1.1.0':
resolution: {integrity: sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==}
'@lezer/xml@1.0.6': '@lezer/xml@1.0.6':
resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==} resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==}
'@lezer/yaml@1.0.4':
resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==}
'@lukeed/ms@2.0.2': '@lukeed/ms@2.0.2':
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -5575,6 +5686,20 @@ snapshots:
'@codemirror/view': 6.39.15 '@codemirror/view': 6.39.15
'@lezer/common': 1.5.1 '@lezer/common': 1.5.1
'@codemirror/lang-angular@0.1.4':
dependencies:
'@codemirror/lang-html': 6.4.11
'@codemirror/lang-javascript': 6.2.4
'@codemirror/language': 6.12.1
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@codemirror/lang-cpp@6.0.3':
dependencies:
'@codemirror/language': 6.12.1
'@lezer/cpp': 1.1.5
'@codemirror/lang-css@6.3.1': '@codemirror/lang-css@6.3.1':
dependencies: dependencies:
'@codemirror/autocomplete': 6.20.0 '@codemirror/autocomplete': 6.20.0
@ -5583,6 +5708,14 @@ snapshots:
'@lezer/common': 1.5.1 '@lezer/common': 1.5.1
'@lezer/css': 1.3.1 '@lezer/css': 1.3.1
'@codemirror/lang-go@6.0.1':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@lezer/common': 1.5.1
'@lezer/go': 1.0.1
'@codemirror/lang-html@6.4.11': '@codemirror/lang-html@6.4.11':
dependencies: dependencies:
'@codemirror/autocomplete': 6.20.0 '@codemirror/autocomplete': 6.20.0
@ -5595,6 +5728,11 @@ snapshots:
'@lezer/css': 1.3.1 '@lezer/css': 1.3.1
'@lezer/html': 1.3.13 '@lezer/html': 1.3.13
'@codemirror/lang-java@6.0.2':
dependencies:
'@codemirror/language': 6.12.1
'@lezer/java': 1.1.3
'@codemirror/lang-javascript@6.2.4': '@codemirror/lang-javascript@6.2.4':
dependencies: dependencies:
'@codemirror/autocomplete': 6.20.0 '@codemirror/autocomplete': 6.20.0
@ -5605,11 +5743,56 @@ snapshots:
'@lezer/common': 1.5.1 '@lezer/common': 1.5.1
'@lezer/javascript': 1.5.4 '@lezer/javascript': 1.5.4
'@codemirror/lang-jinja@6.0.0':
dependencies:
'@codemirror/lang-html': 6.4.11
'@codemirror/language': 6.12.1
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@codemirror/lang-json@6.0.2': '@codemirror/lang-json@6.0.2':
dependencies: dependencies:
'@codemirror/language': 6.12.1 '@codemirror/language': 6.12.1
'@lezer/json': 1.0.3 '@lezer/json': 1.0.3
'@codemirror/lang-less@6.0.2':
dependencies:
'@codemirror/lang-css': 6.3.1
'@codemirror/language': 6.12.1
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@codemirror/lang-liquid@6.3.2':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/lang-html': 6.4.11
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.15
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@codemirror/lang-markdown@6.5.0':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/lang-html': 6.4.11
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.15
'@lezer/common': 1.5.1
'@lezer/markdown': 1.6.3
'@codemirror/lang-php@6.0.2':
dependencies:
'@codemirror/lang-html': 6.4.11
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@lezer/common': 1.5.1
'@lezer/php': 1.0.5
'@codemirror/lang-python@6.2.1': '@codemirror/lang-python@6.2.1':
dependencies: dependencies:
'@codemirror/autocomplete': 6.20.0 '@codemirror/autocomplete': 6.20.0
@ -5618,6 +5801,44 @@ snapshots:
'@lezer/common': 1.5.1 '@lezer/common': 1.5.1
'@lezer/python': 1.1.18 '@lezer/python': 1.1.18
'@codemirror/lang-rust@6.0.2':
dependencies:
'@codemirror/language': 6.12.1
'@lezer/rust': 1.0.2
'@codemirror/lang-sass@6.0.2':
dependencies:
'@codemirror/lang-css': 6.3.1
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@lezer/common': 1.5.1
'@lezer/sass': 1.1.0
'@codemirror/lang-sql@6.10.0':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@codemirror/lang-vue@0.1.3':
dependencies:
'@codemirror/lang-html': 6.4.11
'@codemirror/lang-javascript': 6.2.4
'@codemirror/language': 6.12.1
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@codemirror/lang-wast@6.0.2':
dependencies:
'@codemirror/language': 6.12.1
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@codemirror/lang-xml@6.1.0': '@codemirror/lang-xml@6.1.0':
dependencies: dependencies:
'@codemirror/autocomplete': 6.20.0 '@codemirror/autocomplete': 6.20.0
@ -5627,6 +5848,42 @@ snapshots:
'@lezer/common': 1.5.1 '@lezer/common': 1.5.1
'@lezer/xml': 1.0.6 '@lezer/xml': 1.0.6
'@codemirror/lang-yaml@6.1.2':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/yaml': 1.0.4
'@codemirror/language-data@6.5.2':
dependencies:
'@codemirror/lang-angular': 0.1.4
'@codemirror/lang-cpp': 6.0.3
'@codemirror/lang-css': 6.3.1
'@codemirror/lang-go': 6.0.1
'@codemirror/lang-html': 6.4.11
'@codemirror/lang-java': 6.0.2
'@codemirror/lang-javascript': 6.2.4
'@codemirror/lang-jinja': 6.0.0
'@codemirror/lang-json': 6.0.2
'@codemirror/lang-less': 6.0.2
'@codemirror/lang-liquid': 6.3.2
'@codemirror/lang-markdown': 6.5.0
'@codemirror/lang-php': 6.0.2
'@codemirror/lang-python': 6.2.1
'@codemirror/lang-rust': 6.0.2
'@codemirror/lang-sass': 6.0.2
'@codemirror/lang-sql': 6.10.0
'@codemirror/lang-vue': 0.1.3
'@codemirror/lang-wast': 6.0.2
'@codemirror/lang-xml': 6.1.0
'@codemirror/lang-yaml': 6.1.2
'@codemirror/language': 6.12.1
'@codemirror/legacy-modes': 6.5.2
'@codemirror/language@6.12.1': '@codemirror/language@6.12.1':
dependencies: dependencies:
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
@ -5636,6 +5893,10 @@ snapshots:
'@lezer/lr': 1.4.8 '@lezer/lr': 1.4.8
style-mod: 4.1.3 style-mod: 4.1.3
'@codemirror/legacy-modes@6.5.2':
dependencies:
'@codemirror/language': 6.12.1
'@codemirror/lint@6.9.4': '@codemirror/lint@6.9.4':
dependencies: dependencies:
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
@ -6136,12 +6397,24 @@ snapshots:
'@lezer/common@1.5.1': {} '@lezer/common@1.5.1': {}
'@lezer/cpp@1.1.5':
dependencies:
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/css@1.3.1': '@lezer/css@1.3.1':
dependencies: dependencies:
'@lezer/common': 1.5.1 '@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3 '@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8 '@lezer/lr': 1.4.8
'@lezer/go@1.0.1':
dependencies:
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/highlight@1.2.3': '@lezer/highlight@1.2.3':
dependencies: dependencies:
'@lezer/common': 1.5.1 '@lezer/common': 1.5.1
@ -6152,6 +6425,12 @@ snapshots:
'@lezer/highlight': 1.2.3 '@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8 '@lezer/lr': 1.4.8
'@lezer/java@1.1.3':
dependencies:
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/javascript@1.5.4': '@lezer/javascript@1.5.4':
dependencies: dependencies:
'@lezer/common': 1.5.1 '@lezer/common': 1.5.1
@ -6168,18 +6447,47 @@ snapshots:
dependencies: dependencies:
'@lezer/common': 1.5.1 '@lezer/common': 1.5.1
'@lezer/markdown@1.6.3':
dependencies:
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/php@1.0.5':
dependencies:
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/python@1.1.18': '@lezer/python@1.1.18':
dependencies: dependencies:
'@lezer/common': 1.5.1 '@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3 '@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8 '@lezer/lr': 1.4.8
'@lezer/rust@1.0.2':
dependencies:
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/sass@1.1.0':
dependencies:
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/xml@1.0.6': '@lezer/xml@1.0.6':
dependencies: dependencies:
'@lezer/common': 1.5.1 '@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3 '@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8 '@lezer/lr': 1.4.8
'@lezer/yaml@1.0.4':
dependencies:
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lukeed/ms@2.0.2': {} '@lukeed/ms@2.0.2': {}
'@malept/cross-spawn-promise@1.1.1': '@malept/cross-spawn-promise@1.1.1':

View file

@ -169,6 +169,15 @@ function getPaths(flags, teamName) {
return { claudeDir, teamDir, tasksDir, kanbanPath }; return { claudeDir, teamDir, tasksDir, kanbanPath };
} }
function inferLeadName(paths) {
const config = readJson(path.join(paths.teamDir, 'config.json'), null);
if (!config || !Array.isArray(config.members)) return 'team-lead';
const lead = config.members.find(function (m) {
return m.role && String(m.role).toLowerCase().includes('lead');
});
return lead ? String(lead.name) : (config.members[0] ? String(config.members[0].name) : 'team-lead');
}
function readTask(paths, taskId) { function readTask(paths, taskId) {
const taskPath = path.join(paths.tasksDir, String(taskId) + '.json'); const taskPath = path.join(paths.tasksDir, String(taskId) + '.json');
const task = readJson(taskPath, null); const task = readJson(taskPath, null);
@ -340,7 +349,7 @@ function sendInboxMessage(paths, teamName, flags) {
const text = typeof flags.text === 'string' ? flags.text : ''; const text = typeof flags.text === 'string' ? flags.text : '';
if (!text) die('Missing --text'); if (!text) die('Missing --text');
const summary = typeof flags.summary === 'string' ? flags.summary : undefined; const summary = typeof flags.summary === 'string' ? flags.summary : undefined;
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'user'; const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths);
const inboxPath = path.join(paths.teamDir, 'inboxes', String(to) + '.json'); const inboxPath = path.join(paths.teamDir, 'inboxes', String(to) + '.json');
ensureDir(path.dirname(inboxPath)); ensureDir(path.dirname(inboxPath));
@ -374,7 +383,7 @@ function reviewApprove(paths, teamName, taskId, flags) {
if (!notify) return; if (!notify) return;
const { task } = readTask(paths, taskId); const { task } = readTask(paths, taskId);
if (!task.owner) return; if (!task.owner) return;
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'user'; const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths);
const note = typeof flags.note === 'string' ? flags.note.trim() : ''; const note = typeof flags.note === 'string' ? flags.note.trim() : '';
const text = note const text = note
? 'Task #' + String(taskId) + ' approved.\n\n' + note ? 'Task #' + String(taskId) + ' approved.\n\n' + note
@ -396,7 +405,7 @@ function reviewRequestChanges(paths, teamName, taskId, flags) {
task.status = 'in_progress'; task.status = 'in_progress';
writeTask(taskPath, task); writeTask(taskPath, task);
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'user'; const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths);
const text = const text =
'Task #' + 'Task #' +
String(taskId) + String(taskId) +
@ -481,7 +490,7 @@ async function main() {
const notify = args.flags.notify === true || args.flags['notify-owner'] === true; const notify = args.flags.notify === true || args.flags['notify-owner'] === true;
if (notify && task.owner) { if (notify && task.owner) {
const from = const from =
typeof args.flags.from === 'string' && args.flags.from.trim() ? args.flags.from.trim() : 'user'; typeof args.flags.from === 'string' && args.flags.from.trim() ? args.flags.from.trim() : inferLeadName(paths);
const parts = ['New task assigned to you: #' + String(task.id) + ' "' + String(task.subject) + '".']; const parts = ['New task assigned to you: #' + String(task.id) + ' "' + String(task.subject) + '".'];
const rawDesc = typeof args.flags.description === 'string' ? args.flags.description.trim() const rawDesc = typeof args.flags.description === 'string' ? args.flags.description.trim()
: typeof args.flags.desc === 'string' ? args.flags.desc.trim() : ''; : typeof args.flags.desc === 'string' ? args.flags.desc.trim() : '';

View file

@ -431,8 +431,10 @@ export class TeamDataService {
AGENT_BLOCK_CLOSE AGENT_BLOCK_CLOSE
); );
const leadName = await this.resolveLeadName(teamName);
await this.sendMessage(teamName, { await this.sendMessage(teamName, {
member: request.owner, member: request.owner,
from: leadName,
text: parts.join('\n'), text: parts.join('\n'),
summary: `New task #${task.id} assigned`, summary: `New task #${task.id} assigned`,
}); });
@ -469,8 +471,10 @@ export class TeamDataService {
`node "${toolPath}" --team ${teamName} task complete ${task.id}`, `node "${toolPath}" --team ${teamName} task complete ${task.id}`,
AGENT_BLOCK_CLOSE AGENT_BLOCK_CLOSE
); );
const leadName = await this.resolveLeadName(teamName);
await this.sendMessage(teamName, { await this.sendMessage(teamName, {
member: task.owner, member: task.owner,
from: leadName,
text: parts.join('\n'), text: parts.join('\n'),
summary: `Task #${task.id} started`, summary: `Task #${task.id} started`,
}); });
@ -507,8 +511,10 @@ export class TeamDataService {
`node "${toolPath}" --team ${teamName} task comment ${taskId} --text "<your reply>" --from "<your-name>"`, `node "${toolPath}" --team ${teamName} task comment ${taskId} --text "<your reply>" --from "<your-name>"`,
AGENT_BLOCK_CLOSE, AGENT_BLOCK_CLOSE,
]; ];
const leadName = await this.resolveLeadName(teamName);
await this.sendMessage(teamName, { await this.sendMessage(teamName, {
member: task.owner, member: task.owner,
from: leadName,
text: parts.join('\n'), text: parts.join('\n'),
summary: `Comment on #${taskId}`, summary: `Comment on #${taskId}`,
}); });
@ -524,6 +530,17 @@ export class TeamDataService {
return this.inboxWriter.sendMessage(teamName, request); return this.inboxWriter.sendMessage(teamName, request);
} }
private async resolveLeadName(teamName: string): Promise<string> {
try {
const config = await this.configReader.getConfig(teamName);
if (!config) return 'team-lead';
const lead = config.members?.find((m) => m.role?.toLowerCase().includes('lead'));
return lead?.name ?? config.members?.[0]?.name ?? 'team-lead';
} catch {
return 'team-lead';
}
}
async sendDirectToLead( async sendDirectToLead(
teamName: string, teamName: string,
leadName: string, leadName: string,
@ -582,9 +599,13 @@ export class TeamDataService {
} }
try { try {
const toolPath = await this.toolsInstaller.ensureInstalled(); const [toolPath, leadName] = await Promise.all([
this.toolsInstaller.ensureInstalled(),
this.resolveLeadName(teamName),
]);
await this.sendMessage(teamName, { await this.sendMessage(teamName, {
member: reviewer, member: reviewer,
from: leadName,
text: text:
`Please review task #${taskId}.\n\n` + `Please review task #${taskId}.\n\n` +
`${AGENT_BLOCK_OPEN}\n` + `${AGENT_BLOCK_OPEN}\n` +
@ -786,8 +807,10 @@ export class TeamDataService {
try { try {
await this.taskWriter.updateStatus(teamName, taskId, 'in_progress'); await this.taskWriter.updateStatus(teamName, taskId, 'in_progress');
const leadName = await this.resolveLeadName(teamName);
await this.sendMessage(teamName, { await this.sendMessage(teamName, {
member: task.owner, member: task.owner,
from: leadName,
text: text:
`Task #${taskId} needs fixes.\n\n` + `Task #${taskId} needs fixes.\n\n` +
`${patch.comment?.trim() || 'Reviewer requested changes.'}\n\n` + `${patch.comment?.trim() || 'Reviewer requested changes.'}\n\n` +

View file

@ -131,6 +131,8 @@ interface ProvisioningRun {
directReplyParts: string[]; directReplyParts: string[];
/** Accumulates assistant text during provisioning phase for live UI preview. */ /** Accumulates assistant text during provisioning phase for live UI preview. */
provisioningOutputParts: string[]; provisioningOutputParts: string[];
/** Session ID detected from stream-json output (result.session_id or message.session_id). */
detectedSessionId: string | null;
} }
type ProvisioningAuthSource = type ProvisioningAuthSource =
@ -765,6 +767,7 @@ export class TeamProvisioningService {
leadRelayCapture: null, leadRelayCapture: null,
directReplyParts: [], directReplyParts: [],
provisioningOutputParts: [], provisioningOutputParts: [],
detectedSessionId: null,
progress: { progress: {
runId, runId,
teamName: request.teamName, teamName: request.teamName,
@ -1036,6 +1039,7 @@ export class TeamProvisioningService {
leadRelayCapture: null, leadRelayCapture: null,
directReplyParts: [], directReplyParts: [],
provisioningOutputParts: [], provisioningOutputParts: [],
detectedSessionId: null,
progress: { progress: {
runId, runId,
teamName: request.teamName, teamName: request.teamName,
@ -1522,7 +1526,8 @@ export class TeamProvisioningService {
const aliveTeams = this.getAliveTeams(); const aliveTeams = this.getAliveTeams();
if (aliveTeams.length === 0) return; if (aliveTeams.length === 0) return;
const newResolved = resolveLanguageName(newLangCode); const systemLocale = getSystemLocale();
const newResolved = resolveLanguageName(newLangCode, systemLocale);
for (const teamName of aliveTeams) { for (const teamName of aliveTeams) {
try { try {
@ -1532,7 +1537,15 @@ export class TeamProvisioningService {
const oldCode = config.language || 'system'; const oldCode = config.language || 'system';
if (oldCode === newLangCode) continue; if (oldCode === newLangCode) continue;
const oldResolved = resolveLanguageName(oldCode); // Compare resolved names to avoid spurious notifications
// e.g. switching from 'ru' to 'system' when system locale is Russian
const oldResolved = resolveLanguageName(oldCode, systemLocale);
if (oldResolved === newResolved) {
// Effective language unchanged — just update stored code silently
await this.configReader.updateConfig(teamName, { language: newLangCode });
continue;
}
const message = const message =
`The user has changed the preferred communication language from "${oldResolved}" to "${newResolved}". ` + `The user has changed the preferred communication language from "${oldResolved}" to "${newResolved}". ` +
`Please switch to ${newResolved} for all future responses and broadcast this change to all teammates ` + `Please switch to ${newResolved} for all future responses and broadcast this change to all teammates ` +
@ -1704,6 +1717,17 @@ export class TeamProvisioningService {
} }
} }
// Capture session_id from any message type (first occurrence wins)
if (!run.detectedSessionId) {
const sid = typeof msg.session_id === 'string' ? msg.session_id : undefined;
if (sid && sid.trim().length > 0) {
run.detectedSessionId = sid.trim();
logger.info(
`[${run.teamName}] Detected session ID from stream-json: ${run.detectedSessionId}`
);
}
}
if (msg.type === 'result') { if (msg.type === 'result') {
const subtype = const subtype =
typeof msg.subtype === 'string' typeof msg.subtype === 'string'
@ -1750,7 +1774,7 @@ export class TeamProvisioningService {
}); });
} }
} }
if (!run.provisioningComplete) { if (!run.provisioningComplete && !run.cancelRequested) {
void this.handleProvisioningTurnComplete(run); void this.handleProvisioningTurnComplete(run);
} }
} else if (subtype === 'error') { } else if (subtype === 'error') {
@ -1760,7 +1784,7 @@ export class TeamProvisioningService {
if (run.leadRelayCapture) { if (run.leadRelayCapture) {
run.leadRelayCapture.rejectOnce(errorMsg); run.leadRelayCapture.rejectOnce(errorMsg);
} }
if (!run.provisioningComplete) { if (!run.provisioningComplete && !run.cancelRequested) {
const progress = updateProgress( const progress = updateProgress(
run, run,
'failed', 'failed',
@ -1787,6 +1811,7 @@ export class TeamProvisioningService {
* Process stays alive for subsequent tasks. * Process stays alive for subsequent tasks.
*/ */
private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise<void> { private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise<void> {
if (run.cancelRequested) return;
run.provisioningComplete = true; run.provisioningComplete = true;
// Clear provisioning timeout — no longer needed // Clear provisioning timeout — no longer needed
@ -1797,7 +1822,7 @@ export class TeamProvisioningService {
this.stopFilesystemMonitor(run); this.stopFilesystemMonitor(run);
if (run.isLaunch) { if (run.isLaunch) {
await this.updateConfigPostLaunch(run.teamName, run.request.cwd); await this.updateConfigPostLaunch(run.teamName, run.request.cwd, run.detectedSessionId);
await this.cleanupPrelaunchBackup(run.teamName); await this.cleanupPrelaunchBackup(run.teamName);
const readyMessage = 'Team launched — process alive and ready'; const readyMessage = 'Team launched — process alive and ready';
const progress = updateProgress(run, 'ready', readyMessage, { const progress = updateProgress(run, 'ready', readyMessage, {
@ -1838,7 +1863,7 @@ export class TeamProvisioningService {
// Persist teammates metadata separately from config.json. // Persist teammates metadata separately from config.json.
await this.persistMembersMeta(run.teamName, run.request); await this.persistMembersMeta(run.teamName, run.request);
await this.updateConfigPostLaunch(run.teamName, run.request.cwd); await this.updateConfigPostLaunch(run.teamName, run.request.cwd, run.detectedSessionId);
const progress = updateProgress(run, 'ready', 'Team provisioned — process alive and ready', { const progress = updateProgress(run, 'ready', 'Team provisioned — process alive and ready', {
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer), cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
@ -2388,24 +2413,50 @@ export class TeamProvisioningService {
* Combines session history append and projectPath update to avoid * Combines session history append and projectPath update to avoid
* race conditions with the CLI writing to the same file. * race conditions with the CLI writing to the same file.
*/ */
private async updateConfigPostLaunch(teamName: string, projectPath: string): Promise<void> { private async updateConfigPostLaunch(
teamName: string,
projectPath: string,
detectedSessionId: string | null
): Promise<void> {
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
try { try {
const raw = await fs.promises.readFile(configPath, 'utf8'); const raw = await fs.promises.readFile(configPath, 'utf8');
const config = JSON.parse(raw) as Record<string, unknown>; const config = JSON.parse(raw) as Record<string, unknown>;
// Append session to history const sessionHistory = Array.isArray(config.sessionHistory)
const leadSessionId = config.leadSessionId; ? (config.sessionHistory as string[])
if (typeof leadSessionId === 'string' && leadSessionId.trim().length > 0) { : [];
const sessionHistory = Array.isArray(config.sessionHistory)
? (config.sessionHistory as string[]) // Preserve old leadSessionId in history before overwriting
: []; const oldLeadSessionId = config.leadSessionId;
if (!sessionHistory.includes(leadSessionId)) { if (typeof oldLeadSessionId === 'string' && oldLeadSessionId.trim().length > 0) {
sessionHistory.push(leadSessionId); if (!sessionHistory.includes(oldLeadSessionId)) {
config.sessionHistory = sessionHistory; sessionHistory.push(oldLeadSessionId);
} }
} }
// Update leadSessionId to the new session detected from stream-json
let newSessionId = detectedSessionId;
// Fallback: if stream-json didn't provide session_id, scan project dir for newest JSONL
if (!newSessionId && projectPath.trim()) {
const scannedId = await this.scanForNewestSession(projectPath, sessionHistory);
if (scannedId) {
newSessionId = scannedId;
logger.info(`[${teamName}] Detected new session via project dir scan: ${scannedId}`);
}
}
if (newSessionId) {
config.leadSessionId = newSessionId;
if (!sessionHistory.includes(newSessionId)) {
sessionHistory.push(newSessionId);
}
logger.info(`[${teamName}] Updated leadSessionId: ${newSessionId}`);
}
config.sessionHistory = sessionHistory;
// Save current language setting // Save current language setting
const langCode = ConfigManager.getInstance().getConfig().general.agentLanguage || 'system'; const langCode = ConfigManager.getInstance().getConfig().general.agentLanguage || 'system';
config.language = langCode; config.language = langCode;
@ -2432,6 +2483,41 @@ export class TeamProvisioningService {
} }
} }
/**
* Fallback: scan the project directory for the newest JSONL file
* that isn't already in sessionHistory. Returns the session ID or null.
*/
private async scanForNewestSession(
projectPath: string,
knownSessions: string[]
): Promise<string | null> {
try {
const projectId = encodePath(projectPath);
const baseDir = extractBaseDir(projectId);
const projectDir = path.join(getProjectsBasePath(), baseDir);
const entries = await fs.promises.readdir(projectDir);
const knownSet = new Set(knownSessions);
let newest: { id: string; mtime: number } | null = null;
for (const entry of entries) {
if (!entry.endsWith('.jsonl')) continue;
const sessionId = entry.replace('.jsonl', '');
if (knownSet.has(sessionId)) continue;
const filePath = path.join(projectDir, entry);
const stat = await fs.promises.stat(filePath);
if (!newest || stat.mtimeMs > newest.mtime) {
newest = { id: sessionId, mtime: stat.mtimeMs };
}
}
return newest?.id ?? null;
} catch {
return null;
}
}
private async normalizeTeamConfigForLaunch(teamName: string, configRaw: string): Promise<void> { private async normalizeTeamConfigForLaunch(teamName: string, configRaw: string): Promise<void> {
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
const backupPath = `${configPath}.prelaunch.bak`; const backupPath = `${configPath}.prelaunch.bak`;

View file

@ -22,7 +22,53 @@ interface CliLogsRichViewProps {
} }
/** /**
* A single collapsible group of assistant items. * Derives a scoped Set for a single group from the global prefixed Set.
* Global keys are stored as `groupId::itemId`; this strips the prefix.
*/
function scopedItemIds(globalIds: Set<string>, groupId: string): Set<string> {
const prefix = `${groupId}::`;
const scoped = new Set<string>();
for (const key of globalIds) {
if (key.startsWith(prefix)) {
scoped.add(key.slice(prefix.length));
}
}
return scoped;
}
/**
* Single-item group rendered flat (no collapsible wrapper).
*/
const FlatGroupItem = ({
group,
expandedItemIds,
onItemClick,
}: {
group: StreamJsonGroup;
expandedItemIds: Set<string>;
onItemClick: (itemId: string) => void;
}): React.JSX.Element => {
const groupItemIds = useMemo(
() => scopedItemIds(expandedItemIds, group.id),
[expandedItemIds, group.id]
);
const handleItemClick = useCallback(
(itemId: string) => onItemClick(`${group.id}::${itemId}`),
[group.id, onItemClick]
);
return (
<DisplayItemList
items={group.items}
onItemClick={handleItemClick}
expandedItemIds={groupItemIds}
aiGroupId={group.id}
/>
);
};
/**
* A single collapsible group of assistant items (2+ items).
*/ */
const StreamGroup = ({ const StreamGroup = ({
group, group,
@ -36,37 +82,49 @@ const StreamGroup = ({
onToggle: () => void; onToggle: () => void;
expandedItemIds: Set<string>; expandedItemIds: Set<string>;
onItemClick: (itemId: string) => void; onItemClick: (itemId: string) => void;
}): React.JSX.Element => ( }): React.JSX.Element => {
<div className="rounded border border-[var(--color-border)] bg-[var(--color-surface)]"> // Scope item IDs to this group to avoid cross-group collisions
<button const groupItemIds = useMemo(
type="button" () => scopedItemIds(expandedItemIds, group.id),
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-raised)]" [expandedItemIds, group.id]
onClick={onToggle} );
> const handleItemClick = useCallback(
<ChevronRight (itemId: string) => onItemClick(`${group.id}::${itemId}`),
size={12} [group.id, onItemClick]
className={cn( );
'shrink-0 text-[var(--color-text-muted)] transition-transform duration-150',
isExpanded && 'rotate-90' return (
)} <div className="rounded border border-[var(--color-border)] bg-[var(--color-surface)]">
/> <button
<Bot size={13} className="shrink-0 text-[var(--color-text-muted)]" /> type="button"
<span className="min-w-0 truncate text-[11px] text-[var(--color-text-secondary)]"> className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-raised)]"
{group.summary} onClick={onToggle}
</span> >
</button> <ChevronRight
{isExpanded && ( size={12}
<div className="border-t border-[var(--color-border)] p-2"> className={cn(
<DisplayItemList 'shrink-0 text-[var(--color-text-muted)] transition-transform duration-150',
items={group.items} isExpanded && 'rotate-90'
onItemClick={onItemClick} )}
expandedItemIds={expandedItemIds}
aiGroupId={group.id}
/> />
</div> <Bot size={13} className="shrink-0 text-[var(--color-text-muted)]" />
)} <span className="min-w-0 truncate text-[11px] text-[var(--color-text-secondary)]">
</div> {group.summary}
); </span>
</button>
{isExpanded && (
<div className="border-t border-[var(--color-border)] p-2">
<DisplayItemList
items={group.items}
onItemClick={handleItemClick}
expandedItemIds={groupItemIds}
aiGroupId={group.id}
/>
</div>
)}
</div>
);
};
export const CliLogsRichView = ({ export const CliLogsRichView = ({
cliLogsTail, cliLogsTail,
@ -126,13 +184,14 @@ export const CliLogsRichView = ({
const hasContent = cliLogsTail.trim().length > 0; const hasContent = cliLogsTail.trim().length > 0;
return ( return (
<div <div
ref={scrollRef}
className={cn( className={cn(
'rounded border border-[var(--color-border)] bg-[var(--color-surface)]', 'max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)]',
className className
)} )}
> >
{hasContent ? ( {hasContent ? (
<pre className="max-h-[400px] overflow-y-auto p-2 font-mono text-[11px] leading-relaxed text-[var(--color-text-secondary)]"> <pre className="p-2 font-mono text-[11px] leading-relaxed text-[var(--color-text-secondary)]">
{cliLogsTail} {cliLogsTail}
</pre> </pre>
) : ( ) : (
@ -146,16 +205,26 @@ export const CliLogsRichView = ({
return ( return (
<div ref={scrollRef} className={cn('max-h-[400px] space-y-1.5 overflow-y-auto', className)}> <div ref={scrollRef} className={cn('max-h-[400px] space-y-1.5 overflow-y-auto', className)}>
{groups.map((group) => ( {groups.map((group) =>
<StreamGroup group.items.length === 1 ? (
key={group.id} // Single item — render flat without collapsible group wrapper
group={group} <FlatGroupItem
isExpanded={expandedGroupIds.has(group.id)} key={group.id}
onToggle={() => handleGroupToggle(group.id)} group={group}
expandedItemIds={expandedItemIds} expandedItemIds={expandedItemIds}
onItemClick={handleItemClick} onItemClick={handleItemClick}
/> />
))} ) : (
<StreamGroup
key={group.id}
group={group}
isExpanded={expandedGroupIds.has(group.id)}
onToggle={() => handleGroupToggle(group.id)}
expandedItemIds={expandedItemIds}
onItemClick={handleItemClick}
/>
)
)}
</div> </div>
); );
}; };

View file

@ -200,20 +200,34 @@ export const TeamListView = (): React.JSX.Element => {
); );
} }
if (currentProjectPath) { const aliveSet = new Set(aliveTeams);
const matches = (t: TeamSummary): boolean => { const matchesProject = currentProjectPath
if (t.projectPath && normalizePath(t.projectPath) === currentProjectPath) return true; ? (t: TeamSummary): boolean => {
return t.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ?? false; if (t.projectPath && normalizePath(t.projectPath) === currentProjectPath) return true;
}; return (
result = [...result].sort((a, b) => { t.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ?? false
const aMatch = matches(a) ? 0 : 1; );
const bMatch = matches(b) ? 0 : 1; }
return aMatch - bMatch; : null;
});
} result = [...result].sort((a, b) => {
// 1. Alive (running) teams first
const aliveA = aliveSet.has(a.teamName) ? 0 : 1;
const aliveB = aliveSet.has(b.teamName) ? 0 : 1;
if (aliveA !== aliveB) return aliveA - aliveB;
// 2. Matching current project second
if (matchesProject) {
const projA = matchesProject(a) ? 0 : 1;
const projB = matchesProject(b) ? 0 : 1;
if (projA !== projB) return projA - projB;
}
return 0;
});
return result; return result;
}, [teams, searchQuery, currentProjectPath]); }, [teams, searchQuery, currentProjectPath, aliveTeams]);
// Live branch/worktree for team project paths (poll so it updates during process) // Live branch/worktree for team project paths (poll so it updates during process)
const projectPathsToPoll = useMemo(() => { const projectPathsToPoll = useMemo(() => {

View file

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge'; import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
@ -6,6 +6,7 @@ import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount'; import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
import { useStore } from '@renderer/store';
import { import {
ArrowLeftFromLine, ArrowLeftFromLine,
ArrowRightFromLine, ArrowRightFromLine,
@ -148,6 +149,19 @@ export const KanbanTaskCard = ({
const hasBlockedBy = blockedByIds.length > 0; const hasBlockedBy = blockedByIds.length > 0;
const hasBlocks = blocksIds.length > 0; const hasBlocks = blocksIds.length > 0;
// Lazy-check if task has file changes (only for done/review/approved columns)
const showChangesColumn =
(columnId === 'done' || columnId === 'review' || columnId === 'approved') && !!onViewChanges;
const cacheKey = `${teamName}:${task.id}`;
const taskHasChanges = useStore((s) => s.taskHasChanges[cacheKey]);
const checkTaskHasChanges = useStore((s) => s.checkTaskHasChanges);
useEffect(() => {
if (showChangesColumn && task.status === 'completed' && taskHasChanges == null) {
void checkTaskHasChanges(teamName, task.id);
}
}, [showChangesColumn, task.status, task.id, teamName, taskHasChanges, checkTaskHasChanges]);
return ( return (
<div <div
data-task-id={task.id} data-task-id={task.id}
@ -341,9 +355,10 @@ export const KanbanTaskCard = ({
Move back to DONE Move back to DONE
</Button> </Button>
) : null} ) : null}
</div>
{(columnId === 'done' || columnId === 'review' || columnId === 'approved') && <div className="flex items-center gap-1.5">
onViewChanges ? ( {showChangesColumn && taskHasChanges === true ? (
<button <button
type="button" type="button"
onClick={(e) => { onClick={(e) => {
@ -356,9 +371,8 @@ export const KanbanTaskCard = ({
Changes Changes
</button> </button>
) : null} ) : null}
<UnreadCommentsBadge unreadCount={unreadCount} totalCount={task.comments?.length ?? 0} />
</div> </div>
<UnreadCommentsBadge unreadCount={unreadCount} totalCount={task.comments?.length ?? 0} />
</div> </div>
</div> </div>
); );

View file

@ -1,12 +1,15 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { goToNextChunk, rejectChunk } from '@codemirror/merge'; import { goToNextChunk, rejectChunk } from '@codemirror/merge';
import { isElectronMode } from '@renderer/api';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useDiffNavigation } from '@renderer/hooks/useDiffNavigation'; import { useDiffNavigation } from '@renderer/hooks/useDiffNavigation';
import { useViewedFiles } from '@renderer/hooks/useViewedFiles'; import { useViewedFiles } from '@renderer/hooks/useViewedFiles';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { ChevronDown, Clock, Loader2, X } from 'lucide-react'; import { ChevronDown, Clock, Loader2, Save, Undo2, X } from 'lucide-react';
import { acceptAllChunks, rejectAllChunks } from './CodeMirrorDiffUtils';
import { CodeMirrorDiffView } from './CodeMirrorDiffView'; import { CodeMirrorDiffView } from './CodeMirrorDiffView';
import { ConfidenceBadge } from './ConfidenceBadge'; import { ConfidenceBadge } from './ConfidenceBadge';
import { DiffErrorBoundary } from './DiffErrorBoundary'; import { DiffErrorBoundary } from './DiffErrorBoundary';
@ -18,6 +21,7 @@ import { ReviewToolbar } from './ReviewToolbar';
import { ScopeWarningBanner } from './ScopeWarningBanner'; import { ScopeWarningBanner } from './ScopeWarningBanner';
import { ViewedProgressBar } from './ViewedProgressBar'; import { ViewedProgressBar } from './ViewedProgressBar';
import type { EditorState } from '@codemirror/state';
import type { EditorView } from '@codemirror/view'; import type { EditorView } from '@codemirror/view';
import type { HunkDecision, TaskChangeSetV2 } from '@shared/types'; import type { HunkDecision, TaskChangeSetV2 } from '@shared/types';
@ -49,7 +53,7 @@ export const ChangeReviewDialog = ({
mode, mode,
memberName, memberName,
taskId, taskId,
}: ChangeReviewDialogProps) => { }: ChangeReviewDialogProps): React.ReactElement | null => {
const { const {
activeChangeSet, activeChangeSet,
changeSetLoading, changeSetLoading,
@ -64,12 +68,10 @@ export const ChangeReviewDialog = ({
fileDecisions, fileDecisions,
fileContents, fileContents,
fileContentsLoading, fileContentsLoading,
diffViewMode,
collapseUnchanged, collapseUnchanged,
applying, applying,
applyError, applyError,
setHunkDecision, setHunkDecision,
setDiffViewMode,
setCollapseUnchanged, setCollapseUnchanged,
fetchFileContent, fetchFileContent,
acceptAll, acceptAll,
@ -87,6 +89,10 @@ export const ChangeReviewDialog = ({
const [timelineOpen, setTimelineOpen] = useState(false); const [timelineOpen, setTimelineOpen] = useState(false);
// Counter to force editor rebuild on discard // Counter to force editor rebuild on discard
const [discardCounter, setDiscardCounter] = useState(0); const [discardCounter, setDiscardCounter] = useState(0);
// Cache EditorState per file to preserve undo history between file switches
const editorStateCache = useRef(new Map<string, EditorState>());
// Current file's cached initial state (derived outside render to avoid ref access during render)
const [cachedInitialState, setCachedInitialState] = useState<EditorState | undefined>(undefined);
// Build scope key for viewed storage // Build scope key for viewed storage
const scopeKey = mode === 'task' ? `task:${taskId ?? ''}` : `agent:${memberName ?? ''}`; const scopeKey = mode === 'task' ? `task:${taskId ?? ''}` : `agent:${memberName ?? ''}`;
@ -107,18 +113,54 @@ export const ChangeReviewDialog = ({
progress: viewedProgress, progress: viewedProgress,
} = useViewedFiles(teamName, scopeKey, allFilePaths); } = useViewedFiles(teamName, scopeKey, allFilePaths);
// When collapseUnchanged changes, invalidate cached state for current file
// so the editor is recreated with the new extension config
useEffect(() => {
if (selectedReviewFilePath) {
editorStateCache.current.delete(selectedReviewFilePath);
}
queueMicrotask(() => setCachedInitialState(undefined));
}, [collapseUnchanged]); // eslint-disable-line react-hooks/exhaustive-deps -- only collapseUnchanged triggers cache invalidation
// Editable diff computed values // Editable diff computed values
const editedCount = Object.keys(editedContents).length; const editedCount = Object.keys(editedContents).length;
const hasCurrentFileEdits = !!( const hasCurrentFileEdits = !!(
selectedReviewFilePath && selectedReviewFilePath in editedContents selectedReviewFilePath && selectedReviewFilePath in editedContents
); );
// Save current editor state to cache before switching files
const handleSelectFile = useCallback(
(filePath: string | null) => {
const view = editorViewRef.current;
if (view && selectedReviewFilePath) {
editorStateCache.current.set(selectedReviewFilePath, view.state);
}
setCachedInitialState(filePath ? editorStateCache.current.get(filePath) : undefined);
selectReviewFile(filePath);
},
[selectedReviewFilePath, selectReviewFile]
);
const handleAcceptAll = useCallback(() => {
const view = editorViewRef.current;
if (view) acceptAllChunks(view);
acceptAll();
}, [acceptAll]);
const handleRejectAll = useCallback(() => {
const view = editorViewRef.current;
if (view) rejectAllChunks(view);
rejectAll();
}, [rejectAll]);
const handleSaveCurrentFile = useCallback(() => { const handleSaveCurrentFile = useCallback(() => {
if (selectedReviewFilePath) void saveEditedFile(selectedReviewFilePath); if (selectedReviewFilePath) void saveEditedFile(selectedReviewFilePath);
}, [selectedReviewFilePath, saveEditedFile]); }, [selectedReviewFilePath, saveEditedFile]);
const handleDiscardCurrentFile = useCallback(() => { const handleDiscardCurrentFile = useCallback(() => {
if (selectedReviewFilePath) { if (selectedReviewFilePath) {
editorStateCache.current.delete(selectedReviewFilePath);
setCachedInitialState(undefined);
discardFileEdits(selectedReviewFilePath); discardFileEdits(selectedReviewFilePath);
setDiscardCounter((c) => c + 1); setDiscardCounter((c) => c + 1);
} }
@ -127,7 +169,7 @@ export const ChangeReviewDialog = ({
const diffNav = useDiffNavigation( const diffNav = useDiffNavigation(
activeChangeSet?.files ?? [], activeChangeSet?.files ?? [],
selectedReviewFilePath, selectedReviewFilePath,
selectReviewFile, handleSelectFile,
editorViewRef, editorViewRef,
open, open,
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'accepted'), (filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'accepted'),
@ -166,7 +208,7 @@ export const ChangeReviewDialog = ({
// Escape to close // Escape to close
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onOpenChange(false); if (e.key === 'Escape') onOpenChange(false);
}; };
document.addEventListener('keydown', handler); document.addEventListener('keydown', handler);
@ -250,12 +292,25 @@ export const ChangeReviewDialog = ({
? `Changes by ${memberName ?? 'unknown'}` ? `Changes by ${memberName ?? 'unknown'}`
: `Changes for task #${taskId ?? '?'}`; : `Changes for task #${taskId ?? '?'}`;
const isMacElectron =
isElectronMode() && window.navigator.userAgent.toLowerCase().includes('mac');
if (!open) return null; if (!open) return null;
return ( return (
<div className="fixed inset-0 z-50 flex flex-col bg-surface"> <div className="fixed inset-0 z-50 flex flex-col bg-surface">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between border-b border-border bg-surface-sidebar px-4 py-3"> <div
className="flex items-center justify-between border-b border-border bg-surface-sidebar px-4 py-3"
style={
{
paddingLeft: isMacElectron
? 'var(--macos-traffic-light-padding-left, 72px)'
: undefined,
WebkitAppRegion: isMacElectron ? 'drag' : undefined,
} as React.CSSProperties
}
>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h2 className="text-sm font-medium text-text">{title}</h2> <h2 className="text-sm font-medium text-text">{title}</h2>
{activeChangeSet && ( {activeChangeSet && (
@ -278,6 +333,7 @@ export const ChangeReviewDialog = ({
<button <button
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text" className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
> >
<X className="size-4" /> <X className="size-4" />
</button> </button>
@ -297,29 +353,23 @@ export const ChangeReviewDialog = ({
<ReviewToolbar <ReviewToolbar
stats={reviewStats} stats={reviewStats}
changeStats={changeStats} changeStats={changeStats}
diffViewMode={diffViewMode}
collapseUnchanged={collapseUnchanged} collapseUnchanged={collapseUnchanged}
applying={applying} applying={applying}
autoViewed={autoViewed} autoViewed={autoViewed}
onAutoViewedChange={setAutoViewed} onAutoViewedChange={setAutoViewed}
onAcceptAll={acceptAll} onAcceptAll={handleAcceptAll}
onRejectAll={rejectAll} onRejectAll={handleRejectAll}
onApply={handleApply} onApply={handleApply}
onDiffViewModeChange={setDiffViewMode}
onCollapseUnchangedChange={setCollapseUnchanged} onCollapseUnchangedChange={setCollapseUnchanged}
editedCount={editedCount} editedCount={editedCount}
hasCurrentFileEdits={hasCurrentFileEdits}
saving={applying}
onSaveCurrentFile={handleSaveCurrentFile}
onDiscardCurrentFile={handleDiscardCurrentFile}
/> />
)} )}
{/* Scope warnings */} {/* Scope info / warnings */}
{mode === 'task' && {mode === 'task' &&
activeChangeSet && activeChangeSet &&
isTaskChangeSetV2(activeChangeSet) && isTaskChangeSetV2(activeChangeSet) &&
activeChangeSet.warnings.length > 0 && ( (activeChangeSet.warnings.length > 0 || activeChangeSet.scope.confidence.tier >= 2) && (
<ScopeWarningBanner <ScopeWarningBanner
warnings={activeChangeSet.warnings} warnings={activeChangeSet.warnings}
confidence={activeChangeSet.scope.confidence} confidence={activeChangeSet.scope.confidence}
@ -354,7 +404,7 @@ export const ChangeReviewDialog = ({
<ReviewFileTree <ReviewFileTree
files={activeChangeSet.files} files={activeChangeSet.files}
selectedFilePath={selectedReviewFilePath} selectedFilePath={selectedReviewFilePath}
onSelectFile={selectReviewFile} onSelectFile={handleSelectFile}
viewedSet={viewedSet} viewedSet={viewedSet}
onMarkViewed={markViewed} onMarkViewed={markViewed}
onUnmarkViewed={unmarkViewed} onUnmarkViewed={unmarkViewed}
@ -391,7 +441,7 @@ export const ChangeReviewDialog = ({
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{selectedFile ? ( {selectedFile ? (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* File header with content source badge */} {/* File header with content source badge and save/discard */}
<div className="flex items-center gap-2 border-b border-border px-4 py-2"> <div className="flex items-center gap-2 border-b border-border px-4 py-2">
<span className="text-xs font-medium text-text"> <span className="text-xs font-medium text-text">
{selectedFile.relativePath} {selectedFile.relativePath}
@ -410,7 +460,7 @@ export const ChangeReviewDialog = ({
{/* File-level decision indicator */} {/* File-level decision indicator */}
{fileDecisions[selectedFile.filePath] && ( {fileDecisions[selectedFile.filePath] && (
<span <span
className={`ml-auto rounded px-1.5 py-0.5 text-[10px] ${ className={`rounded px-1.5 py-0.5 text-[10px] ${
fileDecisions[selectedFile.filePath] === 'accepted' fileDecisions[selectedFile.filePath] === 'accepted'
? 'bg-green-500/20 text-green-400' ? 'bg-green-500/20 text-green-400'
: fileDecisions[selectedFile.filePath] === 'rejected' : fileDecisions[selectedFile.filePath] === 'rejected'
@ -421,6 +471,49 @@ export const ChangeReviewDialog = ({
{fileDecisions[selectedFile.filePath]} {fileDecisions[selectedFile.filePath]}
</span> </span>
)} )}
<div className="ml-auto flex items-center gap-1.5">
{hasCurrentFileEdits && (
<>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleDiscardCurrentFile}
className="flex items-center gap-1 rounded bg-orange-500/15 px-2 py-1 text-xs text-orange-400 transition-colors hover:bg-orange-500/25"
>
<Undo2 className="size-3" />
Discard
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
Discard all edits for this file
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleSaveCurrentFile}
disabled={applying}
className="flex items-center gap-1 rounded bg-green-500/15 px-2 py-1 text-xs text-green-400 transition-colors hover:bg-green-500/25 disabled:opacity-50"
>
{applying ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Save className="size-3" />
)}
Save File
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<span>Save file to disk</span>
<kbd className="ml-2 rounded border border-border bg-surface-raised px-1 py-0.5 font-mono text-[10px] text-text-muted">
</kbd>
</TooltipContent>
</Tooltip>
</>
)}
</div>
</div> </div>
{/* Loading state */} {/* Loading state */}
@ -450,6 +543,7 @@ export const ChangeReviewDialog = ({
readOnly={false} readOnly={false}
showMergeControls={true} showMergeControls={true}
collapseUnchanged={collapseUnchanged} collapseUnchanged={collapseUnchanged}
initialState={cachedInitialState}
onHunkAccepted={(idx) => onHunkAccepted={(idx) =>
setHunkDecision(selectedFile.filePath, idx, 'accepted') setHunkDecision(selectedFile.filePath, idx, 'accepted')
} }

View 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 };

View file

@ -1,23 +1,31 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'; import React, { useCallback, useEffect, useRef } from 'react';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { cpp } from '@codemirror/lang-cpp';
import { css } from '@codemirror/lang-css'; import { css } from '@codemirror/lang-css';
import { go } from '@codemirror/lang-go';
import { html } from '@codemirror/lang-html'; import { html } from '@codemirror/lang-html';
import { java } from '@codemirror/lang-java';
import { javascript } from '@codemirror/lang-javascript'; import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json'; import { json } from '@codemirror/lang-json';
import { less } from '@codemirror/lang-less';
import { markdown } from '@codemirror/lang-markdown';
import { php } from '@codemirror/lang-php';
import { python } from '@codemirror/lang-python'; import { python } from '@codemirror/lang-python';
import { rust } from '@codemirror/lang-rust';
import { sass } from '@codemirror/lang-sass';
import { sql } from '@codemirror/lang-sql';
import { xml } from '@codemirror/lang-xml'; import { xml } from '@codemirror/lang-xml';
import { import { yaml } from '@codemirror/lang-yaml';
acceptChunk, import { indentUnit, LanguageDescription, syntaxHighlighting } from '@codemirror/language';
getChunks, import { languages } from '@codemirror/language-data';
goToNextChunk, import { goToNextChunk, goToPreviousChunk, unifiedMergeView } from '@codemirror/merge';
goToPreviousChunk, import { Compartment, EditorState, type Extension } from '@codemirror/state';
rejectChunk, import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
unifiedMergeView,
} from '@codemirror/merge';
import { EditorState, type Extension } from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view'; import { EditorView, keymap } from '@codemirror/view';
import { acceptChunk, getChunks, mergeUndoSupport, rejectChunk } from './CodeMirrorDiffUtils';
interface CodeMirrorDiffViewProps { interface CodeMirrorDiffViewProps {
original: string; original: string;
modified: string; modified: string;
@ -35,10 +43,12 @@ interface CodeMirrorDiffViewProps {
editorViewRef?: React.RefObject<EditorView | null>; editorViewRef?: React.RefObject<EditorView | null>;
/** Called when editor content changes (debounced, only when readOnly=false) */ /** Called when editor content changes (debounced, only when readOnly=false) */
onContentChanged?: (content: string) => void; onContentChanged?: (content: string) => void;
/** Cached EditorState to restore (preserves undo history between file switches) */
initialState?: EditorState;
} }
/** Detect language extension from file name */ /** Synchronous language extension for common file types (bundled by Vite) */
function getLanguageExtension(fileName: string): Extension | null { function getSyncLanguageExtension(fileName: string): Extension | null {
const ext = fileName.split('.').pop()?.toLowerCase(); const ext = fileName.split('.').pop()?.toLowerCase();
switch (ext) { switch (ext) {
case 'ts': case 'ts':
@ -57,19 +67,53 @@ function getLanguageExtension(fileName: string): Extension | null {
case 'jsonl': case 'jsonl':
return json(); return json();
case 'css': case 'css':
case 'scss':
return css(); return css();
case 'scss':
return sass({ indented: false });
case 'sass':
return sass({ indented: true });
case 'less':
return less();
case 'html': case 'html':
case 'htm': case 'htm':
return html(); return html();
case 'xml': case 'xml':
case 'svg': case 'svg':
return xml(); return xml();
case 'md':
case 'mdx':
case 'markdown':
return markdown();
case 'yaml':
case 'yml':
return yaml();
case 'rs':
return rust();
case 'go':
return go();
case 'java':
return java();
case 'c':
case 'h':
case 'cpp':
case 'cxx':
case 'cc':
case 'hpp':
return cpp();
case 'php':
return php();
case 'sql':
return sql();
default: default:
return null; return null;
} }
} }
/** Async fallback: match by filename via @codemirror/language-data for rare languages */
function getAsyncLanguageDesc(fileName: string): LanguageDescription | null {
return LanguageDescription.matchFilename(languages, fileName);
}
/** Compute hunk index for the chunk at a given position */ /** Compute hunk index for the chunk at a given position */
function computeHunkIndexAtPos(state: EditorState, pos: number): number { function computeHunkIndexAtPos(state: EditorState, pos: number): number {
const chunks = getChunks(state); const chunks = getChunks(state);
@ -123,15 +167,15 @@ const diffTheme = EditorView.theme({
'.cm-selectionBackground': { '.cm-selectionBackground': {
backgroundColor: 'rgba(59, 130, 246, 0.3) !important', backgroundColor: 'rgba(59, 130, 246, 0.3) !important',
}, },
// Diff-specific styles // Diff-specific styles — line-level backgrounds (no per-character underlines)
'.cm-changedLine': { '.cm-changedLine': {
backgroundColor: 'var(--diff-added-bg, rgba(46, 160, 67, 0.15))', backgroundColor: 'var(--diff-added-bg, rgba(46, 160, 67, 0.22))',
}, },
'.cm-deletedChunk': { '.cm-deletedChunk': {
backgroundColor: 'var(--diff-removed-bg, rgba(248, 81, 73, 0.15))', backgroundColor: 'var(--diff-removed-bg, rgba(248, 81, 73, 0.15))',
}, },
'.cm-insertedLine': { '.cm-insertedLine': {
backgroundColor: 'var(--diff-added-bg, rgba(46, 160, 67, 0.15))', backgroundColor: 'var(--diff-added-bg, rgba(46, 160, 67, 0.22))',
}, },
'.cm-deletedLine': { '.cm-deletedLine': {
backgroundColor: 'var(--diff-removed-bg, rgba(248, 81, 73, 0.15))', backgroundColor: 'var(--diff-removed-bg, rgba(248, 81, 73, 0.15))',
@ -195,7 +239,8 @@ export const CodeMirrorDiffView = ({
onFullyViewed, onFullyViewed,
editorViewRef: externalViewRef, editorViewRef: externalViewRef,
onContentChanged, onContentChanged,
}: CodeMirrorDiffViewProps) => { initialState,
}: CodeMirrorDiffViewProps): React.ReactElement => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null); const viewRef = useRef<EditorView | null>(null);
const endSentinelRef = useRef<HTMLDivElement>(null); const endSentinelRef = useRef<HTMLDivElement>(null);
@ -221,11 +266,13 @@ export const CodeMirrorDiffView = ({
}); });
}, []); }, []);
const langExtension = useMemo(() => getLanguageExtension(fileName), [fileName]); // Compartment for lazy-injected language support
const langCompartment = useRef(new Compartment());
const buildExtensions = useCallback(() => { const buildExtensions = useCallback(() => {
const extensions: Extension[] = [ const extensions: Extension[] = [
diffTheme, diffTheme,
syntaxHighlighting(oneDarkHighlightStyle),
EditorView.editable.of(!readOnly), EditorView.editable.of(!readOnly),
EditorState.readOnly.of(readOnly), EditorState.readOnly.of(readOnly),
]; ];
@ -233,12 +280,13 @@ export const CodeMirrorDiffView = ({
// Undo/redo support and standard editing keybindings // Undo/redo support and standard editing keybindings
if (!readOnly) { if (!readOnly) {
extensions.push(history()); extensions.push(history());
extensions.push(keymap.of([...defaultKeymap, ...historyKeymap])); extensions.push(mergeUndoSupport);
extensions.push(indentUnit.of(' '));
extensions.push(keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]));
} }
if (langExtension) { // Language placeholder — actual language injected async via compartment reconfigure
extensions.push(langExtension); extensions.push(langCompartment.current.of([]));
}
// Keyboard shortcuts for chunk navigation and accept/reject // Keyboard shortcuts for chunk navigation and accept/reject
extensions.push( extensions.push(
@ -294,7 +342,7 @@ export const CodeMirrorDiffView = ({
// Unified merge view // Unified merge view
const mergeConfig: Parameters<typeof unifiedMergeView>[0] = { const mergeConfig: Parameters<typeof unifiedMergeView>[0] = {
original, original,
highlightChanges: true, highlightChanges: false,
gutter: true, gutter: true,
syntaxHighlightDeletions: true, syntaxHighlightDeletions: true,
}; };
@ -307,7 +355,12 @@ export const CodeMirrorDiffView = ({
} }
if (showMergeControls) { if (showMergeControls) {
mergeConfig.mergeControls = (type, action) => { // NOTE: We intentionally do NOT use the `action` callback from @codemirror/merge.
// CM's DeletionWidget caches DOM via a global WeakMap keyed by chunk.changes.
// When EditorView is recreated (e.g. from cached initialState), toDOM() returns
// the OLD cached DOM whose `action` closure references the DESTROYED view.
// Instead, we call acceptChunk/rejectChunk directly with viewRef.current.
mergeConfig.mergeControls = (type, _action) => {
const btn = document.createElement('button'); const btn = document.createElement('button');
if (type === 'accept') { if (type === 'accept') {
@ -320,7 +373,7 @@ export const CodeMirrorDiffView = ({
if (view) { if (view) {
const pos = view.posAtDOM(btn); const pos = view.posAtDOM(btn);
const hunkIndex = computeHunkIndexAtPos(view.state, pos); const hunkIndex = computeHunkIndexAtPos(view.state, pos);
action(e); acceptChunk(view, pos);
onAcceptRef.current?.(hunkIndex); onAcceptRef.current?.(hunkIndex);
scrollToNextChunk(); scrollToNextChunk();
} }
@ -335,7 +388,7 @@ export const CodeMirrorDiffView = ({
if (view) { if (view) {
const pos = view.posAtDOM(btn); const pos = view.posAtDOM(btn);
const hunkIndex = computeHunkIndexAtPos(view.state, pos); const hunkIndex = computeHunkIndexAtPos(view.state, pos);
action(e); rejectChunk(view, pos);
onRejectRef.current?.(hunkIndex); onRejectRef.current?.(hunkIndex);
scrollToNextChunk(); scrollToNextChunk();
} }
@ -352,7 +405,6 @@ export const CodeMirrorDiffView = ({
}, [ }, [
original, original,
readOnly, readOnly,
langExtension,
showMergeControls, showMergeControls,
collapseUnchangedProp, collapseUnchangedProp,
collapseMargin, collapseMargin,
@ -368,11 +420,13 @@ export const CodeMirrorDiffView = ({
viewRef.current = null; viewRef.current = null;
} }
const view = new EditorView({ const view = initialState
doc: modified, ? new EditorView({ state: initialState, parent: containerRef.current })
extensions: buildExtensions(), : new EditorView({
parent: containerRef.current, doc: modified,
}); extensions: buildExtensions(),
parent: containerRef.current,
});
viewRef.current = view; viewRef.current = view;
// Sync to external ref via holder // Sync to external ref via holder
@ -389,7 +443,39 @@ export const CodeMirrorDiffView = ({
} }
}; };
// We intentionally rebuild the entire editor when key props change // We intentionally rebuild the entire editor when key props change
}, [original, modified, buildExtensions]); }, [original, modified, buildExtensions, initialState]);
// Inject language extension via compartment after editor creation
useEffect(() => {
const view = viewRef.current;
if (!view) return;
// Try synchronous (bundled) language first
const syncLang = getSyncLanguageExtension(fileName);
if (syncLang) {
view.dispatch({ effects: langCompartment.current.reconfigure(syncLang) });
return;
}
// Async fallback for rare languages via @codemirror/language-data
const desc = getAsyncLanguageDesc(fileName);
if (!desc) return;
if (desc.support) {
view.dispatch({ effects: langCompartment.current.reconfigure(desc.support) });
return;
}
let cancelled = false;
void desc.load().then((support: Extension) => {
if (!cancelled && viewRef.current === view) {
view.dispatch({ effects: langCompartment.current.reconfigure(support) });
}
});
return () => {
cancelled = true;
};
}, [fileName, buildExtensions, initialState]);
// Auto-viewed detection via IntersectionObserver // Auto-viewed detection via IntersectionObserver
useEffect(() => { useEffect(() => {
@ -418,6 +504,3 @@ export const CodeMirrorDiffView = ({
</div> </div>
); );
}; };
// Re-export merge utils for external use
export { acceptChunk, getChunks, rejectChunk };

View file

@ -1,3 +1,5 @@
import React from 'react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
interface KeyboardShortcutsHelpProps { interface KeyboardShortcutsHelpProps {
@ -10,10 +12,15 @@ const shortcuts = [
{ keys: ['\u2318+Y'], action: 'Accept change' }, { keys: ['\u2318+Y'], action: 'Accept change' },
{ keys: ['\u2318+N'], action: 'Reject change' }, { keys: ['\u2318+N'], action: 'Reject change' },
{ keys: ['\u2318+\u21A9'], action: 'Save file' }, { keys: ['\u2318+\u21A9'], action: 'Save file' },
{ keys: ['\u2318+Z'], action: 'Undo' },
{ keys: ['\u2318+\u21E7+Z'], action: 'Redo' },
{ keys: ['Esc'], action: 'Close dialog' }, { keys: ['Esc'], action: 'Close dialog' },
]; ];
export const KeyboardShortcutsHelp = ({ open, onOpenChange }: KeyboardShortcutsHelpProps) => { export const KeyboardShortcutsHelp = ({
open,
onOpenChange,
}: KeyboardShortcutsHelpProps): React.ReactElement | null => {
if (!open) return null; if (!open) return null;
return ( return (

View file

@ -1,15 +1,16 @@
import React from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { import {
Check, Check,
Columns2,
Eye, Eye,
EyeOff, EyeOff,
FoldVertical,
GitMerge, GitMerge,
Loader2, Loader2,
Pencil, Pencil,
Rows2, UnfoldVertical,
Save,
Undo2,
X, X,
} from 'lucide-react'; } from 'lucide-react';
@ -18,7 +19,6 @@ import type { ChangeStats } from '@shared/types';
interface ReviewToolbarProps { interface ReviewToolbarProps {
stats: { pending: number; accepted: number; rejected: number }; stats: { pending: number; accepted: number; rejected: number };
changeStats: ChangeStats; changeStats: ChangeStats;
diffViewMode: 'unified' | 'split';
collapseUnchanged: boolean; collapseUnchanged: boolean;
applying: boolean; applying: boolean;
autoViewed: boolean; autoViewed: boolean;
@ -26,20 +26,13 @@ interface ReviewToolbarProps {
onAcceptAll: () => void; onAcceptAll: () => void;
onRejectAll: () => void; onRejectAll: () => void;
onApply: () => void; onApply: () => void;
onDiffViewModeChange: (mode: 'unified' | 'split') => void;
onCollapseUnchangedChange: (collapse: boolean) => void; onCollapseUnchangedChange: (collapse: boolean) => void;
// Editable diff props
editedCount?: number; editedCount?: number;
hasCurrentFileEdits?: boolean;
saving?: boolean;
onSaveCurrentFile?: () => void;
onDiscardCurrentFile?: () => void;
} }
export const ReviewToolbar = ({ export const ReviewToolbar = ({
stats, stats,
changeStats, changeStats,
diffViewMode,
collapseUnchanged, collapseUnchanged,
applying, applying,
autoViewed, autoViewed,
@ -47,14 +40,9 @@ export const ReviewToolbar = ({
onAcceptAll, onAcceptAll,
onRejectAll, onRejectAll,
onApply, onApply,
onDiffViewModeChange,
onCollapseUnchangedChange, onCollapseUnchangedChange,
editedCount = 0, editedCount = 0,
hasCurrentFileEdits = false, }: ReviewToolbarProps): React.ReactElement => {
saving = false,
onSaveCurrentFile,
onDiscardCurrentFile,
}: ReviewToolbarProps) => {
const hasRejected = stats.rejected > 0; const hasRejected = stats.rejected > 0;
const canApply = hasRejected && !applying; const canApply = hasRejected && !applying;
@ -90,118 +78,116 @@ export const ReviewToolbar = ({
<div className="flex-1" /> <div className="flex-1" />
{/* View toggles */} <Tooltip>
<div className="flex items-center gap-1 rounded-md border border-border bg-surface p-0.5"> <TooltipTrigger asChild>
<button <button
onClick={() => onDiffViewModeChange('unified')} onClick={() => onCollapseUnchangedChange(!collapseUnchanged)}
className={cn( className={cn(
'rounded px-2 py-1 text-xs transition-colors', 'flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors',
diffViewMode === 'unified' collapseUnchanged ? 'bg-surface-raised text-text' : 'text-text-muted hover:text-text'
? 'bg-surface-raised text-text' )}
: 'text-text-muted hover:text-text' >
)} {collapseUnchanged ? (
title="Unified view" <FoldVertical className="size-3.5" />
> ) : (
<Rows2 className="size-3.5" /> <UnfoldVertical className="size-3.5" />
</button> )}
<button </button>
onClick={() => onDiffViewModeChange('split')} </TooltipTrigger>
className={cn( <TooltipContent side="bottom">
'rounded px-2 py-1 text-xs transition-colors', {collapseUnchanged ? 'Show all lines' : 'Collapse unchanged regions'}
diffViewMode === 'split' </TooltipContent>
? 'bg-surface-raised text-text' </Tooltip>
: 'text-text-muted hover:text-text'
)}
title="Split view"
>
<Columns2 className="size-3.5" />
</button>
</div>
<button <Tooltip>
onClick={() => onCollapseUnchangedChange(!collapseUnchanged)} <TooltipTrigger asChild>
className={cn( <button
'flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors', onClick={() => onAutoViewedChange(!autoViewed)}
collapseUnchanged ? 'bg-surface-raised text-text' : 'text-text-muted hover:text-text' className={cn(
)} 'flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors',
title={collapseUnchanged ? 'Show all lines' : 'Collapse unchanged'} autoViewed ? 'bg-surface-raised text-text' : 'text-text-muted hover:text-text'
> )}
{collapseUnchanged ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />} >
</button> {autoViewed ? <Eye className="size-3.5" /> : <EyeOff className="size-3.5" />}
<span className="text-[10px]">Auto</span>
<button </button>
onClick={() => onAutoViewedChange(!autoViewed)} </TooltipTrigger>
className={cn( <TooltipContent side="bottom">
'flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors', {autoViewed
autoViewed ? 'bg-surface-raised text-text' : 'text-text-muted hover:text-text' ? 'Auto-mark files as viewed when scrolled to end (ON)'
)} : 'Auto-mark files as viewed when scrolled to end (OFF)'}
title={autoViewed ? 'Auto-mark viewed: ON' : 'Auto-mark viewed: OFF'} </TooltipContent>
> </Tooltip>
{autoViewed ? <Eye className="size-3.5" /> : <EyeOff className="size-3.5" />}
<span className="text-[10px]">Auto</span>
</button>
<div className="h-4 w-px bg-border" /> <div className="h-4 w-px bg-border" />
{/* Edited files indicator + actions */}
{hasCurrentFileEdits && (
<>
<button
onClick={onSaveCurrentFile}
disabled={saving}
className="flex items-center gap-1 rounded bg-green-500/15 px-2 py-1 text-xs text-green-400 transition-colors hover:bg-green-500/25 disabled:opacity-50"
title="Save file to disk (Cmd+Enter)"
>
{saving ? <Loader2 className="size-3 animate-spin" /> : <Save className="size-3" />}
Save File
</button>
<button
onClick={onDiscardCurrentFile}
className="flex items-center gap-1 rounded bg-orange-500/15 px-2 py-1 text-xs text-orange-400 transition-colors hover:bg-orange-500/25"
title="Discard edits for this file"
>
<Undo2 className="size-3" /> Discard
</button>
</>
)}
{editedCount > 0 && ( {editedCount > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/20 px-2 py-0.5 text-xs text-amber-400"> <span className="inline-flex items-center gap-1 rounded-full bg-amber-500/20 px-2 py-0.5 text-xs text-amber-400">
<Pencil className="size-3" /> {editedCount} edited <Pencil className="size-3" /> {editedCount} edited
</span> </span>
)} )}
{(hasCurrentFileEdits || editedCount > 0) && <div className="h-4 w-px bg-border" />} {editedCount > 0 && <div className="h-4 w-px bg-border" />}
{/* Actions */} {/* Actions */}
<button <Tooltip>
onClick={onAcceptAll} <TooltipTrigger asChild>
className="flex items-center gap-1 rounded bg-green-500/15 px-2.5 py-1 text-xs text-green-400 transition-colors hover:bg-green-500/25" <button
> onClick={onAcceptAll}
<Check className="size-3" /> className="flex items-center gap-1 rounded bg-green-500/15 px-2.5 py-1 text-xs text-green-400 transition-colors hover:bg-green-500/25"
Accept All >
</button> <Check className="size-3" />
Accept All
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<span>Accept all changes in current file</span>
<kbd className="ml-2 rounded border border-border bg-surface-raised px-1 py-0.5 font-mono text-[10px] text-text-muted">
Y
</kbd>
</TooltipContent>
</Tooltip>
<button <Tooltip>
onClick={onRejectAll} <TooltipTrigger asChild>
className="flex items-center gap-1 rounded bg-red-500/15 px-2.5 py-1 text-xs text-red-400 transition-colors hover:bg-red-500/25" <button
> onClick={onRejectAll}
<X className="size-3" /> className="flex items-center gap-1 rounded bg-red-500/15 px-2.5 py-1 text-xs text-red-400 transition-colors hover:bg-red-500/25"
Reject All >
</button> <X className="size-3" />
Reject All
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<span>Reject all changes in current file</span>
<kbd className="ml-2 rounded border border-border bg-surface-raised px-1 py-0.5 font-mono text-[10px] text-text-muted">
N
</kbd>
</TooltipContent>
</Tooltip>
<button <Tooltip>
onClick={onApply} <TooltipTrigger asChild>
disabled={!canApply} <button
className={cn( onClick={onApply}
'flex items-center gap-1 rounded px-3 py-1 text-xs font-medium transition-colors', disabled={!canApply}
canApply className={cn(
? 'bg-blue-500/20 text-blue-400 hover:bg-blue-500/30' 'flex items-center gap-1 rounded px-3 py-1 text-xs font-medium transition-colors',
: 'cursor-not-allowed bg-zinc-500/10 text-zinc-600' canApply
)} ? 'bg-blue-500/20 text-blue-400 hover:bg-blue-500/30'
> : 'cursor-not-allowed bg-zinc-500/10 text-zinc-600'
{applying ? <Loader2 className="size-3 animate-spin" /> : <GitMerge className="size-3" />} )}
{applying ? 'Applying...' : 'Apply Changes'} >
</button> {applying ? (
<Loader2 className="size-3 animate-spin" />
) : (
<GitMerge className="size-3" />
)}
{applying ? 'Applying...' : 'Apply All Changes'}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Apply review decisions across all files</TooltipContent>
</Tooltip>
</div> </div>
); );
}; };

View file

@ -1,6 +1,10 @@
import { AlertTriangle, X } from 'lucide-react'; import { useState } from 'react';
import { cn } from '@renderer/lib/utils';
import { AlertTriangle, ChevronRight, Info, ShieldCheck, X } from 'lucide-react';
import type { TaskScopeConfidence } from '@shared/types'; import type { TaskScopeConfidence } from '@shared/types';
import type { FC } from 'react';
interface ScopeWarningBannerProps { interface ScopeWarningBannerProps {
warnings: string[]; warnings: string[];
@ -8,33 +12,93 @@ interface ScopeWarningBannerProps {
onDismiss?: () => void; onDismiss?: () => void;
} }
interface TierConfig {
Icon: FC<{ className?: string }>;
border: string;
bg: string;
accentColor: string;
title: string;
detail: string;
}
const TIER_CONFIGS: Record<number, TierConfig> = {
1: {
Icon: ShieldCheck,
border: 'border-emerald-500/15',
bg: 'bg-emerald-500/5',
accentColor: 'text-emerald-400',
title: 'Scope determined precisely',
detail:
'Both start (TaskUpdate → in_progress) and completion (TaskUpdate → completed) markers found in the session log. The diff includes only file modifications (Edit, Write) between these two boundaries.',
},
2: {
Icon: Info,
border: 'border-blue-500/15',
bg: 'bg-blue-500/5',
accentColor: 'text-blue-400',
title: 'End boundary estimated',
detail:
'Only the start marker (TaskUpdate → in_progress) was found — the task has no completion marker yet. Changes shown from start marker to end of session log.',
},
3: {
Icon: AlertTriangle,
border: 'border-orange-500/20',
bg: 'bg-orange-500/5',
accentColor: 'text-orange-400',
title: 'Start boundary estimated',
detail:
'Only the completion marker (TaskUpdate → completed) was found. The start of work was not captured — this can happen if the task was already in progress when the session began. Changes shown from session start to completion marker.',
},
4: {
Icon: AlertTriangle,
border: 'border-red-500/20',
bg: 'bg-red-500/5',
accentColor: 'text-red-400',
title: 'Showing all session changes',
detail:
'No TaskUpdate markers found in the session log. Cannot determine task-specific boundaries — this can happen with older CLI versions or non-standard workflows. All file modifications from the session are included.',
},
};
export const ScopeWarningBanner = ({ export const ScopeWarningBanner = ({
warnings, warnings,
confidence, confidence,
onDismiss, onDismiss,
}: ScopeWarningBannerProps) => { }: ScopeWarningBannerProps): JSX.Element => {
if (warnings.length === 0 && confidence.tier <= 2) return null; const [expanded, setExpanded] = useState(false);
const config = TIER_CONFIGS[confidence.tier] ?? TIER_CONFIGS[4];
const { Icon } = config;
return ( return (
<div className="flex items-start gap-2 rounded-lg border border-yellow-500/20 bg-yellow-500/10 p-3 text-sm"> <div className={cn('border-b px-4 py-2', config.border, config.bg)}>
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-yellow-400" /> <div className="flex items-center gap-2">
<div className="flex-1"> <Icon className={cn('size-3.5 shrink-0', config.accentColor)} />
<p className="font-medium text-yellow-300"> <span className={cn('text-xs font-medium', config.accentColor)}>{config.title}</span>
{confidence.tier >= 3 <button
? 'Task boundary detection is approximate' onClick={() => setExpanded(!expanded)}
: 'Note about these changes'} className="flex items-center gap-0.5 text-xs text-text-muted transition-colors hover:text-text-secondary"
</p> >
{warnings.map((w, i) => ( Read more
<p key={i} className="mt-1 text-text-secondary"> <ChevronRight className={cn('size-3 transition-transform', expanded && 'rotate-90')} />
{w}
</p>
))}
<p className="mt-1 text-xs text-text-muted">Detection: {confidence.reason}</p>
</div>
{onDismiss && (
<button onClick={onDismiss} className="text-text-muted hover:text-text">
<X className="size-4" />
</button> </button>
{onDismiss && (
<button onClick={onDismiss} className="ml-auto text-text-muted hover:text-text">
<X className="size-3.5" />
</button>
)}
</div>
{expanded && (
<div className="mt-2 space-y-1.5 pl-6 text-xs text-text-secondary">
<p>{config.detail}</p>
{warnings.length > 0 && (
<ul className="list-inside list-disc space-y-0.5 text-text-muted">
{warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
)}
</div>
)} )}
</div> </div>
); );

View file

@ -1,6 +1,12 @@
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
/** Tracks in-flight checkTaskHasChanges calls to avoid duplicate requests */
const taskChangesCheckInFlight = new Set<string>();
/** Negative results cached with timestamp — recheck after 30s */
const taskChangesNegativeCache = new Map<string, number>();
const NEGATIVE_CACHE_TTL = 30_000;
import type { AppState } from '../types'; import type { AppState } from '../types';
import type { import type {
AgentChangeSet, AgentChangeSet,
@ -37,7 +43,6 @@ export interface ChangeReviewSlice {
fileDecisions: Record<string, HunkDecision>; fileDecisions: Record<string, HunkDecision>;
fileContents: Record<string, FileChangeWithContent>; fileContents: Record<string, FileChangeWithContent>;
fileContentsLoading: Record<string, boolean>; fileContentsLoading: Record<string, boolean>;
diffViewMode: 'unified' | 'split';
collapseUnchanged: boolean; collapseUnchanged: boolean;
applyError: string | null; applyError: string | null;
applying: boolean; applying: boolean;
@ -45,6 +50,9 @@ export interface ChangeReviewSlice {
// Editable diff state // Editable diff state
editedContents: Record<string, string>; editedContents: Record<string, string>;
/** Cache: "teamName:taskId" → true/false (has file changes) */
taskHasChanges: Record<string, boolean>;
// Phase 1 actions // Phase 1 actions
fetchAgentChanges: (teamName: string, memberName: string) => Promise<void>; fetchAgentChanges: (teamName: string, memberName: string) => Promise<void>;
fetchTaskChanges: (teamName: string, taskId: string) => Promise<void>; fetchTaskChanges: (teamName: string, taskId: string) => Promise<void>;
@ -59,7 +67,6 @@ export interface ChangeReviewSlice {
rejectAllFile: (filePath: string) => void; rejectAllFile: (filePath: string) => void;
acceptAll: () => void; acceptAll: () => void;
rejectAll: () => void; rejectAll: () => void;
setDiffViewMode: (mode: 'unified' | 'split') => void;
setCollapseUnchanged: (collapse: boolean) => void; setCollapseUnchanged: (collapse: boolean) => void;
fetchFileContent: ( fetchFileContent: (
teamName: string, teamName: string,
@ -74,6 +81,9 @@ export interface ChangeReviewSlice {
discardFileEdits: (filePath: string) => void; discardFileEdits: (filePath: string) => void;
discardAllEdits: () => void; discardAllEdits: () => void;
saveEditedFile: (filePath: string) => Promise<void>; saveEditedFile: (filePath: string) => Promise<void>;
// Task change availability
checkTaskHasChanges: (teamName: string, taskId: string) => Promise<void>;
} }
export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeReviewSlice> = ( export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeReviewSlice> = (
@ -92,7 +102,6 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
fileDecisions: {}, fileDecisions: {},
fileContents: {}, fileContents: {},
fileContentsLoading: {}, fileContentsLoading: {},
diffViewMode: 'unified',
collapseUnchanged: true, collapseUnchanged: true,
applyError: null, applyError: null,
applying: false, applying: false,
@ -100,6 +109,8 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
// Editable diff initial state // Editable diff initial state
editedContents: {}, editedContents: {},
taskHasChanges: {},
fetchAgentChanges: async (teamName: string, memberName: string) => { fetchAgentChanges: async (teamName: string, memberName: string) => {
set({ changeSetLoading: true, changeSetError: null }); set({ changeSetLoading: true, changeSetError: null });
try { try {
@ -120,11 +131,13 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
set({ changeSetLoading: true, changeSetError: null }); set({ changeSetLoading: true, changeSetError: null });
try { try {
const data = await api.review.getTaskChanges(teamName, taskId); const data = await api.review.getTaskChanges(teamName, taskId);
set({ const cacheKey = `${teamName}:${taskId}`;
set((s) => ({
activeChangeSet: data, activeChangeSet: data,
changeSetLoading: false, changeSetLoading: false,
selectedReviewFilePath: data.files[0]?.filePath ?? null, selectedReviewFilePath: data.files[0]?.filePath ?? null,
}); taskHasChanges: { ...s.taskHasChanges, [cacheKey]: data.files.length > 0 },
}));
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch task changes'; const message = error instanceof Error ? error.message : 'Failed to fetch task changes';
logger.error('fetchTaskChanges error:', message); logger.error('fetchTaskChanges error:', message);
@ -241,10 +254,6 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
set({ hunkDecisions: newHunkDecisions, fileDecisions: newFileDecisions }); set({ hunkDecisions: newHunkDecisions, fileDecisions: newFileDecisions });
}, },
setDiffViewMode: (mode: 'unified' | 'split') => {
set({ diffViewMode: mode });
},
setCollapseUnchanged: (collapse: boolean) => { setCollapseUnchanged: (collapse: boolean) => {
set({ collapseUnchanged: collapse }); set({ collapseUnchanged: collapse });
}, },
@ -284,7 +293,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
totalLinesAdded: number; totalLinesAdded: number;
totalLinesRemoved: number; totalLinesRemoved: number;
files: { filePath: string }[]; files: { filePath: string }[];
}) => }): string =>
`${cs.totalFiles}:${cs.totalLinesAdded}:${cs.totalLinesRemoved}:${cs.files.map((f) => f.filePath).join(',')}`; `${cs.totalFiles}:${cs.totalLinesAdded}:${cs.totalLinesRemoved}:${cs.files.map((f) => f.filePath).join(',')}`;
if (memberName && current) { if (memberName && current) {
@ -397,6 +406,35 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
} }
}, },
checkTaskHasChanges: async (teamName: string, taskId: string) => {
const cacheKey = `${teamName}:${taskId}`;
// Positive results are final — no need to recheck
if (get().taskHasChanges[cacheKey] === true) return;
// Prevent duplicate in-flight requests
if (taskChangesCheckInFlight.has(cacheKey)) return;
// Negative results cached with TTL — avoid API spam for tasks that truly have no changes
const negativeTs = taskChangesNegativeCache.get(cacheKey);
if (negativeTs && Date.now() - negativeTs < NEGATIVE_CACHE_TTL) return;
taskChangesCheckInFlight.add(cacheKey);
try {
const data = await api.review.getTaskChanges(teamName, taskId);
if (data.files.length > 0) {
set((s) => ({
taskHasChanges: { ...s.taskHasChanges, [cacheKey]: true },
}));
taskChangesNegativeCache.delete(cacheKey);
} else {
taskChangesNegativeCache.set(cacheKey, Date.now());
}
} catch {
// Don't cache errors in store — allow retry when session data appears later
taskChangesNegativeCache.set(cacheKey, Date.now());
} finally {
taskChangesCheckInFlight.delete(cacheKey);
}
},
invalidateChangeStats: (teamName: string) => { invalidateChangeStats: (teamName: string) => {
set((state) => { set((state) => {
const newCache = { ...state.changeStatsCache }; const newCache = { ...state.changeStatsCache };

View file

@ -34,35 +34,31 @@ interface ContentBlock {
/** /**
* Attempts to extract the content array from a parsed stream-json line. * Attempts to extract the content array from a parsed stream-json line.
* Handles both `{ type: "assistant", content: [...] }` and * Handles both `{ type: "assistant", content: [...] }` (direct) and
* `{ message: { type: "assistant", content: [...] } }` formats. * `{ type: "assistant", message: { type: "message", content: [...] } }` (wrapped) formats.
*/ */
function extractContentBlocks(parsed: unknown): ContentBlock[] | null { function extractContentBlocks(parsed: unknown): ContentBlock[] | null {
if (!parsed || typeof parsed !== 'object') return null; if (!parsed || typeof parsed !== 'object') return null;
const obj = parsed as Record<string, unknown>; const obj = parsed as Record<string, unknown>;
// Only process assistant messages
if (obj.type !== 'assistant') return null;
// Direct format: { type: "assistant", content: [...] } // Direct format: { type: "assistant", content: [...] }
if (obj.type === 'assistant' && Array.isArray(obj.content)) { if (Array.isArray(obj.content)) {
return obj.content as ContentBlock[]; return obj.content as ContentBlock[];
} }
// Wrapped format: { message: { type: "assistant", content: [...] } } // Wrapped format: { type: "assistant", message: { type: "message", content: [...] } }
// The inner message.type is "message" (not "assistant")
if (obj.message && typeof obj.message === 'object') { if (obj.message && typeof obj.message === 'object') {
const msg = obj.message as Record<string, unknown>; const msg = obj.message as Record<string, unknown>;
if (msg.type === 'assistant' && Array.isArray(msg.content)) { if (Array.isArray(msg.content)) {
return msg.content as ContentBlock[]; return msg.content as ContentBlock[];
} }
} }
// Result format: { type: "result", result: { type: "assistant", content: [...] } }
if (obj.type === 'result' && obj.result && typeof obj.result === 'object') {
const result = obj.result as Record<string, unknown>;
if (Array.isArray(result.content)) {
return result.content as ContentBlock[];
}
}
return null; return null;
} }