diff --git a/package.json b/package.json index 0b74d0d3..adf7be3e 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,16 @@ ] }, "dependencies": { + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/lang-xml": "^6.1.0", + "@codemirror/merge": "^6.12.0", + "@codemirror/state": "^6.5.4", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.39.15", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -79,12 +89,14 @@ "clsx": "^2.1.1", "cmdk": "1.0.4", "date-fns": "^3.6.0", + "diff": "^8.0.3", "electron-updater": "^6.7.3", "fastify": "^5.7.4", "highlight.js": "^11.11.1", "idb-keyval": "^6.2.2", "lucide-react": "^0.562.0", "mdast-util-to-hast": "^13.2.1", + "node-diff3": "^3.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e7e7612..38d54c8e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,36 @@ importers: .: dependencies: + '@codemirror/lang-css': + specifier: ^6.3.1 + version: 6.3.1 + '@codemirror/lang-html': + specifier: ^6.4.11 + version: 6.4.11 + '@codemirror/lang-javascript': + specifier: ^6.2.4 + version: 6.2.4 + '@codemirror/lang-json': + specifier: ^6.0.2 + version: 6.0.2 + '@codemirror/lang-python': + specifier: ^6.2.1 + version: 6.2.1 + '@codemirror/lang-xml': + specifier: ^6.1.0 + version: 6.1.0 + '@codemirror/merge': + specifier: ^6.12.0 + version: 6.12.0 + '@codemirror/state': + specifier: ^6.5.4 + version: 6.5.4 + '@codemirror/theme-one-dark': + specifier: ^6.1.3 + version: 6.1.3 + '@codemirror/view': + specifier: ^6.39.15 + version: 6.39.15 '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -65,6 +95,9 @@ importers: date-fns: specifier: ^3.6.0 version: 3.6.0 + diff: + specifier: ^8.0.3 + version: 8.0.3 electron-updater: specifier: ^6.7.3 version: 6.7.3 @@ -83,6 +116,9 @@ importers: mdast-util-to-hast: specifier: ^13.2.1 version: 13.2.1 + node-diff3: + specifier: ^3.2.0 + version: 3.2.0 react: specifier: ^18.3.1 version: 18.3.1 @@ -357,6 +393,45 @@ packages: resolution: {integrity: sha512-DnGHL+v36YVMoWhWZqyJYVZ9dapNm7h4N3/P0lDPirJj0CHVPkjChMCCotj74cg6LW7iPJZFGrdEfh0X0g2bmQ==} engines: {node: '>=18.18'} + '@codemirror/autocomplete@6.20.0': + resolution: {integrity: sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==} + + '@codemirror/lang-css@6.3.1': + resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==} + + '@codemirror/lang-html@6.4.11': + resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==} + + '@codemirror/lang-javascript@6.2.4': + resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==} + + '@codemirror/lang-json@6.0.2': + resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} + + '@codemirror/lang-python@6.2.1': + resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==} + + '@codemirror/lang-xml@6.1.0': + resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==} + + '@codemirror/language@6.12.1': + resolution: {integrity: sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==} + + '@codemirror/lint@6.9.4': + resolution: {integrity: sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==} + + '@codemirror/merge@6.12.0': + resolution: {integrity: sha512-o+36bbapcEHf4Ux75pZ4CKjMBUd14parA0uozvWVlacaT+uxaA3DDefEvWYjngsKU+qsrDe/HOOfsw0Q72pLjA==} + + '@codemirror/state@6.5.4': + resolution: {integrity: sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==} + + '@codemirror/theme-one-dark@6.1.3': + resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==} + + '@codemirror/view@6.39.15': + resolution: {integrity: sha512-aCWjgweIIXLBHh7bY6cACvXuyrZ0xGafjQ2VInjp4RM4gMfscK5uESiNdrH0pE+e1lZr2B4ONGsjchl2KsKZzg==} + '@develar/schema-utils@2.6.5': resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} engines: {node: '>= 8.9.0'} @@ -869,6 +944,33 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lezer/common@1.5.1': + resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==} + + '@lezer/css@1.3.1': + resolution: {integrity: sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/html@1.3.13': + resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==} + + '@lezer/javascript@1.5.4': + resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + + '@lezer/json@1.0.3': + resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} + + '@lezer/lr@1.4.8': + resolution: {integrity: sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==} + + '@lezer/python@1.1.18': + resolution: {integrity: sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==} + + '@lezer/xml@1.0.6': + resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==} + '@lukeed/ms@2.0.2': resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} engines: {node: '>=8'} @@ -885,6 +987,9 @@ packages: resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} engines: {node: '>= 10.0.0'} + '@marijn/find-cluster-break@1.0.2': + resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -2427,6 +2532,9 @@ packages: crc@3.8.0: resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==} + crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2534,6 +2642,10 @@ packages: didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + diff@8.0.3: + resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} + engines: {node: '>=0.3.1'} + dir-compare@3.3.0: resolution: {integrity: sha512-J7/et3WlGUCxjdnD3HAAzQ6nsnc0WL6DD7WcwJb7c39iH1+AWfg+9OqzJNaI6PkBwBvm1mhZNL9iY/nRiZXlPg==} @@ -3976,6 +4088,10 @@ packages: node-api-version@0.2.1: resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} + node-diff3@3.2.0: + resolution: {integrity: sha512-vLh2xJFSyniBLYDEDbXKqD32fQ5vAxmYT4hco8t0EHQ4CQ4BDHhshi7kdvDc6Y1MwGSi1Mhl4unUukPbCayZdw==} + engines: {bun: '>=1.3.0', node: '>=18'} + node-gyp@9.4.1: resolution: {integrity: sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==} engines: {node: ^12.13 || ^14.13 || >=16} @@ -4810,6 +4926,9 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -5162,6 +5281,9 @@ packages: jsdom: optional: true + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + walk-up-path@4.0.0: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} @@ -5433,6 +5555,106 @@ snapshots: - eslint-import-resolver-webpack - supports-color + '@codemirror/autocomplete@6.20.0': + dependencies: + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.15 + '@lezer/common': 1.5.1 + + '@codemirror/lang-css@6.3.1': + dependencies: + '@codemirror/autocomplete': 6.20.0 + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.4 + '@lezer/common': 1.5.1 + '@lezer/css': 1.3.1 + + '@codemirror/lang-html@6.4.11': + dependencies: + '@codemirror/autocomplete': 6.20.0 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-javascript': 6.2.4 + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.15 + '@lezer/common': 1.5.1 + '@lezer/css': 1.3.1 + '@lezer/html': 1.3.13 + + '@codemirror/lang-javascript@6.2.4': + dependencies: + '@codemirror/autocomplete': 6.20.0 + '@codemirror/language': 6.12.1 + '@codemirror/lint': 6.9.4 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.15 + '@lezer/common': 1.5.1 + '@lezer/javascript': 1.5.4 + + '@codemirror/lang-json@6.0.2': + dependencies: + '@codemirror/language': 6.12.1 + '@lezer/json': 1.0.3 + + '@codemirror/lang-python@6.2.1': + dependencies: + '@codemirror/autocomplete': 6.20.0 + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.4 + '@lezer/common': 1.5.1 + '@lezer/python': 1.1.18 + + '@codemirror/lang-xml@6.1.0': + dependencies: + '@codemirror/autocomplete': 6.20.0 + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.15 + '@lezer/common': 1.5.1 + '@lezer/xml': 1.0.6 + + '@codemirror/language@6.12.1': + dependencies: + '@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 + style-mod: 4.1.3 + + '@codemirror/lint@6.9.4': + dependencies: + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.15 + crelt: 1.0.6 + + '@codemirror/merge@6.12.0': + dependencies: + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.15 + '@lezer/highlight': 1.2.3 + style-mod: 4.1.3 + + '@codemirror/state@6.5.4': + dependencies: + '@marijn/find-cluster-break': 1.0.2 + + '@codemirror/theme-one-dark@6.1.3': + dependencies: + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.15 + '@lezer/highlight': 1.2.3 + + '@codemirror/view@6.39.15': + dependencies: + '@codemirror/state': 6.5.4 + crelt: 1.0.6 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + '@develar/schema-utils@2.6.5': dependencies: ajv: 6.14.0 @@ -5899,6 +6121,52 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lezer/common@1.5.1': {} + + '@lezer/css@1.3.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 + + '@lezer/html@1.3.13': + 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 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + + '@lezer/json@1.0.3': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + + '@lezer/lr@1.4.8': + dependencies: + '@lezer/common': 1.5.1 + + '@lezer/python@1.1.18': + 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 + '@lukeed/ms@2.0.2': {} '@malept/cross-spawn-promise@1.1.1': @@ -5918,6 +6186,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@marijn/find-cluster-break@1.0.2': {} + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.8.1 @@ -7555,6 +7825,8 @@ snapshots: buffer: 5.7.1 optional: true + crelt@1.0.6: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -7646,6 +7918,8 @@ snapshots: didyoumean@1.2.2: {} + diff@8.0.3: {} + dir-compare@3.3.0: dependencies: buffer-equal: 1.0.1 @@ -9639,6 +9913,8 @@ snapshots: dependencies: semver: 7.7.3 + node-diff3@3.2.0: {} + node-gyp@9.4.1: dependencies: env-paths: 2.2.1 @@ -10562,6 +10838,8 @@ snapshots: dependencies: js-tokens: 9.0.1 + style-mod@4.1.3: {} + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -10998,6 +11276,8 @@ snapshots: - supports-color - terser + w3c-keyname@2.2.8: {} + walk-up-path@4.0.0: {} wcwidth@1.0.1: diff --git a/src/main/index.ts b/src/main/index.ts index 9a4dd3b9..613b264d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -9,6 +9,10 @@ * - Manage application lifecycle */ +import { ChangeExtractorService } from '@main/services/team/ChangeExtractorService'; +import { FileContentResolver } from '@main/services/team/FileContentResolver'; +import { GitDiffFallback } from '@main/services/team/GitDiffFallback'; +import { ReviewApplierService } from '@main/services/team/ReviewApplierService'; import { CONTEXT_CHANGED, SSH_STATUS, @@ -39,6 +43,7 @@ import { ServiceContext, ServiceContextRegistry, SshConnectionManager, + TaskBoundaryParser, TeamAgentToolsInstaller, TeamDataService, TeamMemberLogsFinder, @@ -303,6 +308,11 @@ function initializeServices(): void { teamProvisioningService = new TeamProvisioningService(); const teamMemberLogsFinder = new TeamMemberLogsFinder(); const memberStatsComputer = new MemberStatsComputer(teamMemberLogsFinder); + const taskBoundaryParser = new TaskBoundaryParser(); + const changeExtractor = new ChangeExtractorService(teamMemberLogsFinder, taskBoundaryParser); + const gitDiffFallback = new GitDiffFallback(); + const fileContentResolver = new FileContentResolver(teamMemberLogsFinder, gitDiffFallback); + const reviewApplier = new ReviewApplierService(); // Fire-and-forget: warm up CLI and install teamctl.js at startup void teamProvisioningService.warmup(); @@ -336,7 +346,11 @@ function initializeServices(): void { { httpServer, startHttpServer: () => startHttpServer(handleModeSwitch), - } + }, + changeExtractor, + fileContentResolver, + reviewApplier, + gitDiffFallback ); // Forward SSH state changes to renderer and HTTP SSE clients diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index ba372729..6f4f4e9c 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -36,6 +36,7 @@ import { registerProjectHandlers, removeProjectHandlers, } from './projects'; +import { initializeReviewHandlers, registerReviewHandlers, removeReviewHandlers } from './review'; import { initializeSearchHandlers, registerSearchHandlers, removeSearchHandlers } from './search'; import { initializeSessionHandlers, @@ -59,7 +60,11 @@ import { registerValidationHandlers, removeValidationHandlers } from './validati import { registerWindowHandlers, removeWindowHandlers } from './window'; import type { + ChangeExtractorService, + FileContentResolver, + GitDiffFallback, MemberStatsComputer, + ReviewApplierService, ServiceContext, ServiceContextRegistry, SshConnectionManager, @@ -89,7 +94,11 @@ export function initializeIpcHandlers( httpServerDeps?: { httpServer: HttpServer; startHttpServer: () => Promise; - } + }, + changeExtractor?: ChangeExtractorService, + fileContentResolver?: FileContentResolver, + reviewApplier?: ReviewApplierService, + gitDiffFallback?: GitDiffFallback ): void { // Initialize domain handlers with registry initializeProjectHandlers(registry); @@ -114,6 +123,14 @@ export function initializeIpcHandlers( if (httpServerDeps) { initializeHttpServerHandlers(httpServerDeps.httpServer, httpServerDeps.startHttpServer); } + if (changeExtractor) { + initializeReviewHandlers({ + extractor: changeExtractor, + applier: reviewApplier ?? undefined, + contentResolver: fileContentResolver ?? undefined, + gitFallback: gitDiffFallback ?? undefined, + }); + } // Register all handlers registerProjectHandlers(ipcMain); @@ -128,6 +145,7 @@ export function initializeIpcHandlers( registerSshHandlers(ipcMain); registerContextHandlers(ipcMain); registerTeamHandlers(ipcMain); + registerReviewHandlers(ipcMain); registerWindowHandlers(ipcMain); if (httpServerDeps) { registerHttpServerHandlers(ipcMain); @@ -153,6 +171,7 @@ export function removeIpcHandlers(): void { removeSshHandlers(ipcMain); removeContextHandlers(ipcMain); removeTeamHandlers(ipcMain); + removeReviewHandlers(ipcMain); removeWindowHandlers(ipcMain); removeHttpServerHandlers(ipcMain); diff --git a/src/main/ipc/review.ts b/src/main/ipc/review.ts new file mode 100644 index 00000000..b51cc0dd --- /dev/null +++ b/src/main/ipc/review.ts @@ -0,0 +1,245 @@ +/** + * IPC handlers for code review / diff view feature. + * + * Паттерн: module-level state + guard + wrapReviewHandler (как teams.ts) + */ + +import { + REVIEW_APPLY_DECISIONS, + REVIEW_CHECK_CONFLICT, + REVIEW_GET_AGENT_CHANGES, + REVIEW_GET_CHANGE_STATS, + REVIEW_GET_FILE_CONTENT, + REVIEW_GET_GIT_FILE_LOG, + REVIEW_GET_TASK_CHANGES, + REVIEW_PREVIEW_REJECT, + REVIEW_REJECT_FILE, + REVIEW_REJECT_HUNKS, + // eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design +} from '@preload/constants/ipcChannels'; +import { createLogger } from '@shared/utils/logger'; + +import type { ChangeExtractorService } from '@main/services/team/ChangeExtractorService'; +import type { FileContentResolver } from '@main/services/team/FileContentResolver'; +import type { GitDiffFallback } from '@main/services/team/GitDiffFallback'; +import type { ReviewApplierService } from '@main/services/team/ReviewApplierService'; +import type { IpcResult } from '@shared/types/ipc'; +import type { + AgentChangeSet, + ApplyReviewRequest, + ApplyReviewResult, + ChangeStats, + ConflictCheckResult, + FileChangeWithContent, + RejectResult, + SnippetDiff, + TaskChangeSetV2, +} from '@shared/types/review'; +import type { IpcMain, IpcMainInvokeEvent } from 'electron'; + +const logger = createLogger('IPC:review'); + +// --- Module-level state --- + +let changeExtractor: ChangeExtractorService | null = null; +let reviewApplier: ReviewApplierService | null = null; +let fileContentResolver: FileContentResolver | null = null; +let gitDiffFallback: GitDiffFallback | null = null; + +function getChangeExtractor(): ChangeExtractorService { + if (!changeExtractor) throw new Error('Review handlers not initialized'); + return changeExtractor; +} + +function getApplier(): ReviewApplierService { + if (!reviewApplier) throw new Error('ReviewApplierService not initialized'); + return reviewApplier; +} + +function getContentResolver(): FileContentResolver { + if (!fileContentResolver) throw new Error('FileContentResolver not initialized'); + return fileContentResolver; +} + +// --- Forward-compatible config object --- + +export interface ReviewHandlerDeps { + extractor: ChangeExtractorService; + applier?: ReviewApplierService; + contentResolver?: FileContentResolver; + gitFallback?: GitDiffFallback; +} + +export function initializeReviewHandlers(deps: ReviewHandlerDeps): void { + changeExtractor = deps.extractor; + if (deps.applier) reviewApplier = deps.applier; + if (deps.contentResolver) fileContentResolver = deps.contentResolver; + if (deps.gitFallback) gitDiffFallback = deps.gitFallback; +} + +export function registerReviewHandlers(ipcMain: IpcMain): void { + // Phase 1 + ipcMain.handle(REVIEW_GET_AGENT_CHANGES, handleGetAgentChanges); + ipcMain.handle(REVIEW_GET_TASK_CHANGES, handleGetTaskChanges); + ipcMain.handle(REVIEW_GET_CHANGE_STATS, handleGetChangeStats); + // Phase 2 + ipcMain.handle(REVIEW_CHECK_CONFLICT, handleCheckConflict); + ipcMain.handle(REVIEW_REJECT_HUNKS, handleRejectHunks); + ipcMain.handle(REVIEW_REJECT_FILE, handleRejectFile); + ipcMain.handle(REVIEW_PREVIEW_REJECT, handlePreviewReject); + ipcMain.handle(REVIEW_APPLY_DECISIONS, handleApplyDecisions); + ipcMain.handle(REVIEW_GET_FILE_CONTENT, handleGetFileContent); + // Phase 4 + ipcMain.handle(REVIEW_GET_GIT_FILE_LOG, handleGetGitFileLog); +} + +export function removeReviewHandlers(ipcMain: IpcMain): void { + // Phase 1 + ipcMain.removeHandler(REVIEW_GET_AGENT_CHANGES); + ipcMain.removeHandler(REVIEW_GET_TASK_CHANGES); + ipcMain.removeHandler(REVIEW_GET_CHANGE_STATS); + // Phase 2 + ipcMain.removeHandler(REVIEW_CHECK_CONFLICT); + ipcMain.removeHandler(REVIEW_REJECT_HUNKS); + ipcMain.removeHandler(REVIEW_REJECT_FILE); + ipcMain.removeHandler(REVIEW_PREVIEW_REJECT); + ipcMain.removeHandler(REVIEW_APPLY_DECISIONS); + ipcMain.removeHandler(REVIEW_GET_FILE_CONTENT); + // Phase 4 + ipcMain.removeHandler(REVIEW_GET_GIT_FILE_LOG); +} + +// --- Локальный wrapReviewHandler --- + +async function wrapReviewHandler( + operation: string, + handler: () => Promise +): Promise> { + try { + const data = await handler(); + return { success: true, data }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`Review handler error [${operation}]:`, message); + return { success: false, error: message }; + } +} + +// --- Phase 1 Handlers --- + +async function handleGetAgentChanges( + _event: IpcMainInvokeEvent, + teamName: string, + memberName: string +): Promise> { + return wrapReviewHandler('getAgentChanges', () => + getChangeExtractor().getAgentChanges(teamName, memberName) + ); +} + +async function handleGetTaskChanges( + _event: IpcMainInvokeEvent, + teamName: string, + taskId: string +): Promise> { + return wrapReviewHandler('getTaskChanges', () => + getChangeExtractor().getTaskChanges(teamName, taskId) + ); +} + +async function handleGetChangeStats( + _event: IpcMainInvokeEvent, + teamName: string, + memberName: string +): Promise> { + return wrapReviewHandler('getChangeStats', () => + getChangeExtractor().getChangeStats(teamName, memberName) + ); +} + +// --- Phase 2 Handlers --- + +async function handleCheckConflict( + _event: IpcMainInvokeEvent, + filePath: string, + expectedModified: string +): Promise> { + return wrapReviewHandler('checkConflict', () => + getApplier().checkConflict(filePath, expectedModified) + ); +} + +async function handleRejectHunks( + _event: IpcMainInvokeEvent, + teamName: string, + filePath: string, + original: string, + modified: string, + hunkIndices: number[], + snippets: SnippetDiff[] +): Promise> { + return wrapReviewHandler('rejectHunks', () => + getApplier().rejectHunks(teamName, filePath, original, modified, hunkIndices, snippets) + ); +} + +async function handleRejectFile( + _event: IpcMainInvokeEvent, + teamName: string, + filePath: string, + original: string, + modified: string +): Promise> { + return wrapReviewHandler('rejectFile', () => + getApplier().rejectFile(teamName, filePath, original, modified) + ); +} + +async function handlePreviewReject( + _event: IpcMainInvokeEvent, + filePath: string, + original: string, + modified: string, + hunkIndices: number[], + snippets: SnippetDiff[] +): Promise> { + return wrapReviewHandler('previewReject', () => + getApplier().previewReject(filePath, original, modified, hunkIndices, snippets) + ); +} + +async function handleApplyDecisions( + _event: IpcMainInvokeEvent, + request: ApplyReviewRequest +): Promise> { + if (!request || !Array.isArray(request.decisions)) { + return { success: false, error: 'Invalid request: decisions array required' }; + } + return wrapReviewHandler('applyDecisions', () => getApplier().applyReviewDecisions(request)); +} + +async function handleGetFileContent( + _event: IpcMainInvokeEvent, + teamName: string, + memberName: string, + filePath: string +): Promise> { + return wrapReviewHandler('getFileContent', () => + getContentResolver().getFileContent(teamName, memberName, filePath) + ); +} + +// --- Phase 4 Handlers --- + +async function handleGetGitFileLog( + _event: IpcMainInvokeEvent, + projectPath: string, + filePath: string +): Promise> { + return wrapReviewHandler('getGitFileLog', async () => { + if (!gitDiffFallback) { + return []; + } + return gitDiffFallback.getFileLog(projectPath, filePath); + }); +} diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts new file mode 100644 index 00000000..27ef36fb --- /dev/null +++ b/src/main/services/team/ChangeExtractorService.ts @@ -0,0 +1,581 @@ +import { createLogger } from '@shared/utils/logger'; +import { diffLines } from 'diff'; +import { createReadStream } from 'fs'; +import { stat } from 'fs/promises'; +import * as readline from 'readline'; + +import type { TaskBoundaryParser } from './TaskBoundaryParser'; +import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; +import type { + AgentChangeSet, + ChangeStats, + FileChangeSummary, + FileEditEvent, + FileEditTimeline, + MemberLogSummary, + SnippetDiff, + TaskChangeScope, + TaskChangeSetV2, +} from '@shared/types'; + +const logger = createLogger('Service:ChangeExtractorService'); + +/** Кеш-запись: данные + mtime файла + время протухания */ +interface CacheEntry { + data: AgentChangeSet; + mtime: number; + expiresAt: number; +} + +/** Ссылка на JSONL файл с привязкой к memberName */ +interface LogFileRef { + filePath: string; + memberName: string; +} + +export class ChangeExtractorService { + private cache = new Map(); + private readonly CACHE_TTL = 3 * 60 * 1000; // 3 мин + + constructor( + private readonly logsFinder: TeamMemberLogsFinder, + private readonly boundaryParser: TaskBoundaryParser + ) {} + + /** Получить все изменения агента */ + async getAgentChanges(teamName: string, memberName: string): Promise { + const cacheKey = `${teamName}:${memberName}`; + const cached = this.cache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.data; + } + + const paths = await this.logsFinder.findMemberLogPaths(teamName, memberName); + + // Собираем все snippets из всех JSONL файлов + const allSnippets: SnippetDiff[] = []; + let latestMtime = 0; + + for (const filePath of paths) { + try { + const fileStat = await stat(filePath); + if (fileStat.mtimeMs > latestMtime) { + latestMtime = fileStat.mtimeMs; + } + } catch { + // Файл может быть удалён между обнаружением и чтением + } + + const snippets = await this.parseJSONLFile(filePath); + allSnippets.push(...snippets); + } + + const files = this.aggregateByFile(allSnippets); + + let totalLinesAdded = 0; + let totalLinesRemoved = 0; + for (const file of files) { + totalLinesAdded += file.linesAdded; + totalLinesRemoved += file.linesRemoved; + } + + const result: AgentChangeSet = { + teamName, + memberName, + files, + totalLinesAdded, + totalLinesRemoved, + totalFiles: files.length, + computedAt: new Date().toISOString(), + }; + + this.cache.set(cacheKey, { + data: result, + mtime: latestMtime, + expiresAt: Date.now() + this.CACHE_TTL, + }); + + return result; + } + + /** Получить изменения для конкретной задачи (Phase 3: per-task scoping) */ + async getTaskChanges(teamName: string, taskId: string): Promise { + const logs = await this.logsFinder.findLogsForTask(teamName, taskId); + const logRefs = await this.resolveLogFileRefs(teamName, logs); + if (logRefs.length === 0) { + return this.emptyTaskChangeSet(teamName, taskId); + } + + // Парсим boundaries для каждого лог-файла и ищем scope данной задачи + const allScopes: TaskChangeScope[] = []; + for (const ref of logRefs) { + const boundaries = await this.boundaryParser.parseBoundaries(ref.filePath); + const scope = boundaries.scopes.find((s) => s.taskId === taskId); + if (scope) { + allScopes.push({ ...scope, memberName: ref.memberName }); + } + } + + // Если scope не найден — fallback на весь файл + if (allScopes.length === 0) { + return this.fallbackSingleTaskScope(teamName, taskId, logRefs); + } + + // Фильтруем snippets по tool_use IDs из scope + const allowedToolUseIds = new Set(allScopes.flatMap((s) => s.toolUseIds)); + const files = await this.extractFilteredChanges(logRefs, allowedToolUseIds); + + const worstTier = Math.max(...allScopes.map((s) => s.confidence.tier)); + const warnings: string[] = []; + if (worstTier >= 3) { + warnings.push('Some task boundaries could not be precisely determined.'); + } + + return { + teamName, + taskId, + files, + totalLinesAdded: files.reduce((sum, f) => sum + f.linesAdded, 0), + totalLinesRemoved: files.reduce((sum, f) => sum + f.linesRemoved, 0), + totalFiles: files.length, + confidence: worstTier <= 1 ? 'high' : worstTier <= 2 ? 'medium' : 'low', + computedAt: new Date().toISOString(), + scope: allScopes[0], + warnings, + }; + } + + /** Получить краткую статистику */ + async getChangeStats(teamName: string, memberName: string): Promise { + const changes = await this.getAgentChanges(teamName, memberName); + return { + linesAdded: changes.totalLinesAdded, + linesRemoved: changes.totalLinesRemoved, + filesChanged: changes.totalFiles, + }; + } + + // ---- Private methods ---- + + /** Парсить один JSONL файл и извлечь все snippets (двухпроходный подход) */ + private async parseJSONLFile(filePath: string): Promise { + // Сначала считываем все записи в память для двух проходов + const entries: Record[] = []; + + try { + const stream = createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + entries.push(JSON.parse(trimmed) as Record); + } catch { + // Пропускаем невалидный JSON + } + } + + rl.close(); + stream.destroy(); + } catch (err) { + logger.debug(`Не удалось прочитать файл ${filePath}: ${String(err)}`); + return []; + } + + // Проход 1: собираем tool_use_id с ошибками + const erroredIds = this.collectErroredToolUseIds(entries); + + // Проход 2: извлекаем snippets из tool_use блоков + const snippets: SnippetDiff[] = []; + // Множество уже встречавшихся файлов (для определения write-new vs write-update) + const seenFiles = new Set(); + + for (const entry of entries) { + const role = this.extractRole(entry); + if (role !== 'assistant') continue; + + const content = this.extractContent(entry); + if (!content) continue; + + const timestamp = + typeof entry.timestamp === 'string' ? entry.timestamp : new Date().toISOString(); + + for (const block of content) { + if ( + !block || + typeof block !== 'object' || + (block as Record).type !== 'tool_use' + ) { + continue; + } + + const toolBlock = block as Record; + const rawName = typeof toolBlock.name === 'string' ? toolBlock.name : ''; + // Убираем proxy_ префикс + const toolName = rawName.startsWith('proxy_') ? rawName.slice(6) : rawName; + const toolUseId = typeof toolBlock.id === 'string' ? toolBlock.id : ''; + const input = toolBlock.input as Record | undefined; + if (!input) continue; + + const isError = erroredIds.has(toolUseId); + + if (toolName === 'Edit') { + const filePath_ = typeof input.file_path === 'string' ? input.file_path : ''; + const oldString = typeof input.old_string === 'string' ? input.old_string : ''; + const newString = typeof input.new_string === 'string' ? input.new_string : ''; + const replaceAll = input.replace_all === true; + + if (filePath_) { + seenFiles.add(filePath_); + snippets.push({ + toolUseId, + filePath: filePath_, + toolName: 'Edit', + type: 'edit', + oldString, + newString, + replaceAll, + timestamp, + isError, + }); + } + } else if (toolName === 'Write') { + const filePath_ = typeof input.file_path === 'string' ? input.file_path : ''; + const writeContent = typeof input.content === 'string' ? input.content : ''; + + if (filePath_) { + const isNew = !seenFiles.has(filePath_); + seenFiles.add(filePath_); + snippets.push({ + toolUseId, + filePath: filePath_, + toolName: 'Write', + type: isNew ? 'write-new' : 'write-update', + oldString: '', + newString: writeContent, + replaceAll: false, + timestamp, + isError, + }); + } + } else if (toolName === 'MultiEdit') { + const filePath_ = typeof input.file_path === 'string' ? input.file_path : ''; + const edits = Array.isArray(input.edits) ? input.edits : []; + + if (filePath_) { + seenFiles.add(filePath_); + for (const edit of edits) { + if (!edit || typeof edit !== 'object') continue; + const editObj = edit as Record; + const oldString = typeof editObj.old_string === 'string' ? editObj.old_string : ''; + const newString = typeof editObj.new_string === 'string' ? editObj.new_string : ''; + snippets.push({ + toolUseId, + filePath: filePath_, + toolName: 'MultiEdit', + type: 'multi-edit', + oldString, + newString, + replaceAll: false, + timestamp, + isError, + }); + } + } + } + // Остальные инструменты (NotebookEdit и пр.) пропускаем + } + } + + return snippets; + } + + /** Извлечь content array из JSONL entry (оба формата: subagent и main) */ + private extractContent(entry: Record): unknown[] | null { + const message = entry.message as Record | undefined; + if (message && Array.isArray(message.content)) return message.content as unknown[]; + if (Array.isArray(entry.content)) return entry.content as unknown[]; + return null; + } + + /** Извлечь роль из JSONL entry */ + private extractRole(entry: Record): string | null { + if (typeof entry.role === 'string') return entry.role; + const message = entry.message as Record | undefined; + if (message && typeof message.role === 'string') return message.role; + return null; + } + + /** Собрать errored tool_use_ids из tool_result блоков */ + private collectErroredToolUseIds(entries: Record[]): Set { + const erroredIds = new Set(); + + for (const entry of entries) { + // tool_result может находиться в entry.content (когда это массив) + if (Array.isArray(entry.content)) { + for (const block of entry.content) { + if (this.isErroredToolResult(block)) { + const toolUseId = (block as Record).tool_use_id; + if (typeof toolUseId === 'string') { + erroredIds.add(toolUseId); + } + } + } + } + + // Также проверяем entry.message.content + const message = entry.message as Record | undefined; + if (message && Array.isArray(message.content)) { + for (const block of message.content) { + if (this.isErroredToolResult(block)) { + const toolUseId = (block as Record).tool_use_id; + if (typeof toolUseId === 'string') { + erroredIds.add(toolUseId); + } + } + } + } + } + + return erroredIds; + } + + /** Проверить, является ли блок tool_result с ошибкой */ + private isErroredToolResult(block: unknown): boolean { + if (!block || typeof block !== 'object') return false; + const obj = block as Record; + return obj.type === 'tool_result' && obj.is_error === true; + } + + /** Агрегировать snippets в FileChangeSummary[] */ + private aggregateByFile(snippets: SnippetDiff[], projectPath?: string): FileChangeSummary[] { + const fileMap = new Map(); + + for (const snippet of snippets) { + // Пропускаем snippets с ошибками при агрегации + if (snippet.isError) continue; + + const existing = fileMap.get(snippet.filePath); + if (existing) { + existing.snippets.push(snippet); + } else { + fileMap.set(snippet.filePath, { + snippets: [snippet], + isNewFile: snippet.type === 'write-new', + }); + } + } + + return [...fileMap.entries()].map(([fp, data]) => { + let totalAdded = 0; + let totalRemoved = 0; + for (const s of data.snippets) { + if (s.isError) continue; + const { added, removed } = this.countLines(s.oldString, s.newString); + totalAdded += added; + totalRemoved += removed; + } + return { + filePath: fp, + relativePath: projectPath + ? fp.replace(projectPath + '/', '') + : fp.split('/').slice(-3).join('/'), + snippets: data.snippets, + linesAdded: totalAdded, + linesRemoved: totalRemoved, + isNewFile: data.isNewFile, + timeline: this.buildTimeline(fp, data.snippets), + }; + }); + } + + /** Build edit timeline from snippets */ + private buildTimeline(filePath: string, snippets: SnippetDiff[]): FileEditTimeline { + const events: FileEditEvent[] = snippets + .filter((s) => !s.isError) + .map((s, idx) => ({ + toolUseId: s.toolUseId, + toolName: s.toolName as FileEditEvent['toolName'], + timestamp: s.timestamp, + summary: this.generateEditSummary(s), + linesAdded: Math.max(0, s.newString.split('\n').length - s.oldString.split('\n').length), + linesRemoved: Math.max(0, s.oldString.split('\n').length - s.newString.split('\n').length), + snippetIndex: idx, + })); + + const timestamps = events.map((e) => new Date(e.timestamp).getTime()).filter((t) => !isNaN(t)); + const durationMs = + timestamps.length >= 2 ? Math.max(...timestamps) - Math.min(...timestamps) : 0; + + return { filePath, events, durationMs }; + } + + private generateEditSummary(snippet: SnippetDiff): string { + switch (snippet.type) { + case 'write-new': + return 'Created new file'; + case 'write-update': + return 'Wrote full file content'; + case 'multi-edit': { + const lines = snippet.oldString.split('\n').length; + return `Multi-edit (${lines} line${lines !== 1 ? 's' : ''})`; + } + case 'edit': { + const added = snippet.newString.split('\n').length; + const removed = snippet.oldString.split('\n').length; + if (removed === 0 || snippet.oldString === '') + return `Added ${added} line${added !== 1 ? 's' : ''}`; + if (added === 0 || snippet.newString === '') + return `Removed ${removed} line${removed !== 1 ? 's' : ''}`; + return `Changed ${removed} → ${added} lines`; + } + default: + return 'File modified'; + } + } + + /** Подсчёт добавленных/удалённых строк через diff */ + private countLines(oldStr: string, newStr: string): { added: number; removed: number } { + if (!oldStr && !newStr) return { added: 0, removed: 0 }; + const changes = diffLines(oldStr, newStr); + let added = 0; + let removed = 0; + for (const c of changes) { + if (c.added) added += c.count ?? 0; + if (c.removed) removed += c.count ?? 0; + } + return { added, removed }; + } + + /** Проверить, содержит ли путь к файлу один из sessionId */ + private pathMatchesAnySession(filePath: string, sessionIds: Set): boolean { + for (const sessionId of sessionIds) { + if (filePath.includes(sessionId)) return true; + } + return false; + } + + /** Конвертировать MemberLogSummary[] в LogFileRef[] через findMemberLogPaths */ + private async resolveLogFileRefs( + teamName: string, + logs: MemberLogSummary[] + ): Promise { + const refs: LogFileRef[] = []; + const byMember = new Map(); + for (const log of logs) { + const name = log.memberName ?? 'unknown'; + if (!byMember.has(name)) byMember.set(name, []); + byMember.get(name)!.push(log); + } + for (const [memberName, memberLogs] of byMember) { + const paths = await this.logsFinder.findMemberLogPaths(teamName, memberName); + for (const log of memberLogs) { + const matchedPath = paths.find((p) => + log.kind === 'subagent' + ? p.includes(log.sessionId) && p.includes(log.subagentId) + : p.includes(log.sessionId) && p.endsWith('.jsonl') + ); + if (matchedPath) { + refs.push({ filePath: matchedPath, memberName }); + } + } + } + return refs; + } + + /** Извлечь изменения из JSONL файлов, фильтруя по tool_use IDs */ + private async extractFilteredChanges( + logRefs: LogFileRef[], + allowedToolUseIds: Set + ): Promise { + const allSnippets: SnippetDiff[] = []; + for (const ref of logRefs) { + const snippets = await this.parseJSONLFile(ref.filePath); + if (allowedToolUseIds.size > 0) { + // Фильтруем только по разрешённым tool_use IDs + for (const s of snippets) { + if (allowedToolUseIds.has(s.toolUseId)) { + allSnippets.push(s); + } + } + } else { + allSnippets.push(...snippets); + } + } + return this.aggregateByFile(allSnippets); + } + + /** Извлечь все изменения из одного файла */ + private async extractAllChanges( + filePath: string, + _memberName: string + ): Promise { + const snippets = await this.parseJSONLFile(filePath); + return this.aggregateByFile(snippets); + } + + /** Fallback: вернуть все изменения из лог-файлов как Tier 4 */ + private async fallbackSingleTaskScope( + teamName: string, + taskId: string, + logRefs: LogFileRef[] + ): Promise { + const allFiles: FileChangeSummary[] = []; + for (const ref of logRefs) { + const files = await this.extractAllChanges(ref.filePath, ref.memberName); + allFiles.push(...files); + } + + const fallbackScope: TaskChangeScope = { + taskId, + memberName: logRefs[0]?.memberName ?? 'unknown', + startLine: 1, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: allFiles.map((f) => f.filePath), + confidence: { tier: 4, label: 'fallback', reason: 'No task boundaries found in JSONL' }, + }; + + return { + teamName, + taskId, + files: allFiles, + totalLinesAdded: allFiles.reduce((sum, f) => sum + f.linesAdded, 0), + totalLinesRemoved: allFiles.reduce((sum, f) => sum + f.linesRemoved, 0), + totalFiles: allFiles.length, + confidence: 'fallback', + computedAt: new Date().toISOString(), + scope: fallbackScope, + warnings: ['No task boundaries found — showing all changes from related sessions.'], + }; + } + + /** Пустой TaskChangeSetV2 */ + private emptyTaskChangeSet(teamName: string, taskId: string): TaskChangeSetV2 { + return { + teamName, + taskId, + files: [], + totalLinesAdded: 0, + totalLinesRemoved: 0, + totalFiles: 0, + confidence: 'fallback', + computedAt: new Date().toISOString(), + scope: { + taskId, + memberName: '', + startLine: 0, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: [], + confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' }, + }, + warnings: ['No log files found for this task.'], + }; + } +} diff --git a/src/main/services/team/FileContentResolver.ts b/src/main/services/team/FileContentResolver.ts new file mode 100644 index 00000000..52d6bb24 --- /dev/null +++ b/src/main/services/team/FileContentResolver.ts @@ -0,0 +1,459 @@ +import { createLogger } from '@shared/utils/logger'; +import { createReadStream } from 'fs'; +import { access, readFile } from 'fs/promises'; +import * as path from 'path'; +import * as readline from 'readline'; + +import type { GitDiffFallback } from './GitDiffFallback'; +import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; +import type { FileChangeWithContent, SnippetDiff } from '@shared/types'; + +const logger = createLogger('Service:FileContentResolver'); + +/** Кеш-запись для resolved content */ +interface ContentCacheEntry { + original: string | null; + modified: string | null; + source: FileChangeWithContent['contentSource']; + expiresAt: number; +} + +/** + * Resolves full file contents (original + modified) for CodeMirror diff view. + * + * Uses three-level resolution strategy: + * 1. File-history backup (most accurate) + * 2. Snippet reconstruction (reverse-apply edits from current disk state) + * 3. Fallback to current file on disk + */ +export class FileContentResolver { + private cache = new Map(); + private readonly CACHE_TTL = 3 * 60 * 1000; // 3 мин (same as ChangeExtractorService) + + constructor( + private readonly logsFinder: TeamMemberLogsFinder, + private readonly gitFallback?: GitDiffFallback + ) {} + + /** + * Resolve full file contents for a single file. + * Returns original (before changes) and modified (after changes) content. + */ + async resolveFileContent( + teamName: string, + memberName: string, + filePath: string, + snippets: SnippetDiff[] + ): Promise<{ + original: string | null; + modified: string | null; + source: FileChangeWithContent['contentSource']; + }> { + const cacheKey = `${teamName}:${memberName}:${filePath}`; + const cached = this.cache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return { original: cached.original, modified: cached.modified, source: cached.source }; + } + + // Read current file from disk (= modified state after agent's changes) + let currentContent: string | null = null; + try { + currentContent = await readFile(filePath, 'utf8'); + } catch { + logger.debug(`Файл недоступен на диске: ${filePath}`); + } + + // Strategy 1: Try file-history backup + const historyResult = await this.tryFileHistoryBackup(teamName, memberName, filePath); + if (historyResult) { + const result = { + original: historyResult, + modified: currentContent, + source: 'file-history' as const, + }; + this.cacheResult(cacheKey, result); + return result; + } + + // Strategy 2: Try snippet reconstruction + const reconstructed = this.trySnippetReconstruction(currentContent, snippets); + if (reconstructed !== null) { + const result = { + original: reconstructed, + modified: currentContent, + source: 'snippet-reconstruction' as const, + }; + this.cacheResult(cacheKey, result); + return result; + } + + // Strategy 3 (Phase 4): Git fallback + if (this.gitFallback) { + const gitResult = await this.tryGitFallback(filePath, currentContent, snippets); + if (gitResult) { + const result = { + original: gitResult, + modified: currentContent, + source: 'git-fallback' as const, + }; + this.cacheResult(cacheKey, result); + return result; + } + } + + // Strategy 4: Fallback — only current file on disk + if (currentContent !== null) { + const result = { + original: null, + modified: currentContent, + source: 'disk-current' as const, + }; + this.cacheResult(cacheKey, result); + return result; + } + + // Nothing available + return { original: null, modified: null, source: 'unavailable' }; + } + + /** + * Get full file content for a single file (IPC-facing method). + * Returns a FileChangeWithContent object ready for the renderer. + */ + async getFileContent( + teamName: string, + memberName: string, + filePath: string + ): Promise { + const resolved = await this.resolveFileContent(teamName, memberName, filePath, []); + return { + filePath, + relativePath: filePath.split('/').slice(-3).join('/'), + snippets: [], + linesAdded: 0, + linesRemoved: 0, + isNewFile: false, + originalFullContent: resolved.original, + modifiedFullContent: resolved.modified, + contentSource: resolved.source, + }; + } + + /** + * Resolve full contents for multiple files at once. + * Returns a map of filePath -> FileChangeWithContent. + */ + async resolveAllFileContents( + teamName: string, + memberName: string, + files: { + filePath: string; + relativePath: string; + snippets: SnippetDiff[]; + linesAdded: number; + linesRemoved: number; + isNewFile: boolean; + }[] + ): Promise> { + const results = new Map(); + + // Resolve all files in parallel + const promises = files.map(async (file) => { + const resolved = await this.resolveFileContent( + teamName, + memberName, + file.filePath, + file.snippets + ); + const entry: FileChangeWithContent = { + filePath: file.filePath, + relativePath: file.relativePath, + snippets: file.snippets, + linesAdded: file.linesAdded, + linesRemoved: file.linesRemoved, + isNewFile: file.isNewFile, + originalFullContent: resolved.original, + modifiedFullContent: resolved.modified, + contentSource: resolved.source, + }; + results.set(file.filePath, entry); + }); + + await Promise.all(promises); + return results; + } + + // ── Private: Resolution strategies ── + + /** + * Strategy 1: Read original content from Claude's file-history backup. + * + * Claude saves file snapshots at `~/.claude/file-history/{sessionId}/{backupFileName}`. + * The mapping is stored as `type: "file-history-snapshot"` entries in JSONL. + */ + private async tryFileHistoryBackup( + teamName: string, + memberName: string, + filePath: string + ): Promise { + let logPaths: string[]; + try { + logPaths = await this.logsFinder.findMemberLogPaths(teamName, memberName); + } catch { + return null; + } + + if (logPaths.length === 0) return null; + + for (const logPath of logPaths) { + const sessionId = this.extractSessionId(logPath); + if (!sessionId) continue; + + const backupFileName = await this.findFileHistoryBackup(logPath, filePath); + if (!backupFileName) continue; + + // Construct the file-history path + const homeDir = process.env.HOME || process.env.USERPROFILE || ''; + const historyPath = path.join(homeDir, '.claude', 'file-history', sessionId, backupFileName); + + try { + await access(historyPath); + const content = await readFile(historyPath, 'utf8'); + logger.debug(`File-history backup найден: ${historyPath}`); + return content; + } catch { + // Backup file doesn't exist, try next log + continue; + } + } + + return null; + } + + /** + * Extract sessionId from a JSONL log path. + * + * Paths can be: + * - `~/.claude/projects/{encodedPath}/{sessionId}.jsonl` (lead session) + * - `~/.claude/projects/{encodedPath}/{sessionId}/subagents/agent-{id}.jsonl` (subagent) + * + * For lead sessions, sessionId = filename without extension. + * For subagents, sessionId = the parent directory's parent name. + */ + private extractSessionId(logPath: string): string | null { + const parts = logPath.split(path.sep); + + // Check if it's a subagent path: .../{sessionId}/subagents/agent-xxx.jsonl + const subagentsIdx = parts.indexOf('subagents'); + if (subagentsIdx > 0) { + return parts[subagentsIdx - 1] || null; + } + + // Lead session: .../{sessionId}.jsonl + const fileName = parts[parts.length - 1]; + if (fileName?.endsWith('.jsonl')) { + return fileName.replace('.jsonl', ''); + } + + return null; + } + + /** + * Stream a JSONL file looking for file-history-snapshot entries that reference the target file. + * Returns the backup file name if found. + */ + private async findFileHistoryBackup( + logPath: string, + targetFilePath: string + ): Promise { + try { + const stream = createReadStream(logPath, { encoding: 'utf8' }); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) continue; + + // Quick check before JSON parse + if (!trimmed.includes('file-history-snapshot')) continue; + + try { + const entry = JSON.parse(trimmed) as Record; + if (entry.type !== 'file-history-snapshot') continue; + + const snapshot = entry.snapshot as Record | undefined; + if (!snapshot) continue; + + const trackedFileBackups = snapshot.trackedFileBackups as + | Record + | undefined; + if (!trackedFileBackups) continue; + + const backupFileName = trackedFileBackups[targetFilePath]; + if (backupFileName) { + rl.close(); + stream.destroy(); + return backupFileName; + } + } catch { + // Skip malformed JSON + } + } + + rl.close(); + stream.destroy(); + } catch { + logger.debug(`Не удалось прочитать JSONL для file-history: ${logPath}`); + } + + return null; + } + + /** + * Strategy 2: Reconstruct original content by reverse-applying snippets. + * + * Algorithm: + * 1. Start with current file content from disk (= modified state) + * 2. Sort snippets by timestamp DESCENDING (newest first) + * 3. For each snippet, reverse the edit operation + * 4. Result = original content before any agent changes + * + * Returns null if reconstruction is not possible (chain broken). + */ + private trySnippetReconstruction( + currentContent: string | null, + snippets: SnippetDiff[] + ): string | null { + if (!currentContent) return null; + if (snippets.length === 0) return null; + + // Filter out errored snippets + const validSnippets = snippets.filter((s) => !s.isError); + if (validSnippets.length === 0) return null; + + // Sort by timestamp descending (reverse order to undo newest first) + const sorted = [...validSnippets].sort( + (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + ); + + let content = currentContent; + + for (const snippet of sorted) { + switch (snippet.type) { + case 'write-new': { + // File was created by agent -> original was empty + return ''; + } + + case 'write-update': { + // Full file overwrite — can't reconstruct previous content from snippets alone + return null; + } + + case 'edit': + case 'multi-edit': { + if (snippet.replaceAll) { + // Reverse replaceAll: replace all occurrences of newString -> oldString + if (!content.includes(snippet.newString)) { + // Chain broken — newString not in current content + return null; + } + content = content.split(snippet.newString).join(snippet.oldString); + } else { + // Reverse single edit: replace first occurrence of newString -> oldString + const idx = content.indexOf(snippet.newString); + if (idx === -1) { + // Chain broken — can't find the new string to reverse + return null; + } + content = + content.substring(0, idx) + + snippet.oldString + + content.substring(idx + snippet.newString.length); + } + break; + } + } + } + + return content; + } + + // ── Private: Git fallback (Phase 4) ── + + /** + * Strategy 3 (Phase 4): Git fallback — find original content from git history. + * Uses the timestamp of the first snippet to locate a commit before changes. + */ + private async tryGitFallback( + filePath: string, + _currentContent: string | null, + snippets: SnippetDiff[] + ): Promise { + if (!this.gitFallback) return null; + + // Determine project path from file path (heuristic: find .git parent) + const projectPath = this.guessProjectPath(filePath); + if (!projectPath) return null; + + const isGit = await this.gitFallback.isGitRepo(projectPath); + if (!isGit) return null; + + // Use earliest snippet timestamp to find the "before" state + const timestamps = snippets + .filter((s) => !s.isError && s.timestamp) + .map((s) => s.timestamp) + .sort((a, b) => a.localeCompare(b)); + const firstTimestamp = timestamps[0]; + if (!firstTimestamp) return null; + + const commitHash = await this.gitFallback.findCommitNearTimestamp( + projectPath, + filePath, + firstTimestamp + ); + if (!commitHash) return null; + + const original = await this.gitFallback.getFileAtCommit(projectPath, filePath, commitHash); + return original; + } + + /** + * Guess the project root path from a file path. + * Simple heuristic: look for common markers (package.json, .git directory). + */ + private guessProjectPath(filePath: string): string | null { + const parts = filePath.split('/'); + // Walk up from file, looking for typical project root indicators + for (let i = parts.length - 1; i >= 1; i--) { + const candidate = parts.slice(0, i).join('/'); + // Simple heuristic: paths with these patterns are likely project roots + if (candidate.endsWith('/src') || candidate.endsWith('/lib')) { + return parts.slice(0, i - 1).join('/') || null; + } + } + // Fallback: take the first 4-5 components as project path + if (parts.length > 4) { + return parts.slice(0, Math.min(parts.length - 2, 5)).join('/'); + } + return null; + } + + // ── Private: Cache helpers ── + + private cacheResult( + key: string, + result: { + original: string | null; + modified: string | null; + source: FileChangeWithContent['contentSource']; + } + ): void { + this.cache.set(key, { + original: result.original, + modified: result.modified, + source: result.source, + expiresAt: Date.now() + this.CACHE_TTL, + }); + } +} diff --git a/src/main/services/team/GitDiffFallback.ts b/src/main/services/team/GitDiffFallback.ts new file mode 100644 index 00000000..bfe2a3d6 --- /dev/null +++ b/src/main/services/team/GitDiffFallback.ts @@ -0,0 +1,134 @@ +import { execFile } from 'child_process'; +import { promisify } from 'util'; + +const execFileAsync = promisify(execFile); + +const GIT_TIMEOUT = 10_000; // 10s timeout for all git operations +const GIT_MAX_BUFFER = 10 * 1024 * 1024; // 10MB + +export class GitDiffFallback { + private gitRepoCache = new Map(); + + /** + * Get file contents at a specific commit. + * Used when file-history-snapshot is unavailable. + */ + async getFileAtCommit( + projectPath: string, + filePath: string, + commitHash: string + ): Promise { + try { + const relativePath = filePath.startsWith(projectPath + '/') + ? filePath.slice(projectPath.length + 1) + : filePath; + const { stdout } = await execFileAsync('git', ['show', `${commitHash}:${relativePath}`], { + cwd: projectPath, + maxBuffer: GIT_MAX_BUFFER, + timeout: GIT_TIMEOUT, + }); + return stdout; + } catch { + return null; + } + } + + /** + * Find the commit closest to (but before) a given timestamp for a file. + */ + async findCommitNearTimestamp( + projectPath: string, + filePath: string, + timestamp: string + ): Promise { + try { + const relativePath = filePath.startsWith(projectPath + '/') + ? filePath.slice(projectPath.length + 1) + : filePath; + const { stdout } = await execFileAsync( + 'git', + ['log', '--format=%H', '--before', timestamp, '-1', '--', relativePath], + { cwd: projectPath, timeout: GIT_TIMEOUT } + ); + return stdout.trim() || null; + } catch { + return null; + } + } + + /** + * Get git diff for a file between two refs. + */ + async getGitDiff( + projectPath: string, + filePath: string, + fromCommit: string, + toCommit: string = 'HEAD' + ): Promise { + try { + const relativePath = filePath.startsWith(projectPath + '/') + ? filePath.slice(projectPath.length + 1) + : filePath; + const { stdout } = await execFileAsync( + 'git', + ['diff', fromCommit, toCommit, '--', relativePath], + { cwd: projectPath, timeout: GIT_TIMEOUT } + ); + return stdout || null; + } catch { + return null; + } + } + + /** + * Get file change log (for timeline enrichment). + */ + async getFileLog( + projectPath: string, + filePath: string, + maxCount: number = 20 + ): Promise<{ hash: string; timestamp: string; message: string }[]> { + try { + const relativePath = filePath.startsWith(projectPath + '/') + ? filePath.slice(projectPath.length + 1) + : filePath; + const { stdout } = await execFileAsync( + 'git', + ['log', `--max-count=${maxCount}`, '--format=%H|%aI|%s', '--', relativePath], + { cwd: projectPath, timeout: GIT_TIMEOUT } + ); + + return stdout + .trim() + .split('\n') + .filter((line) => line.includes('|')) + .map((line) => { + const [hash, timestamp, ...msgParts] = line.split('|'); + return { hash, timestamp, message: msgParts.join('|') }; + }); + } catch { + return []; + } + } + + /** + * Check if a path is inside a git repository. + * Result is cached per projectPath for the session lifetime. + */ + async isGitRepo(projectPath: string): Promise { + const cached = this.gitRepoCache.get(projectPath); + if (cached !== undefined) return cached; + + try { + await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], { + cwd: projectPath, + timeout: GIT_TIMEOUT, + }); + this.gitRepoCache.set(projectPath, true); + return true; + } catch { + this.gitRepoCache.set(projectPath, false); + return false; + } + } +} diff --git a/src/main/services/team/ReviewApplierService.ts b/src/main/services/team/ReviewApplierService.ts new file mode 100644 index 00000000..2a94c7d4 --- /dev/null +++ b/src/main/services/team/ReviewApplierService.ts @@ -0,0 +1,487 @@ +import { createLogger } from '@shared/utils/logger'; +import { applyPatch, structuredPatch } from 'diff'; +import { readFile, writeFile } from 'fs/promises'; +import { diff3Merge } from 'node-diff3'; + +import type { + ApplyReviewRequest, + ApplyReviewResult, + ConflictCheckResult, + FileChangeWithContent, + RejectResult, + SnippetDiff, +} from '@shared/types'; +import type { StructuredPatchHunk } from 'diff'; + +const logger = createLogger('Service:ReviewApplierService'); + +/** + * Service for applying reject decisions from code review. + * + * Supports: + * - Conflict detection (file changed since review was computed) + * - Hunk-level rejection (reverse specific hunks) + * - File-level rejection (restore entire file to original) + * - Preview mode (show what would change without writing) + * - Batch review application + */ +export class ReviewApplierService { + /** + * Check if the file on disk has been modified since the review was computed. + * Compares current disk content against the expected modified content. + */ + async checkConflict(filePath: string, expectedModified: string): Promise { + let currentContent: string; + try { + currentContent = await readFile(filePath, 'utf8'); + } catch { + return { + hasConflict: true, + conflictContent: null, + currentContent: '', + originalContent: expectedModified, + }; + } + + const hasConflict = currentContent !== expectedModified; + + return { + hasConflict, + conflictContent: hasConflict ? currentContent : null, + currentContent, + originalContent: expectedModified, + }; + } + + /** + * Reject specific hunks from a file's changes. + * + * PRIMARY approach: snippet-level replacement with positional reverse. + * FALLBACK: hunk-level inverse patch when snippet replacement fails. + */ + async rejectHunks( + _teamName: string, + filePath: string, + original: string, + modified: string, + hunkIndices: number[], + snippets: SnippetDiff[] + ): Promise { + // Try snippet-level reverse first (most accurate) + const snippetResult = this.trySnippetLevelReject(modified, hunkIndices, snippets); + if (snippetResult) { + try { + await writeFile(filePath, snippetResult.newContent, 'utf8'); + return snippetResult; + } catch (err) { + return { + success: false, + newContent: modified, + hadConflicts: false, + conflictDescription: `Не удалось записать файл: ${String(err)}`, + }; + } + } + + // Fallback: hunk-level inverse patch + const patchResult = this.tryHunkLevelReject(original, modified, hunkIndices); + if (patchResult) { + try { + await writeFile(filePath, patchResult.newContent, 'utf8'); + return patchResult; + } catch (err) { + return { + success: false, + newContent: modified, + hadConflicts: false, + conflictDescription: `Не удалось записать файл: ${String(err)}`, + }; + } + } + + // Both approaches failed — try three-way merge as last resort + const mergeResult = threeWayMerge(original, modified, original); + if (!mergeResult.hasConflicts) { + try { + await writeFile(filePath, mergeResult.content, 'utf8'); + return { + success: true, + newContent: mergeResult.content, + hadConflicts: false, + }; + } catch (err) { + return { + success: false, + newContent: modified, + hadConflicts: false, + conflictDescription: `Не удалось записать файл: ${String(err)}`, + }; + } + } + + return { + success: false, + newContent: modified, + hadConflicts: true, + conflictDescription: 'Не удалось применить reject: все стратегии завершились неудачно', + }; + } + + /** + * Reject the entire file — restore to original content. + */ + async rejectFile( + _teamName: string, + filePath: string, + original: string, + modified: string + ): Promise { + // Check for conflicts first + const conflict = await this.checkConflict(filePath, modified); + if (conflict.hasConflict) { + // File was modified since review — try three-way merge + const currentContent = conflict.currentContent; + const mergeResult = threeWayMerge(modified, currentContent, original); + + if (mergeResult.hasConflicts) { + return { + success: false, + newContent: currentContent, + hadConflicts: true, + conflictDescription: + 'Файл был изменён после вычисления review, и три-сторонний merge обнаружил конфликты', + }; + } + + try { + await writeFile(filePath, mergeResult.content, 'utf8'); + return { + success: true, + newContent: mergeResult.content, + hadConflicts: false, + }; + } catch (err) { + return { + success: false, + newContent: currentContent, + hadConflicts: false, + conflictDescription: `Не удалось записать файл: ${String(err)}`, + }; + } + } + + // No conflict — simply write original content + try { + await writeFile(filePath, original, 'utf8'); + return { + success: true, + newContent: original, + hadConflicts: false, + }; + } catch (err) { + return { + success: false, + newContent: modified, + hadConflicts: false, + conflictDescription: `Не удалось записать файл: ${String(err)}`, + }; + } + } + + /** + * Preview what a reject operation would produce WITHOUT writing to disk. + */ + async previewReject( + _filePath: string, + original: string, + modified: string, + hunkIndices: number[], + snippets: SnippetDiff[] + ): Promise<{ preview: string; hasConflicts: boolean }> { + // Try snippet-level reverse + const snippetResult = this.trySnippetLevelReject(modified, hunkIndices, snippets); + if (snippetResult) { + return { preview: snippetResult.newContent, hasConflicts: false }; + } + + // Fallback: hunk-level inverse patch + const patchResult = this.tryHunkLevelReject(original, modified, hunkIndices); + if (patchResult) { + return { preview: patchResult.newContent, hasConflicts: patchResult.hadConflicts }; + } + + // Final fallback — three-way merge + const mergeResult = threeWayMerge(original, modified, original); + return { preview: mergeResult.content, hasConflicts: mergeResult.hasConflicts }; + } + + /** + * Apply all review decisions in batch. + */ + async applyReviewDecisions( + request: ApplyReviewRequest, + fileContents = new Map() + ): Promise { + let applied = 0; + let skipped = 0; + let conflicts = 0; + const errors: ApplyReviewResult['errors'] = []; + + for (const decision of request.decisions) { + const fileContent = fileContents.get(decision.filePath); + if (!fileContent) { + skipped++; + continue; + } + + // Skip files where all hunks are accepted (nothing to reject) + if (decision.fileDecision === 'accepted') { + skipped++; + continue; + } + + const original = fileContent.originalFullContent; + const modified = fileContent.modifiedFullContent; + + if (original === null || modified === null) { + errors.push({ + filePath: decision.filePath, + error: 'Содержимое файла недоступно для применения review', + }); + continue; + } + + try { + if (decision.fileDecision === 'rejected') { + // Reject entire file + const result = await this.rejectFile( + request.teamName, + decision.filePath, + original, + modified + ); + if (result.success) { + applied++; + } else { + if (result.hadConflicts) conflicts++; + errors.push({ + filePath: decision.filePath, + error: result.conflictDescription || 'Не удалось применить reject', + }); + } + } else { + // Partial reject — only specific hunks + const rejectedHunkIndices = Object.entries(decision.hunkDecisions) + .filter(([, d]) => d === 'rejected') + .map(([idx]) => parseInt(idx, 10)); + + if (rejectedHunkIndices.length === 0) { + skipped++; + continue; + } + + const result = await this.rejectHunks( + request.teamName, + decision.filePath, + original, + modified, + rejectedHunkIndices, + fileContent.snippets + ); + + if (result.success) { + applied++; + } else { + if (result.hadConflicts) conflicts++; + errors.push({ + filePath: decision.filePath, + error: result.conflictDescription || 'Не удалось применить reject', + }); + } + } + } catch (err) { + errors.push({ + filePath: decision.filePath, + error: `Неожиданная ошибка: ${String(err)}`, + }); + } + } + + return { applied, skipped, conflicts, errors }; + } + + // ── Private: Rejection strategies ── + + /** + * Snippet-level rejection: reverse specific snippets by position (most accurate). + * + * Maps hunk indices to snippet indices, then reverses each snippet's edit + * in reverse positional order to avoid index shift. + */ + private trySnippetLevelReject( + modified: string, + hunkIndices: number[], + snippets: SnippetDiff[] + ): RejectResult | null { + const validSnippets = snippets.filter((s) => !s.isError); + if (validSnippets.length === 0) return null; + + // Map hunk indices to snippet indices. + // The structured patch hunks roughly correspond to groups of consecutive edit snippets, + // but a simple approach: use hunkIndices as snippet indices (Phase 2 assumption). + const snippetsToReject = hunkIndices + .filter((idx) => idx >= 0 && idx < validSnippets.length) + .map((idx) => validSnippets[idx]) + .filter(Boolean); + + if (snippetsToReject.length === 0) return null; + + let content = modified; + + // Sort by position in file descending to avoid index shift when replacing + // We find each snippet's newString position and sort by that + const positioned = snippetsToReject + .map((snippet) => { + const pos = content.indexOf(snippet.newString); + return { snippet, pos }; + }) + .filter((item) => item.pos !== -1) + .sort((a, b) => b.pos - a.pos); + + if (positioned.length !== snippetsToReject.length) { + // Some snippets' newStrings not found in current content — can't do snippet-level + return null; + } + + for (const { snippet, pos } of positioned) { + if (snippet.type === 'write-new') { + // Can't partially reject a file creation at snippet level + continue; + } + + if (snippet.replaceAll) { + content = content.split(snippet.newString).join(snippet.oldString); + } else { + content = + content.substring(0, pos) + + snippet.oldString + + content.substring(pos + snippet.newString.length); + } + } + + return { + success: true, + newContent: content, + hadConflicts: false, + }; + } + + /** + * Hunk-level rejection: create inverse patch for rejected hunks and apply it. + */ + private tryHunkLevelReject( + original: string, + modified: string, + hunkIndices: number[] + ): RejectResult | null { + // Create structured patch + const patch = structuredPatch('file', 'file', original, modified); + + if (!patch.hunks || patch.hunks.length === 0) return null; + + // Validate hunk indices + const validIndices = hunkIndices.filter((idx) => idx >= 0 && idx < patch.hunks.length); + if (validIndices.length === 0) return null; + + // Build a partial inverse patch: only reverse the rejected hunks + const inversedHunks: StructuredPatchHunk[] = []; + for (const idx of validIndices) { + const hunk = patch.hunks[idx]; + if (!hunk) continue; + inversedHunks.push(invertHunk(hunk)); + } + + if (inversedHunks.length === 0) return null; + + // Create a partial inverse patch with the inverted hunks + const inversePatch = { + oldFileName: 'file', + newFileName: 'file', + oldHeader: undefined, + newHeader: undefined, + hunks: inversedHunks, + }; + + // Apply the inverse patch to the modified content + const result = applyPatch(modified, inversePatch, { fuzzFactor: 2 }); + + if (result === false) { + logger.debug('Hunk-level inverse patch не удался'); + return null; + } + + return { + success: true, + newContent: result, + hadConflicts: false, + }; + } +} + +// ── Module-level helpers ── + +/** + * Invert a single hunk: swap added/removed lines, swap old/new start/lines. + */ +function invertHunk(hunk: StructuredPatchHunk): StructuredPatchHunk { + const invertedLines = hunk.lines.map((line) => { + if (line.startsWith('+')) return '-' + line.substring(1); + if (line.startsWith('-')) return '+' + line.substring(1); + return line; // context lines remain unchanged + }); + + return { + oldStart: hunk.newStart, + oldLines: hunk.newLines, + newStart: hunk.oldStart, + newLines: hunk.oldLines, + lines: invertedLines, + }; +} + +/** + * Three-way merge using node-diff3. + * + * @param base base version (common ancestor) + * @param ours "our" version (current state) + * @param theirs "their" version (desired state) + * @returns merged content and conflict indicator + */ +function threeWayMerge( + base: string, + ours: string, + theirs: string +): { content: string; hasConflicts: boolean } { + const regions = diff3Merge(ours, base, theirs); + let hasConflicts = false; + const parts: string[] = []; + + for (const region of regions) { + if (region.ok) { + parts.push(region.ok.join('\n')); + } else if (region.conflict) { + hasConflicts = true; + // Include conflict markers for visibility + parts.push('<<<<<<< current'); + parts.push(region.conflict.a.join('\n')); + parts.push('======='); + parts.push(region.conflict.b.join('\n')); + parts.push('>>>>>>> original'); + } + } + + return { + content: parts.join('\n'), + hasConflicts, + }; +} diff --git a/src/main/services/team/TaskBoundaryParser.ts b/src/main/services/team/TaskBoundaryParser.ts new file mode 100644 index 00000000..0180f803 --- /dev/null +++ b/src/main/services/team/TaskBoundaryParser.ts @@ -0,0 +1,378 @@ +import { createLogger } from '@shared/utils/logger'; +import { createReadStream } from 'fs'; +import { stat } from 'fs/promises'; +import * as readline from 'readline'; + +import type { + TaskBoundariesResult, + TaskBoundary, + TaskChangeScope, + TaskScopeConfidence, +} from '@shared/types'; + +const logger = createLogger('Service:TaskBoundaryParser'); + +/** Файл-модифицирующие инструменты, которые включаем в scope.toolUseIds */ +const FILE_MODIFYING_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']); + +/** Кеш-запись: данные + mtime файла + время протухания */ +interface BoundaryCacheEntry { + data: TaskBoundariesResult; + mtime: number; + expiresAt: number; +} + +/** Информация о tool_use блоке собранная при парсинге */ +interface ToolUseInfo { + toolUseId: string; + toolName: string; + filePath?: string; +} + +/** Regex для teamctl task команд */ +const TEAMCTL_TASK_REGEX = /task\s+(start|complete|set-status)\s+(\d+)/; + +export class TaskBoundaryParser { + private cache = new Map(); + private readonly CACHE_TTL = 60 * 1000; // 60s + + /** Парсинг JSONL файла для обнаружения границ задач */ + async parseBoundaries(filePath: string): Promise { + // 1. Проверяем кеш (TTL + mtime) + let fileStat; + try { + fileStat = await stat(filePath); + } catch (err) { + logger.debug(`Cannot stat file ${filePath}: ${String(err)}`); + return { boundaries: [], scopes: [], isSingleTaskSession: true, detectedMechanism: 'none' }; + } + + const cached = this.cache.get(filePath); + if (cached?.mtime === fileStat.mtimeMs && cached.expiresAt > Date.now()) { + return cached.data; + } + + // 2. Стриминг JSONL + const boundaries: TaskBoundary[] = []; + const allToolUsesByLine = new Map(); + let lineNumber = 0; + let detectedMechanism: 'TaskUpdate' | 'teamctl' | 'none' = 'none'; + + try { + const stream = createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + + for await (const line of rl) { + lineNumber++; + const trimmed = line.trim(); + if (!trimmed) continue; + + try { + const entry = JSON.parse(trimmed) as Record; + const timestamp = typeof entry.timestamp === 'string' ? entry.timestamp : ''; + + const content = this.extractContent(entry); + if (!Array.isArray(content)) continue; + + // Собираем ВСЕ tool_use блоки для scope tracking + for (const block of content) { + if (!block || typeof block !== 'object') continue; + const b = block as Record; + if (b.type !== 'tool_use') continue; + const rawName = typeof b.name === 'string' ? b.name : ''; + const toolName = rawName.replace(/^proxy_/, ''); + const toolUseId = typeof b.id === 'string' ? b.id : ''; + const input = b.input as Record | undefined; + const fp = typeof input?.file_path === 'string' ? input.file_path : undefined; + if (!allToolUsesByLine.has(lineNumber)) allToolUsesByLine.set(lineNumber, []); + allToolUsesByLine.get(lineNumber)!.push({ toolUseId, toolName, filePath: fp }); + } + + // Пробуем TaskUpdate + const taskUpdateBounds = this.extractTaskUpdateBoundaries(content, lineNumber, timestamp); + if (taskUpdateBounds.length > 0) { + detectedMechanism = 'TaskUpdate'; + boundaries.push(...taskUpdateBounds); + continue; + } + + // Пробуем teamctl + const teamctlBounds = this.extractTeamctlBoundaries(content, lineNumber, timestamp); + if (teamctlBounds.length > 0) { + detectedMechanism = 'teamctl'; + boundaries.push(...teamctlBounds); + } + } catch { + // Пропускаем невалидные строки + } + } + + rl.close(); + stream.destroy(); + } catch (err) { + logger.debug(`Error reading file ${filePath}: ${String(err)}`); + } + + // 3. Вычисляем scopes + const scopes = this.computeScopes(boundaries, allToolUsesByLine, lineNumber); + const uniqueTaskIds = new Set(boundaries.map((b) => b.taskId)); + const isSingleTaskSession = uniqueTaskIds.size <= 1; + + const result: TaskBoundariesResult = { + boundaries, + scopes, + isSingleTaskSession, + detectedMechanism, + }; + this.cache.set(filePath, { + data: result, + mtime: fileStat.mtimeMs, + expiresAt: Date.now() + this.CACHE_TTL, + }); + return result; + } + + /** Получить scope для конкретной задачи */ + async getTaskScope(filePath: string, taskId: string): Promise { + const result = await this.parseBoundaries(filePath); + return result.scopes.find((s) => s.taskId === taskId) ?? null; + } + + /** Очистить кеш (для тестов) */ + clearCache(): void { + this.cache.clear(); + } + + // ── Приватные методы ── + + /** Извлечь content array из JSONL entry (оба формата: subagent и main) */ + private extractContent(entry: Record): unknown[] | null { + const message = entry.message as Record | undefined; + if (message && Array.isArray(message.content)) return message.content as unknown[]; + if (Array.isArray(entry.content)) return entry.content as unknown[]; + return null; + } + + /** + * Найти TaskUpdate/proxy_TaskUpdate tool_use блоки. + * status: in_progress → start, completed → complete + */ + private extractTaskUpdateBoundaries( + content: unknown[], + lineNumber: number, + timestamp: string + ): TaskBoundary[] { + const results: TaskBoundary[] = []; + + for (const block of content) { + if (!block || typeof block !== 'object') continue; + const b = block as Record; + if (b.type !== 'tool_use') continue; + + const rawName = typeof b.name === 'string' ? b.name : ''; + const toolName = rawName.replace(/^proxy_/, ''); + if (toolName !== 'TaskUpdate') continue; + + const input = b.input as Record | undefined; + if (!input) continue; + + const rawTaskId = input.taskId; + const taskId = + typeof rawTaskId === 'string' + ? rawTaskId + : typeof rawTaskId === 'number' + ? String(rawTaskId) + : ''; + if (!taskId) continue; + + const status = typeof input.status === 'string' ? input.status : ''; + let event: 'start' | 'complete' | null = null; + if (status === 'in_progress') event = 'start'; + else if (status === 'completed') event = 'complete'; + + if (event) { + const toolUseId = typeof b.id === 'string' ? b.id : undefined; + results.push({ + taskId, + event, + lineNumber, + timestamp, + mechanism: 'TaskUpdate', + toolUseId, + }); + } + } + + return results; + } + + /** + * Найти teamctl task start/complete/set-status команды в Bash tool_use блоках. + * Regex: /task\s+(start|complete|set-status)\s+(\d+)/ + */ + private extractTeamctlBoundaries( + content: unknown[], + lineNumber: number, + timestamp: string + ): TaskBoundary[] { + const results: TaskBoundary[] = []; + + for (const block of content) { + if (!block || typeof block !== 'object') continue; + const b = block as Record; + if (b.type !== 'tool_use') continue; + + const rawName = typeof b.name === 'string' ? b.name : ''; + const toolName = rawName.replace(/^proxy_/, ''); + if (toolName !== 'Bash') continue; + + const input = b.input as Record | undefined; + if (!input) continue; + + const command = typeof input.command === 'string' ? input.command : ''; + if (!command.includes('teamctl')) continue; + + const match = TEAMCTL_TASK_REGEX.exec(command); + if (!match) continue; + + const action = match[1]; // start | complete | set-status + const taskId = match[2]; + + let event: 'start' | 'complete' | null = null; + if (action === 'start') event = 'start'; + else if (action === 'complete') event = 'complete'; + else if (action === 'set-status') { + // set-status может быть start или complete — определяем по аргументам + if (command.includes('in_progress') || command.includes('in-progress')) event = 'start'; + else if (command.includes('completed') || command.includes('done')) event = 'complete'; + } + + if (event) { + const toolUseId = typeof b.id === 'string' ? b.id : undefined; + results.push({ + taskId, + event, + lineNumber, + timestamp, + mechanism: 'teamctl', + toolUseId, + }); + } + } + + return results; + } + + /** + * Вычислить scopes для каждой задачи на основе границ. + * + * Tier 1 (high): обе границы (start + complete) + * Tier 2 (medium): только start (end = конец файла) + * Tier 3 (low): только complete (start = начало файла) + * Tier 4 (fallback): нет границ (весь файл) + */ + private computeScopes( + boundaries: TaskBoundary[], + allToolUsesByLine: Map, + totalLines: number + ): TaskChangeScope[] { + // Группируем по taskId + const byTask = new Map(); + for (const b of boundaries) { + if (!byTask.has(b.taskId)) byTask.set(b.taskId, []); + byTask.get(b.taskId)!.push(b); + } + + const scopes: TaskChangeScope[] = []; + + for (const [taskId, taskBoundaries] of byTask) { + const starts = taskBoundaries.filter((b) => b.event === 'start'); + const completes = taskBoundaries.filter((b) => b.event === 'complete'); + + const hasStart = starts.length > 0; + const hasComplete = completes.length > 0; + + // Определяем границы строк + let startLine: number; + let endLine: number; + let startTimestamp: string; + let endTimestamp: string; + let confidence: TaskScopeConfidence; + + if (hasStart && hasComplete) { + // Tier 1: обе границы + const firstStart = starts.reduce( + (a, b) => (a.lineNumber < b.lineNumber ? a : b), + starts[0] + ); + const lastComplete = completes.reduce( + (a, b) => (a.lineNumber > b.lineNumber ? a : b), + completes[0] + ); + startLine = firstStart.lineNumber; + endLine = lastComplete.lineNumber; + startTimestamp = firstStart.timestamp; + endTimestamp = lastComplete.timestamp; + confidence = { tier: 1, label: 'high', reason: 'Both start and complete markers found' }; + } else if (hasStart) { + // Tier 2: только start + const firstStart = starts.reduce( + (a, b) => (a.lineNumber < b.lineNumber ? a : b), + starts[0] + ); + startLine = firstStart.lineNumber; + endLine = totalLines; + startTimestamp = firstStart.timestamp; + endTimestamp = ''; + confidence = { + tier: 2, + label: 'medium', + reason: 'Only start marker found, end assumed at file end', + }; + } else { + // Tier 3: только complete + const lastComplete = completes.reduce( + (a, b) => (a.lineNumber > b.lineNumber ? a : b), + completes[0] + ); + startLine = 1; + endLine = lastComplete.lineNumber; + startTimestamp = ''; + endTimestamp = lastComplete.timestamp; + confidence = { + tier: 3, + label: 'low', + reason: 'Only complete marker found, start assumed at file beginning', + }; + } + + // Собираем tool_use IDs в диапазоне [startLine, endLine], только файл-модифицирующие + const toolUseIds: string[] = []; + const filePaths = new Set(); + + for (const [line, tools] of allToolUsesByLine) { + if (line < startLine || line > endLine) continue; + for (const tool of tools) { + if (FILE_MODIFYING_TOOLS.has(tool.toolName) && tool.toolUseId) { + toolUseIds.push(tool.toolUseId); + if (tool.filePath) filePaths.add(tool.filePath); + } + } + } + + scopes.push({ + taskId, + memberName: '', // будет заполнен вызывающим кодом + startLine, + endLine, + startTimestamp, + endTimestamp, + toolUseIds, + filePaths: [...filePaths], + confidence, + }); + } + + return scopes; + } +} diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index 41802284..e9749189 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -243,6 +243,36 @@ export class TeamMemberLogsFinder { return paths; } + /** Быстрая проверка: содержит ли файл TaskUpdate/teamctl маркер для данного taskId */ + async hasTaskUpdateMarker(filePath: string, taskId: string): Promise { + const stream = createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + + const escapedTaskId = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = new RegExp(`"taskId"\\s*:\\s*"${escapedTaskId}"`); + + try { + for await (const line of rl) { + if (line.includes('TaskUpdate') && pattern.test(line)) { + rl.close(); + stream.destroy(); + return true; + } + if (line.includes('teamctl') && line.includes('task') && line.includes(taskId)) { + rl.close(); + stream.destroy(); + return true; + } + } + } catch { + // ignore read errors + } + + rl.close(); + stream.destroy(); + return false; + } + private async discoverProjectSessions(teamName: string): Promise<{ projectDir: string; projectId: string; diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index 604dcf6c..8644e453 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -1,5 +1,10 @@ +export { ChangeExtractorService } from './ChangeExtractorService'; export { ClaudeBinaryResolver } from './ClaudeBinaryResolver'; +export { FileContentResolver } from './FileContentResolver'; +export { GitDiffFallback } from './GitDiffFallback'; export { MemberStatsComputer } from './MemberStatsComputer'; +export { ReviewApplierService } from './ReviewApplierService'; +export { TaskBoundaryParser } from './TaskBoundaryParser'; export { TeamAgentToolsInstaller } from './TeamAgentToolsInstaller'; export { TeamAttachmentStore } from './TeamAttachmentStore'; export { TeamConfigReader } from './TeamConfigReader'; diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index f42633d3..a42671e7 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -284,3 +284,41 @@ export const TEAM_UPDATE_MEMBER_ROLE = 'team:updateMemberRole'; /** Get attachment data for a message */ export const TEAM_GET_ATTACHMENTS = 'team:getAttachments'; + +// ============================================================================= +// Review API Channels +// ============================================================================= + +/** Получить все изменения агента */ +export const REVIEW_GET_AGENT_CHANGES = 'review:getAgentChanges'; + +/** Получить изменения задачи */ +export const REVIEW_GET_TASK_CHANGES = 'review:getTaskChanges'; + +/** Получить краткую статистику изменений */ +export const REVIEW_GET_CHANGE_STATS = 'review:getChangeStats'; + +// Phase 2 — Review actions + +/** Проверить конфликт файла (изменён ли на диске) */ +export const REVIEW_CHECK_CONFLICT = 'review:checkConflict'; + +/** Откатить выбранные hunks */ +export const REVIEW_REJECT_HUNKS = 'review:rejectHunks'; + +/** Откатить весь файл к оригиналу */ +export const REVIEW_REJECT_FILE = 'review:rejectFile'; + +/** Preview результата reject (без записи на диск) */ +export const REVIEW_PREVIEW_REJECT = 'review:previewReject'; + +/** Применить batch решений review */ +export const REVIEW_APPLY_DECISIONS = 'review:applyDecisions'; + +/** Получить полное содержимое файла для diff view */ +export const REVIEW_GET_FILE_CONTENT = 'review:getFileContent'; + +// Phase 4 — Git fallback + +/** Get git file change log */ +export const REVIEW_GET_GIT_FILE_LOG = 'review:getGitFileLog'; diff --git a/src/preload/index.ts b/src/preload/index.ts index caff4697..aa7676a6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -10,6 +10,16 @@ import { HTTP_SERVER_GET_STATUS, HTTP_SERVER_START, HTTP_SERVER_STOP, + REVIEW_APPLY_DECISIONS, + REVIEW_CHECK_CONFLICT, + REVIEW_GET_AGENT_CHANGES, + REVIEW_GET_CHANGE_STATS, + REVIEW_GET_FILE_CONTENT, + REVIEW_GET_GIT_FILE_LOG, + REVIEW_GET_TASK_CHANGES, + REVIEW_PREVIEW_REJECT, + REVIEW_REJECT_FILE, + REVIEW_REJECT_HUNKS, SSH_CONNECT, SSH_DISCONNECT, SSH_GET_CONFIG_HOSTS, @@ -93,13 +103,19 @@ import { import type { AddMemberRequest, + AgentChangeSet, AppConfig, + ApplyReviewRequest, + ApplyReviewResult, AttachmentFileData, + ChangeStats, ClaudeRootFolderSelection, ClaudeRootInfo, + ConflictCheckResult, ContextInfo, CreateTaskRequest, ElectronAPI, + FileChangeWithContent, GlobalTask, HttpServerStatus, IpcResult, @@ -107,14 +123,17 @@ import type { MemberFullStats, MemberLogSummary, NotificationTrigger, + RejectResult, SendMessageRequest, SendMessageResult, SessionsByIdsOptions, SessionsPaginationOptions, + SnippetDiff, SshConfigHostEntry, SshConnectionConfig, SshConnectionStatus, SshLastConnection, + TaskChangeSetV2, TaskComment, TeamChangeEvent, TeamConfig, @@ -670,6 +689,81 @@ const electronAPI: ElectronAPI = { }; }, }, + + // ===== Review API ===== + review: { + getAgentChanges: async (teamName: string, memberName: string) => { + return invokeIpcWithResult(REVIEW_GET_AGENT_CHANGES, teamName, memberName); + }, + getTaskChanges: async (teamName: string, taskId: string) => { + return invokeIpcWithResult(REVIEW_GET_TASK_CHANGES, teamName, taskId); + }, + getChangeStats: async (teamName: string, memberName: string) => { + return invokeIpcWithResult(REVIEW_GET_CHANGE_STATS, teamName, memberName); + }, + getFileContent: async (teamName: string, memberName: string | undefined, filePath: string) => { + return invokeIpcWithResult( + REVIEW_GET_FILE_CONTENT, + teamName, + memberName ?? '', + filePath + ); + }, + applyDecisions: async (request: ApplyReviewRequest) => { + return invokeIpcWithResult(REVIEW_APPLY_DECISIONS, request); + }, + // Phase 2 + checkConflict: async (filePath: string, expectedModified: string) => { + return invokeIpcWithResult( + REVIEW_CHECK_CONFLICT, + filePath, + expectedModified + ); + }, + rejectHunks: async ( + filePath: string, + original: string, + modified: string, + hunkIndices: number[], + snippets: SnippetDiff[] + ) => { + return invokeIpcWithResult( + REVIEW_REJECT_HUNKS, + filePath, + original, + modified, + hunkIndices, + snippets + ); + }, + rejectFile: async (filePath: string, original: string, modified: string) => { + return invokeIpcWithResult(REVIEW_REJECT_FILE, filePath, original, modified); + }, + previewReject: async ( + filePath: string, + original: string, + modified: string, + hunkIndices: number[], + snippets: SnippetDiff[] + ) => { + return invokeIpcWithResult<{ preview: string; hasConflicts: boolean }>( + REVIEW_PREVIEW_REJECT, + filePath, + original, + modified, + hunkIndices, + snippets + ); + }, + // Phase 4 + getGitFileLog: async (projectPath: string, filePath: string) => { + return invokeIpcWithResult<{ hash: string; timestamp: string; message: string }[]>( + REVIEW_GET_GIT_FILE_LOG, + projectPath, + filePath + ); + }, + }, }; // Use contextBridge to securely expose the API to the renderer process diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index e2f8ce47..0e4729a0 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -772,4 +772,44 @@ export class HttpAPIClient implements ElectronAPI { return () => {}; }, }; + + // Review API stubs + review = { + getAgentChanges: async (_teamName: string, _memberName: string) => { + throw new Error('Review is not available in browser mode'); + }, + getTaskChanges: async (_teamName: string, _taskId: string) => { + throw new Error('Review is not available in browser mode'); + }, + getChangeStats: async (_teamName: string, _memberName: string) => { + throw new Error('Review is not available in browser mode'); + }, + getFileContent: async ( + _teamName: string, + _memberName: string | undefined, + _filePath: string + ) => { + throw new Error('Review is not available in browser mode'); + }, + applyDecisions: async () => { + throw new Error('Review is not available in browser mode'); + }, + // Phase 2 stubs + checkConflict: async () => { + throw new Error('Review is not available in browser mode'); + }, + rejectHunks: async () => { + throw new Error('Review is not available in browser mode'); + }, + rejectFile: async () => { + throw new Error('Review is not available in browser mode'); + }, + previewReject: async () => { + throw new Error('Review is not available in browser mode'); + }, + // Phase 4 stubs + getGitFileLog: async () => { + throw new Error('Review is not available in browser mode'); + }, + }; } diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 0d450eb5..47eb84a6 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -52,6 +52,7 @@ import { MemberDetailDialog } from './members/MemberDetailDialog'; import { MemberList } from './members/MemberList'; import { MessageComposer } from './messages/MessageComposer'; import { MessagesFilterPopover } from './messages/MessagesFilterPopover'; +import { ChangeReviewDialog } from './review/ChangeReviewDialog'; import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { TeamProvisioningBanner } from './TeamProvisioningBanner'; import { TeamSessionsSection } from './TeamSessionsSection'; @@ -119,6 +120,12 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>( undefined ); + const [reviewDialogState, setReviewDialogState] = useState<{ + open: boolean; + mode: 'agent' | 'task'; + memberName?: string; + taskId?: string; + }>({ open: false, mode: 'task' }); // Active teams for conflict warning in LaunchTeamDialog const [activeTeamsForLaunch, setActiveTeamsForLaunch] = useState< @@ -486,6 +493,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele } }, [teamName, refreshTeamData]); + const handleViewChanges = useCallback((taskId: string) => { + setReviewDialogState({ open: true, mode: 'task', taskId }); + }, []); + const handleDeleteTeam = useCallback((): void => { setDeleteConfirmOpen(true); }, []); @@ -962,6 +973,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele } }} onTaskClick={(task) => setSelectedTask(task)} + onViewChanges={handleViewChanges} onAddTask={(startImmediately) => openCreateTaskDialog('', '', '', startImmediately)} /> @@ -1316,6 +1328,15 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele void updateTaskOwner(teamName, taskId, owner); }} /> + + setReviewDialogState((prev) => ({ ...prev, open }))} + teamName={teamName} + mode={reviewDialogState.mode} + memberName={reviewDialogState.memberName} + taskId={reviewDialogState.taskId} + /> ); }; diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index 1cf6f2d3..95ff2e6a 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -76,6 +76,8 @@ interface KanbanBoardProps { onCancelTask: (taskId: string) => void; onScrollToTask?: (taskId: string) => void; onTaskClick?: (task: TeamTask) => void; + /** Открывает diff-просмотр изменений задачи. */ + onViewChanges?: (taskId: string) => void; /** Вызывается после изменения порядка задач в колонке (drag-and-drop). */ onColumnOrderChange?: (columnId: KanbanColumnId, orderedTaskIds: string[]) => void; /** Слот слева в одной строке с фильтром и переключателем вида (например, поле поиска). */ @@ -151,6 +153,7 @@ interface SortableKanbanTaskCardProps { onCancelTask: (taskId: string) => void; onScrollToTask?: (taskId: string) => void; onTaskClick?: (task: TeamTask) => void; + onViewChanges?: (taskId: string) => void; } const SortableKanbanTaskCard = ({ @@ -169,6 +172,7 @@ const SortableKanbanTaskCard = ({ onCancelTask, onScrollToTask, onTaskClick, + onViewChanges, }: SortableKanbanTaskCardProps): React.JSX.Element => { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id, @@ -201,6 +205,7 @@ const SortableKanbanTaskCard = ({ onCancelTask={onCancelTask} onScrollToTask={onScrollToTask} onTaskClick={onTaskClick} + onViewChanges={onViewChanges} /> ); @@ -224,6 +229,7 @@ export const KanbanBoard = ({ onCancelTask, onScrollToTask, onTaskClick, + onViewChanges, onColumnOrderChange, toolbarLeft, onAddTask, @@ -335,6 +341,7 @@ export const KanbanBoard = ({ onCancelTask={onCancelTask} onScrollToTask={onScrollToTask} onTaskClick={onTaskClick} + onViewChanges={onViewChanges} /> ))} @@ -363,6 +370,7 @@ export const KanbanBoard = ({ onCancelTask={onCancelTask} onScrollToTask={onScrollToTask} onTaskClick={onTaskClick} + onViewChanges={onViewChanges} /> ))} {addButton} diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index 9ed722fa..7126db7c 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -6,7 +6,14 @@ 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 { ArrowLeftFromLine, ArrowRightFromLine, CheckCircle2, Play, XCircle } from 'lucide-react'; +import { + ArrowLeftFromLine, + ArrowRightFromLine, + CheckCircle2, + FileCode, + Play, + XCircle, +} from 'lucide-react'; import type { KanbanColumnId, KanbanTaskState, ResolvedTeamMember, TeamTask } from '@shared/types'; @@ -27,6 +34,7 @@ interface KanbanTaskCardProps { onCancelTask: (taskId: string) => void; onScrollToTask?: (taskId: string) => void; onTaskClick?: (task: TeamTask) => void; + onViewChanges?: (taskId: string) => void; } interface DependencyBadgeProps { @@ -132,6 +140,7 @@ export const KanbanTaskCard = ({ onCancelTask, onScrollToTask, onTaskClick, + onViewChanges, }: KanbanTaskCardProps): React.JSX.Element => { const unreadCount = useUnreadCommentCount(teamName, task.id, task.comments); const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? []; @@ -332,6 +341,21 @@ export const KanbanTaskCard = ({ Move back to DONE ) : null} + + {(columnId === 'done' || columnId === 'review' || columnId === 'approved') && + onViewChanges ? ( + + ) : null} diff --git a/src/renderer/components/team/review/ChangeReviewDialog.tsx b/src/renderer/components/team/review/ChangeReviewDialog.tsx new file mode 100644 index 00000000..93984f91 --- /dev/null +++ b/src/renderer/components/team/review/ChangeReviewDialog.tsx @@ -0,0 +1,446 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +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 { CodeMirrorDiffView } from './CodeMirrorDiffView'; +import { ConfidenceBadge } from './ConfidenceBadge'; +import { DiffErrorBoundary } from './DiffErrorBoundary'; +import { FileEditTimeline } from './FileEditTimeline'; +import { KeyboardShortcutsHelp } from './KeyboardShortcutsHelp'; +import { ReviewDiffContent } from './ReviewDiffContent'; +import { ReviewFileTree } from './ReviewFileTree'; +import { ReviewToolbar } from './ReviewToolbar'; +import { ScopeWarningBanner } from './ScopeWarningBanner'; +import { ViewedProgressBar } from './ViewedProgressBar'; + +import type { EditorView } from '@codemirror/view'; +import type { HunkDecision, TaskChangeSetV2 } from '@shared/types'; + +interface ChangeReviewDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + teamName: string; + mode: 'agent' | 'task'; + memberName?: string; + taskId?: string; +} + +const CONTENT_SOURCE_LABELS: Record = { + 'file-history': 'File History', + 'snippet-reconstruction': 'Reconstructed', + 'disk-current': 'Current Disk', + 'git-fallback': 'Git Fallback', + unavailable: 'Unavailable', +}; + +function isTaskChangeSetV2(cs: { teamName: string }): cs is TaskChangeSetV2 { + return 'scope' in cs; +} + +export const ChangeReviewDialog = ({ + open, + onOpenChange, + teamName, + mode, + memberName, + taskId, +}: ChangeReviewDialogProps) => { + const { + activeChangeSet, + changeSetLoading, + changeSetError, + selectedReviewFilePath, + fetchAgentChanges, + fetchTaskChanges, + selectReviewFile, + clearChangeReview, + // Phase 2 + hunkDecisions, + fileDecisions, + fileContents, + fileContentsLoading, + diffViewMode, + collapseUnchanged, + applying, + applyError, + setHunkDecision, + setDiffViewMode, + setCollapseUnchanged, + fetchFileContent, + acceptAll, + rejectAll, + applyReview, + } = useStore(); + + const editorViewRef = useRef(null); + const [autoViewed, setAutoViewed] = useState(true); + const [timelineOpen, setTimelineOpen] = useState(false); + + // Build scope key for viewed storage + const scopeKey = mode === 'task' ? `task:${taskId ?? ''}` : `agent:${memberName ?? ''}`; + + // File paths for viewed tracking + const allFilePaths = useMemo( + () => (activeChangeSet?.files ?? []).map((f) => f.filePath), + [activeChangeSet] + ); + + const { + viewedSet, + isViewed, + markViewed, + unmarkViewed, + viewedCount, + totalCount: viewedTotalCount, + progress: viewedProgress, + } = useViewedFiles(teamName, scopeKey, allFilePaths); + + const diffNav = useDiffNavigation( + activeChangeSet?.files ?? [], + selectedReviewFilePath, + selectReviewFile, + editorViewRef, + open, + (filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'accepted'), + (filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'rejected'), + () => onOpenChange(false) + ); + + // Auto-viewed callback + const handleFullyViewed = useCallback(() => { + if (autoViewed && selectedReviewFilePath && !isViewed(selectedReviewFilePath)) { + markViewed(selectedReviewFilePath); + } + }, [autoViewed, selectedReviewFilePath, isViewed, markViewed]); + + // Load data on open + useEffect(() => { + if (!open) return; + if (mode === 'agent' && memberName) { + void fetchAgentChanges(teamName, memberName); + } else if (mode === 'task' && taskId) { + void fetchTaskChanges(teamName, taskId); + } + return () => clearChangeReview(); + }, [ + open, + mode, + teamName, + memberName, + taskId, + fetchAgentChanges, + fetchTaskChanges, + clearChangeReview, + ]); + + // Escape to close + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onOpenChange(false); + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [open, onOpenChange]); + + // Lazy-load file content when file selected + useEffect(() => { + if (!open || !selectedReviewFilePath) return; + if (fileContents[selectedReviewFilePath] || fileContentsLoading[selectedReviewFilePath]) return; + void fetchFileContent(teamName, memberName, selectedReviewFilePath); + }, [ + open, + selectedReviewFilePath, + teamName, + memberName, + fileContents, + fileContentsLoading, + fetchFileContent, + ]); + + const selectedFile = useMemo(() => { + if (!activeChangeSet || !selectedReviewFilePath) return null; + return activeChangeSet.files.find((f) => f.filePath === selectedReviewFilePath) ?? null; + }, [activeChangeSet, selectedReviewFilePath]); + + const fileContent = selectedReviewFilePath ? fileContents[selectedReviewFilePath] : null; + const isFileContentLoading = selectedReviewFilePath + ? (fileContentsLoading[selectedReviewFilePath] ?? false) + : false; + + // Compute toolbar stats + const reviewStats = useMemo(() => { + if (!activeChangeSet) return { pending: 0, accepted: 0, rejected: 0 }; + + let pending = 0; + let accepted = 0; + let rejected = 0; + + for (const file of activeChangeSet.files) { + for (let i = 0; i < file.snippets.length; i++) { + const key = `${file.filePath}:${i}`; + const decision: HunkDecision = hunkDecisions[key] ?? 'pending'; + if (decision === 'pending') pending++; + else if (decision === 'accepted') accepted++; + else if (decision === 'rejected') rejected++; + } + } + + return { pending, accepted, rejected }; + }, [activeChangeSet, hunkDecisions]); + + const changeStats = useMemo(() => { + if (!activeChangeSet) return { linesAdded: 0, linesRemoved: 0, filesChanged: 0 }; + return { + linesAdded: activeChangeSet.totalLinesAdded, + linesRemoved: activeChangeSet.totalLinesRemoved, + filesChanged: activeChangeSet.totalFiles, + }; + }, [activeChangeSet]); + + const handleApply = useCallback(() => { + void applyReview(teamName, taskId, memberName); + }, [applyReview, teamName, taskId, memberName]); + + const title = + mode === 'agent' + ? `Changes by ${memberName ?? 'unknown'}` + : `Changes for task #${taskId ?? '?'}`; + + if (!open) return null; + + return ( +
+ {/* Header */} +
+
+

{title}

+ {activeChangeSet && ( + <> + + {activeChangeSet.totalFiles} files, +{activeChangeSet.totalLinesAdded} - + {activeChangeSet.totalLinesRemoved} + + {mode === 'task' && isTaskChangeSetV2(activeChangeSet) && ( + + )} + + + )} +
+ +
+ + {/* Keyboard shortcuts help */} + + + {/* Review toolbar */} + {!changeSetLoading && + !changeSetError && + activeChangeSet && + activeChangeSet.files.length > 0 && ( + + )} + + {/* Scope warnings */} + {mode === 'task' && + activeChangeSet && + isTaskChangeSetV2(activeChangeSet) && + activeChangeSet.warnings.length > 0 && ( + + )} + + {/* Apply error */} + {applyError && ( +
+ {applyError} +
+ )} + + {/* Content */} +
+ {changeSetLoading && ( +
+ Loading changes... +
+ )} + + {changeSetError && ( +
+ {changeSetError} +
+ )} + + {!changeSetLoading && !changeSetError && activeChangeSet && ( + <> + {/* File tree */} +
+ + + {/* Edit Timeline */} + {selectedFile?.timeline && selectedFile.timeline.events.length > 0 && ( +
+ + {timelineOpen && ( + diffNav.goToHunk(idx)} + activeSnippetIndex={diffNav.currentHunkIndex} + /> + )} +
+ )} +
+ + {/* Diff content */} +
+ {selectedFile ? ( +
+ {/* File header with content source badge */} +
+ + {selectedFile.relativePath} + + {selectedFile.isNewFile && ( + + NEW + + )} + {fileContent?.contentSource && ( + + {CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? + fileContent.contentSource} + + )} + {/* File-level decision indicator */} + {fileDecisions[selectedFile.filePath] && ( + + {fileDecisions[selectedFile.filePath]} + + )} +
+ + {/* Loading state */} + {isFileContentLoading && ( +
+ + Loading file content... +
+ )} + + {/* CodeMirror diff view when file content is available */} + {!isFileContentLoading && + fileContent && + fileContent.contentSource !== 'unavailable' && + fileContent.originalFullContent !== null && + fileContent.modifiedFullContent !== null && ( +
+ + + setHunkDecision(selectedFile.filePath, idx, 'accepted') + } + onHunkRejected={(idx) => + setHunkDecision(selectedFile.filePath, idx, 'rejected') + } + onFullyViewed={handleFullyViewed} + editorViewRef={editorViewRef} + /> + +
+ )} + + {/* Fallback: Phase 1 snippet view when content unavailable */} + {!isFileContentLoading && + (!fileContent || fileContent.contentSource === 'unavailable') && ( +
+ +
+ )} +
+ ) : ( +
+ Select a file to view changes +
+ )} +
+ + )} + + {!changeSetLoading && !changeSetError && activeChangeSet?.files.length === 0 && ( +
+ No file changes detected +
+ )} +
+
+ ); +}; diff --git a/src/renderer/components/team/review/ChangeStatsBadge.tsx b/src/renderer/components/team/review/ChangeStatsBadge.tsx new file mode 100644 index 00000000..db74d39d --- /dev/null +++ b/src/renderer/components/team/review/ChangeStatsBadge.tsx @@ -0,0 +1,20 @@ +interface ChangeStatsBadgeProps { + linesAdded: number; + linesRemoved: number; + className?: string; +} + +export const ChangeStatsBadge = ({ + linesAdded, + linesRemoved, + className = '', +}: ChangeStatsBadgeProps) => { + if (linesAdded === 0 && linesRemoved === 0) return null; + + return ( + + {linesAdded > 0 && +{linesAdded}} + {linesRemoved > 0 && -{linesRemoved}} + + ); +}; diff --git a/src/renderer/components/team/review/CodeMirrorDiffView.tsx b/src/renderer/components/team/review/CodeMirrorDiffView.tsx new file mode 100644 index 00000000..d409d039 --- /dev/null +++ b/src/renderer/components/team/review/CodeMirrorDiffView.tsx @@ -0,0 +1,356 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; + +import { css } from '@codemirror/lang-css'; +import { html } from '@codemirror/lang-html'; +import { javascript } from '@codemirror/lang-javascript'; +import { json } from '@codemirror/lang-json'; +import { python } from '@codemirror/lang-python'; +import { xml } from '@codemirror/lang-xml'; +import { + acceptChunk, + getChunks, + goToNextChunk, + goToPreviousChunk, + rejectChunk, + unifiedMergeView, +} from '@codemirror/merge'; +import { EditorState, type Extension } from '@codemirror/state'; +import { EditorView, keymap } from '@codemirror/view'; + +interface CodeMirrorDiffViewProps { + original: string; + modified: string; + fileName: string; + maxHeight?: string; + readOnly?: boolean; + showMergeControls?: boolean; + collapseUnchanged?: boolean; + collapseMargin?: number; + onHunkAccepted?: (hunkIndex: number) => void; + onHunkRejected?: (hunkIndex: number) => void; + /** Called when the user scrolls to the end of the diff (auto-viewed) */ + onFullyViewed?: () => void; + /** Ref to expose the EditorView for external navigation */ + editorViewRef?: React.RefObject; +} + +/** Detect language extension from file name */ +function getLanguageExtension(fileName: string): Extension | null { + const ext = fileName.split('.').pop()?.toLowerCase(); + switch (ext) { + case 'ts': + case 'tsx': + case 'js': + case 'jsx': + case 'mjs': + case 'cjs': + return javascript({ + jsx: ext === 'tsx' || ext === 'jsx', + typescript: ext === 'ts' || ext === 'tsx', + }); + case 'py': + return python(); + case 'json': + case 'jsonl': + return json(); + case 'css': + case 'scss': + return css(); + case 'html': + case 'htm': + return html(); + case 'xml': + case 'svg': + return xml(); + default: + return null; + } +} + +/** Compute hunk index for the chunk at a given position */ +function computeHunkIndexAtPos(state: EditorState, pos: number): number { + const chunks = getChunks(state); + if (!chunks) return 0; + + let index = 0; + for (const chunk of chunks.chunks) { + if (pos >= chunk.fromA && pos <= chunk.toA) { + return index; + } + if (pos >= chunk.fromB && pos <= chunk.toB) { + return index; + } + index++; + } + return 0; +} + +/** Custom dark theme for diff view using CSS variables */ +const diffTheme = EditorView.theme({ + '&': { + backgroundColor: 'var(--color-surface)', + color: 'var(--color-text)', + fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', + fontSize: '13px', + }, + '&.cm-focused': { + outline: 'none', + }, + '.cm-gutters': { + backgroundColor: 'var(--color-surface)', + borderRight: '1px solid var(--color-border)', + color: 'var(--color-text-muted)', + fontSize: '12px', + }, + '.cm-activeLineGutter': { + backgroundColor: 'transparent', + }, + '.cm-activeLine': { + backgroundColor: 'transparent', + }, + '.cm-scroller': { + overflow: 'auto', + }, + '.cm-content': { + caretColor: 'var(--color-text)', + }, + '.cm-cursor': { + borderLeftColor: 'var(--color-text)', + }, + '.cm-selectionBackground': { + backgroundColor: 'rgba(59, 130, 246, 0.3) !important', + }, + // Diff-specific styles + '.cm-changedLine': { + backgroundColor: 'var(--diff-added-bg, rgba(46, 160, 67, 0.15))', + }, + '.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))', + }, + '.cm-deletedLine': { + backgroundColor: 'var(--diff-removed-bg, rgba(248, 81, 73, 0.15))', + }, + // Merge control buttons + '.cm-merge-accept': { + cursor: 'pointer', + padding: '0 4px', + margin: '0 2px', + borderRadius: '3px', + fontSize: '11px', + fontWeight: '500', + lineHeight: '18px', + display: 'inline-block', + color: '#3fb950', + backgroundColor: 'rgba(46, 160, 67, 0.15)', + border: '1px solid rgba(46, 160, 67, 0.3)', + '&:hover': { + backgroundColor: 'rgba(46, 160, 67, 0.3)', + }, + }, + '.cm-merge-reject': { + cursor: 'pointer', + padding: '0 4px', + margin: '0 2px', + borderRadius: '3px', + fontSize: '11px', + fontWeight: '500', + lineHeight: '18px', + display: 'inline-block', + color: '#f85149', + backgroundColor: 'rgba(248, 81, 73, 0.15)', + border: '1px solid rgba(248, 81, 73, 0.3)', + '&:hover': { + backgroundColor: 'rgba(248, 81, 73, 0.3)', + }, + }, + // Collapse unchanged region marker + '.cm-collapsedLines': { + backgroundColor: 'var(--color-surface-raised)', + color: 'var(--color-text-muted)', + fontSize: '12px', + padding: '2px 8px', + cursor: 'pointer', + borderTop: '1px solid var(--color-border)', + borderBottom: '1px solid var(--color-border)', + }, +}); + +export const CodeMirrorDiffView = ({ + original, + modified, + fileName, + maxHeight = '100%', + readOnly = true, + showMergeControls = false, + collapseUnchanged: collapseUnchangedProp = true, + collapseMargin = 3, + onHunkAccepted, + onHunkRejected, + onFullyViewed, + editorViewRef: externalViewRef, +}: CodeMirrorDiffViewProps) => { + const containerRef = useRef(null); + const viewRef = useRef(null); + const endSentinelRef = useRef(null); + // Local ref to hold externalViewRef for syncing via useEffect + const externalViewRefHolder = useRef(externalViewRef); + + // Stabilize callbacks via useEffect (cannot update refs during render) + const onAcceptRef = useRef(onHunkAccepted); + const onRejectRef = useRef(onHunkRejected); + useEffect(() => { + onAcceptRef.current = onHunkAccepted; + onRejectRef.current = onHunkRejected; + externalViewRefHolder.current = externalViewRef; + }, [onHunkAccepted, onHunkRejected, externalViewRef]); + + const langExtension = useMemo(() => getLanguageExtension(fileName), [fileName]); + + const buildExtensions = useCallback(() => { + const extensions: Extension[] = [ + diffTheme, + EditorView.editable.of(!readOnly), + EditorState.readOnly.of(readOnly), + ]; + + if (langExtension) { + extensions.push(langExtension); + } + + // Keyboard shortcuts for chunk navigation + extensions.push( + keymap.of([ + { + key: 'Ctrl-Alt-ArrowDown', + run: goToNextChunk, + }, + { + key: 'Ctrl-Alt-ArrowUp', + run: goToPreviousChunk, + }, + ]) + ); + + // Unified merge view + const mergeConfig: Parameters[0] = { + original, + highlightChanges: true, + gutter: true, + syntaxHighlightDeletions: true, + }; + + if (collapseUnchangedProp) { + mergeConfig.collapseUnchanged = { + margin: collapseMargin, + minSize: 4, + }; + } + + if (showMergeControls) { + mergeConfig.mergeControls = (type, action) => { + const btn = document.createElement('button'); + + if (type === 'accept') { + btn.textContent = '\u2713'; + btn.title = 'Accept change'; + btn.className = 'cm-merge-accept'; + btn.onmousedown = (e) => { + e.preventDefault(); + const view = viewRef.current; + if (view) { + const pos = view.posAtDOM(btn); + const hunkIndex = computeHunkIndexAtPos(view.state, pos); + action(e); + onAcceptRef.current?.(hunkIndex); + } + }; + } else { + btn.textContent = '\u2717'; + btn.title = 'Reject change'; + btn.className = 'cm-merge-reject'; + btn.onmousedown = (e) => { + e.preventDefault(); + const view = viewRef.current; + if (view) { + const pos = view.posAtDOM(btn); + const hunkIndex = computeHunkIndexAtPos(view.state, pos); + action(e); + onRejectRef.current?.(hunkIndex); + } + }; + } + + return btn; + }; + } + + extensions.push(unifiedMergeView(mergeConfig)); + + return extensions; + }, [original, readOnly, langExtension, showMergeControls, collapseUnchangedProp, collapseMargin]); + + useEffect(() => { + if (!containerRef.current) return; + + // Destroy previous view + if (viewRef.current) { + viewRef.current.destroy(); + viewRef.current = null; + } + + const view = new EditorView({ + doc: modified, + extensions: buildExtensions(), + parent: containerRef.current, + }); + + viewRef.current = view; + // Sync to external ref via holder + const extRef = externalViewRefHolder.current; + if (extRef) { + (extRef as React.MutableRefObject).current = view; + } + + return () => { + view.destroy(); + viewRef.current = null; + if (extRef) { + (extRef as React.MutableRefObject).current = null; + } + }; + // We intentionally rebuild the entire editor when key props change + }, [original, modified, buildExtensions]); + + // Auto-viewed detection via IntersectionObserver + useEffect(() => { + if (!endSentinelRef.current || !onFullyViewed) return; + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + onFullyViewed(); + } + } + }, + { threshold: 1.0 } + ); + + observer.observe(endSentinelRef.current); + return () => observer.disconnect(); + }, [onFullyViewed]); + + return ( +
+
+ {/* Invisible sentinel for auto-viewed detection */} +
+
+ ); +}; + +// Re-export merge utils for external use +export { acceptChunk, getChunks, rejectChunk }; diff --git a/src/renderer/components/team/review/ConfidenceBadge.tsx b/src/renderer/components/team/review/ConfidenceBadge.tsx new file mode 100644 index 00000000..6b902228 --- /dev/null +++ b/src/renderer/components/team/review/ConfidenceBadge.tsx @@ -0,0 +1,31 @@ +import type { TaskScopeConfidence } from '@shared/types'; + +interface ConfidenceBadgeProps { + confidence: TaskScopeConfidence; + showTooltip?: boolean; +} + +const TIER_COLORS: Record = { + 1: 'bg-green-500/20 text-green-400 border-green-500/30', + 2: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30', + 3: 'bg-orange-500/20 text-orange-400 border-orange-500/30', + 4: 'bg-red-500/20 text-red-400 border-red-500/30', +}; + +const TIER_LABELS: Record = { + 1: 'High confidence', + 2: 'Medium confidence', + 3: 'Low confidence', + 4: 'Best effort', +}; + +export const ConfidenceBadge = ({ confidence, showTooltip = true }: ConfidenceBadgeProps) => { + return ( + + {TIER_LABELS[confidence.tier] ?? TIER_LABELS[4]} + + ); +}; diff --git a/src/renderer/components/team/review/ConflictDialog.tsx b/src/renderer/components/team/review/ConflictDialog.tsx new file mode 100644 index 00000000..4698b3eb --- /dev/null +++ b/src/renderer/components/team/review/ConflictDialog.tsx @@ -0,0 +1,145 @@ +import { useCallback, useState } from 'react'; + +import { cn } from '@renderer/lib/utils'; +import { AlertTriangle, X } from 'lucide-react'; + +interface ConflictDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + filePath: string; + conflictContent: string; + onResolveKeepCurrent: () => void; + onResolveUseOriginal: () => void; + onResolveManual: (content: string) => void; +} + +export const ConflictDialog = ({ + open, + onOpenChange, + filePath, + conflictContent, + onResolveKeepCurrent, + onResolveUseOriginal, + onResolveManual, +}: ConflictDialogProps) => { + const [editMode, setEditMode] = useState(false); + const [editContent, setEditContent] = useState(conflictContent); + + const handleManualSave = useCallback(() => { + onResolveManual(editContent); + setEditMode(false); + }, [editContent, onResolveManual]); + + if (!open) return null; + + return ( +
+
+ {/* Header */} +
+ +
+

Conflict Detected

+

+ This file has been modified since the agent's changes +

+
+ +
+ + {/* File path */} +
+ {filePath} +
+ + {/* Content */} +
+ {editMode ? ( +