feat: enhance diff view and review process with new features

- Updated SnippetDiff to include replaceAll for handling multiple replacements in edits.
- Improved rejectHunks logic to support delete operations and handle replaceAll scenarios.
- Added validation for path traversal in rejectHunks to enhance security.
- Enhanced previewReject to ensure consistency with rejectHunks and prevent conflicts.
- Updated ReviewAPI and IPC handlers to accommodate new features and ensure backward compatibility.
- Improved documentation to reflect changes in diff view and review processes.
This commit is contained in:
iliya 2026-02-24 21:18:17 +02:00
parent 61c62741c3
commit 545fa2cfb8
4 changed files with 328 additions and 75 deletions

View file

@ -20,10 +20,11 @@ pnpm add diff # jsdiff v8 — structuredPatch, createPatch для вычис
export interface SnippetDiff {
toolUseId: string;
filePath: string;
toolName: 'Edit' | 'Write' | 'MultiEdit' | 'NotebookEdit';
toolName: 'Edit' | 'Write' | 'MultiEdit';
type: 'edit' | 'write-new' | 'write-update' | 'multi-edit';
oldString: string; // пустая строка для Write (create)
newString: string;
replaceAll: boolean; // Edit с replace_all: true → все вхождения old_string заменяются
timestamp: string; // ISO timestamp из JSONL
isError: boolean; // пропускаем если true
}
@ -108,8 +109,17 @@ export class ChangeExtractorService {
```json
{ "file_path": "/abs/path", "edits": [{ "old_string": "...", "new_string": "..." }, ...] }
```
5. **Пропуск ошибок**: Следующий за tool_use блок `tool_result` с `is_error: true` → пропускаем этот tool_use
6. **Фильтрация proxy_ префикса**: Имена инструментов приходят как `proxy_Edit` — нужно strip prefix (паттерн из MemberStatsComputer)
5. **NotebookEdit — SKIP**: `NotebookEdit` имеет другую структуру input (`notebook_path`, `cell_number`, `new_source`) — **нет** `file_path`, `old_string`, `new_string`. Пропускаем при парсинге (`toolName !== 'NotebookEdit'` guard). НЕ включаем в SnippetDiff.
6. **replace_all** — при `replace_all: true` в Edit input:
- В SnippetDiff записываем `replaceAll: true`
- При snippet chain reconstruction используем `content.replaceAll(oldString, newString)` вместо `content.replace()`
- При reject — нужно откатить ВСЕ вхождения, не только первое (см. Phase 2)
7. **Пропуск ошибок**`tool_result` с `is_error: true` находится в **ДРУГОМ JSONL entry** (следующий user/isMeta entry), а НЕ в том же content массиве:
- Парсить JSONL попарно: assistant entry (с tool_use) → user entry (с tool_result)
- Маппить `tool_use.id``tool_result.tool_use_id`
- Если `is_error: true` → пропускаем соответствующий tool_use
- **Простой подход**: первый pass — собрать `Set<string>` errored tool_use_id из всех tool_result блоков. Второй pass — фильтровать tool_use по этому set.
8. **Фильтрация proxy_ префикса**: Имена инструментов приходят как `proxy_Edit` — нужно strip prefix (паттерн из MemberStatsComputer)
7. **Подсчёт строк** (через `jsdiff.diffLines`):
```typescript
import { diffLines } from 'diff';
@ -423,6 +433,7 @@ export type AppState = ProjectSlice &
| Файл | Тип | ~LOC |
|------|-----|---:|
| `src/shared/types/review.ts` | NEW | 80 |
| `src/shared/types/index.ts` | MODIFY | +1 (re-export review types из barrel) |
| `src/shared/types/api.ts` | MODIFY | +15 (ReviewAPI interface + ElectronAPI field) |
| `src/main/services/team/ChangeExtractorService.ts` | NEW | 350 |
| `src/main/ipc/review.ts` | NEW | 100 (с wrapReviewHandler) |

View file

@ -216,12 +216,14 @@ export class ReviewApplierService {
/**
* Preview reject без записи на диск.
* Принимает snippets для consistency с rejectHunks (иначе preview и actual reject дадут разные результаты).
*/
async previewReject(
filePath: string,
original: string,
modified: string,
hunkIndicesToReject: number[]
hunkIndicesToReject: number[],
snippets: SnippetDiff[]
): Promise<{ preview: string; hasConflicts: boolean }>;
/**
@ -278,6 +280,19 @@ async rejectHunks(
for (const snippet of rejectedSnippets) {
if (snippet.type === 'write-new') continue; // Обрабатывается через rejectFile()
// Guard: пустой newString (delete operation) — indexOf('') вернёт 0, сломает файл
if (!snippet.newString) {
// Delete reject = вставить oldString обратно. Требует позиционный контекст.
// Fallback на hunk-level для таких случаев.
return this.rejectHunksFallback(filePath, original, modified, hunkIndicesToReject);
}
// replaceAll: true — все вхождения были заменены, нужно откатить все
if (snippet.replaceAll) {
content = content.replaceAll(snippet.newString, snippet.oldString);
continue; // Не добавляем в positioned — уже обработано
}
const offset = content.indexOf(snippet.newString);
if (offset === -1) {
// Snippet не найден — fallback на hunk-level
@ -443,6 +458,23 @@ initializeReviewHandlers({
```
`registerReviewHandlers()` и `removeReviewHandlers()` уже зарегистрированы в Phase 1.
**ВАЖНО**: `removeReviewHandlers()` нужно обновить — добавить Phase 2 каналы:
```typescript
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);
}
```
**ВАЖНО**: Обновить `ReviewAPI` в `src/shared/types/api.ts` — добавить Phase 2 методы:
```typescript
export interface ReviewAPI {
@ -452,9 +484,9 @@ export interface ReviewAPI {
getChangeStats: (...) => Promise<ChangeStats>;
// 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: (...) => Promise<{ preview: string; hasConflicts: boolean }>;
rejectHunks: (teamName: string, filePath: string, original: string, modified: string, hunkIndices: number[], snippets: SnippetDiff[]) => Promise<RejectResult>;
rejectFile: (teamName: string, filePath: string, original: string, modified: string) => Promise<RejectResult>;
previewReject: (filePath: string, original: string, modified: string, hunkIndices: number[], snippets: SnippetDiff[]) => Promise<{ preview: string; hasConflicts: boolean }>;
applyDecisions: (request: ApplyReviewRequest) => Promise<ApplyReviewResult>;
getFileContent: (teamName: string, memberName: string, filePath: string) => Promise<FileChangeWithContent>;
}
@ -518,22 +550,49 @@ async function handleGetFileContent(
): Promise<IpcResult<FileChangeWithContent>> {
return wrapReviewHandler('review:getFileContent', async () => {
const resolver = getContentResolver();
const result = await resolver.resolveFileContent(teamName, memberName, filePath);
return result;
const resolved = await resolver.resolveFileContent(teamName, memberName, filePath);
// ВАЖНО: resolver возвращает { original, modified, source },
// но ReviewAPI.getFileContent обещает FileChangeWithContent.
// Нужно маппить в полный тип:
const extractor = getChangeExtractor();
const changeSet = await extractor.getAgentChanges(teamName, memberName);
const fileSummary = changeSet.files.find(f => f.filePath === filePath);
return {
filePath,
relativePath: fileSummary?.relativePath ?? filePath.split('/').pop() ?? filePath,
snippets: fileSummary?.snippets ?? [],
linesAdded: fileSummary?.linesAdded ?? 0,
linesRemoved: fileSummary?.linesRemoved ?? 0,
isNewFile: fileSummary?.isNewFile ?? false,
originalFullContent: resolved.original,
modifiedFullContent: resolved.modified,
contentSource: resolved.source,
};
});
}
async function handleRejectHunks(
_event: IpcMainInvokeEvent,
teamName: string, // для path traversal validation
filePath: string,
original: string,
modified: string,
hunkIndices: number[],
snippets: SnippetDiff[] // R1 fix: renderer MUST передать snippets
): Promise<IpcResult<RejectResult>> {
// Security fix: валидация что filePath внутри проекта
// TODO: получить projectPath из team config и проверить path.resolve(filePath).startsWith(projectPath)
return wrapReviewHandler('review:rejectHunks', async () => {
// Security: path traversal protection — ОБЯЗАТЕЛЬНО перед writeFile!
// Получаем projectPath из team config через TeamDataService
const teamData = getTeamDataService(); // добавить в ReviewHandlerDeps
const team = await teamData.getTeam(teamName); // teamName из IPC args
if (team?.projectPath) {
const resolved = require('path').resolve(filePath);
if (!resolved.startsWith(team.projectPath)) {
throw new Error('File path outside project directory');
}
}
const applier = getApplier();
return await applier.rejectHunks(filePath, original, modified, hunkIndices, snippets);
});
@ -544,17 +603,44 @@ async function handleApplyDecisions(
request: ApplyReviewRequest
): Promise<IpcResult<ApplyReviewResult>> {
return wrapReviewHandler('review:applyDecisions', async () => {
// Validation: хотя бы один из taskId/memberName обязателен
if (!request.taskId && !request.memberName) {
throw new Error('Either taskId or memberName must be provided');
}
const applier = getApplier();
const resolver = getContentResolver();
// Resolve all file contents first
const filePaths = request.decisions.map(d => d.filePath);
// В task mode memberName может быть undefined — resolver должен определить
// member из task scope. В agent mode memberName обязательно задан.
const memberName = request.memberName ?? '';
const contents = await resolver.resolveAllFileContents(
request.teamName,
request.memberName ?? '',
memberName,
filePaths
);
// Dry-run: сначала previewReject для всех файлов, чтобы обнаружить ошибки ДО записи
const rejectedDecisions = request.decisions.filter(d =>
Object.values(d.hunkDecisions).some(v => v === 'rejected')
);
for (const decision of rejectedDecisions) {
const fc = contents.get(decision.filePath);
if (!fc?.originalFullContent || !fc?.modifiedFullContent) continue;
const preview = await applier.previewReject(
decision.filePath, fc.originalFullContent, fc.modifiedFullContent,
Object.entries(decision.hunkDecisions)
.filter(([, v]) => v === 'rejected')
.map(([k]) => Number(k))
);
if (preview.hasConflicts) {
throw new Error(`Conflict detected in ${decision.filePath}. Resolve before applying.`);
}
}
return await applier.applyReviewDecisions(request, contents);
});
}
@ -579,9 +665,9 @@ review: {
invokeIpcWithResult<RejectResult>(REVIEW_REJECT_HUNKS, filePath, original, modified, hunkIndices, snippets),
rejectFile: (filePath: string, original: string, modified: string) =>
invokeIpcWithResult<RejectResult>(REVIEW_REJECT_FILE, filePath, original, modified),
previewReject: (filePath: string, original: string, modified: string, hunkIndices: number[]) =>
previewReject: (filePath: string, original: string, modified: string, hunkIndices: number[], snippets: SnippetDiff[]) =>
invokeIpcWithResult<{ preview: string; hasConflicts: boolean }>(
REVIEW_PREVIEW_REJECT, filePath, original, modified, hunkIndices
REVIEW_PREVIEW_REJECT, filePath, original, modified, hunkIndices, snippets
),
applyDecisions: (request: ApplyReviewRequest) =>
invokeIpcWithResult<ApplyReviewResult>(REVIEW_APPLY_DECISIONS, request),
@ -622,6 +708,12 @@ export interface ChangeReviewSlice {
/** В процессе apply */
applying: boolean;
// Phase 1 actions (MUST be included — Phase 2 interface is full superset)
fetchAgentChanges: (teamName: string, memberName: string) => Promise<void>;
fetchTaskChanges: (teamName: string, taskId: string) => Promise<void>;
selectReviewFile: (filePath: string | null) => 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;
@ -631,10 +723,13 @@ export interface ChangeReviewSlice {
rejectAll: () => void;
setDiffViewMode: (mode: 'unified' | 'split') => void;
setCollapseUnchanged: (collapse: boolean) => void;
fetchFileContent: (teamName: string, memberName: string, filePath: string) => Promise<void>;
/** memberName optional — в task mode определяется из changeSet */
fetchFileContent: (teamName: string, memberName: string | undefined, filePath: string) => Promise<void>;
previewReject: (filePath: string) => Promise<{ preview: string; hasConflicts: boolean }>;
applyReview: (teamName: string, taskId?: string, memberName?: string) => Promise<void>;
clearChangeReview: () => void;
/** Инвалидировать changeStatsCache при team data refresh */
invalidateChangeStats: (teamName: string) => void;
}
```
@ -671,6 +766,22 @@ applyReview: async (teamName, taskId, memberName) => {
const { hunkDecisions, fileDecisions, activeChangeSet } = get();
if (!activeChangeSet) throw new Error('No active change set');
// Stale check: пересчитать computedAt и сравнить с текущим
// Если не совпадает — данные устарели (file watcher мог обновить между review и apply)
const freshSet = taskId
? await api.review.getTaskChanges(teamName, taskId)
: await api.review.getAgentChanges(teamName, memberName!);
if (freshSet.computedAt !== activeChangeSet.computedAt) {
set({
applying: false,
applyError: 'Changes have been updated since you started reviewing. Please review again.',
activeChangeSet: freshSet, // обновляем данные
hunkDecisions: {}, // сбрасываем decisions
fileDecisions: {},
});
return;
}
// Собрать decisions
const decisions: FileReviewDecision[] = activeChangeSet.files.map(file => {
const perHunk: Record<number, HunkDecision> = {};
@ -752,7 +863,7 @@ import { json } from '@codemirror/lang-json';
import { css } from '@codemirror/lang-css';
import { html } from '@codemirror/lang-html';
import { xml } from '@codemirror/lang-xml';
import { oneDark } from '@codemirror/theme-one-dark';
// НЕ используем @codemirror/theme-one-dark — вместо этого CSS variables
interface CodeMirrorDiffViewProps {
/** Полное содержимое файла ДО изменений */
@ -857,77 +968,110 @@ export function CodeMirrorDiffView({
: undefined,
```
4. **Event tracking для accept/reject — VERIFIED API:**
4. **Event tracking для accept/reject — через mergeControls callback (НЕ Transaction аннотации!):**
**ВАЖНО**: `Transaction.userEvent` значения `"accept"`/`"revert"` — это internal implementation detail
`@codemirror/merge`, **не документированные публично**. Могут измениться без предупреждения.
Вместо перехвата аннотаций — используем `mergeControls` callback:
```typescript
// VERIFIED: CodeMirror merge помечает transactions через annotation:
// - Accept: Transaction.userEvent = "accept"
// - Reject: Transaction.userEvent = "revert"
//
// НЕ используем tr.isUserEvent() — используем tr.annotation()!
// mergeControls callback уже вызывается при клике accept/reject.
// Вычисляем hunk index ВНУТРИ callback через getChunks():
import { getChunks } from '@codemirror/merge';
EditorView.updateListener.of((update) => {
for (const tr of update.transactions) {
const userEvent = tr.annotation(Transaction.userEvent);
if (userEvent === 'accept') {
// Определяем hunk index по позиции cursor ПЕРЕД транзакцией
const pos = tr.startState.selection.main.head;
const hunkIndex = computeHunkIndexAtPos(tr.startState, pos);
onHunkAccepted?.(hunkIndex);
mergeControls: showMergeControls
? (type: 'reject' | 'accept', action: (e: MouseEvent) => void) => {
const btn = document.createElement('button');
btn.className = type === 'accept' ? 'cm-merge-accept-btn' : 'cm-merge-reject-btn';
btn.textContent = type === 'accept' ? 'Accept' : 'Reject';
btn.onmousedown = (e) => {
// 1. Вычисляем hunk index ДО action (action изменит state)
const view = editorRef.current;
if (view) {
const pos = view.state.selection.main.head;
const hunkIndex = computeHunkIndexAtPos(view.state, pos);
// 2. Выполняем оригинальное CM action
action(e);
// 3. Callback в React
if (type === 'accept') onHunkAccepted?.(hunkIndex);
else onHunkRejected?.(hunkIndex);
} else {
action(e);
}
};
return btn;
}
if (userEvent === 'revert') {
const pos = tr.startState.selection.main.head;
const hunkIndex = computeHunkIndexAtPos(tr.startState, pos);
onHunkRejected?.(hunkIndex);
}
}
});
: undefined,
```
**ВАЖНО: Chunk positions — NO PUBLIC API!**
Для **keyboard shortcuts** (Phase 4) — используем `acceptChunk(view, pos)` / `rejectChunk(view, pos)` программно и вызываем callback напрямую.
`@codemirror/merge` НЕ экспортирует публичный API для получения позиций chunks.
Нужно вычислять hunk index самостоятельно через diff algorithm:
**Chunk positions через `getChunks()` — PUBLIC API:**
`@codemirror/merge` экспортирует `getChunks(state)` для получения позиций chunks.
**НЕ используем jsdiff** для вычисления hunk позиций — jsdiff и CM используют РАЗНЫЕ diff алгоритмы, границы hunks могут не совпадать!
```typescript
// Вычисляем позиции hunks через jsdiff (уже установлен)
import { structuredPatch } from 'diff';
function computeHunkRanges(original: string, modified: string) {
const patch = structuredPatch('', '', original, modified);
// patch.hunks содержит newStart/newLines для каждого hunk
return patch.hunks.map((h, idx) => ({
index: idx,
fromLine: h.newStart,
toLine: h.newStart + h.newLines,
}));
}
import { getChunks, acceptChunk, rejectChunk } from '@codemirror/merge';
function computeHunkIndexAtPos(state: EditorState, pos: number): number {
const result = getChunks(state);
if (!result) return -1;
const { chunks } = result;
const line = state.doc.lineAt(pos).number;
const ranges = hunkRangesRef.current; // Кешируем при создании editor
for (const r of ranges) {
if (line >= r.fromLine && line <= r.toLine) return r.index;
}
return -1; // Not in a hunk
return chunks.findIndex(c => line >= c.fromB && line < c.toB);
}
// Программный accept/reject по позиции (вместо перехвата Transaction аннотаций):
function acceptHunkAtPos(view: EditorView, pos: number): boolean {
return acceptChunk(view, pos);
}
function rejectHunkAtPos(view: EditorView, pos: number): boolean {
return rejectChunk(view, pos);
}
```
5. **Keyboard navigation через StateCommand — VERIFIED API:**
**ВАЖНО**: `acceptChunk`/`rejectChunk` — публичные функции из `@codemirror/merge`.
Принимают `(view: EditorView, pos?: number)`, возвращают `boolean`.
Если `pos` не указан — работают с chunk под курсором.
5. **Keyboard navigation — прямой вызов (НЕ .run()):**
```typescript
// goToNextChunk и goToPreviousChunk — это StateCommand объекты.
// Используются через keymap ИЛИ вызов .run(view):
// goToNextChunk и goToPreviousChunk — это функции (Command type).
// Используются через keymap ИЛИ прямой вызов:
keymap.of([
{ key: 'Ctrl-Alt-ArrowDown', run: goToNextChunk },
{ key: 'Ctrl-Alt-ArrowUp', run: goToPreviousChunk },
]),
// Программный вызов:
// Программный вызов (прямой, НЕ через .run()!):
// goToNextChunk(editorRef.current!) // returns boolean
```
6. **Unified vs Split — РАЗНЫЕ классы!**
Toggle unified ↔ split требует **полного пересоздания**:
- **Unified**: `new EditorView({ extensions: [unifiedMergeView({...})] })`
- **Split**: `new MergeView({ a: {...}, b: {...}, parent, revertControls: 'a-to-b' })`
Это разные DOM-структуры и разные lifecycle. При переключении — `destroy()` старый + создать новый.
В split mode: `MergeView` имеет `.a` и `.b` EditorView, accept/reject через `revertControls` (не `mergeControls`).
```typescript
// Ref должен быть union:
const viewRef = useRef<EditorView | MergeView | null>(null);
// Helper для получения активного EditorView:
function getActiveEditorView(): EditorView | null {
const ref = viewRef.current;
if (!ref) return null;
if ('b' in ref) return ref.b; // MergeView → use "modified" side
return ref; // EditorView (unified)
}
```
5. **Тема (CSS variables integration)**:
```typescript
const customTheme = EditorView.theme({
@ -982,7 +1126,7 @@ export function CodeMirrorDiffView({
'.cm-merge-reject-btn:hover': {
backgroundColor: 'rgba(239, 68, 68, 0.35)',
},
}, { dark: true });
}); // БЕЗ { dark: true } — CSS variables адаптируются к теме автоматически
```
6. **Extensions assembly — VERIFIED unifiedMergeView config:**

View file

@ -565,8 +565,10 @@ async getTaskChanges(teamName: string, taskId: string): Promise<TaskChangeSetV2>
const boundaries = await this.boundaryParser.parseBoundaries(ref.filePath);
const scope = boundaries.scopes.find(s => s.taskId === taskId);
if (scope) {
scope.memberName = ref.memberName;
allScopes.push(scope);
// CRITICAL: НЕ мутируем scope напрямую — он из кеша TaskBoundaryParser!
// Мутация scope.memberName = ... портит кешированный объект при повторных вызовах.
const scopeCopy = { ...scope, memberName: ref.memberName };
allScopes.push(scopeCopy);
}
}
@ -735,14 +737,63 @@ async hasTaskUpdateMarker(filePath: string, taskId: string): Promise<boolean> {
// Но findLogsForTask() НЕ МЕНЯЕТСЯ.
```
### 5. IPC (без изменений)
### 5. Обновление `src/main/index.ts` (MODIFY)
Phase 3 создаёт `TaskBoundaryParser` и передаёт в `ChangeExtractorService`:
```typescript
// В initializeIpcHandlers() или рядом с ним:
import { TaskBoundaryParser } from '@main/services/team/TaskBoundaryParser';
// Phase 1 было:
// const changeExtractor = new ChangeExtractorService(teamMemberLogsFinder);
// Phase 3 →:
const taskBoundaryParser = new TaskBoundaryParser();
const changeExtractor = new ChangeExtractorService(teamMemberLogsFinder, taskBoundaryParser);
// ReviewHandlerDeps не меняется (Phase 3 не добавляет новых deps в review.ts).
// TaskBoundaryParser используется ВНУТРИ ChangeExtractorService.
```
### 6. IPC (без изменений)
Phase 1 уже определил `REVIEW_GET_TASK_CHANGES`. Phase 3 не добавляет новых каналов — только улучшает backend точность.
### 6. Preload bridge (без изменений)
### 6. Preload bridge и Store — обновление типов
Тип `TaskChangeSet` расширяется до `TaskChangeSetV2` (backwards compatible через extends).
**IMPORTANT: Обновить типы в 3 местах:**
1. **Preload bridge** (`src/preload/index.ts`): generic тип IPC-вызова обновить:
```typescript
// Phase 1:
getTaskChanges: (teamName: string, taskId: string) =>
invokeIpcWithResult<TaskChangeSet>(REVIEW_GET_TASK_CHANGES, teamName, taskId),
// Phase 3 → заменить на:
getTaskChanges: (teamName: string, taskId: string) =>
invokeIpcWithResult<TaskChangeSetV2>(REVIEW_GET_TASK_CHANGES, teamName, taskId),
```
2. **Store type** (`src/renderer/store/slices/changeReviewSlice.ts`):
```typescript
// Phase 2 тип:
activeChangeSet: AgentChangeSet | TaskChangeSet | null;
// Phase 3 → расширить:
activeChangeSet: AgentChangeSet | TaskChangeSet | TaskChangeSetV2 | null;
```
3. **ReviewAPI** (`src/shared/types/api.ts`): return type обновить:
```typescript
// Phase 1:
getTaskChanges: (teamName: string, taskId: string) => Promise<TaskChangeSet>;
// Phase 3 →:
getTaskChanges: (teamName: string, taskId: string) => Promise<TaskChangeSetV2>;
```
Все три изменения backwards compatible: `TaskChangeSetV2 extends TaskChangeSet`, поэтому все Phase 2 компоненты продолжают работать. Phase 3 компоненты используют `isTaskChangeSetV2()` type guard для доступа к `.scope` и `.warnings`.
---
## Frontend
@ -959,12 +1010,15 @@ JSONL Timeline:
| `src/main/services/team/ChangeExtractorService.ts` | MODIFY | +150 |
| `src/main/services/team/TeamMemberLogsFinder.ts` | MODIFY | +40 |
| `src/main/services/team/index.ts` | MODIFY | +1 |
| `src/main/index.ts` | MODIFY | +5 (создать TaskBoundaryParser, передать в ChangeExtractorService) |
| `src/main/index.ts` | MODIFY | +5 (см. ниже) |
| `src/preload/index.ts` | MODIFY | +1 (generic `<TaskChangeSetV2>`) |
| `src/shared/types/api.ts` | MODIFY | +1 (return type `TaskChangeSetV2`) |
| `src/renderer/store/slices/changeReviewSlice.ts` | MODIFY | +1 (union type) |
| `src/renderer/components/team/review/ConfidenceBadge.tsx` | NEW | 45 |
| `src/renderer/components/team/review/ScopeWarningBanner.tsx` | NEW | 50 |
| `src/renderer/components/team/review/ChangeReviewDialog.tsx` | MODIFY | +20 |
| `src/renderer/components/team/kanban/KanbanTaskCard.tsx` | MODIFY | +15 |
| **Итого** | 3 NEW + 6 MODIFY | ~750 |
| **Итого** | 3 NEW + 9 MODIFY | ~760 |
---

View file

@ -135,17 +135,17 @@ useEffect(() => {
**Scroll-to-hunk через CodeMirror API — VERIFIED:**
```typescript
// VERIFIED: goToNextChunk и goToPreviousChunk — это StateCommand объекты.
// Вызываются через .run(view) или через keymap:
// VERIFIED: goToNextChunk и goToPreviousChunk — это (view: EditorView) => boolean функции.
// Вызываются НАПРЯМУЮ, НЕ через .run():
import { goToNextChunk, goToPreviousChunk } from '@codemirror/merge';
function scrollToHunk(editorView: EditorView, direction: 'next' | 'prev') {
function scrollToHunk(editorView: EditorView, direction: 'next' | 'prev'): boolean {
if (direction === 'next') {
goToNextChunk.run(editorView); // StateCommand.run() — НЕ прямой вызов!
return goToNextChunk(editorView); // Прямой вызов! Возвращает boolean.
} else {
goToPreviousChunk.run(editorView);
return goToPreviousChunk(editorView);
}
// Возвращает boolean: true если нашёл chunk, false если конец/начало
// true = нашёл chunk и перешёл, false = конец/начало (нет больше chunks)
}
```
@ -189,6 +189,23 @@ const MAX_TOTAL_ENTRIES = 50; // M2 fix: max number of scope keys in stora
*
* M2 fix: scopeKey включает version hash (computedAt) для инвалидации
* при перевычислении changeSet.
*
* ФОРМАТ scopeKey:
* - Task mode: `task:{taskId}` (пример: `task:42`)
* - Agent mode: `agent:{memberName}` (пример: `agent:researcher`)
* - Full team: `team` (для полного team review без фильтрации)
*
* Вызывающий код генерирует scopeKey:
* ```typescript
* function buildScopeKey(mode: 'task' | 'agent' | 'team', id?: string): string {
* if (mode === 'task') return `task:${id}`;
* if (mode === 'agent') return `agent:${id}`;
* return 'team';
* }
* ```
*
* Инвалидация: При изменении computedAt в activeChangeSet, viewed state
* сбрасывается через useEffect в useViewedFiles (version bump → re-read).
*/
interface ViewedStorageEntry {
@ -759,13 +776,40 @@ getGitFileLog: async (projectPath: string, filePath: string) =>
window.electronAPI.review.getGitFileLog(projectPath, filePath),
```
#### IPC: `src/preload/constants/ipcChannels.ts` (MODIFY)
#### IPC channel: `src/preload/constants/ipcChannels.ts` (MODIFY)
```typescript
// Phase 4 additions
export const REVIEW_GET_GIT_FILE_LOG = 'review:getGitFileLog';
```
#### IPC handler: `src/main/ipc/review.ts` (MODIFY)
Добавить handler и регистрацию в `registerReviewHandlers()`:
```typescript
// Handler
async function handleGetGitFileLog(
_event: IpcMainInvokeEvent,
projectPath: string,
filePath: string
): Promise<IpcResult<Array<{ hash: string; timestamp: string; message: string }>>> {
return wrapReviewHandler(async () => {
const deps = getReviewDeps();
if (!deps.gitFallback) {
return [];
}
return deps.gitFallback.getFileLog(projectPath, filePath);
});
}
// В registerReviewHandlers():
ipcMain.handle(REVIEW_GET_GIT_FILE_LOG, handleGetGitFileLog);
// В removeReviewHandlers():
ipcMain.removeHandler(REVIEW_GET_GIT_FILE_LOG);
```
#### Preload: `src/preload/index.ts` (MODIFY)
```typescript