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:
iliya 2026-02-24 23:39:41 +02:00
parent 373d1a722b
commit 190cafdb8e
40 changed files with 5685 additions and 4 deletions

View file

@ -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",

View file

@ -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:

View file

@ -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

View file

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

View 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.'],
};
}
}

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

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

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

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

View file

@ -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;

View file

@ -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';

View file

@ -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';

View file

@ -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

View file

@ -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');
},
};
}

View file

@ -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>
);
};

View file

@ -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}

View file

@ -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} />

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

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

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

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

View 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&apos;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>
);
};

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

View 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 '??:??';
}
}

View file

@ -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>
);
};

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

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

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

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

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

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

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

View file

@ -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),
}));
// =============================================================================

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

View file

@ -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;

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

View file

@ -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;
}
// =============================================================================

View file

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