feat: implement diff view with 4 phases — review, accept/reject, task scoping, enhanced UX
Phase 1: Core diff extraction and display - ChangeExtractorService: JSONL streaming parser with snippet extraction - FileContentResolver: 3-level content resolution (file-history → snippets → disk) - ReviewApplierService: hunk-level accept/reject with conflict detection - CodeMirrorDiffView: unified merge view with syntax highlighting - ReviewFileTree: file browser with status indicators - changeReviewSlice: Zustand state for review workflow Phase 2: Interactive review with accept/reject - Per-hunk and per-file accept/reject decisions - Conflict checking before apply - ReviewToolbar with bulk actions - DiffErrorBoundary for graceful degradation Phase 3: Per-task change scoping - TaskBoundaryParser: detects task boundaries in JSONL (Tier 1-4 confidence) - TaskChangeSetV2 with scope + warnings - ConfidenceBadge and ScopeWarningBanner components Phase 4: Enhanced features - Keyboard navigation (j/k/n/p/a/x shortcuts via useDiffNavigation) - Viewed file tracking (localStorage + useViewedFiles hook) - File edit timeline (chronological events per file) - Git fallback (GitDiffFallback service for incomplete JSONL data) - Auto-viewed detection (IntersectionObserver sentinel)
This commit is contained in:
parent
373d1a722b
commit
190cafdb8e
40 changed files with 5685 additions and 4 deletions
12
package.json
12
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",
|
||||
|
|
|
|||
280
pnpm-lock.yaml
280
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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
}
|
||||
},
|
||||
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);
|
||||
|
||||
|
|
|
|||
245
src/main/ipc/review.ts
Normal file
245
src/main/ipc/review.ts
Normal file
|
|
@ -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<T>(
|
||||
operation: string,
|
||||
handler: () => Promise<T>
|
||||
): Promise<IpcResult<T>> {
|
||||
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<IpcResult<AgentChangeSet>> {
|
||||
return wrapReviewHandler('getAgentChanges', () =>
|
||||
getChangeExtractor().getAgentChanges(teamName, memberName)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleGetTaskChanges(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: string,
|
||||
taskId: string
|
||||
): Promise<IpcResult<TaskChangeSetV2>> {
|
||||
return wrapReviewHandler('getTaskChanges', () =>
|
||||
getChangeExtractor().getTaskChanges(teamName, taskId)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleGetChangeStats(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: string,
|
||||
memberName: string
|
||||
): Promise<IpcResult<ChangeStats>> {
|
||||
return wrapReviewHandler('getChangeStats', () =>
|
||||
getChangeExtractor().getChangeStats(teamName, memberName)
|
||||
);
|
||||
}
|
||||
|
||||
// --- Phase 2 Handlers ---
|
||||
|
||||
async function handleCheckConflict(
|
||||
_event: IpcMainInvokeEvent,
|
||||
filePath: string,
|
||||
expectedModified: string
|
||||
): Promise<IpcResult<ConflictCheckResult>> {
|
||||
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<IpcResult<RejectResult>> {
|
||||
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<IpcResult<RejectResult>> {
|
||||
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<IpcResult<{ preview: string; hasConflicts: boolean }>> {
|
||||
return wrapReviewHandler('previewReject', () =>
|
||||
getApplier().previewReject(filePath, original, modified, hunkIndices, snippets)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleApplyDecisions(
|
||||
_event: IpcMainInvokeEvent,
|
||||
request: ApplyReviewRequest
|
||||
): Promise<IpcResult<ApplyReviewResult>> {
|
||||
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<IpcResult<FileChangeWithContent>> {
|
||||
return wrapReviewHandler('getFileContent', () =>
|
||||
getContentResolver().getFileContent(teamName, memberName, filePath)
|
||||
);
|
||||
}
|
||||
|
||||
// --- Phase 4 Handlers ---
|
||||
|
||||
async function handleGetGitFileLog(
|
||||
_event: IpcMainInvokeEvent,
|
||||
projectPath: string,
|
||||
filePath: string
|
||||
): Promise<IpcResult<{ hash: string; timestamp: string; message: string }[]>> {
|
||||
return wrapReviewHandler('getGitFileLog', async () => {
|
||||
if (!gitDiffFallback) {
|
||||
return [];
|
||||
}
|
||||
return gitDiffFallback.getFileLog(projectPath, filePath);
|
||||
});
|
||||
}
|
||||
581
src/main/services/team/ChangeExtractorService.ts
Normal file
581
src/main/services/team/ChangeExtractorService.ts
Normal file
|
|
@ -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<string, CacheEntry>();
|
||||
private readonly CACHE_TTL = 3 * 60 * 1000; // 3 мин
|
||||
|
||||
constructor(
|
||||
private readonly logsFinder: TeamMemberLogsFinder,
|
||||
private readonly boundaryParser: TaskBoundaryParser
|
||||
) {}
|
||||
|
||||
/** Получить все изменения агента */
|
||||
async getAgentChanges(teamName: string, memberName: string): Promise<AgentChangeSet> {
|
||||
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<TaskChangeSetV2> {
|
||||
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<ChangeStats> {
|
||||
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<SnippetDiff[]> {
|
||||
// Сначала считываем все записи в память для двух проходов
|
||||
const entries: Record<string, unknown>[] = [];
|
||||
|
||||
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<string, unknown>);
|
||||
} 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<string>();
|
||||
|
||||
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<string, unknown>).type !== 'tool_use'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const toolBlock = block as Record<string, unknown>;
|
||||
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<string, unknown> | 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<string, unknown>;
|
||||
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<string, unknown>): unknown[] | null {
|
||||
const message = entry.message as Record<string, unknown> | 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, unknown>): string | null {
|
||||
if (typeof entry.role === 'string') return entry.role;
|
||||
const message = entry.message as Record<string, unknown> | undefined;
|
||||
if (message && typeof message.role === 'string') return message.role;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Собрать errored tool_use_ids из tool_result блоков */
|
||||
private collectErroredToolUseIds(entries: Record<string, unknown>[]): Set<string> {
|
||||
const erroredIds = new Set<string>();
|
||||
|
||||
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<string, unknown>).tool_use_id;
|
||||
if (typeof toolUseId === 'string') {
|
||||
erroredIds.add(toolUseId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Также проверяем entry.message.content
|
||||
const message = entry.message as Record<string, unknown> | undefined;
|
||||
if (message && Array.isArray(message.content)) {
|
||||
for (const block of message.content) {
|
||||
if (this.isErroredToolResult(block)) {
|
||||
const toolUseId = (block as Record<string, unknown>).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<string, unknown>;
|
||||
return obj.type === 'tool_result' && obj.is_error === true;
|
||||
}
|
||||
|
||||
/** Агрегировать snippets в FileChangeSummary[] */
|
||||
private aggregateByFile(snippets: SnippetDiff[], projectPath?: string): FileChangeSummary[] {
|
||||
const fileMap = new Map<string, { snippets: SnippetDiff[]; isNewFile: boolean }>();
|
||||
|
||||
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<string>): 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<LogFileRef[]> {
|
||||
const refs: LogFileRef[] = [];
|
||||
const byMember = new Map<string, MemberLogSummary[]>();
|
||||
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<string>
|
||||
): Promise<FileChangeSummary[]> {
|
||||
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<FileChangeSummary[]> {
|
||||
const snippets = await this.parseJSONLFile(filePath);
|
||||
return this.aggregateByFile(snippets);
|
||||
}
|
||||
|
||||
/** Fallback: вернуть все изменения из лог-файлов как Tier 4 */
|
||||
private async fallbackSingleTaskScope(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
logRefs: LogFileRef[]
|
||||
): Promise<TaskChangeSetV2> {
|
||||
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.'],
|
||||
};
|
||||
}
|
||||
}
|
||||
459
src/main/services/team/FileContentResolver.ts
Normal file
459
src/main/services/team/FileContentResolver.ts
Normal file
|
|
@ -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<string, ContentCacheEntry>();
|
||||
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<FileChangeWithContent> {
|
||||
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<Map<string, FileChangeWithContent>> {
|
||||
const results = new Map<string, FileChangeWithContent>();
|
||||
|
||||
// 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<string | null> {
|
||||
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<string | null> {
|
||||
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<string, unknown>;
|
||||
if (entry.type !== 'file-history-snapshot') continue;
|
||||
|
||||
const snapshot = entry.snapshot as Record<string, unknown> | undefined;
|
||||
if (!snapshot) continue;
|
||||
|
||||
const trackedFileBackups = snapshot.trackedFileBackups as
|
||||
| Record<string, string>
|
||||
| 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<string | null> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
134
src/main/services/team/GitDiffFallback.ts
Normal file
134
src/main/services/team/GitDiffFallback.ts
Normal file
|
|
@ -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<string, boolean>();
|
||||
|
||||
/**
|
||||
* Get file contents at a specific commit.
|
||||
* Used when file-history-snapshot is unavailable.
|
||||
*/
|
||||
async getFileAtCommit(
|
||||
projectPath: string,
|
||||
filePath: string,
|
||||
commitHash: string
|
||||
): Promise<string | null> {
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
487
src/main/services/team/ReviewApplierService.ts
Normal file
487
src/main/services/team/ReviewApplierService.ts
Normal file
|
|
@ -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<ConflictCheckResult> {
|
||||
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<RejectResult> {
|
||||
// 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<RejectResult> {
|
||||
// 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<string, FileChangeWithContent>()
|
||||
): Promise<ApplyReviewResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
378
src/main/services/team/TaskBoundaryParser.ts
Normal file
378
src/main/services/team/TaskBoundaryParser.ts
Normal file
|
|
@ -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<string, BoundaryCacheEntry>();
|
||||
private readonly CACHE_TTL = 60 * 1000; // 60s
|
||||
|
||||
/** Парсинг JSONL файла для обнаружения границ задач */
|
||||
async parseBoundaries(filePath: string): Promise<TaskBoundariesResult> {
|
||||
// 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<number, ToolUseInfo[]>();
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown> | 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<TaskChangeScope | null> {
|
||||
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<string, unknown>): unknown[] | null {
|
||||
const message = entry.message as Record<string, unknown> | 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<string, unknown>;
|
||||
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<string, unknown> | 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<string, unknown>;
|
||||
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<string, unknown> | 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<number, ToolUseInfo[]>,
|
||||
totalLines: number
|
||||
): TaskChangeScope[] {
|
||||
// Группируем по taskId
|
||||
const byTask = new Map<string, TaskBoundary[]>();
|
||||
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<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -243,6 +243,36 @@ export class TeamMemberLogsFinder {
|
|||
return paths;
|
||||
}
|
||||
|
||||
/** Быстрая проверка: содержит ли файл TaskUpdate/teamctl маркер для данного taskId */
|
||||
async hasTaskUpdateMarker(filePath: string, taskId: string): Promise<boolean> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<AgentChangeSet>(REVIEW_GET_AGENT_CHANGES, teamName, memberName);
|
||||
},
|
||||
getTaskChanges: async (teamName: string, taskId: string) => {
|
||||
return invokeIpcWithResult<TaskChangeSetV2>(REVIEW_GET_TASK_CHANGES, teamName, taskId);
|
||||
},
|
||||
getChangeStats: async (teamName: string, memberName: string) => {
|
||||
return invokeIpcWithResult<ChangeStats>(REVIEW_GET_CHANGE_STATS, teamName, memberName);
|
||||
},
|
||||
getFileContent: async (teamName: string, memberName: string | undefined, filePath: string) => {
|
||||
return invokeIpcWithResult<FileChangeWithContent>(
|
||||
REVIEW_GET_FILE_CONTENT,
|
||||
teamName,
|
||||
memberName ?? '',
|
||||
filePath
|
||||
);
|
||||
},
|
||||
applyDecisions: async (request: ApplyReviewRequest) => {
|
||||
return invokeIpcWithResult<ApplyReviewResult>(REVIEW_APPLY_DECISIONS, request);
|
||||
},
|
||||
// Phase 2
|
||||
checkConflict: async (filePath: string, expectedModified: string) => {
|
||||
return invokeIpcWithResult<ConflictCheckResult>(
|
||||
REVIEW_CHECK_CONFLICT,
|
||||
filePath,
|
||||
expectedModified
|
||||
);
|
||||
},
|
||||
rejectHunks: async (
|
||||
filePath: string,
|
||||
original: string,
|
||||
modified: string,
|
||||
hunkIndices: number[],
|
||||
snippets: SnippetDiff[]
|
||||
) => {
|
||||
return invokeIpcWithResult<RejectResult>(
|
||||
REVIEW_REJECT_HUNKS,
|
||||
filePath,
|
||||
original,
|
||||
modified,
|
||||
hunkIndices,
|
||||
snippets
|
||||
);
|
||||
},
|
||||
rejectFile: async (filePath: string, original: string, modified: string) => {
|
||||
return invokeIpcWithResult<RejectResult>(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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
</CollapsibleTeamSection>
|
||||
|
|
@ -1316,6 +1328,15 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
void updateTaskOwner(teamName, taskId, owner);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ChangeReviewDialog
|
||||
open={reviewDialogState.open}
|
||||
onOpenChange={(open) => setReviewDialogState((prev) => ({ ...prev, open }))}
|
||||
teamName={teamName}
|
||||
mode={reviewDialogState.mode}
|
||||
memberName={reviewDialogState.memberName}
|
||||
taskId={reviewDialogState.taskId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
|
|
@ -363,6 +370,7 @@ export const KanbanBoard = ({
|
|||
onCancelTask={onCancelTask}
|
||||
onScrollToTask={onScrollToTask}
|
||||
onTaskClick={onTaskClick}
|
||||
onViewChanges={onViewChanges}
|
||||
/>
|
||||
))}
|
||||
{addButton}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{(columnId === 'done' || columnId === 'review' || columnId === 'approved') &&
|
||||
onViewChanges ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewChanges(task.id);
|
||||
}}
|
||||
className="flex items-center gap-1 text-[10px] text-[var(--color-text-muted)] transition-colors hover:text-blue-400"
|
||||
>
|
||||
<FileCode className="size-3" />
|
||||
Changes
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<UnreadCommentsBadge unreadCount={unreadCount} totalCount={task.comments?.length ?? 0} />
|
||||
|
|
|
|||
446
src/renderer/components/team/review/ChangeReviewDialog.tsx
Normal file
446
src/renderer/components/team/review/ChangeReviewDialog.tsx
Normal file
|
|
@ -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<string, string> = {
|
||||
'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<EditorView | null>(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 (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-surface">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border bg-surface-sidebar px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-sm font-medium text-text">{title}</h2>
|
||||
{activeChangeSet && (
|
||||
<>
|
||||
<span className="text-xs text-text-muted">
|
||||
{activeChangeSet.totalFiles} files, +{activeChangeSet.totalLinesAdded} -
|
||||
{activeChangeSet.totalLinesRemoved}
|
||||
</span>
|
||||
{mode === 'task' && isTaskChangeSetV2(activeChangeSet) && (
|
||||
<ConfidenceBadge confidence={activeChangeSet.scope.confidence} />
|
||||
)}
|
||||
<ViewedProgressBar
|
||||
viewed={viewedCount}
|
||||
total={viewedTotalCount}
|
||||
progress={viewedProgress}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Keyboard shortcuts help */}
|
||||
<KeyboardShortcutsHelp
|
||||
open={diffNav.showShortcutsHelp}
|
||||
onOpenChange={diffNav.setShowShortcutsHelp}
|
||||
/>
|
||||
|
||||
{/* Review toolbar */}
|
||||
{!changeSetLoading &&
|
||||
!changeSetError &&
|
||||
activeChangeSet &&
|
||||
activeChangeSet.files.length > 0 && (
|
||||
<ReviewToolbar
|
||||
stats={reviewStats}
|
||||
changeStats={changeStats}
|
||||
diffViewMode={diffViewMode}
|
||||
collapseUnchanged={collapseUnchanged}
|
||||
applying={applying}
|
||||
autoViewed={autoViewed}
|
||||
onAutoViewedChange={setAutoViewed}
|
||||
onAcceptAll={acceptAll}
|
||||
onRejectAll={rejectAll}
|
||||
onApply={handleApply}
|
||||
onDiffViewModeChange={setDiffViewMode}
|
||||
onCollapseUnchangedChange={setCollapseUnchanged}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Scope warnings */}
|
||||
{mode === 'task' &&
|
||||
activeChangeSet &&
|
||||
isTaskChangeSetV2(activeChangeSet) &&
|
||||
activeChangeSet.warnings.length > 0 && (
|
||||
<ScopeWarningBanner
|
||||
warnings={activeChangeSet.warnings}
|
||||
confidence={activeChangeSet.scope.confidence}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Apply error */}
|
||||
{applyError && (
|
||||
<div className="border-b border-red-500/20 bg-red-500/10 px-4 py-2 text-xs text-red-400">
|
||||
{applyError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{changeSetLoading && (
|
||||
<div className="flex w-full items-center justify-center text-sm text-text-muted">
|
||||
Loading changes...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{changeSetError && (
|
||||
<div className="flex w-full items-center justify-center text-sm text-red-400">
|
||||
{changeSetError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!changeSetLoading && !changeSetError && activeChangeSet && (
|
||||
<>
|
||||
{/* File tree */}
|
||||
<div className="w-64 shrink-0 overflow-y-auto border-r border-border bg-surface-sidebar">
|
||||
<ReviewFileTree
|
||||
files={activeChangeSet.files}
|
||||
selectedFilePath={selectedReviewFilePath}
|
||||
onSelectFile={selectReviewFile}
|
||||
viewedSet={viewedSet}
|
||||
onMarkViewed={markViewed}
|
||||
onUnmarkViewed={unmarkViewed}
|
||||
/>
|
||||
|
||||
{/* Edit Timeline */}
|
||||
{selectedFile?.timeline && selectedFile.timeline.events.length > 0 && (
|
||||
<div className="border-t border-border">
|
||||
<button
|
||||
onClick={() => setTimelineOpen(!timelineOpen)}
|
||||
className="flex w-full items-center gap-1.5 px-3 py-2 text-xs text-text-secondary hover:text-text"
|
||||
>
|
||||
<Clock className="size-3.5" />
|
||||
<span>Edit Timeline ({selectedFile.timeline.events.length})</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'ml-auto size-3 transition-transform',
|
||||
timelineOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{timelineOpen && (
|
||||
<FileEditTimeline
|
||||
timeline={selectedFile.timeline}
|
||||
onEventClick={(idx) => diffNav.goToHunk(idx)}
|
||||
activeSnippetIndex={diffNav.currentHunkIndex}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Diff content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{selectedFile ? (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* File header with content source badge */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-2">
|
||||
<span className="text-xs font-medium text-text">
|
||||
{selectedFile.relativePath}
|
||||
</span>
|
||||
{selectedFile.isNewFile && (
|
||||
<span className="rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] text-green-400">
|
||||
NEW
|
||||
</span>
|
||||
)}
|
||||
{fileContent?.contentSource && (
|
||||
<span className="rounded bg-surface-raised px-1.5 py-0.5 text-[10px] text-text-muted">
|
||||
{CONTENT_SOURCE_LABELS[fileContent.contentSource] ??
|
||||
fileContent.contentSource}
|
||||
</span>
|
||||
)}
|
||||
{/* File-level decision indicator */}
|
||||
{fileDecisions[selectedFile.filePath] && (
|
||||
<span
|
||||
className={`ml-auto rounded px-1.5 py-0.5 text-[10px] ${
|
||||
fileDecisions[selectedFile.filePath] === 'accepted'
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: fileDecisions[selectedFile.filePath] === 'rejected'
|
||||
? 'bg-red-500/20 text-red-400'
|
||||
: 'bg-zinc-500/20 text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{fileDecisions[selectedFile.filePath]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{isFileContentLoading && (
|
||||
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-text-muted">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading file content...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CodeMirror diff view when file content is available */}
|
||||
{!isFileContentLoading &&
|
||||
fileContent &&
|
||||
fileContent.contentSource !== 'unavailable' &&
|
||||
fileContent.originalFullContent !== null &&
|
||||
fileContent.modifiedFullContent !== null && (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<DiffErrorBoundary
|
||||
filePath={selectedFile.filePath}
|
||||
oldString={fileContent.originalFullContent}
|
||||
newString={fileContent.modifiedFullContent}
|
||||
>
|
||||
<CodeMirrorDiffView
|
||||
original={fileContent.originalFullContent}
|
||||
modified={fileContent.modifiedFullContent}
|
||||
fileName={selectedFile.relativePath}
|
||||
showMergeControls={true}
|
||||
collapseUnchanged={collapseUnchanged}
|
||||
onHunkAccepted={(idx) =>
|
||||
setHunkDecision(selectedFile.filePath, idx, 'accepted')
|
||||
}
|
||||
onHunkRejected={(idx) =>
|
||||
setHunkDecision(selectedFile.filePath, idx, 'rejected')
|
||||
}
|
||||
onFullyViewed={handleFullyViewed}
|
||||
editorViewRef={editorViewRef}
|
||||
/>
|
||||
</DiffErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback: Phase 1 snippet view when content unavailable */}
|
||||
{!isFileContentLoading &&
|
||||
(!fileContent || fileContent.contentSource === 'unavailable') && (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<ReviewDiffContent file={selectedFile} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-text-muted">
|
||||
Select a file to view changes
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!changeSetLoading && !changeSetError && activeChangeSet?.files.length === 0 && (
|
||||
<div className="flex w-full items-center justify-center text-sm text-text-muted">
|
||||
No file changes detected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
20
src/renderer/components/team/review/ChangeStatsBadge.tsx
Normal file
20
src/renderer/components/team/review/ChangeStatsBadge.tsx
Normal file
|
|
@ -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 (
|
||||
<span className={`inline-flex items-center gap-1 font-mono text-[11px] ${className}`}>
|
||||
{linesAdded > 0 && <span className="text-green-400">+{linesAdded}</span>}
|
||||
{linesRemoved > 0 && <span className="text-red-400">-{linesRemoved}</span>}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
356
src/renderer/components/team/review/CodeMirrorDiffView.tsx
Normal file
356
src/renderer/components/team/review/CodeMirrorDiffView.tsx
Normal file
|
|
@ -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<EditorView | null>;
|
||||
}
|
||||
|
||||
/** 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<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const endSentinelRef = useRef<HTMLDivElement>(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<typeof unifiedMergeView>[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<EditorView | null>).current = view;
|
||||
}
|
||||
|
||||
return () => {
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
if (extRef) {
|
||||
(extRef as React.MutableRefObject<EditorView | null>).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 (
|
||||
<div className="flex flex-col" style={{ maxHeight }}>
|
||||
<div ref={containerRef} className="flex-1 overflow-hidden rounded-lg border border-border" />
|
||||
{/* Invisible sentinel for auto-viewed detection */}
|
||||
<div ref={endSentinelRef} className="h-px shrink-0" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Re-export merge utils for external use
|
||||
export { acceptChunk, getChunks, rejectChunk };
|
||||
31
src/renderer/components/team/review/ConfidenceBadge.tsx
Normal file
31
src/renderer/components/team/review/ConfidenceBadge.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import type { TaskScopeConfidence } from '@shared/types';
|
||||
|
||||
interface ConfidenceBadgeProps {
|
||||
confidence: TaskScopeConfidence;
|
||||
showTooltip?: boolean;
|
||||
}
|
||||
|
||||
const TIER_COLORS: Record<number, string> = {
|
||||
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<number, string> = {
|
||||
1: 'High confidence',
|
||||
2: 'Medium confidence',
|
||||
3: 'Low confidence',
|
||||
4: 'Best effort',
|
||||
};
|
||||
|
||||
export const ConfidenceBadge = ({ confidence, showTooltip = true }: ConfidenceBadgeProps) => {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded border px-2 py-0.5 text-xs ${TIER_COLORS[confidence.tier] ?? TIER_COLORS[4]}`}
|
||||
title={showTooltip ? confidence.reason : undefined}
|
||||
>
|
||||
{TIER_LABELS[confidence.tier] ?? TIER_LABELS[4]}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
145
src/renderer/components/team/review/ConflictDialog.tsx
Normal file
145
src/renderer/components/team/review/ConflictDialog.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60">
|
||||
<div className="mx-4 flex max-h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-lg border border-border bg-surface shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||
<AlertTriangle className="size-4 text-yellow-400" />
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-medium text-text">Conflict Detected</h3>
|
||||
<p className="text-xs text-text-muted">
|
||||
This file has been modified since the agent's changes
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* File path */}
|
||||
<div className="border-b border-border bg-surface-raised px-4 py-2">
|
||||
<span className="font-mono text-xs text-text-secondary">{filePath}</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{editMode ? (
|
||||
<textarea
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
className="h-64 w-full resize-y rounded border border-border bg-surface p-3 font-mono text-xs text-text focus:border-blue-500/50 focus:outline-none"
|
||||
spellCheck={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="max-h-64 overflow-auto rounded border border-border bg-surface font-mono text-xs leading-5">
|
||||
{conflictContent.split('\n').map((line, i) => {
|
||||
const isMarker =
|
||||
line.startsWith('<<<<<<<') ||
|
||||
line.startsWith('=======') ||
|
||||
line.startsWith('>>>>>>>');
|
||||
const lineClass = isMarker
|
||||
? 'px-3 bg-yellow-500/10 text-yellow-400 font-medium'
|
||||
: 'px-3 text-text-secondary';
|
||||
|
||||
return (
|
||||
<div key={i} className={lineClass}>
|
||||
<span className="mr-3 inline-block w-8 text-right text-text-muted opacity-50">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="whitespace-pre">{line}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2 border-t border-border px-4 py-3">
|
||||
{editMode ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setEditMode(false)}
|
||||
className="rounded px-3 py-1.5 text-xs text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleManualSave}
|
||||
className="rounded bg-blue-500/20 px-3 py-1.5 text-xs text-blue-400 transition-colors hover:bg-blue-500/30"
|
||||
>
|
||||
Save Resolution
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditContent(conflictContent);
|
||||
setEditMode(true);
|
||||
}}
|
||||
className="rounded px-3 py-1.5 text-xs text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
Edit Manually
|
||||
</button>
|
||||
<button
|
||||
onClick={onResolveUseOriginal}
|
||||
className={cn(
|
||||
'rounded px-3 py-1.5 text-xs transition-colors',
|
||||
'bg-red-500/15 text-red-400 hover:bg-red-500/25'
|
||||
)}
|
||||
>
|
||||
Use Original
|
||||
</button>
|
||||
<button
|
||||
onClick={onResolveKeepCurrent}
|
||||
className={cn(
|
||||
'rounded px-3 py-1.5 text-xs transition-colors',
|
||||
'bg-green-500/15 text-green-400 hover:bg-green-500/25'
|
||||
)}
|
||||
>
|
||||
Keep Current
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
101
src/renderer/components/team/review/DiffErrorBoundary.tsx
Normal file
101
src/renderer/components/team/review/DiffErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { Component, type ReactNode } from 'react';
|
||||
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface DiffErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
filePath: string;
|
||||
oldString?: string;
|
||||
newString?: string;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
interface DiffErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class DiffErrorBoundary extends Component<DiffErrorBoundaryProps, DiffErrorBoundaryState> {
|
||||
constructor(props: DiffErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): DiffErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||
console.error(
|
||||
'[DiffErrorBoundary] Error rendering diff for',
|
||||
this.props.filePath,
|
||||
error,
|
||||
errorInfo
|
||||
);
|
||||
}
|
||||
|
||||
render(): JSX.Element {
|
||||
if (!this.state.hasError) {
|
||||
return <>{this.props.children}</>;
|
||||
}
|
||||
|
||||
const { filePath, oldString, newString, onRetry } = this.props;
|
||||
const { error } = this.state;
|
||||
|
||||
return (
|
||||
<div className="m-4 rounded-lg border border-red-500/20 bg-red-500/10 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<AlertTriangle className="size-4 text-red-400" />
|
||||
<span className="text-sm font-medium text-red-300">Failed to render diff view</span>
|
||||
</div>
|
||||
|
||||
<p className="mb-3 text-xs text-red-300/80">
|
||||
{error?.message ?? 'An unexpected error occurred while rendering the diff.'}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={() => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
onRetry();
|
||||
}}
|
||||
className="rounded bg-red-500/20 px-3 py-1 text-xs text-red-300 transition-colors hover:bg-red-500/30"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(oldString || newString) && (
|
||||
<details className="mt-3">
|
||||
<summary className="cursor-pointer text-xs text-red-300/60 hover:text-red-300/80">
|
||||
Show raw diff data
|
||||
</summary>
|
||||
<div className="mt-2 max-h-60 overflow-auto rounded bg-surface p-2 font-mono text-xs text-text-muted">
|
||||
<div className="mb-1 text-text-secondary">File: {filePath}</div>
|
||||
{oldString && (
|
||||
<div className="mb-2">
|
||||
<div className="mb-0.5 text-red-400">--- Original</div>
|
||||
<pre className="whitespace-pre-wrap">{oldString.slice(0, 2000)}</pre>
|
||||
{oldString.length > 2000 && (
|
||||
<span className="text-text-muted">... ({oldString.length} chars total)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{newString && (
|
||||
<div>
|
||||
<div className="mb-0.5 text-green-400">+++ Modified</div>
|
||||
<pre className="whitespace-pre-wrap">{newString.slice(0, 2000)}</pre>
|
||||
{newString.length > 2000 && (
|
||||
<span className="text-text-muted">... ({newString.length} chars total)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
81
src/renderer/components/team/review/FileEditTimeline.tsx
Normal file
81
src/renderer/components/team/review/FileEditTimeline.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { cn } from '@renderer/lib/utils';
|
||||
|
||||
import type { FileEditTimeline as FileEditTimelineType } from '@shared/types/review';
|
||||
|
||||
interface FileEditTimelineProps {
|
||||
timeline: FileEditTimelineType;
|
||||
onEventClick?: (snippetIndex: number) => void;
|
||||
activeSnippetIndex?: number;
|
||||
}
|
||||
|
||||
export const FileEditTimeline = ({
|
||||
timeline,
|
||||
onEventClick,
|
||||
activeSnippetIndex,
|
||||
}: FileEditTimelineProps) => {
|
||||
if (timeline.events.length === 0) {
|
||||
return <div className="px-3 py-2 text-xs text-text-muted">No edit events</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0 px-3 py-2">
|
||||
{timeline.events.map((event, idx) => {
|
||||
const isActive = activeSnippetIndex === event.snippetIndex;
|
||||
const isLast = idx === timeline.events.length - 1;
|
||||
const time = formatTime(event.timestamp);
|
||||
|
||||
return (
|
||||
<div key={`${event.toolUseId}-${idx}`} className="flex">
|
||||
{/* Timeline line + dot */}
|
||||
<div className="flex w-5 shrink-0 flex-col items-center">
|
||||
<div
|
||||
className={cn(
|
||||
'mt-1.5 size-2 shrink-0 rounded-full',
|
||||
isActive ? 'bg-blue-400' : 'bg-zinc-500'
|
||||
)}
|
||||
/>
|
||||
{!isLast && <div className="w-px flex-1 bg-zinc-700" />}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<button
|
||||
onClick={() => onEventClick?.(event.snippetIndex)}
|
||||
className={cn(
|
||||
'mb-1.5 flex w-full items-center gap-2 rounded px-1.5 py-1 text-left transition-colors',
|
||||
isActive
|
||||
? 'bg-blue-500/10 text-blue-300'
|
||||
: 'text-text-secondary hover:bg-surface-raised hover:text-text'
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0 font-mono text-[10px] text-text-muted">{time}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs">{event.summary}</span>
|
||||
<span className="flex shrink-0 items-center gap-0.5 text-[10px]">
|
||||
{event.linesAdded > 0 && (
|
||||
<span className="text-green-400">+{event.linesAdded}</span>
|
||||
)}
|
||||
{event.linesRemoved > 0 && (
|
||||
<span className="text-red-400">-{event.linesRemoved}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function formatTime(timestamp: string): string {
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
if (isNaN(date.getTime())) return '??:??';
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
} catch {
|
||||
return '??:??';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { X } from 'lucide-react';
|
||||
|
||||
interface KeyboardShortcutsHelpProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const shortcuts = [
|
||||
{ keys: ['j', '\u2193'], action: 'Next hunk' },
|
||||
{ keys: ['k', '\u2191'], action: 'Previous hunk' },
|
||||
{ keys: ['n'], action: 'Next file' },
|
||||
{ keys: ['p', 'Shift+N'], action: 'Previous file' },
|
||||
{ keys: ['a'], action: 'Accept current hunk' },
|
||||
{ keys: ['x'], action: 'Reject current hunk' },
|
||||
{ keys: ['?'], action: 'Toggle shortcuts help' },
|
||||
{ keys: ['Esc'], action: 'Close dialog' },
|
||||
];
|
||||
|
||||
export const KeyboardShortcutsHelp = ({ open, onOpenChange }: KeyboardShortcutsHelpProps) => {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute right-4 top-14 z-50 w-64 rounded-lg border border-border bg-surface-overlay p-3 shadow-lg">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-text">Keyboard Shortcuts</span>
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="rounded p-0.5 text-text-muted hover:bg-surface-raised hover:text-text"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{shortcuts.map(({ keys, action }) => (
|
||||
<div key={action} className="flex items-center justify-between text-xs">
|
||||
<span className="text-text-secondary">{action}</span>
|
||||
<div className="flex gap-1">
|
||||
{keys.map((key) => (
|
||||
<kbd
|
||||
key={key}
|
||||
className="rounded border border-border bg-surface-raised px-1.5 py-0.5 font-mono text-[10px] text-text-muted"
|
||||
>
|
||||
{key}
|
||||
</kbd>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
107
src/renderer/components/team/review/ReviewDiffContent.tsx
Normal file
107
src/renderer/components/team/review/ReviewDiffContent.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { diffLines } from 'diff';
|
||||
|
||||
import type { FileChangeSummary, SnippetDiff } from '@shared/types/review';
|
||||
|
||||
interface ReviewDiffContentProps {
|
||||
file: FileChangeSummary;
|
||||
}
|
||||
|
||||
const SnippetDiffView = ({ snippet, index }: { snippet: SnippetDiff; index: number }) => {
|
||||
const diffResult = useMemo(() => {
|
||||
if (snippet.type === 'write-new') {
|
||||
// Весь файл — новый
|
||||
return diffLines('', snippet.newString);
|
||||
}
|
||||
if (snippet.type === 'write-update') {
|
||||
// Полная перезапись
|
||||
return diffLines('', snippet.newString);
|
||||
}
|
||||
return diffLines(snippet.oldString, snippet.newString);
|
||||
}, [snippet]);
|
||||
|
||||
const toolLabel =
|
||||
snippet.type === 'write-new'
|
||||
? 'New file'
|
||||
: snippet.type === 'write-update'
|
||||
? 'Full rewrite'
|
||||
: snippet.type === 'multi-edit'
|
||||
? 'Multi-edit'
|
||||
: 'Edit';
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
{/* Заголовок snippet */}
|
||||
<div className="flex items-center justify-between border-b border-border bg-surface-raised px-3 py-1.5">
|
||||
<span className="text-xs text-text-muted">
|
||||
#{index + 1} {toolLabel}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted">
|
||||
{snippet.timestamp ? new Date(snippet.timestamp).toLocaleTimeString() : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Строки диффа */}
|
||||
<div className="overflow-x-auto font-mono text-xs leading-5">
|
||||
{diffResult.map((part, i) => {
|
||||
const lines = part.value.replace(/\n$/, '').split('\n');
|
||||
return lines.map((line, j) => {
|
||||
let bgClass = '';
|
||||
let prefix = ' ';
|
||||
let textClass = 'text-text-secondary';
|
||||
|
||||
if (part.added) {
|
||||
bgClass = 'bg-[var(--diff-added-bg,rgba(46,160,67,0.15))]';
|
||||
prefix = '+';
|
||||
textClass = 'text-[var(--diff-added-text,#3fb950)]';
|
||||
} else if (part.removed) {
|
||||
bgClass = 'bg-[var(--diff-removed-bg,rgba(248,81,73,0.15))]';
|
||||
prefix = '-';
|
||||
textClass = 'text-[var(--diff-removed-text,#f85149)]';
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`${i}-${j}`} className={`px-3 ${bgClass} ${textClass}`}>
|
||||
<span className="inline-block w-4 select-none text-text-muted opacity-50">
|
||||
{prefix}
|
||||
</span>
|
||||
<span className="whitespace-pre">{line}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ReviewDiffContent = ({ file }: ReviewDiffContentProps) => {
|
||||
const nonErrorSnippets = useMemo(() => file.snippets.filter((s) => !s.isError), [file.snippets]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
{/* Заголовок файла */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-text">{file.relativePath}</span>
|
||||
{file.isNewFile && (
|
||||
<span className="rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] text-green-400">
|
||||
NEW
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto text-xs text-text-muted">
|
||||
{nonErrorSnippets.length} change{nonErrorSnippets.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Snippets */}
|
||||
{nonErrorSnippets.map((snippet, index) => (
|
||||
<SnippetDiffView key={snippet.toolUseId} snippet={snippet} index={index} />
|
||||
))}
|
||||
|
||||
{nonErrorSnippets.length === 0 && (
|
||||
<div className="py-8 text-center text-sm text-text-muted">No changes to display</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
248
src/renderer/components/team/review/ReviewFileTree.tsx
Normal file
248
src/renderer/components/team/review/ReviewFileTree.tsx
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { Check, Circle, CircleDot, File, FolderOpen, X as XIcon } from 'lucide-react';
|
||||
|
||||
import type { HunkDecision } from '@shared/types';
|
||||
import type { FileChangeSummary } from '@shared/types/review';
|
||||
|
||||
interface ReviewFileTreeProps {
|
||||
files: FileChangeSummary[];
|
||||
selectedFilePath: string | null;
|
||||
onSelectFile: (filePath: string) => void;
|
||||
viewedSet?: Set<string>;
|
||||
onMarkViewed?: (filePath: string) => void;
|
||||
onUnmarkViewed?: (filePath: string) => void;
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
name: string;
|
||||
fullPath: string;
|
||||
isFile: boolean;
|
||||
file?: FileChangeSummary;
|
||||
children: TreeNode[];
|
||||
}
|
||||
|
||||
type FileStatus = 'pending' | 'accepted' | 'rejected' | 'mixed';
|
||||
|
||||
function buildTree(files: FileChangeSummary[]): TreeNode[] {
|
||||
const root: TreeNode = { name: '', fullPath: '', isFile: false, children: [] };
|
||||
|
||||
for (const file of files) {
|
||||
const parts = file.relativePath.split('/');
|
||||
let current = root;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const isLast = i === parts.length - 1;
|
||||
const fullPath = parts.slice(0, i + 1).join('/');
|
||||
|
||||
let child = current.children.find((c) => c.name === part);
|
||||
if (!child) {
|
||||
child = {
|
||||
name: part,
|
||||
fullPath,
|
||||
isFile: isLast,
|
||||
file: isLast ? file : undefined,
|
||||
children: [],
|
||||
};
|
||||
current.children.push(child);
|
||||
}
|
||||
current = child;
|
||||
}
|
||||
}
|
||||
|
||||
function collapse(node: TreeNode): TreeNode {
|
||||
const collapsed: TreeNode = { ...node, children: node.children.map(collapse) };
|
||||
if (!collapsed.isFile && collapsed.children.length === 1 && !collapsed.children[0].isFile) {
|
||||
const child = collapsed.children[0];
|
||||
return {
|
||||
...child,
|
||||
name: `${collapsed.name}/${child.name}`,
|
||||
children: child.children,
|
||||
};
|
||||
}
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
return collapse(root).children;
|
||||
}
|
||||
|
||||
function getFileStatus(
|
||||
file: FileChangeSummary,
|
||||
hunkDecisions: Record<string, HunkDecision>
|
||||
): FileStatus {
|
||||
if (file.snippets.length === 0) return 'pending';
|
||||
|
||||
const decisions: HunkDecision[] = [];
|
||||
for (let i = 0; i < file.snippets.length; i++) {
|
||||
const key = `${file.filePath}:${i}`;
|
||||
decisions.push(hunkDecisions[key] ?? 'pending');
|
||||
}
|
||||
|
||||
const allAccepted = decisions.every((d) => d === 'accepted');
|
||||
const allRejected = decisions.every((d) => d === 'rejected');
|
||||
const allPending = decisions.every((d) => d === 'pending');
|
||||
|
||||
if (allPending) return 'pending';
|
||||
if (allAccepted) return 'accepted';
|
||||
if (allRejected) return 'rejected';
|
||||
return 'mixed';
|
||||
}
|
||||
|
||||
const FileStatusIcon = ({ status }: { status: FileStatus }) => {
|
||||
switch (status) {
|
||||
case 'accepted':
|
||||
return <Check className="size-3 shrink-0 text-green-400" />;
|
||||
case 'rejected':
|
||||
return <XIcon className="size-3 shrink-0 text-red-400" />;
|
||||
case 'mixed':
|
||||
return <CircleDot className="size-3 shrink-0 text-yellow-400" />;
|
||||
case 'pending':
|
||||
default:
|
||||
return <Circle className="size-3 shrink-0 text-zinc-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const TreeItem = ({
|
||||
node,
|
||||
selectedFilePath,
|
||||
onSelectFile,
|
||||
depth,
|
||||
hunkDecisions,
|
||||
viewedSet,
|
||||
onMarkViewed,
|
||||
onUnmarkViewed,
|
||||
}: {
|
||||
node: TreeNode;
|
||||
selectedFilePath: string | null;
|
||||
onSelectFile: (filePath: string) => void;
|
||||
depth: number;
|
||||
hunkDecisions: Record<string, HunkDecision>;
|
||||
viewedSet?: Set<string>;
|
||||
onMarkViewed?: (filePath: string) => void;
|
||||
onUnmarkViewed?: (filePath: string) => void;
|
||||
}) => {
|
||||
if (node.isFile && node.file) {
|
||||
const isSelected = node.file.filePath === selectedFilePath;
|
||||
const status = getFileStatus(node.file, hunkDecisions);
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSelectFile(node.file!.filePath)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs transition-colors',
|
||||
isSelected
|
||||
? 'bg-blue-500/20 text-blue-300'
|
||||
: 'text-text-secondary hover:bg-surface-raised hover:text-text'
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||
>
|
||||
<FileStatusIcon status={status} />
|
||||
<File className="size-3.5 shrink-0" />
|
||||
{viewedSet && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={viewedSet.has(node.file.filePath)}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.target.checked) {
|
||||
onMarkViewed?.(node.file!.filePath);
|
||||
} else {
|
||||
onUnmarkViewed?.(node.file!.filePath);
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="size-3 shrink-0 rounded border-zinc-600 accent-green-500"
|
||||
aria-label={`Mark ${node.name} as viewed`}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate',
|
||||
viewedSet?.has(node.file.filePath) && 'text-text-muted line-through'
|
||||
)}
|
||||
>
|
||||
{node.name}
|
||||
</span>
|
||||
<span className="ml-1 flex shrink-0 items-center gap-1">
|
||||
{node.file.linesAdded > 0 && (
|
||||
<span className="text-green-400">+{node.file.linesAdded}</span>
|
||||
)}
|
||||
{node.file.linesRemoved > 0 && (
|
||||
<span className="text-red-400">-{node.file.linesRemoved}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center gap-2 px-2 py-1 text-xs text-text-muted"
|
||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||
>
|
||||
<FolderOpen className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{node.name}</span>
|
||||
</div>
|
||||
{[...node.children]
|
||||
.sort((a, b) => {
|
||||
if (a.isFile !== b.isFile) return a.isFile ? 1 : -1;
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.map((child) => (
|
||||
<TreeItem
|
||||
key={child.fullPath}
|
||||
node={child}
|
||||
selectedFilePath={selectedFilePath}
|
||||
onSelectFile={onSelectFile}
|
||||
depth={depth + 1}
|
||||
hunkDecisions={hunkDecisions}
|
||||
viewedSet={viewedSet}
|
||||
onMarkViewed={onMarkViewed}
|
||||
onUnmarkViewed={onUnmarkViewed}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ReviewFileTree = ({
|
||||
files,
|
||||
selectedFilePath,
|
||||
onSelectFile,
|
||||
viewedSet,
|
||||
onMarkViewed,
|
||||
onUnmarkViewed,
|
||||
}: ReviewFileTreeProps) => {
|
||||
const hunkDecisions = useStore((state) => state.hunkDecisions);
|
||||
const tree = useMemo(() => buildTree(files), [files]);
|
||||
|
||||
if (files.length === 0) {
|
||||
return <div className="p-4 text-center text-xs text-text-muted">No changed files</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-1">
|
||||
{[...tree]
|
||||
.sort((a, b) => {
|
||||
if (a.isFile !== b.isFile) return a.isFile ? 1 : -1;
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.map((node) => (
|
||||
<TreeItem
|
||||
key={node.fullPath}
|
||||
node={node}
|
||||
selectedFilePath={selectedFilePath}
|
||||
onSelectFile={onSelectFile}
|
||||
depth={0}
|
||||
hunkDecisions={hunkDecisions}
|
||||
viewedSet={viewedSet}
|
||||
onMarkViewed={onMarkViewed}
|
||||
onUnmarkViewed={onUnmarkViewed}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
155
src/renderer/components/team/review/ReviewToolbar.tsx
Normal file
155
src/renderer/components/team/review/ReviewToolbar.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { cn } from '@renderer/lib/utils';
|
||||
import { Check, Columns2, Eye, EyeOff, GitMerge, Loader2, Rows2, X } from 'lucide-react';
|
||||
|
||||
import type { ChangeStats } from '@shared/types';
|
||||
|
||||
interface ReviewToolbarProps {
|
||||
stats: { pending: number; accepted: number; rejected: number };
|
||||
changeStats: ChangeStats;
|
||||
diffViewMode: 'unified' | 'split';
|
||||
collapseUnchanged: boolean;
|
||||
applying: boolean;
|
||||
autoViewed: boolean;
|
||||
onAutoViewedChange: (auto: boolean) => void;
|
||||
onAcceptAll: () => void;
|
||||
onRejectAll: () => void;
|
||||
onApply: () => void;
|
||||
onDiffViewModeChange: (mode: 'unified' | 'split') => void;
|
||||
onCollapseUnchangedChange: (collapse: boolean) => void;
|
||||
}
|
||||
|
||||
export const ReviewToolbar = ({
|
||||
stats,
|
||||
changeStats,
|
||||
diffViewMode,
|
||||
collapseUnchanged,
|
||||
applying,
|
||||
autoViewed,
|
||||
onAutoViewedChange,
|
||||
onAcceptAll,
|
||||
onRejectAll,
|
||||
onApply,
|
||||
onDiffViewModeChange,
|
||||
onCollapseUnchangedChange,
|
||||
}: ReviewToolbarProps) => {
|
||||
const hasRejected = stats.rejected > 0;
|
||||
const canApply = hasRejected && !applying;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-b border-border bg-surface-sidebar px-4 py-2">
|
||||
{/* Decision stats */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{stats.pending > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-zinc-500/20 px-2 py-0.5 text-zinc-400">
|
||||
{stats.pending} pending
|
||||
</span>
|
||||
)}
|
||||
{stats.accepted > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-green-500/20 px-2 py-0.5 text-green-400">
|
||||
<Check className="size-3" />
|
||||
{stats.accepted} accepted
|
||||
</span>
|
||||
)}
|
||||
{stats.rejected > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-red-500/20 px-2 py-0.5 text-red-400">
|
||||
<X className="size-3" />
|
||||
{stats.rejected} rejected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Change stats */}
|
||||
<div className="flex items-center gap-1 text-xs text-text-muted">
|
||||
<span className="text-green-400">+{changeStats.linesAdded}</span>
|
||||
<span className="text-red-400">-{changeStats.linesRemoved}</span>
|
||||
<span className="ml-1">across {changeStats.filesChanged} files</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* View toggles */}
|
||||
<div className="flex items-center gap-1 rounded-md border border-border bg-surface p-0.5">
|
||||
<button
|
||||
onClick={() => onDiffViewModeChange('unified')}
|
||||
className={cn(
|
||||
'rounded px-2 py-1 text-xs transition-colors',
|
||||
diffViewMode === 'unified'
|
||||
? 'bg-surface-raised text-text'
|
||||
: 'text-text-muted hover:text-text'
|
||||
)}
|
||||
title="Unified view"
|
||||
>
|
||||
<Rows2 className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDiffViewModeChange('split')}
|
||||
className={cn(
|
||||
'rounded px-2 py-1 text-xs transition-colors',
|
||||
diffViewMode === 'split'
|
||||
? 'bg-surface-raised text-text'
|
||||
: 'text-text-muted hover:text-text'
|
||||
)}
|
||||
title="Split view"
|
||||
>
|
||||
<Columns2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onCollapseUnchangedChange(!collapseUnchanged)}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors',
|
||||
collapseUnchanged ? 'bg-surface-raised text-text' : 'text-text-muted hover:text-text'
|
||||
)}
|
||||
title={collapseUnchanged ? 'Show all lines' : 'Collapse unchanged'}
|
||||
>
|
||||
{collapseUnchanged ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onAutoViewedChange(!autoViewed)}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors',
|
||||
autoViewed ? 'bg-surface-raised text-text' : 'text-text-muted hover:text-text'
|
||||
)}
|
||||
title={autoViewed ? 'Auto-mark viewed: ON' : 'Auto-mark viewed: OFF'}
|
||||
>
|
||||
{autoViewed ? <Eye className="size-3.5" /> : <EyeOff className="size-3.5" />}
|
||||
<span className="text-[10px]">Auto</span>
|
||||
</button>
|
||||
|
||||
<div className="h-4 w-px bg-border" />
|
||||
|
||||
{/* Actions */}
|
||||
<button
|
||||
onClick={onAcceptAll}
|
||||
className="flex items-center gap-1 rounded bg-green-500/15 px-2.5 py-1 text-xs text-green-400 transition-colors hover:bg-green-500/25"
|
||||
>
|
||||
<Check className="size-3" />
|
||||
Accept All
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onRejectAll}
|
||||
className="flex items-center gap-1 rounded bg-red-500/15 px-2.5 py-1 text-xs text-red-400 transition-colors hover:bg-red-500/25"
|
||||
>
|
||||
<X className="size-3" />
|
||||
Reject All
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onApply}
|
||||
disabled={!canApply}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded px-3 py-1 text-xs font-medium transition-colors',
|
||||
canApply
|
||||
? 'bg-blue-500/20 text-blue-400 hover:bg-blue-500/30'
|
||||
: 'cursor-not-allowed bg-zinc-500/10 text-zinc-600'
|
||||
)}
|
||||
>
|
||||
{applying ? <Loader2 className="size-3 animate-spin" /> : <GitMerge className="size-3" />}
|
||||
{applying ? 'Applying...' : 'Apply Changes'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
41
src/renderer/components/team/review/ScopeWarningBanner.tsx
Normal file
41
src/renderer/components/team/review/ScopeWarningBanner.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { AlertTriangle, X } from 'lucide-react';
|
||||
|
||||
import type { TaskScopeConfidence } from '@shared/types';
|
||||
|
||||
interface ScopeWarningBannerProps {
|
||||
warnings: string[];
|
||||
confidence: TaskScopeConfidence;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
export const ScopeWarningBanner = ({
|
||||
warnings,
|
||||
confidence,
|
||||
onDismiss,
|
||||
}: ScopeWarningBannerProps) => {
|
||||
if (warnings.length === 0 && confidence.tier <= 2) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-yellow-500/20 bg-yellow-500/10 p-3 text-sm">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-yellow-400" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-yellow-300">
|
||||
{confidence.tier >= 3
|
||||
? 'Task boundary detection is approximate'
|
||||
: 'Note about these changes'}
|
||||
</p>
|
||||
{warnings.map((w, i) => (
|
||||
<p key={i} className="mt-1 text-text-secondary">
|
||||
{w}
|
||||
</p>
|
||||
))}
|
||||
<p className="mt-1 text-xs text-text-muted">Detection: {confidence.reason}</p>
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<button onClick={onDismiss} className="text-text-muted hover:text-text">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
23
src/renderer/components/team/review/ViewedProgressBar.tsx
Normal file
23
src/renderer/components/team/review/ViewedProgressBar.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
interface ViewedProgressBarProps {
|
||||
viewed: number;
|
||||
total: number;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export const ViewedProgressBar = ({ viewed, total, progress }: ViewedProgressBarProps) => {
|
||||
if (total === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-zinc-700/50">
|
||||
<div
|
||||
className="h-full rounded-full bg-green-500/70 transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-text-muted">
|
||||
{viewed}/{total} viewed
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
211
src/renderer/hooks/useDiffNavigation.ts
Normal file
211
src/renderer/hooks/useDiffNavigation.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { goToNextChunk, goToPreviousChunk } from '@codemirror/merge';
|
||||
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type { FileChangeSummary } from '@shared/types/review';
|
||||
|
||||
interface DiffNavigationState {
|
||||
currentHunkIndex: number;
|
||||
totalHunks: number;
|
||||
goToNextHunk: () => void;
|
||||
goToPrevHunk: () => void;
|
||||
goToNextFile: () => void;
|
||||
goToPrevFile: () => void;
|
||||
goToHunk: (index: number) => void;
|
||||
acceptCurrentHunk: () => void;
|
||||
rejectCurrentHunk: () => void;
|
||||
showShortcutsHelp: boolean;
|
||||
setShowShortcutsHelp: (show: boolean) => void;
|
||||
}
|
||||
|
||||
export function useDiffNavigation(
|
||||
files: FileChangeSummary[],
|
||||
selectedFilePath: string | null,
|
||||
onSelectFile: (path: string) => void,
|
||||
editorViewRef: React.RefObject<EditorView | null>,
|
||||
isDialogOpen: boolean,
|
||||
onHunkAccepted?: (filePath: string, hunkIndex: number) => void,
|
||||
onHunkRejected?: (filePath: string, hunkIndex: number) => void,
|
||||
onClose?: () => void
|
||||
): DiffNavigationState {
|
||||
// Track hunk index keyed by file path to auto-reset on file change
|
||||
const [hunkState, setHunkState] = useState<{ filePath: string | null; index: number }>({
|
||||
filePath: selectedFilePath,
|
||||
index: 0,
|
||||
});
|
||||
const [showShortcutsHelp, setShowShortcutsHelp] = useState(false);
|
||||
|
||||
const selectedFile = files.find((f) => f.filePath === selectedFilePath);
|
||||
const totalHunks = selectedFile?.snippets.length ?? 0;
|
||||
|
||||
// Derive currentHunkIndex: reset to 0 when selectedFilePath changes
|
||||
const currentHunkIndex = hunkState.filePath === selectedFilePath ? hunkState.index : 0;
|
||||
|
||||
const setCurrentHunkIndex = useCallback(
|
||||
(updater: number | ((prev: number) => number)) => {
|
||||
setHunkState((prev) => {
|
||||
const newIndex =
|
||||
typeof updater === 'function'
|
||||
? updater(prev.filePath === selectedFilePath ? prev.index : 0)
|
||||
: updater;
|
||||
return { filePath: selectedFilePath, index: newIndex };
|
||||
});
|
||||
},
|
||||
[selectedFilePath]
|
||||
);
|
||||
|
||||
const goToNextHunk = useCallback(() => {
|
||||
const view = editorViewRef.current;
|
||||
if (view) {
|
||||
goToNextChunk(view);
|
||||
}
|
||||
setCurrentHunkIndex((prev) => Math.min(prev + 1, totalHunks - 1));
|
||||
}, [editorViewRef, totalHunks, setCurrentHunkIndex]);
|
||||
|
||||
const goToPrevHunk = useCallback(() => {
|
||||
const view = editorViewRef.current;
|
||||
if (view) {
|
||||
goToPreviousChunk(view);
|
||||
}
|
||||
setCurrentHunkIndex((prev) => Math.max(prev - 1, 0));
|
||||
}, [editorViewRef, setCurrentHunkIndex]);
|
||||
|
||||
const goToNextFile = useCallback(() => {
|
||||
if (files.length === 0) return;
|
||||
const currentIdx = files.findIndex((f) => f.filePath === selectedFilePath);
|
||||
const nextIdx = currentIdx < files.length - 1 ? currentIdx + 1 : 0;
|
||||
onSelectFile(files[nextIdx].filePath);
|
||||
}, [files, selectedFilePath, onSelectFile]);
|
||||
|
||||
const goToPrevFile = useCallback(() => {
|
||||
if (files.length === 0) return;
|
||||
const currentIdx = files.findIndex((f) => f.filePath === selectedFilePath);
|
||||
const prevIdx = currentIdx > 0 ? currentIdx - 1 : files.length - 1;
|
||||
onSelectFile(files[prevIdx].filePath);
|
||||
}, [files, selectedFilePath, onSelectFile]);
|
||||
|
||||
const goToHunk = useCallback(
|
||||
(index: number) => {
|
||||
setCurrentHunkIndex(Math.max(0, Math.min(index, totalHunks - 1)));
|
||||
},
|
||||
[totalHunks, setCurrentHunkIndex]
|
||||
);
|
||||
|
||||
const acceptCurrentHunk = useCallback(() => {
|
||||
if (selectedFilePath && onHunkAccepted) {
|
||||
onHunkAccepted(selectedFilePath, currentHunkIndex);
|
||||
}
|
||||
}, [selectedFilePath, currentHunkIndex, onHunkAccepted]);
|
||||
|
||||
const rejectCurrentHunk = useCallback(() => {
|
||||
if (selectedFilePath && onHunkRejected) {
|
||||
onHunkRejected(selectedFilePath, currentHunkIndex);
|
||||
}
|
||||
}, [selectedFilePath, currentHunkIndex, onHunkRejected]);
|
||||
|
||||
// Store refs for stable closure
|
||||
const goToNextHunkRef = useRef(goToNextHunk);
|
||||
const goToPrevHunkRef = useRef(goToPrevHunk);
|
||||
const goToNextFileRef = useRef(goToNextFile);
|
||||
const goToPrevFileRef = useRef(goToPrevFile);
|
||||
const acceptCurrentHunkRef = useRef(acceptCurrentHunk);
|
||||
const rejectCurrentHunkRef = useRef(rejectCurrentHunk);
|
||||
const onCloseRef = useRef(onClose);
|
||||
|
||||
useEffect(() => {
|
||||
goToNextHunkRef.current = goToNextHunk;
|
||||
goToPrevHunkRef.current = goToPrevHunk;
|
||||
goToNextFileRef.current = goToNextFile;
|
||||
goToPrevFileRef.current = goToPrevFile;
|
||||
acceptCurrentHunkRef.current = acceptCurrentHunk;
|
||||
rejectCurrentHunkRef.current = rejectCurrentHunk;
|
||||
onCloseRef.current = onClose;
|
||||
}, [
|
||||
goToNextHunk,
|
||||
goToPrevHunk,
|
||||
goToNextFile,
|
||||
goToPrevFile,
|
||||
acceptCurrentHunk,
|
||||
rejectCurrentHunk,
|
||||
onClose,
|
||||
]);
|
||||
|
||||
// Keyboard handler
|
||||
useEffect(() => {
|
||||
if (!isDialogOpen) return;
|
||||
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
// Don't intercept when focus is in input/textarea
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'j':
|
||||
case 'ArrowDown':
|
||||
if (!event.metaKey && !event.ctrlKey && !event.altKey) {
|
||||
event.preventDefault();
|
||||
goToNextHunkRef.current();
|
||||
}
|
||||
break;
|
||||
case 'k':
|
||||
case 'ArrowUp':
|
||||
if (!event.metaKey && !event.ctrlKey && !event.altKey) {
|
||||
event.preventDefault();
|
||||
goToPrevHunkRef.current();
|
||||
}
|
||||
break;
|
||||
case 'n':
|
||||
if (!event.shiftKey) {
|
||||
event.preventDefault();
|
||||
goToNextFileRef.current();
|
||||
} else {
|
||||
event.preventDefault();
|
||||
goToPrevFileRef.current();
|
||||
}
|
||||
break;
|
||||
case 'p':
|
||||
event.preventDefault();
|
||||
goToPrevFileRef.current();
|
||||
break;
|
||||
case 'a':
|
||||
event.preventDefault();
|
||||
acceptCurrentHunkRef.current();
|
||||
break;
|
||||
case 'x':
|
||||
event.preventDefault();
|
||||
rejectCurrentHunkRef.current();
|
||||
break;
|
||||
case '?':
|
||||
event.preventDefault();
|
||||
setShowShortcutsHelp((prev) => !prev);
|
||||
break;
|
||||
case 'Escape':
|
||||
if (showShortcutsHelp) {
|
||||
event.preventDefault();
|
||||
setShowShortcutsHelp(false);
|
||||
}
|
||||
// Note: main Escape handling for closing dialog is in ChangeReviewDialog itself
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [isDialogOpen, showShortcutsHelp]);
|
||||
|
||||
return {
|
||||
currentHunkIndex,
|
||||
totalHunks,
|
||||
goToNextHunk,
|
||||
goToPrevHunk,
|
||||
goToNextFile,
|
||||
goToPrevFile,
|
||||
goToHunk,
|
||||
acceptCurrentHunk,
|
||||
rejectCurrentHunk,
|
||||
showShortcutsHelp,
|
||||
setShowShortcutsHelp,
|
||||
};
|
||||
}
|
||||
74
src/renderer/hooks/useViewedFiles.ts
Normal file
74
src/renderer/hooks/useViewedFiles.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import * as storage from '@renderer/utils/diffViewedStorage';
|
||||
|
||||
interface UseViewedFilesResult {
|
||||
viewedSet: Set<string>;
|
||||
isViewed: (filePath: string) => boolean;
|
||||
markViewed: (filePath: string) => void;
|
||||
unmarkViewed: (filePath: string) => void;
|
||||
markAllViewed: (filePaths: string[]) => void;
|
||||
clearAll: () => void;
|
||||
viewedCount: number;
|
||||
totalCount: number;
|
||||
/** Progress 0-100 */
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export function useViewedFiles(
|
||||
teamName: string,
|
||||
scopeKey: string,
|
||||
totalFiles: string[]
|
||||
): UseViewedFilesResult {
|
||||
// version bump pattern for re-reading localStorage
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
const viewedSet = useMemo(() => {
|
||||
// version is used to trigger re-read
|
||||
if (version < 0) return new Set<string>();
|
||||
return storage.getViewedFiles(teamName, scopeKey);
|
||||
}, [teamName, scopeKey, version]);
|
||||
|
||||
const markViewed = useCallback(
|
||||
(filePath: string) => {
|
||||
storage.markFileViewed(teamName, scopeKey, filePath);
|
||||
setVersion((v) => v + 1);
|
||||
},
|
||||
[teamName, scopeKey]
|
||||
);
|
||||
|
||||
const unmarkViewed = useCallback(
|
||||
(filePath: string) => {
|
||||
storage.unmarkFileViewed(teamName, scopeKey, filePath);
|
||||
setVersion((v) => v + 1);
|
||||
},
|
||||
[teamName, scopeKey]
|
||||
);
|
||||
|
||||
const markAllViewedFn = useCallback(
|
||||
(filePaths: string[]) => {
|
||||
storage.markAllViewed(teamName, scopeKey, filePaths);
|
||||
setVersion((v) => v + 1);
|
||||
},
|
||||
[teamName, scopeKey]
|
||||
);
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
storage.clearViewed(teamName, scopeKey);
|
||||
setVersion((v) => v + 1);
|
||||
}, [teamName, scopeKey]);
|
||||
|
||||
const viewedCount = totalFiles.filter((f) => viewedSet.has(f)).length;
|
||||
|
||||
return {
|
||||
viewedSet,
|
||||
isViewed: (fp: string) => viewedSet.has(fp),
|
||||
markViewed,
|
||||
unmarkViewed,
|
||||
markAllViewed: markAllViewedFn,
|
||||
clearAll,
|
||||
viewedCount,
|
||||
totalCount: totalFiles.length,
|
||||
progress: totalFiles.length > 0 ? Math.round((viewedCount / totalFiles.length) * 100) : 0,
|
||||
};
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { api } from '@renderer/api';
|
|||
import { cleanupStale as cleanupCommentReadState } from '@renderer/services/commentReadStorage';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { createChangeReviewSlice } from './slices/changeReviewSlice';
|
||||
import { createConfigSlice } from './slices/configSlice';
|
||||
import { createConnectionSlice } from './slices/connectionSlice';
|
||||
import { createContextSlice } from './slices/contextSlice';
|
||||
|
|
@ -48,6 +49,7 @@ export const useStore = create<AppState>()((...args) => ({
|
|||
...createConnectionSlice(...args),
|
||||
...createContextSlice(...args),
|
||||
...createUpdateSlice(...args),
|
||||
...createChangeReviewSlice(...args),
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
358
src/renderer/store/slices/changeReviewSlice.ts
Normal file
358
src/renderer/store/slices/changeReviewSlice.ts
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
import { api } from '@renderer/api';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { AppState } from '../types';
|
||||
import type {
|
||||
AgentChangeSet,
|
||||
ApplyReviewRequest,
|
||||
ChangeStats,
|
||||
FileChangeWithContent,
|
||||
FileReviewDecision,
|
||||
HunkDecision,
|
||||
TaskChangeSet,
|
||||
TaskChangeSetV2,
|
||||
} from '@shared/types';
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
const logger = createLogger('changeReviewSlice');
|
||||
|
||||
function mapReviewError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes('conflict')) return 'File has been modified since agent changes.';
|
||||
if (message.includes('ENOENT')) return 'File no longer exists on disk.';
|
||||
if (message.includes('EACCES') || message.includes('Permission')) return 'Permission denied.';
|
||||
return message || 'Failed to apply review changes';
|
||||
}
|
||||
|
||||
export interface ChangeReviewSlice {
|
||||
// Phase 1 state
|
||||
activeChangeSet: AgentChangeSet | TaskChangeSet | TaskChangeSetV2 | null;
|
||||
changeSetLoading: boolean;
|
||||
changeSetError: string | null;
|
||||
selectedReviewFilePath: string | null;
|
||||
changeStatsCache: Record<string, ChangeStats>;
|
||||
|
||||
// Phase 2 state
|
||||
hunkDecisions: Record<string, HunkDecision>;
|
||||
fileDecisions: Record<string, HunkDecision>;
|
||||
fileContents: Record<string, FileChangeWithContent>;
|
||||
fileContentsLoading: Record<string, boolean>;
|
||||
diffViewMode: 'unified' | 'split';
|
||||
collapseUnchanged: boolean;
|
||||
applyError: string | null;
|
||||
applying: boolean;
|
||||
|
||||
// Phase 1 actions
|
||||
fetchAgentChanges: (teamName: string, memberName: string) => Promise<void>;
|
||||
fetchTaskChanges: (teamName: string, taskId: string) => Promise<void>;
|
||||
selectReviewFile: (filePath: string | null) => void;
|
||||
clearChangeReview: () => void;
|
||||
fetchChangeStats: (teamName: string, memberName: string) => Promise<void>;
|
||||
|
||||
// Phase 2 actions
|
||||
setHunkDecision: (filePath: string, hunkIndex: number, decision: HunkDecision) => void;
|
||||
setFileDecision: (filePath: string, decision: HunkDecision) => void;
|
||||
acceptAllFile: (filePath: string) => void;
|
||||
rejectAllFile: (filePath: string) => void;
|
||||
acceptAll: () => void;
|
||||
rejectAll: () => void;
|
||||
setDiffViewMode: (mode: 'unified' | 'split') => void;
|
||||
setCollapseUnchanged: (collapse: boolean) => void;
|
||||
fetchFileContent: (
|
||||
teamName: string,
|
||||
memberName: string | undefined,
|
||||
filePath: string
|
||||
) => Promise<void>;
|
||||
applyReview: (teamName: string, taskId?: string, memberName?: string) => Promise<void>;
|
||||
invalidateChangeStats: (teamName: string) => void;
|
||||
}
|
||||
|
||||
export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeReviewSlice> = (
|
||||
set,
|
||||
get
|
||||
) => ({
|
||||
// Phase 1 initial state
|
||||
activeChangeSet: null,
|
||||
changeSetLoading: false,
|
||||
changeSetError: null,
|
||||
selectedReviewFilePath: null,
|
||||
changeStatsCache: {},
|
||||
|
||||
// Phase 2 initial state
|
||||
hunkDecisions: {},
|
||||
fileDecisions: {},
|
||||
fileContents: {},
|
||||
fileContentsLoading: {},
|
||||
diffViewMode: 'unified',
|
||||
collapseUnchanged: true,
|
||||
applyError: null,
|
||||
applying: false,
|
||||
|
||||
fetchAgentChanges: async (teamName: string, memberName: string) => {
|
||||
set({ changeSetLoading: true, changeSetError: null });
|
||||
try {
|
||||
const data = await api.review.getAgentChanges(teamName, memberName);
|
||||
set({
|
||||
activeChangeSet: data,
|
||||
changeSetLoading: false,
|
||||
selectedReviewFilePath: data.files[0]?.filePath ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch agent changes';
|
||||
logger.error('fetchAgentChanges error:', message);
|
||||
set({ changeSetError: message, changeSetLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchTaskChanges: async (teamName: string, taskId: string) => {
|
||||
set({ changeSetLoading: true, changeSetError: null });
|
||||
try {
|
||||
const data = await api.review.getTaskChanges(teamName, taskId);
|
||||
set({
|
||||
activeChangeSet: data,
|
||||
changeSetLoading: false,
|
||||
selectedReviewFilePath: data.files[0]?.filePath ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch task changes';
|
||||
logger.error('fetchTaskChanges error:', message);
|
||||
set({ changeSetError: message, changeSetLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
selectReviewFile: (filePath: string | null) => {
|
||||
set({ selectedReviewFilePath: filePath });
|
||||
},
|
||||
|
||||
clearChangeReview: () => {
|
||||
set({
|
||||
activeChangeSet: null,
|
||||
changeSetLoading: false,
|
||||
changeSetError: null,
|
||||
selectedReviewFilePath: null,
|
||||
hunkDecisions: {},
|
||||
fileDecisions: {},
|
||||
fileContents: {},
|
||||
fileContentsLoading: {},
|
||||
applyError: null,
|
||||
applying: false,
|
||||
});
|
||||
},
|
||||
|
||||
fetchChangeStats: async (teamName: string, memberName: string) => {
|
||||
try {
|
||||
const stats = await api.review.getChangeStats(teamName, memberName);
|
||||
const key = `${teamName}:${memberName}`;
|
||||
set((state) => ({
|
||||
changeStatsCache: { ...state.changeStatsCache, [key]: stats },
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('fetchChangeStats error:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Phase 2 actions ──
|
||||
|
||||
setHunkDecision: (filePath: string, hunkIndex: number, decision: HunkDecision) => {
|
||||
const key = `${filePath}:${hunkIndex}`;
|
||||
set((state) => ({
|
||||
hunkDecisions: { ...state.hunkDecisions, [key]: decision },
|
||||
}));
|
||||
},
|
||||
|
||||
setFileDecision: (filePath: string, decision: HunkDecision) => {
|
||||
set((state) => ({
|
||||
fileDecisions: { ...state.fileDecisions, [filePath]: decision },
|
||||
}));
|
||||
},
|
||||
|
||||
acceptAllFile: (filePath: string) => {
|
||||
const state = get();
|
||||
const file = state.activeChangeSet?.files.find((f) => f.filePath === filePath);
|
||||
if (!file) return;
|
||||
|
||||
const newHunkDecisions = { ...state.hunkDecisions };
|
||||
for (let i = 0; i < file.snippets.length; i++) {
|
||||
newHunkDecisions[`${filePath}:${i}`] = 'accepted';
|
||||
}
|
||||
set({
|
||||
hunkDecisions: newHunkDecisions,
|
||||
fileDecisions: { ...state.fileDecisions, [filePath]: 'accepted' },
|
||||
});
|
||||
},
|
||||
|
||||
rejectAllFile: (filePath: string) => {
|
||||
const state = get();
|
||||
const file = state.activeChangeSet?.files.find((f) => f.filePath === filePath);
|
||||
if (!file) return;
|
||||
|
||||
const newHunkDecisions = { ...state.hunkDecisions };
|
||||
for (let i = 0; i < file.snippets.length; i++) {
|
||||
newHunkDecisions[`${filePath}:${i}`] = 'rejected';
|
||||
}
|
||||
set({
|
||||
hunkDecisions: newHunkDecisions,
|
||||
fileDecisions: { ...state.fileDecisions, [filePath]: 'rejected' },
|
||||
});
|
||||
},
|
||||
|
||||
acceptAll: () => {
|
||||
const state = get();
|
||||
if (!state.activeChangeSet) return;
|
||||
|
||||
const newHunkDecisions: Record<string, HunkDecision> = {};
|
||||
const newFileDecisions: Record<string, HunkDecision> = {};
|
||||
|
||||
for (const file of state.activeChangeSet.files) {
|
||||
newFileDecisions[file.filePath] = 'accepted';
|
||||
for (let i = 0; i < file.snippets.length; i++) {
|
||||
newHunkDecisions[`${file.filePath}:${i}`] = 'accepted';
|
||||
}
|
||||
}
|
||||
set({ hunkDecisions: newHunkDecisions, fileDecisions: newFileDecisions });
|
||||
},
|
||||
|
||||
rejectAll: () => {
|
||||
const state = get();
|
||||
if (!state.activeChangeSet) return;
|
||||
|
||||
const newHunkDecisions: Record<string, HunkDecision> = {};
|
||||
const newFileDecisions: Record<string, HunkDecision> = {};
|
||||
|
||||
for (const file of state.activeChangeSet.files) {
|
||||
newFileDecisions[file.filePath] = 'rejected';
|
||||
for (let i = 0; i < file.snippets.length; i++) {
|
||||
newHunkDecisions[`${file.filePath}:${i}`] = 'rejected';
|
||||
}
|
||||
}
|
||||
set({ hunkDecisions: newHunkDecisions, fileDecisions: newFileDecisions });
|
||||
},
|
||||
|
||||
setDiffViewMode: (mode: 'unified' | 'split') => {
|
||||
set({ diffViewMode: mode });
|
||||
},
|
||||
|
||||
setCollapseUnchanged: (collapse: boolean) => {
|
||||
set({ collapseUnchanged: collapse });
|
||||
},
|
||||
|
||||
fetchFileContent: async (teamName: string, memberName: string | undefined, filePath: string) => {
|
||||
const state = get();
|
||||
// Skip if already loaded or loading
|
||||
if (state.fileContents[filePath] || state.fileContentsLoading[filePath]) return;
|
||||
|
||||
set((s) => ({
|
||||
fileContentsLoading: { ...s.fileContentsLoading, [filePath]: true },
|
||||
}));
|
||||
|
||||
try {
|
||||
const content = await api.review.getFileContent(teamName, memberName, filePath);
|
||||
set((s) => ({
|
||||
fileContents: { ...s.fileContents, [filePath]: content },
|
||||
fileContentsLoading: { ...s.fileContentsLoading, [filePath]: false },
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('fetchFileContent error:', error);
|
||||
set((s) => ({
|
||||
fileContentsLoading: { ...s.fileContentsLoading, [filePath]: false },
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
applyReview: async (teamName: string, taskId?: string, memberName?: string) => {
|
||||
set({ applying: true, applyError: null });
|
||||
|
||||
try {
|
||||
// Stale check: re-fetch changes and compare computedAt
|
||||
const state = get();
|
||||
const currentComputedAt = state.activeChangeSet?.computedAt;
|
||||
|
||||
if (memberName) {
|
||||
const fresh = await api.review.getAgentChanges(teamName, memberName);
|
||||
if (fresh.computedAt !== currentComputedAt) {
|
||||
set({
|
||||
activeChangeSet: fresh,
|
||||
applying: false,
|
||||
applyError: 'Changes have been updated since you started reviewing. Please re-review.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else if (taskId) {
|
||||
const fresh = await api.review.getTaskChanges(teamName, taskId);
|
||||
if (fresh.computedAt !== currentComputedAt) {
|
||||
set({
|
||||
activeChangeSet: fresh,
|
||||
applying: false,
|
||||
applyError: 'Changes have been updated since you started reviewing. Please re-review.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Build FileReviewDecision[] from hunkDecisions/fileDecisions
|
||||
const { hunkDecisions, fileDecisions, activeChangeSet } = get();
|
||||
if (!activeChangeSet) {
|
||||
set({ applying: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const decisions: FileReviewDecision[] = [];
|
||||
|
||||
for (const file of activeChangeSet.files) {
|
||||
const fileDecision = fileDecisions[file.filePath] ?? 'pending';
|
||||
const hunkDecs: Record<number, HunkDecision> = {};
|
||||
|
||||
for (let i = 0; i < file.snippets.length; i++) {
|
||||
const key = `${file.filePath}:${i}`;
|
||||
hunkDecs[i] = hunkDecisions[key] ?? 'pending';
|
||||
}
|
||||
|
||||
// Only include files that have at least one rejected hunk
|
||||
const hasRejected =
|
||||
fileDecision === 'rejected' || Object.values(hunkDecs).some((d) => d === 'rejected');
|
||||
if (hasRejected) {
|
||||
decisions.push({
|
||||
filePath: file.filePath,
|
||||
fileDecision,
|
||||
hunkDecisions: hunkDecs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (decisions.length === 0) {
|
||||
set({ applying: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const request: ApplyReviewRequest = {
|
||||
teamName,
|
||||
taskId,
|
||||
memberName,
|
||||
decisions,
|
||||
};
|
||||
|
||||
await api.review.applyDecisions(request);
|
||||
|
||||
set({ applying: false });
|
||||
} catch (error) {
|
||||
logger.error('applyReview error:', error);
|
||||
set({
|
||||
applying: false,
|
||||
applyError: mapReviewError(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
invalidateChangeStats: (teamName: string) => {
|
||||
set((state) => {
|
||||
const newCache = { ...state.changeStatsCache };
|
||||
// Remove all entries for this team
|
||||
for (const key of Object.keys(newCache)) {
|
||||
if (key.startsWith(`${teamName}:`)) {
|
||||
delete newCache[key];
|
||||
}
|
||||
}
|
||||
return { changeStatsCache: newCache };
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
* Contains the combined AppState interface and shared types used across slices.
|
||||
*/
|
||||
|
||||
import type { ChangeReviewSlice } from './slices/changeReviewSlice';
|
||||
import type { ConfigSlice } from './slices/configSlice';
|
||||
import type { ConnectionSlice } from './slices/connectionSlice';
|
||||
import type { ContextSlice } from './slices/contextSlice';
|
||||
|
|
@ -92,4 +93,5 @@ export type AppState = ProjectSlice &
|
|||
ConfigSlice &
|
||||
ConnectionSlice &
|
||||
ContextSlice &
|
||||
UpdateSlice;
|
||||
UpdateSlice &
|
||||
ChangeReviewSlice;
|
||||
|
|
|
|||
120
src/renderer/utils/diffViewedStorage.ts
Normal file
120
src/renderer/utils/diffViewedStorage.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/**
|
||||
* localStorage-based tracking of "viewed" files in diff review.
|
||||
* Pattern follows teamMessageReadStorage.ts.
|
||||
*/
|
||||
|
||||
const STORAGE_PREFIX = 'diff-viewed';
|
||||
const MAX_TOTAL_ENTRIES = 50;
|
||||
|
||||
interface ViewedStorageEntry {
|
||||
files: string[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function getStorageKey(teamName: string, scopeKey: string): string {
|
||||
return `${STORAGE_PREFIX}:${teamName}:${scopeKey}`;
|
||||
}
|
||||
|
||||
function parseEntry(raw: string | null): ViewedStorageEntry | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
// Migration from old format (plain string[]) → new format
|
||||
if (Array.isArray(parsed)) {
|
||||
return { files: parsed as string[], updatedAt: new Date(0).toISOString() };
|
||||
}
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
'files' in parsed &&
|
||||
Array.isArray((parsed as ViewedStorageEntry).files)
|
||||
) {
|
||||
return parsed as ViewedStorageEntry;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveEntry(teamName: string, scopeKey: string, entry: ViewedStorageEntry): void {
|
||||
try {
|
||||
localStorage.setItem(getStorageKey(teamName, scopeKey), JSON.stringify(entry));
|
||||
} catch (error) {
|
||||
console.warn('[diffViewedStorage] localStorage write failed:', error);
|
||||
try {
|
||||
cleanupOldViewedEntries();
|
||||
localStorage.setItem(getStorageKey(teamName, scopeKey), JSON.stringify(entry));
|
||||
} catch {
|
||||
// Full failure — silently ignore, viewed state is not critical
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Cleanup old entries when localStorage is full */
|
||||
export function cleanupOldViewedEntries(): void {
|
||||
const keys: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key?.startsWith(STORAGE_PREFIX)) keys.push(key);
|
||||
}
|
||||
if (keys.length > MAX_TOTAL_ENTRIES) {
|
||||
const sorted = keys
|
||||
.map((k) => ({ key: k, entry: parseEntry(localStorage.getItem(k)) }))
|
||||
.sort((a, b) => (a.entry?.updatedAt ?? '').localeCompare(b.entry?.updatedAt ?? ''));
|
||||
for (let i = 0; i < sorted.length - MAX_TOTAL_ENTRIES; i++) {
|
||||
localStorage.removeItem(sorted[i].key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Get set of viewed file paths */
|
||||
export function getViewedFiles(teamName: string, scopeKey: string): Set<string> {
|
||||
const entry = parseEntry(localStorage.getItem(getStorageKey(teamName, scopeKey)));
|
||||
return entry ? new Set(entry.files) : new Set();
|
||||
}
|
||||
|
||||
/** Mark a file as viewed */
|
||||
export function markFileViewed(teamName: string, scopeKey: string, filePath: string): void {
|
||||
const set = getViewedFiles(teamName, scopeKey);
|
||||
set.add(filePath);
|
||||
saveEntry(teamName, scopeKey, {
|
||||
files: [...set],
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Unmark a file as viewed */
|
||||
export function unmarkFileViewed(teamName: string, scopeKey: string, filePath: string): void {
|
||||
const set = getViewedFiles(teamName, scopeKey);
|
||||
set.delete(filePath);
|
||||
if (set.size === 0) {
|
||||
try {
|
||||
localStorage.removeItem(getStorageKey(teamName, scopeKey));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
saveEntry(teamName, scopeKey, {
|
||||
files: [...set],
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Mark all files as viewed */
|
||||
export function markAllViewed(teamName: string, scopeKey: string, filePaths: string[]): void {
|
||||
saveEntry(teamName, scopeKey, {
|
||||
files: filePaths,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear all viewed marks */
|
||||
export function clearViewed(teamName: string, scopeKey: string): void {
|
||||
try {
|
||||
localStorage.removeItem(getStorageKey(teamName, scopeKey));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,17 @@ import type {
|
|||
NotificationTrigger,
|
||||
TriggerTestResult,
|
||||
} from './notifications';
|
||||
import type {
|
||||
AgentChangeSet,
|
||||
ApplyReviewRequest,
|
||||
ApplyReviewResult,
|
||||
ChangeStats,
|
||||
ConflictCheckResult,
|
||||
FileChangeWithContent,
|
||||
RejectResult,
|
||||
SnippetDiff,
|
||||
TaskChangeSetV2,
|
||||
} from './review';
|
||||
import type {
|
||||
AddMemberRequest,
|
||||
AttachmentFileData,
|
||||
|
|
@ -420,6 +431,45 @@ export interface TeamsAPI {
|
|||
) => () => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Review API
|
||||
// =============================================================================
|
||||
|
||||
export interface ReviewAPI {
|
||||
// Phase 1
|
||||
getAgentChanges: (teamName: string, memberName: string) => Promise<AgentChangeSet>;
|
||||
getTaskChanges: (teamName: string, taskId: string) => Promise<TaskChangeSetV2>;
|
||||
getChangeStats: (teamName: string, memberName: string) => Promise<ChangeStats>;
|
||||
getFileContent: (
|
||||
teamName: string,
|
||||
memberName: string | undefined,
|
||||
filePath: string
|
||||
) => Promise<FileChangeWithContent>;
|
||||
applyDecisions: (request: ApplyReviewRequest) => Promise<ApplyReviewResult>;
|
||||
// Phase 2
|
||||
checkConflict: (filePath: string, expectedModified: string) => Promise<ConflictCheckResult>;
|
||||
rejectHunks: (
|
||||
filePath: string,
|
||||
original: string,
|
||||
modified: string,
|
||||
hunkIndices: number[],
|
||||
snippets: SnippetDiff[]
|
||||
) => Promise<RejectResult>;
|
||||
rejectFile: (filePath: string, original: string, modified: string) => Promise<RejectResult>;
|
||||
previewReject: (
|
||||
filePath: string,
|
||||
original: string,
|
||||
modified: string,
|
||||
hunkIndices: number[],
|
||||
snippets: SnippetDiff[]
|
||||
) => Promise<{ preview: string; hasConflicts: boolean }>;
|
||||
// Phase 4
|
||||
getGitFileLog: (
|
||||
projectPath: string,
|
||||
filePath: string
|
||||
) => Promise<{ hash: string; timestamp: string; message: string }[]>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Electron API
|
||||
// =============================================================================
|
||||
|
|
@ -541,6 +591,9 @@ export interface ElectronAPI {
|
|||
|
||||
// Team management API
|
||||
teams: TeamsAPI;
|
||||
|
||||
// Review API
|
||||
review: ReviewAPI;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -26,3 +26,6 @@ export type * from './ipc';
|
|||
|
||||
// Re-export Team Management types
|
||||
export type * from './team';
|
||||
|
||||
// Re-export Review types (Phase 1)
|
||||
export type * from './review';
|
||||
|
|
|
|||
183
src/shared/types/review.ts
Normal file
183
src/shared/types/review.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
/** Один snippet-level дифф от одного tool_use */
|
||||
export interface SnippetDiff {
|
||||
toolUseId: string;
|
||||
filePath: string;
|
||||
toolName: 'Edit' | 'Write' | 'MultiEdit';
|
||||
type: 'edit' | 'write-new' | 'write-update' | 'multi-edit';
|
||||
oldString: string;
|
||||
newString: string;
|
||||
replaceAll: boolean;
|
||||
timestamp: string;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
/** Агрегированные изменения по файлу */
|
||||
export interface FileChangeSummary {
|
||||
filePath: string;
|
||||
relativePath: string;
|
||||
snippets: SnippetDiff[];
|
||||
linesAdded: number;
|
||||
linesRemoved: number;
|
||||
isNewFile: boolean;
|
||||
/** Edit timeline for this file (Phase 4) */
|
||||
timeline?: FileEditTimeline;
|
||||
}
|
||||
|
||||
/** Полный набор изменений агента */
|
||||
export interface AgentChangeSet {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
files: FileChangeSummary[];
|
||||
totalLinesAdded: number;
|
||||
totalLinesRemoved: number;
|
||||
totalFiles: number;
|
||||
computedAt: string;
|
||||
}
|
||||
|
||||
/** Полный набор изменений задачи */
|
||||
export interface TaskChangeSet {
|
||||
teamName: string;
|
||||
taskId: string;
|
||||
files: FileChangeSummary[];
|
||||
totalLinesAdded: number;
|
||||
totalLinesRemoved: number;
|
||||
totalFiles: number;
|
||||
confidence: 'high' | 'medium' | 'low' | 'fallback';
|
||||
computedAt: string;
|
||||
}
|
||||
|
||||
/** Краткая статистика для badge */
|
||||
export interface ChangeStats {
|
||||
linesAdded: number;
|
||||
linesRemoved: number;
|
||||
filesChanged: number;
|
||||
}
|
||||
|
||||
// ── Phase 2: Diff View types ──
|
||||
|
||||
/** Результат проверки конфликтов */
|
||||
export interface ConflictCheckResult {
|
||||
hasConflict: boolean;
|
||||
conflictContent: string | null;
|
||||
currentContent: string;
|
||||
originalContent: string;
|
||||
}
|
||||
|
||||
/** Результат операции reject */
|
||||
export interface RejectResult {
|
||||
success: boolean;
|
||||
newContent: string;
|
||||
hadConflicts: boolean;
|
||||
conflictDescription?: string;
|
||||
}
|
||||
|
||||
/** Решение по hunk */
|
||||
export type HunkDecision = 'accepted' | 'rejected' | 'pending';
|
||||
|
||||
/** Решение по файлу */
|
||||
export interface FileReviewDecision {
|
||||
filePath: string;
|
||||
fileDecision: HunkDecision;
|
||||
hunkDecisions: Record<number, HunkDecision>;
|
||||
}
|
||||
|
||||
/** Запрос на применение review */
|
||||
export interface ApplyReviewRequest {
|
||||
teamName: string;
|
||||
taskId?: string;
|
||||
memberName?: string;
|
||||
decisions: FileReviewDecision[];
|
||||
}
|
||||
|
||||
/** Результат применения review */
|
||||
export interface ApplyReviewResult {
|
||||
applied: number;
|
||||
skipped: number;
|
||||
conflicts: number;
|
||||
errors: { filePath: string; error: string }[];
|
||||
}
|
||||
|
||||
/** Полный file content для CodeMirror */
|
||||
export interface FileChangeWithContent extends FileChangeSummary {
|
||||
originalFullContent: string | null;
|
||||
modifiedFullContent: string | null;
|
||||
contentSource:
|
||||
| 'file-history'
|
||||
| 'snippet-reconstruction'
|
||||
| 'disk-current'
|
||||
| 'git-fallback'
|
||||
| 'unavailable';
|
||||
}
|
||||
|
||||
// ── Phase 3: Per-Task Scoping types ──
|
||||
|
||||
/** Обнаруженная граница задачи в JSONL */
|
||||
export interface TaskBoundary {
|
||||
taskId: string;
|
||||
event: 'start' | 'complete';
|
||||
lineNumber: number;
|
||||
timestamp: string;
|
||||
mechanism: 'TaskUpdate' | 'teamctl';
|
||||
toolUseId?: string;
|
||||
}
|
||||
|
||||
/** Детализированный уровень уверенности */
|
||||
export interface TaskScopeConfidence {
|
||||
tier: 1 | 2 | 3 | 4;
|
||||
label: 'high' | 'medium' | 'low' | 'fallback';
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** Scope изменений для одной задачи */
|
||||
export interface TaskChangeScope {
|
||||
taskId: string;
|
||||
memberName: string;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
startTimestamp: string;
|
||||
endTimestamp: string;
|
||||
toolUseIds: string[];
|
||||
filePaths: string[];
|
||||
confidence: TaskScopeConfidence;
|
||||
}
|
||||
|
||||
/** Результат парсинга всех границ задач из JSONL файла */
|
||||
export interface TaskBoundariesResult {
|
||||
boundaries: TaskBoundary[];
|
||||
scopes: TaskChangeScope[];
|
||||
isSingleTaskSession: boolean;
|
||||
detectedMechanism: 'TaskUpdate' | 'teamctl' | 'none';
|
||||
}
|
||||
|
||||
/** Расширенный TaskChangeSet с confidence деталями (backwards compatible) */
|
||||
export interface TaskChangeSetV2 extends TaskChangeSet {
|
||||
scope: TaskChangeScope;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
// ── Phase 4: Enhanced Features types ──
|
||||
|
||||
/** Одно событие в timeline файла */
|
||||
export interface FileEditEvent {
|
||||
/** tool_use.id */
|
||||
toolUseId: string;
|
||||
/** Тип операции */
|
||||
toolName: 'Edit' | 'Write' | 'MultiEdit' | 'NotebookEdit';
|
||||
/** Timestamp из JSONL */
|
||||
timestamp: string;
|
||||
/** Краткое описание: "Edited 3 lines", "Created new file", etc */
|
||||
summary: string;
|
||||
/** +/- строк */
|
||||
linesAdded: number;
|
||||
linesRemoved: number;
|
||||
/** Индекс snippet в FileChangeSummary.snippets[] */
|
||||
snippetIndex: number;
|
||||
}
|
||||
|
||||
/** Timeline для файла */
|
||||
export interface FileEditTimeline {
|
||||
filePath: string;
|
||||
events: FileEditEvent[];
|
||||
/** Общая длительность (first event → last event) */
|
||||
durationMs: number;
|
||||
}
|
||||
Loading…
Reference in a new issue