From fd3176716b79c6b2ae5bde9af82f78ab1ce9d9a7 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Feb 2026 11:40:01 +0200 Subject: [PATCH] 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. --- electron.vite.config.ts | 3 + package.json | 12 + pnpm-lock.yaml | 308 ++++++++++++++++++ .../services/team/TeamAgentToolsInstaller.ts | 17 +- src/main/services/team/TeamDataService.ts | 25 +- .../services/team/TeamProvisioningService.ts | 118 ++++++- .../components/team/CliLogsRichView.tsx | 155 ++++++--- src/renderer/components/team/TeamListView.tsx | 38 ++- .../components/team/kanban/KanbanTaskCard.tsx | 24 +- .../team/review/ChangeReviewDialog.tsx | 136 ++++++-- .../team/review/CodeMirrorDiffUtils.ts | 75 +++++ .../team/review/CodeMirrorDiffView.tsx | 157 ++++++--- .../team/review/KeyboardShortcutsHelp.tsx | 9 +- .../components/team/review/ReviewToolbar.tsx | 218 ++++++------- .../team/review/ScopeWarningBanner.tsx | 106 ++++-- .../store/slices/changeReviewSlice.ts | 58 +++- src/renderer/utils/streamJsonParser.ts | 22 +- 17 files changed, 1181 insertions(+), 300 deletions(-) create mode 100644 src/renderer/components/team/review/CodeMirrorDiffUtils.ts diff --git a/electron.vite.config.ts b/electron.vite.config.ts index ad7b62be..87083335 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -81,6 +81,9 @@ export default defineConfig({ } }, renderer: { + optimizeDeps: { + include: ['@codemirror/language-data'] + }, resolve: { alias: { '@renderer': resolve(__dirname, 'src/renderer'), diff --git a/package.json b/package.json index b5e280e9..3a8ec652 100644 --- a/package.json +++ b/package.json @@ -61,12 +61,24 @@ }, "dependencies": { "@codemirror/commands": "^6.10.2", + "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-go": "^6.0.1", "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-java": "^6.0.2", "@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-less": "^6.0.2", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/lang-php": "^6.0.2", "@codemirror/lang-python": "^6.2.1", + "@codemirror/lang-rust": "^6.0.2", + "@codemirror/lang-sass": "^6.0.2", + "@codemirror/lang-sql": "^6.10.0", "@codemirror/lang-xml": "^6.1.0", + "@codemirror/lang-yaml": "^6.1.2", + "@codemirror/language": "^6.12.1", + "@codemirror/language-data": "^6.5.2", "@codemirror/merge": "^6.12.0", "@codemirror/state": "^6.5.4", "@codemirror/theme-one-dark": "^6.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 257aa8f5..800f1b5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,24 +11,60 @@ importers: '@codemirror/commands': specifier: ^6.10.2 version: 6.10.2 + '@codemirror/lang-cpp': + specifier: ^6.0.3 + version: 6.0.3 '@codemirror/lang-css': specifier: ^6.3.1 version: 6.3.1 + '@codemirror/lang-go': + specifier: ^6.0.1 + version: 6.0.1 '@codemirror/lang-html': specifier: ^6.4.11 version: 6.4.11 + '@codemirror/lang-java': + specifier: ^6.0.2 + version: 6.0.2 '@codemirror/lang-javascript': specifier: ^6.2.4 version: 6.2.4 '@codemirror/lang-json': specifier: ^6.0.2 version: 6.0.2 + '@codemirror/lang-less': + specifier: ^6.0.2 + version: 6.0.2 + '@codemirror/lang-markdown': + specifier: ^6.5.0 + version: 6.5.0 + '@codemirror/lang-php': + specifier: ^6.0.2 + version: 6.0.2 '@codemirror/lang-python': specifier: ^6.2.1 version: 6.2.1 + '@codemirror/lang-rust': + specifier: ^6.0.2 + version: 6.0.2 + '@codemirror/lang-sass': + specifier: ^6.0.2 + version: 6.0.2 + '@codemirror/lang-sql': + specifier: ^6.10.0 + version: 6.10.0 '@codemirror/lang-xml': specifier: ^6.1.0 version: 6.1.0 + '@codemirror/lang-yaml': + specifier: ^6.1.2 + version: 6.1.2 + '@codemirror/language': + specifier: ^6.12.1 + version: 6.12.1 + '@codemirror/language-data': + specifier: ^6.5.2 + version: 6.5.2 '@codemirror/merge': specifier: ^6.12.0 version: 6.12.0 @@ -402,27 +438,78 @@ packages: '@codemirror/commands@6.10.2': resolution: {integrity: sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==} + '@codemirror/lang-angular@0.1.4': + resolution: {integrity: sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==} + + '@codemirror/lang-cpp@6.0.3': + resolution: {integrity: sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==} + '@codemirror/lang-css@6.3.1': resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==} + '@codemirror/lang-go@6.0.1': + resolution: {integrity: sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==} + '@codemirror/lang-html@6.4.11': resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==} + '@codemirror/lang-java@6.0.2': + resolution: {integrity: sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==} + '@codemirror/lang-javascript@6.2.4': resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==} + '@codemirror/lang-jinja@6.0.0': + resolution: {integrity: sha512-47MFmRcR8UAxd8DReVgj7WJN1WSAMT7OJnewwugZM4XiHWkOjgJQqvEM1NpMj9ALMPyxmlziEI1opH9IaEvmaw==} + '@codemirror/lang-json@6.0.2': resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} + '@codemirror/lang-less@6.0.2': + resolution: {integrity: sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==} + + '@codemirror/lang-liquid@6.3.2': + resolution: {integrity: sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw==} + + '@codemirror/lang-markdown@6.5.0': + resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==} + + '@codemirror/lang-php@6.0.2': + resolution: {integrity: sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==} + '@codemirror/lang-python@6.2.1': resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==} + '@codemirror/lang-rust@6.0.2': + resolution: {integrity: sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==} + + '@codemirror/lang-sass@6.0.2': + resolution: {integrity: sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==} + + '@codemirror/lang-sql@6.10.0': + resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==} + + '@codemirror/lang-vue@0.1.3': + resolution: {integrity: sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==} + + '@codemirror/lang-wast@6.0.2': + resolution: {integrity: sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==} + '@codemirror/lang-xml@6.1.0': resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==} + '@codemirror/lang-yaml@6.1.2': + resolution: {integrity: sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==} + + '@codemirror/language-data@6.5.2': + resolution: {integrity: sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==} + '@codemirror/language@6.12.1': resolution: {integrity: sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==} + '@codemirror/legacy-modes@6.5.2': + resolution: {integrity: sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==} + '@codemirror/lint@6.9.4': resolution: {integrity: sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==} @@ -953,15 +1040,24 @@ packages: '@lezer/common@1.5.1': resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==} + '@lezer/cpp@1.1.5': + resolution: {integrity: sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw==} + '@lezer/css@1.3.1': resolution: {integrity: sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==} + '@lezer/go@1.0.1': + resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==} + '@lezer/highlight@1.2.3': resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} '@lezer/html@1.3.13': resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==} + '@lezer/java@1.1.3': + resolution: {integrity: sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==} + '@lezer/javascript@1.5.4': resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} @@ -971,12 +1067,27 @@ packages: '@lezer/lr@1.4.8': resolution: {integrity: sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==} + '@lezer/markdown@1.6.3': + resolution: {integrity: sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==} + + '@lezer/php@1.0.5': + resolution: {integrity: sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==} + '@lezer/python@1.1.18': resolution: {integrity: sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==} + '@lezer/rust@1.0.2': + resolution: {integrity: sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==} + + '@lezer/sass@1.1.0': + resolution: {integrity: sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==} + '@lezer/xml@1.0.6': resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==} + '@lezer/yaml@1.0.4': + resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==} + '@lukeed/ms@2.0.2': resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} engines: {node: '>=8'} @@ -5575,6 +5686,20 @@ snapshots: '@codemirror/view': 6.39.15 '@lezer/common': 1.5.1 + '@codemirror/lang-angular@0.1.4': + dependencies: + '@codemirror/lang-html': 6.4.11 + '@codemirror/lang-javascript': 6.2.4 + '@codemirror/language': 6.12.1 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + + '@codemirror/lang-cpp@6.0.3': + dependencies: + '@codemirror/language': 6.12.1 + '@lezer/cpp': 1.1.5 + '@codemirror/lang-css@6.3.1': dependencies: '@codemirror/autocomplete': 6.20.0 @@ -5583,6 +5708,14 @@ snapshots: '@lezer/common': 1.5.1 '@lezer/css': 1.3.1 + '@codemirror/lang-go@6.0.1': + dependencies: + '@codemirror/autocomplete': 6.20.0 + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.4 + '@lezer/common': 1.5.1 + '@lezer/go': 1.0.1 + '@codemirror/lang-html@6.4.11': dependencies: '@codemirror/autocomplete': 6.20.0 @@ -5595,6 +5728,11 @@ snapshots: '@lezer/css': 1.3.1 '@lezer/html': 1.3.13 + '@codemirror/lang-java@6.0.2': + dependencies: + '@codemirror/language': 6.12.1 + '@lezer/java': 1.1.3 + '@codemirror/lang-javascript@6.2.4': dependencies: '@codemirror/autocomplete': 6.20.0 @@ -5605,11 +5743,56 @@ snapshots: '@lezer/common': 1.5.1 '@lezer/javascript': 1.5.4 + '@codemirror/lang-jinja@6.0.0': + dependencies: + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.1 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + '@codemirror/lang-json@6.0.2': dependencies: '@codemirror/language': 6.12.1 '@lezer/json': 1.0.3 + '@codemirror/lang-less@6.0.2': + dependencies: + '@codemirror/lang-css': 6.3.1 + '@codemirror/language': 6.12.1 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + + '@codemirror/lang-liquid@6.3.2': + dependencies: + '@codemirror/autocomplete': 6.20.0 + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.15 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + + '@codemirror/lang-markdown@6.5.0': + dependencies: + '@codemirror/autocomplete': 6.20.0 + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.15 + '@lezer/common': 1.5.1 + '@lezer/markdown': 1.6.3 + + '@codemirror/lang-php@6.0.2': + dependencies: + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.4 + '@lezer/common': 1.5.1 + '@lezer/php': 1.0.5 + '@codemirror/lang-python@6.2.1': dependencies: '@codemirror/autocomplete': 6.20.0 @@ -5618,6 +5801,44 @@ snapshots: '@lezer/common': 1.5.1 '@lezer/python': 1.1.18 + '@codemirror/lang-rust@6.0.2': + dependencies: + '@codemirror/language': 6.12.1 + '@lezer/rust': 1.0.2 + + '@codemirror/lang-sass@6.0.2': + dependencies: + '@codemirror/lang-css': 6.3.1 + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.4 + '@lezer/common': 1.5.1 + '@lezer/sass': 1.1.0 + + '@codemirror/lang-sql@6.10.0': + dependencies: + '@codemirror/autocomplete': 6.20.0 + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.4 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + + '@codemirror/lang-vue@0.1.3': + dependencies: + '@codemirror/lang-html': 6.4.11 + '@codemirror/lang-javascript': 6.2.4 + '@codemirror/language': 6.12.1 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + + '@codemirror/lang-wast@6.0.2': + dependencies: + '@codemirror/language': 6.12.1 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + '@codemirror/lang-xml@6.1.0': dependencies: '@codemirror/autocomplete': 6.20.0 @@ -5627,6 +5848,42 @@ snapshots: '@lezer/common': 1.5.1 '@lezer/xml': 1.0.6 + '@codemirror/lang-yaml@6.1.2': + dependencies: + '@codemirror/autocomplete': 6.20.0 + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.4 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + '@lezer/yaml': 1.0.4 + + '@codemirror/language-data@6.5.2': + dependencies: + '@codemirror/lang-angular': 0.1.4 + '@codemirror/lang-cpp': 6.0.3 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-go': 6.0.1 + '@codemirror/lang-html': 6.4.11 + '@codemirror/lang-java': 6.0.2 + '@codemirror/lang-javascript': 6.2.4 + '@codemirror/lang-jinja': 6.0.0 + '@codemirror/lang-json': 6.0.2 + '@codemirror/lang-less': 6.0.2 + '@codemirror/lang-liquid': 6.3.2 + '@codemirror/lang-markdown': 6.5.0 + '@codemirror/lang-php': 6.0.2 + '@codemirror/lang-python': 6.2.1 + '@codemirror/lang-rust': 6.0.2 + '@codemirror/lang-sass': 6.0.2 + '@codemirror/lang-sql': 6.10.0 + '@codemirror/lang-vue': 0.1.3 + '@codemirror/lang-wast': 6.0.2 + '@codemirror/lang-xml': 6.1.0 + '@codemirror/lang-yaml': 6.1.2 + '@codemirror/language': 6.12.1 + '@codemirror/legacy-modes': 6.5.2 + '@codemirror/language@6.12.1': dependencies: '@codemirror/state': 6.5.4 @@ -5636,6 +5893,10 @@ snapshots: '@lezer/lr': 1.4.8 style-mod: 4.1.3 + '@codemirror/legacy-modes@6.5.2': + dependencies: + '@codemirror/language': 6.12.1 + '@codemirror/lint@6.9.4': dependencies: '@codemirror/state': 6.5.4 @@ -6136,12 +6397,24 @@ snapshots: '@lezer/common@1.5.1': {} + '@lezer/cpp@1.1.5': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + '@lezer/css@1.3.1': dependencies: '@lezer/common': 1.5.1 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.8 + '@lezer/go@1.0.1': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + '@lezer/highlight@1.2.3': dependencies: '@lezer/common': 1.5.1 @@ -6152,6 +6425,12 @@ snapshots: '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.8 + '@lezer/java@1.1.3': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + '@lezer/javascript@1.5.4': dependencies: '@lezer/common': 1.5.1 @@ -6168,18 +6447,47 @@ snapshots: dependencies: '@lezer/common': 1.5.1 + '@lezer/markdown@1.6.3': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + + '@lezer/php@1.0.5': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + '@lezer/python@1.1.18': dependencies: '@lezer/common': 1.5.1 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.8 + '@lezer/rust@1.0.2': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + + '@lezer/sass@1.1.0': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + '@lezer/xml@1.0.6': dependencies: '@lezer/common': 1.5.1 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.8 + '@lezer/yaml@1.0.4': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + '@lukeed/ms@2.0.2': {} '@malept/cross-spawn-promise@1.1.1': diff --git a/src/main/services/team/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts index b4579c5a..17227baf 100644 --- a/src/main/services/team/TeamAgentToolsInstaller.ts +++ b/src/main/services/team/TeamAgentToolsInstaller.ts @@ -169,6 +169,15 @@ function getPaths(flags, teamName) { return { claudeDir, teamDir, tasksDir, kanbanPath }; } +function inferLeadName(paths) { + const config = readJson(path.join(paths.teamDir, 'config.json'), null); + if (!config || !Array.isArray(config.members)) return 'team-lead'; + const lead = config.members.find(function (m) { + return m.role && String(m.role).toLowerCase().includes('lead'); + }); + return lead ? String(lead.name) : (config.members[0] ? String(config.members[0].name) : 'team-lead'); +} + function readTask(paths, taskId) { const taskPath = path.join(paths.tasksDir, String(taskId) + '.json'); const task = readJson(taskPath, null); @@ -340,7 +349,7 @@ function sendInboxMessage(paths, teamName, flags) { const text = typeof flags.text === 'string' ? flags.text : ''; if (!text) die('Missing --text'); const summary = typeof flags.summary === 'string' ? flags.summary : undefined; - const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'user'; + const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths); const inboxPath = path.join(paths.teamDir, 'inboxes', String(to) + '.json'); ensureDir(path.dirname(inboxPath)); @@ -374,7 +383,7 @@ function reviewApprove(paths, teamName, taskId, flags) { if (!notify) return; const { task } = readTask(paths, taskId); if (!task.owner) return; - const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'user'; + const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths); const note = typeof flags.note === 'string' ? flags.note.trim() : ''; const text = note ? 'Task #' + String(taskId) + ' approved.\n\n' + note @@ -396,7 +405,7 @@ function reviewRequestChanges(paths, teamName, taskId, flags) { task.status = 'in_progress'; writeTask(taskPath, task); - const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'user'; + const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths); const text = 'Task #' + String(taskId) + @@ -481,7 +490,7 @@ async function main() { const notify = args.flags.notify === true || args.flags['notify-owner'] === true; if (notify && task.owner) { const from = - typeof args.flags.from === 'string' && args.flags.from.trim() ? args.flags.from.trim() : 'user'; + typeof args.flags.from === 'string' && args.flags.from.trim() ? args.flags.from.trim() : inferLeadName(paths); const parts = ['New task assigned to you: #' + String(task.id) + ' "' + String(task.subject) + '".']; const rawDesc = typeof args.flags.description === 'string' ? args.flags.description.trim() : typeof args.flags.desc === 'string' ? args.flags.desc.trim() : ''; diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 607bc234..9c039994 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -431,8 +431,10 @@ export class TeamDataService { AGENT_BLOCK_CLOSE ); + const leadName = await this.resolveLeadName(teamName); await this.sendMessage(teamName, { member: request.owner, + from: leadName, text: parts.join('\n'), summary: `New task #${task.id} assigned`, }); @@ -469,8 +471,10 @@ export class TeamDataService { `node "${toolPath}" --team ${teamName} task complete ${task.id}`, AGENT_BLOCK_CLOSE ); + const leadName = await this.resolveLeadName(teamName); await this.sendMessage(teamName, { member: task.owner, + from: leadName, text: parts.join('\n'), summary: `Task #${task.id} started`, }); @@ -507,8 +511,10 @@ export class TeamDataService { `node "${toolPath}" --team ${teamName} task comment ${taskId} --text "" --from ""`, AGENT_BLOCK_CLOSE, ]; + const leadName = await this.resolveLeadName(teamName); await this.sendMessage(teamName, { member: task.owner, + from: leadName, text: parts.join('\n'), summary: `Comment on #${taskId}`, }); @@ -524,6 +530,17 @@ export class TeamDataService { return this.inboxWriter.sendMessage(teamName, request); } + private async resolveLeadName(teamName: string): Promise { + try { + const config = await this.configReader.getConfig(teamName); + if (!config) return 'team-lead'; + const lead = config.members?.find((m) => m.role?.toLowerCase().includes('lead')); + return lead?.name ?? config.members?.[0]?.name ?? 'team-lead'; + } catch { + return 'team-lead'; + } + } + async sendDirectToLead( teamName: string, leadName: string, @@ -582,9 +599,13 @@ export class TeamDataService { } try { - const toolPath = await this.toolsInstaller.ensureInstalled(); + const [toolPath, leadName] = await Promise.all([ + this.toolsInstaller.ensureInstalled(), + this.resolveLeadName(teamName), + ]); await this.sendMessage(teamName, { member: reviewer, + from: leadName, text: `Please review task #${taskId}.\n\n` + `${AGENT_BLOCK_OPEN}\n` + @@ -786,8 +807,10 @@ export class TeamDataService { try { await this.taskWriter.updateStatus(teamName, taskId, 'in_progress'); + const leadName = await this.resolveLeadName(teamName); await this.sendMessage(teamName, { member: task.owner, + from: leadName, text: `Task #${taskId} needs fixes.\n\n` + `${patch.comment?.trim() || 'Reviewer requested changes.'}\n\n` + diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 5c165244..9765db7d 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -131,6 +131,8 @@ interface ProvisioningRun { directReplyParts: string[]; /** Accumulates assistant text during provisioning phase for live UI preview. */ provisioningOutputParts: string[]; + /** Session ID detected from stream-json output (result.session_id or message.session_id). */ + detectedSessionId: string | null; } type ProvisioningAuthSource = @@ -765,6 +767,7 @@ export class TeamProvisioningService { leadRelayCapture: null, directReplyParts: [], provisioningOutputParts: [], + detectedSessionId: null, progress: { runId, teamName: request.teamName, @@ -1036,6 +1039,7 @@ export class TeamProvisioningService { leadRelayCapture: null, directReplyParts: [], provisioningOutputParts: [], + detectedSessionId: null, progress: { runId, teamName: request.teamName, @@ -1522,7 +1526,8 @@ export class TeamProvisioningService { const aliveTeams = this.getAliveTeams(); if (aliveTeams.length === 0) return; - const newResolved = resolveLanguageName(newLangCode); + const systemLocale = getSystemLocale(); + const newResolved = resolveLanguageName(newLangCode, systemLocale); for (const teamName of aliveTeams) { try { @@ -1532,7 +1537,15 @@ export class TeamProvisioningService { const oldCode = config.language || 'system'; if (oldCode === newLangCode) continue; - const oldResolved = resolveLanguageName(oldCode); + // Compare resolved names to avoid spurious notifications + // e.g. switching from 'ru' to 'system' when system locale is Russian + const oldResolved = resolveLanguageName(oldCode, systemLocale); + if (oldResolved === newResolved) { + // Effective language unchanged — just update stored code silently + await this.configReader.updateConfig(teamName, { language: newLangCode }); + continue; + } + const message = `The user has changed the preferred communication language from "${oldResolved}" to "${newResolved}". ` + `Please switch to ${newResolved} for all future responses and broadcast this change to all teammates ` + @@ -1704,6 +1717,17 @@ export class TeamProvisioningService { } } + // Capture session_id from any message type (first occurrence wins) + if (!run.detectedSessionId) { + const sid = typeof msg.session_id === 'string' ? msg.session_id : undefined; + if (sid && sid.trim().length > 0) { + run.detectedSessionId = sid.trim(); + logger.info( + `[${run.teamName}] Detected session ID from stream-json: ${run.detectedSessionId}` + ); + } + } + if (msg.type === 'result') { const subtype = typeof msg.subtype === 'string' @@ -1750,7 +1774,7 @@ export class TeamProvisioningService { }); } } - if (!run.provisioningComplete) { + if (!run.provisioningComplete && !run.cancelRequested) { void this.handleProvisioningTurnComplete(run); } } else if (subtype === 'error') { @@ -1760,7 +1784,7 @@ export class TeamProvisioningService { if (run.leadRelayCapture) { run.leadRelayCapture.rejectOnce(errorMsg); } - if (!run.provisioningComplete) { + if (!run.provisioningComplete && !run.cancelRequested) { const progress = updateProgress( run, 'failed', @@ -1787,6 +1811,7 @@ export class TeamProvisioningService { * Process stays alive for subsequent tasks. */ private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise { + if (run.cancelRequested) return; run.provisioningComplete = true; // Clear provisioning timeout — no longer needed @@ -1797,7 +1822,7 @@ export class TeamProvisioningService { this.stopFilesystemMonitor(run); if (run.isLaunch) { - await this.updateConfigPostLaunch(run.teamName, run.request.cwd); + await this.updateConfigPostLaunch(run.teamName, run.request.cwd, run.detectedSessionId); await this.cleanupPrelaunchBackup(run.teamName); const readyMessage = 'Team launched — process alive and ready'; const progress = updateProgress(run, 'ready', readyMessage, { @@ -1838,7 +1863,7 @@ export class TeamProvisioningService { // Persist teammates metadata separately from config.json. await this.persistMembersMeta(run.teamName, run.request); - await this.updateConfigPostLaunch(run.teamName, run.request.cwd); + await this.updateConfigPostLaunch(run.teamName, run.request.cwd, run.detectedSessionId); const progress = updateProgress(run, 'ready', 'Team provisioned — process alive and ready', { cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer), @@ -2388,24 +2413,50 @@ export class TeamProvisioningService { * Combines session history append and projectPath update to avoid * race conditions with the CLI writing to the same file. */ - private async updateConfigPostLaunch(teamName: string, projectPath: string): Promise { + private async updateConfigPostLaunch( + teamName: string, + projectPath: string, + detectedSessionId: string | null + ): Promise { const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); try { const raw = await fs.promises.readFile(configPath, 'utf8'); const config = JSON.parse(raw) as Record; - // Append session to history - const leadSessionId = config.leadSessionId; - if (typeof leadSessionId === 'string' && leadSessionId.trim().length > 0) { - const sessionHistory = Array.isArray(config.sessionHistory) - ? (config.sessionHistory as string[]) - : []; - if (!sessionHistory.includes(leadSessionId)) { - sessionHistory.push(leadSessionId); - config.sessionHistory = sessionHistory; + const sessionHistory = Array.isArray(config.sessionHistory) + ? (config.sessionHistory as string[]) + : []; + + // Preserve old leadSessionId in history before overwriting + const oldLeadSessionId = config.leadSessionId; + if (typeof oldLeadSessionId === 'string' && oldLeadSessionId.trim().length > 0) { + if (!sessionHistory.includes(oldLeadSessionId)) { + sessionHistory.push(oldLeadSessionId); } } + // Update leadSessionId to the new session detected from stream-json + let newSessionId = detectedSessionId; + + // Fallback: if stream-json didn't provide session_id, scan project dir for newest JSONL + if (!newSessionId && projectPath.trim()) { + const scannedId = await this.scanForNewestSession(projectPath, sessionHistory); + if (scannedId) { + newSessionId = scannedId; + logger.info(`[${teamName}] Detected new session via project dir scan: ${scannedId}`); + } + } + + if (newSessionId) { + config.leadSessionId = newSessionId; + if (!sessionHistory.includes(newSessionId)) { + sessionHistory.push(newSessionId); + } + logger.info(`[${teamName}] Updated leadSessionId: ${newSessionId}`); + } + + config.sessionHistory = sessionHistory; + // Save current language setting const langCode = ConfigManager.getInstance().getConfig().general.agentLanguage || 'system'; config.language = langCode; @@ -2432,6 +2483,41 @@ export class TeamProvisioningService { } } + /** + * Fallback: scan the project directory for the newest JSONL file + * that isn't already in sessionHistory. Returns the session ID or null. + */ + private async scanForNewestSession( + projectPath: string, + knownSessions: string[] + ): Promise { + 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 { const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); const backupPath = `${configPath}.prelaunch.bak`; diff --git a/src/renderer/components/team/CliLogsRichView.tsx b/src/renderer/components/team/CliLogsRichView.tsx index 626dfeb7..8fbd0d2c 100644 --- a/src/renderer/components/team/CliLogsRichView.tsx +++ b/src/renderer/components/team/CliLogsRichView.tsx @@ -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, groupId: string): Set { + const prefix = `${groupId}::`; + const scoped = new Set(); + 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; + 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 ( + + ); +}; + +/** + * A single collapsible group of assistant items (2+ items). */ const StreamGroup = ({ group, @@ -36,37 +82,49 @@ const StreamGroup = ({ onToggle: () => void; expandedItemIds: Set; onItemClick: (itemId: string) => void; -}): React.JSX.Element => ( -
- - {isExpanded && ( -
- { + // Scope item IDs to this group to avoid cross-group collisions + const groupItemIds = useMemo( + () => scopedItemIds(expandedItemIds, group.id), + [expandedItemIds, group.id] + ); + const handleItemClick = useCallback( + (itemId: string) => onItemClick(`${group.id}::${itemId}`), + [group.id, onItemClick] + ); + + return ( +
+
- )} -
-); + + + {group.summary} + + + {isExpanded && ( +
+ +
+ )} +
+ ); +}; export const CliLogsRichView = ({ cliLogsTail, @@ -126,13 +184,14 @@ export const CliLogsRichView = ({ const hasContent = cliLogsTail.trim().length > 0; return (
{hasContent ? ( -
+          
             {cliLogsTail}
           
) : ( @@ -146,16 +205,26 @@ export const CliLogsRichView = ({ return (
- {groups.map((group) => ( - handleGroupToggle(group.id)} - expandedItemIds={expandedItemIds} - onItemClick={handleItemClick} - /> - ))} + {groups.map((group) => + group.items.length === 1 ? ( + // Single item — render flat without collapsible group wrapper + + ) : ( + handleGroupToggle(group.id)} + expandedItemIds={expandedItemIds} + onItemClick={handleItemClick} + /> + ) + )}
); }; diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 3f7e856a..46d64c59 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -200,20 +200,34 @@ export const TeamListView = (): React.JSX.Element => { ); } - if (currentProjectPath) { - const matches = (t: TeamSummary): boolean => { - if (t.projectPath && normalizePath(t.projectPath) === currentProjectPath) return true; - return t.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ?? false; - }; - result = [...result].sort((a, b) => { - const aMatch = matches(a) ? 0 : 1; - const bMatch = matches(b) ? 0 : 1; - return aMatch - bMatch; - }); - } + const aliveSet = new Set(aliveTeams); + const matchesProject = currentProjectPath + ? (t: TeamSummary): boolean => { + if (t.projectPath && normalizePath(t.projectPath) === currentProjectPath) return true; + return ( + t.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ?? false + ); + } + : null; + + result = [...result].sort((a, b) => { + // 1. Alive (running) teams first + const aliveA = aliveSet.has(a.teamName) ? 0 : 1; + const aliveB = aliveSet.has(b.teamName) ? 0 : 1; + if (aliveA !== aliveB) return aliveA - aliveB; + + // 2. Matching current project second + if (matchesProject) { + const projA = matchesProject(a) ? 0 : 1; + const projB = matchesProject(b) ? 0 : 1; + if (projA !== projB) return projA - projB; + } + + return 0; + }); return result; - }, [teams, searchQuery, currentProjectPath]); + }, [teams, searchQuery, currentProjectPath, aliveTeams]); // Live branch/worktree for team project paths (poll so it updates during process) const projectPathsToPoll = useMemo(() => { diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index 7126db7c..16a46400 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge'; @@ -6,6 +6,7 @@ import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount'; +import { useStore } from '@renderer/store'; import { ArrowLeftFromLine, ArrowRightFromLine, @@ -148,6 +149,19 @@ export const KanbanTaskCard = ({ const hasBlockedBy = blockedByIds.length > 0; const hasBlocks = blocksIds.length > 0; + // Lazy-check if task has file changes (only for done/review/approved columns) + const showChangesColumn = + (columnId === 'done' || columnId === 'review' || columnId === 'approved') && !!onViewChanges; + const cacheKey = `${teamName}:${task.id}`; + const taskHasChanges = useStore((s) => s.taskHasChanges[cacheKey]); + const checkTaskHasChanges = useStore((s) => s.checkTaskHasChanges); + + useEffect(() => { + if (showChangesColumn && task.status === 'completed' && taskHasChanges == null) { + void checkTaskHasChanges(teamName, task.id); + } + }, [showChangesColumn, task.status, task.id, teamName, taskHasChanges, checkTaskHasChanges]); + return (
) : null} +
- {(columnId === 'done' || columnId === 'review' || columnId === 'approved') && - onViewChanges ? ( +
+ {showChangesColumn && taskHasChanges === true ? ( ) : null} +
- -
); diff --git a/src/renderer/components/team/review/ChangeReviewDialog.tsx b/src/renderer/components/team/review/ChangeReviewDialog.tsx index 34529c4f..48b36876 100644 --- a/src/renderer/components/team/review/ChangeReviewDialog.tsx +++ b/src/renderer/components/team/review/ChangeReviewDialog.tsx @@ -1,12 +1,15 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { goToNextChunk, rejectChunk } from '@codemirror/merge'; +import { isElectronMode } from '@renderer/api'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useDiffNavigation } from '@renderer/hooks/useDiffNavigation'; import { useViewedFiles } from '@renderer/hooks/useViewedFiles'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; -import { ChevronDown, Clock, Loader2, X } from 'lucide-react'; +import { ChevronDown, Clock, Loader2, Save, Undo2, X } from 'lucide-react'; +import { acceptAllChunks, rejectAllChunks } from './CodeMirrorDiffUtils'; import { CodeMirrorDiffView } from './CodeMirrorDiffView'; import { ConfidenceBadge } from './ConfidenceBadge'; import { DiffErrorBoundary } from './DiffErrorBoundary'; @@ -18,6 +21,7 @@ import { ReviewToolbar } from './ReviewToolbar'; import { ScopeWarningBanner } from './ScopeWarningBanner'; import { ViewedProgressBar } from './ViewedProgressBar'; +import type { EditorState } from '@codemirror/state'; import type { EditorView } from '@codemirror/view'; import type { HunkDecision, TaskChangeSetV2 } from '@shared/types'; @@ -49,7 +53,7 @@ export const ChangeReviewDialog = ({ mode, memberName, taskId, -}: ChangeReviewDialogProps) => { +}: ChangeReviewDialogProps): React.ReactElement | null => { const { activeChangeSet, changeSetLoading, @@ -64,12 +68,10 @@ export const ChangeReviewDialog = ({ fileDecisions, fileContents, fileContentsLoading, - diffViewMode, collapseUnchanged, applying, applyError, setHunkDecision, - setDiffViewMode, setCollapseUnchanged, fetchFileContent, acceptAll, @@ -87,6 +89,10 @@ export const ChangeReviewDialog = ({ const [timelineOpen, setTimelineOpen] = useState(false); // Counter to force editor rebuild on discard const [discardCounter, setDiscardCounter] = useState(0); + // Cache EditorState per file to preserve undo history between file switches + const editorStateCache = useRef(new Map()); + // Current file's cached initial state (derived outside render to avoid ref access during render) + const [cachedInitialState, setCachedInitialState] = useState(undefined); // Build scope key for viewed storage const scopeKey = mode === 'task' ? `task:${taskId ?? ''}` : `agent:${memberName ?? ''}`; @@ -107,18 +113,54 @@ export const ChangeReviewDialog = ({ progress: viewedProgress, } = useViewedFiles(teamName, scopeKey, allFilePaths); + // When collapseUnchanged changes, invalidate cached state for current file + // so the editor is recreated with the new extension config + useEffect(() => { + if (selectedReviewFilePath) { + editorStateCache.current.delete(selectedReviewFilePath); + } + queueMicrotask(() => setCachedInitialState(undefined)); + }, [collapseUnchanged]); // eslint-disable-line react-hooks/exhaustive-deps -- only collapseUnchanged triggers cache invalidation + // Editable diff computed values const editedCount = Object.keys(editedContents).length; const hasCurrentFileEdits = !!( selectedReviewFilePath && selectedReviewFilePath in editedContents ); + // Save current editor state to cache before switching files + const handleSelectFile = useCallback( + (filePath: string | null) => { + const view = editorViewRef.current; + if (view && selectedReviewFilePath) { + editorStateCache.current.set(selectedReviewFilePath, view.state); + } + setCachedInitialState(filePath ? editorStateCache.current.get(filePath) : undefined); + selectReviewFile(filePath); + }, + [selectedReviewFilePath, selectReviewFile] + ); + + const handleAcceptAll = useCallback(() => { + const view = editorViewRef.current; + if (view) acceptAllChunks(view); + acceptAll(); + }, [acceptAll]); + + const handleRejectAll = useCallback(() => { + const view = editorViewRef.current; + if (view) rejectAllChunks(view); + rejectAll(); + }, [rejectAll]); + const handleSaveCurrentFile = useCallback(() => { if (selectedReviewFilePath) void saveEditedFile(selectedReviewFilePath); }, [selectedReviewFilePath, saveEditedFile]); const handleDiscardCurrentFile = useCallback(() => { if (selectedReviewFilePath) { + editorStateCache.current.delete(selectedReviewFilePath); + setCachedInitialState(undefined); discardFileEdits(selectedReviewFilePath); setDiscardCounter((c) => c + 1); } @@ -127,7 +169,7 @@ export const ChangeReviewDialog = ({ const diffNav = useDiffNavigation( activeChangeSet?.files ?? [], selectedReviewFilePath, - selectReviewFile, + handleSelectFile, editorViewRef, open, (filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'accepted'), @@ -166,7 +208,7 @@ export const ChangeReviewDialog = ({ // Escape to close useEffect(() => { if (!open) return; - const handler = (e: KeyboardEvent) => { + const handler = (e: KeyboardEvent): void => { if (e.key === 'Escape') onOpenChange(false); }; document.addEventListener('keydown', handler); @@ -250,12 +292,25 @@ export const ChangeReviewDialog = ({ ? `Changes by ${memberName ?? 'unknown'}` : `Changes for task #${taskId ?? '?'}`; + const isMacElectron = + isElectronMode() && window.navigator.userAgent.toLowerCase().includes('mac'); + if (!open) return null; return (
{/* Header */} -
+

{title}

{activeChangeSet && ( @@ -278,6 +333,7 @@ export const ChangeReviewDialog = ({ @@ -297,29 +353,23 @@ export const ChangeReviewDialog = ({ )} - {/* Scope warnings */} + {/* Scope info / warnings */} {mode === 'task' && activeChangeSet && isTaskChangeSetV2(activeChangeSet) && - activeChangeSet.warnings.length > 0 && ( + (activeChangeSet.warnings.length > 0 || activeChangeSet.scope.confidence.tier >= 2) && ( {selectedFile ? (
- {/* File header with content source badge */} + {/* File header with content source badge and save/discard */}
{selectedFile.relativePath} @@ -410,7 +460,7 @@ export const ChangeReviewDialog = ({ {/* File-level decision indicator */} {fileDecisions[selectedFile.filePath] && ( )} + +
+ {hasCurrentFileEdits && ( + <> + + + + + + Discard all edits for this file + + + + + + + + Save file to disk + + ⌘↵ + + + + + )} +
{/* Loading state */} @@ -450,6 +543,7 @@ export const ChangeReviewDialog = ({ readOnly={false} showMergeControls={true} collapseUnchanged={collapseUnchanged} + initialState={cachedInitialState} onHunkAccepted={(idx) => setHunkDecision(selectedFile.filePath, idx, 'accepted') } diff --git a/src/renderer/components/team/review/CodeMirrorDiffUtils.ts b/src/renderer/components/team/review/CodeMirrorDiffUtils.ts new file mode 100644 index 00000000..9ae3c933 --- /dev/null +++ b/src/renderer/components/team/review/CodeMirrorDiffUtils.ts @@ -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[] = []; + 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 }; diff --git a/src/renderer/components/team/review/CodeMirrorDiffView.tsx b/src/renderer/components/team/review/CodeMirrorDiffView.tsx index 85c97ec5..0ab81583 100644 --- a/src/renderer/components/team/review/CodeMirrorDiffView.tsx +++ b/src/renderer/components/team/review/CodeMirrorDiffView.tsx @@ -1,23 +1,31 @@ -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; -import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; +import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'; +import { cpp } from '@codemirror/lang-cpp'; import { css } from '@codemirror/lang-css'; +import { go } from '@codemirror/lang-go'; import { html } from '@codemirror/lang-html'; +import { java } from '@codemirror/lang-java'; import { javascript } from '@codemirror/lang-javascript'; import { json } from '@codemirror/lang-json'; +import { less } from '@codemirror/lang-less'; +import { markdown } from '@codemirror/lang-markdown'; +import { php } from '@codemirror/lang-php'; import { python } from '@codemirror/lang-python'; +import { rust } from '@codemirror/lang-rust'; +import { sass } from '@codemirror/lang-sass'; +import { sql } from '@codemirror/lang-sql'; import { xml } from '@codemirror/lang-xml'; -import { - acceptChunk, - getChunks, - goToNextChunk, - goToPreviousChunk, - rejectChunk, - unifiedMergeView, -} from '@codemirror/merge'; -import { EditorState, type Extension } from '@codemirror/state'; +import { yaml } from '@codemirror/lang-yaml'; +import { indentUnit, LanguageDescription, syntaxHighlighting } from '@codemirror/language'; +import { languages } from '@codemirror/language-data'; +import { goToNextChunk, goToPreviousChunk, unifiedMergeView } from '@codemirror/merge'; +import { Compartment, EditorState, type Extension } from '@codemirror/state'; +import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark'; import { EditorView, keymap } from '@codemirror/view'; +import { acceptChunk, getChunks, mergeUndoSupport, rejectChunk } from './CodeMirrorDiffUtils'; + interface CodeMirrorDiffViewProps { original: string; modified: string; @@ -35,10 +43,12 @@ interface CodeMirrorDiffViewProps { editorViewRef?: React.RefObject; /** Called when editor content changes (debounced, only when readOnly=false) */ onContentChanged?: (content: string) => void; + /** Cached EditorState to restore (preserves undo history between file switches) */ + initialState?: EditorState; } -/** Detect language extension from file name */ -function getLanguageExtension(fileName: string): Extension | null { +/** Synchronous language extension for common file types (bundled by Vite) */ +function getSyncLanguageExtension(fileName: string): Extension | null { const ext = fileName.split('.').pop()?.toLowerCase(); switch (ext) { case 'ts': @@ -57,19 +67,53 @@ function getLanguageExtension(fileName: string): Extension | null { case 'jsonl': return json(); case 'css': - case 'scss': return css(); + case 'scss': + return sass({ indented: false }); + case 'sass': + return sass({ indented: true }); + case 'less': + return less(); case 'html': case 'htm': return html(); case 'xml': case 'svg': return xml(); + case 'md': + case 'mdx': + case 'markdown': + return markdown(); + case 'yaml': + case 'yml': + return yaml(); + case 'rs': + return rust(); + case 'go': + return go(); + case 'java': + return java(); + case 'c': + case 'h': + case 'cpp': + case 'cxx': + case 'cc': + case 'hpp': + return cpp(); + case 'php': + return php(); + case 'sql': + return sql(); default: return null; } } +/** Async fallback: match by filename via @codemirror/language-data for rare languages */ +function getAsyncLanguageDesc(fileName: string): LanguageDescription | null { + return LanguageDescription.matchFilename(languages, fileName); +} + /** Compute hunk index for the chunk at a given position */ function computeHunkIndexAtPos(state: EditorState, pos: number): number { const chunks = getChunks(state); @@ -123,15 +167,15 @@ const diffTheme = EditorView.theme({ '.cm-selectionBackground': { backgroundColor: 'rgba(59, 130, 246, 0.3) !important', }, - // Diff-specific styles + // Diff-specific styles — line-level backgrounds (no per-character underlines) '.cm-changedLine': { - backgroundColor: 'var(--diff-added-bg, rgba(46, 160, 67, 0.15))', + backgroundColor: 'var(--diff-added-bg, rgba(46, 160, 67, 0.22))', }, '.cm-deletedChunk': { backgroundColor: 'var(--diff-removed-bg, rgba(248, 81, 73, 0.15))', }, '.cm-insertedLine': { - backgroundColor: 'var(--diff-added-bg, rgba(46, 160, 67, 0.15))', + backgroundColor: 'var(--diff-added-bg, rgba(46, 160, 67, 0.22))', }, '.cm-deletedLine': { backgroundColor: 'var(--diff-removed-bg, rgba(248, 81, 73, 0.15))', @@ -195,7 +239,8 @@ export const CodeMirrorDiffView = ({ onFullyViewed, editorViewRef: externalViewRef, onContentChanged, -}: CodeMirrorDiffViewProps) => { + initialState, +}: CodeMirrorDiffViewProps): React.ReactElement => { const containerRef = useRef(null); const viewRef = useRef(null); const endSentinelRef = useRef(null); @@ -221,11 +266,13 @@ export const CodeMirrorDiffView = ({ }); }, []); - const langExtension = useMemo(() => getLanguageExtension(fileName), [fileName]); + // Compartment for lazy-injected language support + const langCompartment = useRef(new Compartment()); const buildExtensions = useCallback(() => { const extensions: Extension[] = [ diffTheme, + syntaxHighlighting(oneDarkHighlightStyle), EditorView.editable.of(!readOnly), EditorState.readOnly.of(readOnly), ]; @@ -233,12 +280,13 @@ export const CodeMirrorDiffView = ({ // Undo/redo support and standard editing keybindings if (!readOnly) { extensions.push(history()); - extensions.push(keymap.of([...defaultKeymap, ...historyKeymap])); + extensions.push(mergeUndoSupport); + extensions.push(indentUnit.of(' ')); + extensions.push(keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap])); } - if (langExtension) { - extensions.push(langExtension); - } + // Language placeholder — actual language injected async via compartment reconfigure + extensions.push(langCompartment.current.of([])); // Keyboard shortcuts for chunk navigation and accept/reject extensions.push( @@ -294,7 +342,7 @@ export const CodeMirrorDiffView = ({ // Unified merge view const mergeConfig: Parameters[0] = { original, - highlightChanges: true, + highlightChanges: false, gutter: true, syntaxHighlightDeletions: true, }; @@ -307,7 +355,12 @@ export const CodeMirrorDiffView = ({ } if (showMergeControls) { - mergeConfig.mergeControls = (type, action) => { + // NOTE: We intentionally do NOT use the `action` callback from @codemirror/merge. + // CM's DeletionWidget caches DOM via a global WeakMap keyed by chunk.changes. + // When EditorView is recreated (e.g. from cached initialState), toDOM() returns + // the OLD cached DOM whose `action` closure references the DESTROYED view. + // Instead, we call acceptChunk/rejectChunk directly with viewRef.current. + mergeConfig.mergeControls = (type, _action) => { const btn = document.createElement('button'); if (type === 'accept') { @@ -320,7 +373,7 @@ export const CodeMirrorDiffView = ({ if (view) { const pos = view.posAtDOM(btn); const hunkIndex = computeHunkIndexAtPos(view.state, pos); - action(e); + acceptChunk(view, pos); onAcceptRef.current?.(hunkIndex); scrollToNextChunk(); } @@ -335,7 +388,7 @@ export const CodeMirrorDiffView = ({ if (view) { const pos = view.posAtDOM(btn); const hunkIndex = computeHunkIndexAtPos(view.state, pos); - action(e); + rejectChunk(view, pos); onRejectRef.current?.(hunkIndex); scrollToNextChunk(); } @@ -352,7 +405,6 @@ export const CodeMirrorDiffView = ({ }, [ original, readOnly, - langExtension, showMergeControls, collapseUnchangedProp, collapseMargin, @@ -368,11 +420,13 @@ export const CodeMirrorDiffView = ({ viewRef.current = null; } - const view = new EditorView({ - doc: modified, - extensions: buildExtensions(), - parent: containerRef.current, - }); + const view = initialState + ? new EditorView({ state: initialState, parent: containerRef.current }) + : new EditorView({ + doc: modified, + extensions: buildExtensions(), + parent: containerRef.current, + }); viewRef.current = view; // Sync to external ref via holder @@ -389,7 +443,39 @@ export const CodeMirrorDiffView = ({ } }; // We intentionally rebuild the entire editor when key props change - }, [original, modified, buildExtensions]); + }, [original, modified, buildExtensions, initialState]); + + // Inject language extension via compartment after editor creation + useEffect(() => { + const view = viewRef.current; + if (!view) return; + + // Try synchronous (bundled) language first + const syncLang = getSyncLanguageExtension(fileName); + if (syncLang) { + view.dispatch({ effects: langCompartment.current.reconfigure(syncLang) }); + return; + } + + // Async fallback for rare languages via @codemirror/language-data + const desc = getAsyncLanguageDesc(fileName); + if (!desc) return; + + if (desc.support) { + view.dispatch({ effects: langCompartment.current.reconfigure(desc.support) }); + return; + } + + let cancelled = false; + void desc.load().then((support: Extension) => { + if (!cancelled && viewRef.current === view) { + view.dispatch({ effects: langCompartment.current.reconfigure(support) }); + } + }); + return () => { + cancelled = true; + }; + }, [fileName, buildExtensions, initialState]); // Auto-viewed detection via IntersectionObserver useEffect(() => { @@ -418,6 +504,3 @@ export const CodeMirrorDiffView = ({
); }; - -// Re-export merge utils for external use -export { acceptChunk, getChunks, rejectChunk }; diff --git a/src/renderer/components/team/review/KeyboardShortcutsHelp.tsx b/src/renderer/components/team/review/KeyboardShortcutsHelp.tsx index cdabccc5..2c482846 100644 --- a/src/renderer/components/team/review/KeyboardShortcutsHelp.tsx +++ b/src/renderer/components/team/review/KeyboardShortcutsHelp.tsx @@ -1,3 +1,5 @@ +import React from 'react'; + import { X } from 'lucide-react'; interface KeyboardShortcutsHelpProps { @@ -10,10 +12,15 @@ const shortcuts = [ { keys: ['\u2318+Y'], action: 'Accept change' }, { keys: ['\u2318+N'], action: 'Reject change' }, { keys: ['\u2318+\u21A9'], action: 'Save file' }, + { keys: ['\u2318+Z'], action: 'Undo' }, + { keys: ['\u2318+\u21E7+Z'], action: 'Redo' }, { keys: ['Esc'], action: 'Close dialog' }, ]; -export const KeyboardShortcutsHelp = ({ open, onOpenChange }: KeyboardShortcutsHelpProps) => { +export const KeyboardShortcutsHelp = ({ + open, + onOpenChange, +}: KeyboardShortcutsHelpProps): React.ReactElement | null => { if (!open) return null; return ( diff --git a/src/renderer/components/team/review/ReviewToolbar.tsx b/src/renderer/components/team/review/ReviewToolbar.tsx index 070fac30..3fc6ca60 100644 --- a/src/renderer/components/team/review/ReviewToolbar.tsx +++ b/src/renderer/components/team/review/ReviewToolbar.tsx @@ -1,15 +1,16 @@ +import React from 'react'; + +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { cn } from '@renderer/lib/utils'; import { Check, - Columns2, Eye, EyeOff, + FoldVertical, GitMerge, Loader2, Pencil, - Rows2, - Save, - Undo2, + UnfoldVertical, X, } from 'lucide-react'; @@ -18,7 +19,6 @@ import type { ChangeStats } from '@shared/types'; interface ReviewToolbarProps { stats: { pending: number; accepted: number; rejected: number }; changeStats: ChangeStats; - diffViewMode: 'unified' | 'split'; collapseUnchanged: boolean; applying: boolean; autoViewed: boolean; @@ -26,20 +26,13 @@ interface ReviewToolbarProps { onAcceptAll: () => void; onRejectAll: () => void; onApply: () => void; - onDiffViewModeChange: (mode: 'unified' | 'split') => void; onCollapseUnchangedChange: (collapse: boolean) => void; - // Editable diff props editedCount?: number; - hasCurrentFileEdits?: boolean; - saving?: boolean; - onSaveCurrentFile?: () => void; - onDiscardCurrentFile?: () => void; } export const ReviewToolbar = ({ stats, changeStats, - diffViewMode, collapseUnchanged, applying, autoViewed, @@ -47,14 +40,9 @@ export const ReviewToolbar = ({ onAcceptAll, onRejectAll, onApply, - onDiffViewModeChange, onCollapseUnchangedChange, editedCount = 0, - hasCurrentFileEdits = false, - saving = false, - onSaveCurrentFile, - onDiscardCurrentFile, -}: ReviewToolbarProps) => { +}: ReviewToolbarProps): React.ReactElement => { const hasRejected = stats.rejected > 0; const canApply = hasRejected && !applying; @@ -90,118 +78,116 @@ export const ReviewToolbar = ({
- {/* View toggles */} -
- - -
+ + + + + + {collapseUnchanged ? 'Show all lines' : 'Collapse unchanged regions'} + + - - - + + + + + + {autoViewed + ? 'Auto-mark files as viewed when scrolled to end (ON)' + : 'Auto-mark files as viewed when scrolled to end (OFF)'} + +
- {/* Edited files indicator + actions */} - {hasCurrentFileEdits && ( - <> - - - - )} {editedCount > 0 && ( {editedCount} edited )} - {(hasCurrentFileEdits || editedCount > 0) &&
} + {editedCount > 0 &&
} {/* Actions */} - + + + + + + Accept all changes in current file + + ⌘Y + + + - + + + + + + Reject all changes in current file + + ⌘N + + + - + + + + + Apply review decisions across all files +
); }; diff --git a/src/renderer/components/team/review/ScopeWarningBanner.tsx b/src/renderer/components/team/review/ScopeWarningBanner.tsx index 8b8fdb82..0c0cf948 100644 --- a/src/renderer/components/team/review/ScopeWarningBanner.tsx +++ b/src/renderer/components/team/review/ScopeWarningBanner.tsx @@ -1,6 +1,10 @@ -import { AlertTriangle, X } from 'lucide-react'; +import { useState } from 'react'; + +import { cn } from '@renderer/lib/utils'; +import { AlertTriangle, ChevronRight, Info, ShieldCheck, X } from 'lucide-react'; import type { TaskScopeConfidence } from '@shared/types'; +import type { FC } from 'react'; interface ScopeWarningBannerProps { warnings: string[]; @@ -8,33 +12,93 @@ interface ScopeWarningBannerProps { onDismiss?: () => void; } +interface TierConfig { + Icon: FC<{ className?: string }>; + border: string; + bg: string; + accentColor: string; + title: string; + detail: string; +} + +const TIER_CONFIGS: Record = { + 1: { + Icon: ShieldCheck, + border: 'border-emerald-500/15', + bg: 'bg-emerald-500/5', + accentColor: 'text-emerald-400', + title: 'Scope determined precisely', + detail: + 'Both start (TaskUpdate → in_progress) and completion (TaskUpdate → completed) markers found in the session log. The diff includes only file modifications (Edit, Write) between these two boundaries.', + }, + 2: { + Icon: Info, + border: 'border-blue-500/15', + bg: 'bg-blue-500/5', + accentColor: 'text-blue-400', + title: 'End boundary estimated', + detail: + 'Only the start marker (TaskUpdate → in_progress) was found — the task has no completion marker yet. Changes shown from start marker to end of session log.', + }, + 3: { + Icon: AlertTriangle, + border: 'border-orange-500/20', + bg: 'bg-orange-500/5', + accentColor: 'text-orange-400', + title: 'Start boundary estimated', + detail: + 'Only the completion marker (TaskUpdate → completed) was found. The start of work was not captured — this can happen if the task was already in progress when the session began. Changes shown from session start to completion marker.', + }, + 4: { + Icon: AlertTriangle, + border: 'border-red-500/20', + bg: 'bg-red-500/5', + accentColor: 'text-red-400', + title: 'Showing all session changes', + detail: + 'No TaskUpdate markers found in the session log. Cannot determine task-specific boundaries — this can happen with older CLI versions or non-standard workflows. All file modifications from the session are included.', + }, +}; + export const ScopeWarningBanner = ({ warnings, confidence, onDismiss, -}: ScopeWarningBannerProps) => { - if (warnings.length === 0 && confidence.tier <= 2) return null; +}: ScopeWarningBannerProps): JSX.Element => { + const [expanded, setExpanded] = useState(false); + const config = TIER_CONFIGS[confidence.tier] ?? TIER_CONFIGS[4]; + const { Icon } = config; return ( -
- -
-

- {confidence.tier >= 3 - ? 'Task boundary detection is approximate' - : 'Note about these changes'} -

- {warnings.map((w, i) => ( -

- {w} -

- ))} -

Detection: {confidence.reason}

-
- {onDismiss && ( - + {onDismiss && ( + + )} +
+ + {expanded && ( +
+

{config.detail}

+ {warnings.length > 0 && ( +
    + {warnings.map((w, i) => ( +
  • {w}
  • + ))} +
+ )} +
)}
); diff --git a/src/renderer/store/slices/changeReviewSlice.ts b/src/renderer/store/slices/changeReviewSlice.ts index 1017392c..ce414a30 100644 --- a/src/renderer/store/slices/changeReviewSlice.ts +++ b/src/renderer/store/slices/changeReviewSlice.ts @@ -1,6 +1,12 @@ import { api } from '@renderer/api'; import { createLogger } from '@shared/utils/logger'; +/** Tracks in-flight checkTaskHasChanges calls to avoid duplicate requests */ +const taskChangesCheckInFlight = new Set(); +/** Negative results cached with timestamp — recheck after 30s */ +const taskChangesNegativeCache = new Map(); +const NEGATIVE_CACHE_TTL = 30_000; + import type { AppState } from '../types'; import type { AgentChangeSet, @@ -37,7 +43,6 @@ export interface ChangeReviewSlice { fileDecisions: Record; fileContents: Record; fileContentsLoading: Record; - diffViewMode: 'unified' | 'split'; collapseUnchanged: boolean; applyError: string | null; applying: boolean; @@ -45,6 +50,9 @@ export interface ChangeReviewSlice { // Editable diff state editedContents: Record; + /** Cache: "teamName:taskId" → true/false (has file changes) */ + taskHasChanges: Record; + // Phase 1 actions fetchAgentChanges: (teamName: string, memberName: string) => Promise; fetchTaskChanges: (teamName: string, taskId: string) => Promise; @@ -59,7 +67,6 @@ export interface ChangeReviewSlice { rejectAllFile: (filePath: string) => void; acceptAll: () => void; rejectAll: () => void; - setDiffViewMode: (mode: 'unified' | 'split') => void; setCollapseUnchanged: (collapse: boolean) => void; fetchFileContent: ( teamName: string, @@ -74,6 +81,9 @@ export interface ChangeReviewSlice { discardFileEdits: (filePath: string) => void; discardAllEdits: () => void; saveEditedFile: (filePath: string) => Promise; + + // Task change availability + checkTaskHasChanges: (teamName: string, taskId: string) => Promise; } export const createChangeReviewSlice: StateCreator = ( @@ -92,7 +102,6 @@ export const createChangeReviewSlice: StateCreator { set({ changeSetLoading: true, changeSetError: null }); try { @@ -120,11 +131,13 @@ export const createChangeReviewSlice: StateCreator ({ activeChangeSet: data, changeSetLoading: false, selectedReviewFilePath: data.files[0]?.filePath ?? null, - }); + taskHasChanges: { ...s.taskHasChanges, [cacheKey]: data.files.length > 0 }, + })); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to fetch task changes'; logger.error('fetchTaskChanges error:', message); @@ -241,10 +254,6 @@ export const createChangeReviewSlice: StateCreator { - set({ diffViewMode: mode }); - }, - setCollapseUnchanged: (collapse: boolean) => { set({ collapseUnchanged: collapse }); }, @@ -284,7 +293,7 @@ export const createChangeReviewSlice: StateCreator + }): string => `${cs.totalFiles}:${cs.totalLinesAdded}:${cs.totalLinesRemoved}:${cs.files.map((f) => f.filePath).join(',')}`; if (memberName && current) { @@ -397,6 +406,35 @@ export const createChangeReviewSlice: StateCreator { + const cacheKey = `${teamName}:${taskId}`; + // Positive results are final — no need to recheck + if (get().taskHasChanges[cacheKey] === true) return; + // Prevent duplicate in-flight requests + if (taskChangesCheckInFlight.has(cacheKey)) return; + // Negative results cached with TTL — avoid API spam for tasks that truly have no changes + const negativeTs = taskChangesNegativeCache.get(cacheKey); + if (negativeTs && Date.now() - negativeTs < NEGATIVE_CACHE_TTL) return; + + taskChangesCheckInFlight.add(cacheKey); + try { + const data = await api.review.getTaskChanges(teamName, taskId); + if (data.files.length > 0) { + set((s) => ({ + taskHasChanges: { ...s.taskHasChanges, [cacheKey]: true }, + })); + taskChangesNegativeCache.delete(cacheKey); + } else { + taskChangesNegativeCache.set(cacheKey, Date.now()); + } + } catch { + // Don't cache errors in store — allow retry when session data appears later + taskChangesNegativeCache.set(cacheKey, Date.now()); + } finally { + taskChangesCheckInFlight.delete(cacheKey); + } + }, + invalidateChangeStats: (teamName: string) => { set((state) => { const newCache = { ...state.changeStatsCache }; diff --git a/src/renderer/utils/streamJsonParser.ts b/src/renderer/utils/streamJsonParser.ts index aa7732c1..895fd740 100644 --- a/src/renderer/utils/streamJsonParser.ts +++ b/src/renderer/utils/streamJsonParser.ts @@ -34,35 +34,31 @@ interface ContentBlock { /** * Attempts to extract the content array from a parsed stream-json line. - * Handles both `{ type: "assistant", content: [...] }` and - * `{ message: { type: "assistant", content: [...] } }` formats. + * Handles both `{ type: "assistant", content: [...] }` (direct) and + * `{ type: "assistant", message: { type: "message", content: [...] } }` (wrapped) formats. */ function extractContentBlocks(parsed: unknown): ContentBlock[] | null { if (!parsed || typeof parsed !== 'object') return null; const obj = parsed as Record; + // Only process assistant messages + if (obj.type !== 'assistant') return null; + // Direct format: { type: "assistant", content: [...] } - if (obj.type === 'assistant' && Array.isArray(obj.content)) { + if (Array.isArray(obj.content)) { return obj.content as ContentBlock[]; } - // Wrapped format: { message: { type: "assistant", content: [...] } } + // Wrapped format: { type: "assistant", message: { type: "message", content: [...] } } + // The inner message.type is "message" (not "assistant") if (obj.message && typeof obj.message === 'object') { const msg = obj.message as Record; - if (msg.type === 'assistant' && Array.isArray(msg.content)) { + if (Array.isArray(msg.content)) { return msg.content as ContentBlock[]; } } - // Result format: { type: "result", result: { type: "assistant", content: [...] } } - if (obj.type === 'result' && obj.result && typeof obj.result === 'object') { - const result = obj.result as Record; - if (Array.isArray(result.content)) { - return result.content as ContentBlock[]; - } - } - return null; }