diff --git a/docs/iterations/diff-view/phase-1-read-only-diff.md b/docs/iterations/diff-view/phase-1-read-only-diff.md index 35043de3..c52e0d0a 100644 --- a/docs/iterations/diff-view/phase-1-read-only-diff.md +++ b/docs/iterations/diff-view/phase-1-read-only-diff.md @@ -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` 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) | diff --git a/docs/iterations/diff-view/phase-2-accept-reject.md b/docs/iterations/diff-view/phase-2-accept-reject.md index f6cedb32..25269b6c 100644 --- a/docs/iterations/diff-view/phase-2-accept-reject.md +++ b/docs/iterations/diff-view/phase-2-accept-reject.md @@ -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; // Phase 2 checkConflict: (filePath: string, expectedModified: string) => Promise; - rejectHunks: (filePath: string, original: string, modified: string, hunkIndices: number[], snippets: SnippetDiff[]) => Promise; - rejectFile: (filePath: string, original: string, modified: string) => Promise; - previewReject: (...) => Promise<{ preview: string; hasConflicts: boolean }>; + rejectHunks: (teamName: string, filePath: string, original: string, modified: string, hunkIndices: number[], snippets: SnippetDiff[]) => Promise; + rejectFile: (teamName: string, filePath: string, original: string, modified: string) => Promise; + previewReject: (filePath: string, original: string, modified: string, hunkIndices: number[], snippets: SnippetDiff[]) => Promise<{ preview: string; hasConflicts: boolean }>; applyDecisions: (request: ApplyReviewRequest) => Promise; getFileContent: (teamName: string, memberName: string, filePath: string) => Promise; } @@ -518,22 +550,49 @@ async function handleGetFileContent( ): Promise> { 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> { - // 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> { 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(REVIEW_REJECT_HUNKS, filePath, original, modified, hunkIndices, snippets), rejectFile: (filePath: string, original: string, modified: string) => invokeIpcWithResult(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(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; + fetchTaskChanges: (teamName: string, taskId: string) => Promise; + selectReviewFile: (filePath: string | null) => void; + fetchChangeStats: (teamName: string, memberName: string) => Promise; + // 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; + /** memberName optional — в task mode определяется из changeSet */ + fetchFileContent: (teamName: string, memberName: string | undefined, filePath: string) => Promise; previewReject: (filePath: string) => Promise<{ preview: string; hasConflicts: boolean }>; applyReview: (teamName: string, taskId?: string, memberName?: string) => Promise; 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 = {}; @@ -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(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:** diff --git a/docs/iterations/diff-view/phase-3-per-task-scoping.md b/docs/iterations/diff-view/phase-3-per-task-scoping.md index 5030def2..f4bf6a70 100644 --- a/docs/iterations/diff-view/phase-3-per-task-scoping.md +++ b/docs/iterations/diff-view/phase-3-per-task-scoping.md @@ -565,8 +565,10 @@ async getTaskChanges(teamName: string, taskId: string): Promise 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 { // Но 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(REVIEW_GET_TASK_CHANGES, teamName, taskId), +// Phase 3 → заменить на: +getTaskChanges: (teamName: string, taskId: string) => + invokeIpcWithResult(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; +// Phase 3 →: +getTaskChanges: (teamName: string, taskId: string) => Promise; +``` + +Все три изменения 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 ``) | +| `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 | --- diff --git a/docs/iterations/diff-view/phase-4-enhanced-features.md b/docs/iterations/diff-view/phase-4-enhanced-features.md index 6681ed90..2acb8b36 100644 --- a/docs/iterations/diff-view/phase-4-enhanced-features.md +++ b/docs/iterations/diff-view/phase-4-enhanced-features.md @@ -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>> { + 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