agent-ecosystem/docs/iterations/diff-view/phase-1-read-only-diff.md
iliya e160626c64 feat: implement diff view functionality with read-only and accept/reject capabilities
- Introduced a comprehensive implementation plan for the diff view feature, structured in four phases: MVP (read-only), accept/reject per hunk, per-task scoping, and enhanced features.
- Phase 1 includes a read-only diff view per agent, utilizing JSONL data to display file changes.
- Defined new types for file changes and review data, and established IPC channels for fetching member changes and reading file content.
- Developed backend services for extracting file changes and aggregating review data, alongside frontend components for displaying diffs and managing state.
- Subsequent phases will enhance the diff view with accept/reject functionality, task-specific change scoping, and improved user experience features.
2026-02-24 20:29:41 +02:00

302 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Phase 1: Read-Only Diff View
## Цель
Показать пользователю что конкретно изменил каждый агент/задача. Без accept/reject — только просмотр.
Кнопка "View Changes" на карточке задачи и в деталях участника.
## Зависимости (npm)
```bash
pnpm add diff # jsdiff v8 — structuredPatch, createPatch для вычисления диффов
```
---
## Backend
### 1. Типы: `src/shared/types/review.ts` (NEW)
```typescript
/** Один snippet-level дифф от одного tool_use */
export interface SnippetDiff {
toolUseId: string;
filePath: string;
toolName: 'Edit' | 'Write' | 'MultiEdit' | 'NotebookEdit';
type: 'edit' | 'write-new' | 'write-update' | 'multi-edit';
oldString: string; // пустая строка для Write (create)
newString: string;
timestamp: string; // ISO timestamp из JSONL
isError: boolean; // пропускаем если true
}
/** Агрегированные изменения по файлу */
export interface FileChangeSummary {
filePath: string;
relativePath: string; // относительно projectPath
snippets: SnippetDiff[];
linesAdded: number;
linesRemoved: number;
isNewFile: boolean;
}
/** Полный набор изменений агента */
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';
computedAt: string;
}
/** Краткая статистика для badge на карточке */
export interface ChangeStats {
linesAdded: number;
linesRemoved: number;
filesChanged: number;
}
```
### 2. Сервис: `src/main/services/team/ChangeExtractorService.ts` (NEW)
**Задача**: Парсить subagent JSONL файлы, извлекать `tool_use.input` для Edit/Write/MultiEdit.
**Паттерн**: Повторяет `MemberStatsComputer` — стримит JSONL, извлекает контент из блоков.
```typescript
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
export class ChangeExtractorService {
private cache = new Map<string, { data: AgentChangeSet; expiresAt: number }>();
private readonly CACHE_TTL = 3 * 60 * 1000; // 3 мин как в MemberStatsComputer
constructor(private logsFinder: TeamMemberLogsFinder) {}
async getAgentChanges(teamName: string, memberName: string): Promise<AgentChangeSet>;
async getTaskChanges(teamName: string, taskId: string): Promise<TaskChangeSet>;
async getChangeStats(teamName: string, memberName: string): Promise<ChangeStats>;
}
```
**Ключевые нюансы парсинга subagent JSONL:**
1. **Структура entry**: `obj.message.content` — массив блоков (в отличие от main session где `obj.content`)
2. **Edit tool_use.input**:
```json
{ "file_path": "/abs/path", "old_string": "...", "new_string": "...", "replace_all": false }
```
3. **Write tool_use.input**:
```json
{ "file_path": "/abs/path", "content": "..." }
```
- Write (create) — файл раньше не существовал. Определяем: если `old_string` нет и это первое обращение к файлу → `type: 'write-new'`
- Write (update) — файл уже был. `type: 'write-update'`, `oldString` будет пустой (без file-history нет "before")
4. **MultiEdit tool_use.input**:
```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)
7. **Подсчёт строк**: `linesAdded = newString.split('\n').length - oldString.split('\n').length` (для добавленных), аналогично для removed
**Task scoping (для `getTaskChanges`):**
1. Найти JSONL файлы агента через `logsFinder.findLogsForTask(teamName, taskId)`
2. Парсить файлы, ища маркеры `TaskUpdate` tool_use:
- `input.taskId === taskId && input.status === 'in_progress'` → начало
- `input.taskId === taskId && input.status === 'completed'` → конец
3. Альтернативно: Bash teamctl `task start|complete <id>` (regex)
4. Все tool_use Edit/Write между start и end маркерами = изменения задачи
5. Если 86% кейс (1 задача в сессии): вся сессия = задача
**Confidence scoring:**
- `high`: Найдены оба маркера (start + end) ИЛИ single-task session
- `medium`: Найден только end-маркер
- `low`: Нет маркеров, используем fallback (owner + text search)
### 3. IPC каналы: `src/preload/constants/ipcChannels.ts` (MODIFY)
Добавить 3 канала:
```typescript
export const REVIEW_GET_AGENT_CHANGES = 'review:getAgentChanges';
export const REVIEW_GET_TASK_CHANGES = 'review:getTaskChanges';
export const REVIEW_GET_CHANGE_STATS = 'review:getChangeStats';
```
### 4. IPC хендлеры: `src/main/ipc/review.ts` (NEW)
**Паттерн**: Копируем из `src/main/ipc/teams.ts` — module-level state + guard + wrapHandler.
```typescript
import { IpcMain, IpcMainInvokeEvent } from 'electron';
import { IpcResult } from '@shared/types/api';
import { ChangeExtractorService } from '@main/services/team/ChangeExtractorService';
import { REVIEW_GET_AGENT_CHANGES, REVIEW_GET_TASK_CHANGES, REVIEW_GET_CHANGE_STATS } from '@preload/constants/ipcChannels';
let changeExtractor: ChangeExtractorService | null = null;
export function initializeReviewHandlers(service: ChangeExtractorService): void {
changeExtractor = service;
}
export function registerReviewHandlers(ipcMain: IpcMain): void {
ipcMain.handle(REVIEW_GET_AGENT_CHANGES, handleGetAgentChanges);
ipcMain.handle(REVIEW_GET_TASK_CHANGES, handleGetTaskChanges);
ipcMain.handle(REVIEW_GET_CHANGE_STATS, handleGetChangeStats);
}
export function removeReviewHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(REVIEW_GET_AGENT_CHANGES);
ipcMain.removeHandler(REVIEW_GET_TASK_CHANGES);
ipcMain.removeHandler(REVIEW_GET_CHANGE_STATS);
}
// Handlers follow wrapTeamHandler pattern from teams.ts
```
### 5. Регистрация в main process
В `src/main/index.ts` (или где инициализируются IPC):
- Создать `ChangeExtractorService` с зависимостью `TeamMemberLogsFinder`
- Вызвать `initializeReviewHandlers(changeExtractor)`
- Вызвать `registerReviewHandlers(ipcMain)` после team handlers
### 6. Preload bridge: `src/preload/index.ts` (MODIFY)
Добавить в `electronAPI`:
```typescript
review: {
getAgentChanges: (teamName: string, memberName: string) =>
invokeIpcWithResult<AgentChangeSet>(REVIEW_GET_AGENT_CHANGES, teamName, memberName),
getTaskChanges: (teamName: string, taskId: string) =>
invokeIpcWithResult<TaskChangeSet>(REVIEW_GET_TASK_CHANGES, teamName, taskId),
getChangeStats: (teamName: string, memberName: string) =>
invokeIpcWithResult<ChangeStats>(REVIEW_GET_CHANGE_STATS, teamName, memberName),
},
```
---
## Frontend
### 7. Zustand slice: `src/renderer/store/slices/changeReviewSlice.ts` (NEW)
```typescript
export interface ChangeReviewSlice {
// State
activeChangeSet: AgentChangeSet | TaskChangeSet | null;
changeSetLoading: boolean;
changeSetError: string | null;
selectedReviewFilePath: string | null;
changeStatsCache: Record<string, ChangeStats>; // key = "teamName:memberName"
// 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>;
}
```
**Паттерн**: Копируем из teamSlice — loading/error/data + async actions с try/catch.
Зарегистрировать в `src/renderer/store/index.ts` как новый slice.
### 8. Компоненты
#### `src/renderer/components/team/review/ChangeReviewDialog.tsx` (NEW)
- **Dialog shell**: Полноэкранный overlay (или большой dialog)
- Открывается из KanbanTaskCard или MemberDetailDialog
- Props: `open`, `onOpenChange`, `teamName`, `mode: 'agent' | 'task'`, `memberName?`, `taskId?`
- При открытии вызывает `fetchAgentChanges` или `fetchTaskChanges`
- Содержит resizable split panel:
- Слева: `ReviewFileTree`
- Справа: `ReviewDiffContent`
#### `src/renderer/components/team/review/ReviewFileTree.tsx` (NEW)
- Список файлов из `activeChangeSet.files`
- Каждый файл показывает: имя, +N -M badge, иконку статуса
- Клик выбирает файл → `selectReviewFile(filePath)`
- Группировка по директориям (tree view)
- Выделение активного файла
#### `src/renderer/components/team/review/ReviewDiffContent.tsx` (NEW)
- Показывает диффы для выбранного файла
- Phase 1: простой HTML-рендер (old_string красным, new_string зелёным)
- Использует `jsdiff.diffLines()` для вычисления unified diff из old_string/new_string
- Подсветка синтаксиса через существующий `highlight.js` (уже установлен)
- CSS переменные: `--diff-added-bg`, `--diff-removed-bg` и т.д. (уже есть в index.css)
- Если файл имеет несколько snippets — показываем все последовательно с разделителями
#### `src/renderer/components/team/review/ChangeStatsBadge.tsx` (NEW)
- Маленький inline badge: `+142 -38`
- Зелёный для добавленных, красный для удалённых
- Используется в KanbanTaskCard и MemberCard
### 9. Интеграция в существующие компоненты
#### `KanbanTaskCard.tsx` (MODIFY)
- Добавить `ChangeStatsBadge` рядом с subject (для задач в done/review/approved)
- Добавить кнопку "View Changes" (иконка `FileCode` или `GitCompare` из lucide)
- Клик открывает `ChangeReviewDialog` с `mode: 'task'`
#### `TeamDetailView.tsx` (MODIFY)
- Добавить рендер `ChangeReviewDialog` (один инстанс на уровне TeamDetailView)
- State: `reviewDialogState: { open: boolean; mode: 'agent' | 'task'; memberName?: string; taskId?: string }`
- Прокинуть callback `onViewChanges` в KanbanBoard → KanbanTaskCard
---
## Файлы
| Файл | Тип | ~LOC |
|------|-----|---:|
| `src/shared/types/review.ts` | NEW | 80 |
| `src/main/services/team/ChangeExtractorService.ts` | NEW | 350 |
| `src/main/ipc/review.ts` | NEW | 80 |
| `src/main/services/team/index.ts` | MODIFY | +1 |
| `src/main/index.ts` | MODIFY | +10 |
| `src/preload/constants/ipcChannels.ts` | MODIFY | +3 |
| `src/preload/index.ts` | MODIFY | +10 |
| `src/renderer/store/slices/changeReviewSlice.ts` | NEW | 100 |
| `src/renderer/store/index.ts` | MODIFY | +5 |
| `src/renderer/components/team/review/ChangeReviewDialog.tsx` | NEW | 150 |
| `src/renderer/components/team/review/ReviewFileTree.tsx` | NEW | 180 |
| `src/renderer/components/team/review/ReviewDiffContent.tsx` | NEW | 250 |
| `src/renderer/components/team/review/ChangeStatsBadge.tsx` | NEW | 40 |
| `src/renderer/components/team/kanban/KanbanTaskCard.tsx` | MODIFY | +30 |
| `src/renderer/components/team/TeamDetailView.tsx` | MODIFY | +40 |
| **Итого** | 8 NEW + 7 MODIFY | ~1,330 |
---
## Edge Cases
1. **Файл редактировался несколько раз** — показываем все snippets в хронологическом порядке
2. **Write (update) без old_string** — показываем только новое содержимое с пометкой "Full file content"
3. **MultiEdit** — каждая пара old_string/new_string отдельным snippet
4. **Ошибка парсинга JSONL** — graceful degradation, показываем то что смогли распарсить
5. **Пустой changeSet** — "No file changes detected" empty state
6. **Очень длинные файлы** — виртуальный скроллинг через `@tanstack/react-virtual` (уже установлен)
7. **Binary файлы** — пропускаем, не показываем дифф
## Тестирование
- Unit test для `ChangeExtractorService.parseFile()` с моковым JSONL
- Unit test для task scoping (TaskUpdate маркеры)
- Unit test для `ChangeStatsBadge` рендеринга
- Ручное тестирование на реальных team sessions из `~/.claude/projects/`