agent-ecosystem/docs/iterations/diff-view/phase-1-read-only-diff.md
iliya 545fa2cfb8 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.
2026-02-24 21:18:17 +02:00

21 KiB
Raw Blame History

Phase 1: Read-Only Diff View

Цель

Показать пользователю что конкретно изменил каждый агент/задача. Без accept/reject — только просмотр. Кнопка "View Changes" на карточке задачи и в деталях участника.

Зависимости (npm)

pnpm add diff    # jsdiff v8 — structuredPatch, createPatch для вычисления диффов

Backend

1. Типы: src/shared/types/review.ts (NEW)

/** Один 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;    // пустая строка для Write (create)
  newString: string;
  replaceAll: boolean;  // Edit с replace_all: true → все вхождения old_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' | 'fallback';  // 'fallback' добавлен для Phase 3 Tier 4
  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, извлекает контент из блоков.

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:
    { "file_path": "/abs/path", "old_string": "...", "new_string": "...", "replace_all": false }
    
  3. Write tool_use.input:
    { "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:
    { "file_path": "/abs/path", "edits": [{ "old_string": "...", "new_string": "..." }, ...] }
    
  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.idtool_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)
  9. Подсчёт строк (через jsdiff.diffLines):
    import { diffLines } from 'diff';
    const changes = diffLines(oldString, newString);
    const linesAdded = changes.filter(c => c.added).reduce((sum, c) => sum + (c.count ?? 0), 0);
    const linesRemoved = changes.filter(c => c.removed).reduce((sum, c) => sum + (c.count ?? 0), 0);
    
    НЕ использовать newString.split('\n').length - oldString.split('\n').length — это даёт "net difference", а не отдельные added/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 канала:

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.

import { IpcMain, IpcMainInvokeEvent } from 'electron';
import type { IpcResult } from '@shared/types'; // IpcResult живёт в @shared/types/ipc.ts, barrel через @shared/types
import { ChangeExtractorService } from '@main/services/team/ChangeExtractorService';
import { REVIEW_GET_AGENT_CHANGES, REVIEW_GET_TASK_CHANGES, REVIEW_GET_CHANGE_STATS } from '@preload/constants/ipcChannels';
import { createLogger } from '@shared/utils/logger';

const logger = createLogger('IPC:review');

// --- Module-level state (паттерн из teams.ts) ---

let changeExtractor: ChangeExtractorService | null = null;

function getChangeExtractor(): ChangeExtractorService {
  if (!changeExtractor) throw new Error('Review handlers not initialized');
  return changeExtractor;
}

// --- Forward-compatible config object (Phase 2/3/4 добавят новые сервисы) ---

interface ReviewHandlerDeps {
  extractor: ChangeExtractorService;
  // Phase 2 добавит: applier?: ReviewApplierService; contentResolver?: FileContentResolver;
  // Phase 4 добавит: gitFallback?: GitDiffFallback;
}

export function initializeReviewHandlers(deps: ReviewHandlerDeps): void {
  changeExtractor = deps.extractor;
}

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

// --- Local wrapReviewHandler (копия wrapTeamHandler из teams.ts, НЕ экспортируется) ---

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

// --- Handlers ---

async function handleGetAgentChanges(
  _event: IpcMainInvokeEvent,
  teamName: string,
  memberName: string
): Promise<IpcResult<AgentChangeSet>> {
  return wrapReviewHandler('getAgentChanges', () =>
    getChangeExtractor().getAgentChanges(teamName, memberName)
  );
}

// ... аналогично handleGetTaskChanges, handleGetChangeStats

5. Регистрация в main process

Файл: src/main/ipc/handlers.ts — единственное место регистрации ВСЕХ IPC handlers.

Шаг 1: Создание сервиса — в src/main/index.ts, функция initializeServices() (после строки ~305 где создаются team services):

// После создания teamMemberLogsFinder и memberStatsComputer:
const changeExtractor = new ChangeExtractorService(teamMemberLogsFinder);

Шаг 2: Инициализация — в src/main/ipc/handlers.ts, функция initializeIpcHandlers().

ВАЖНО: initializeIpcHandlers() использует ПОЗИЦИОННЫЕ параметры (9 штук, строки 76-92). НЕ менять сигнатуру на объект! Вместо этого добавить changeExtractor как 10-й позиционный параметр:

// handlers.ts — расширение сигнатуры:
export function initializeIpcHandlers(
  registry: ServiceContextRegistry,
  updater: UpdaterService,
  sshManager: SshConnectionManager,
  teamDataService: TeamDataService,
  teamProvisioningService: TeamProvisioningService,
  teamMemberLogsFinder: TeamMemberLogsFinder,
  memberStatsComputer: MemberStatsComputer,
  contextCallbacks: { ... },
  httpServerDeps?: { ... },
  changeExtractor?: ChangeExtractorService  // ← Phase 1 addition (optional для backward compat)
): void {
  // ... existing initialization ...
  if (changeExtractor) {
    initializeReviewHandlers({ extractor: changeExtractor });
  }
// index.ts — добавить 10-й аргумент при вызове:
initializeIpcHandlers(
  contextRegistry,
  updaterService,
  sshConnectionManager,
  teamDataService,
  teamProvisioningService,
  teamMemberLogsFinder,
  memberStatsComputer,
  contextCallbacks,
  httpServerDeps,
  changeExtractor  // ← Phase 1
);

Шаг 3: Регистрация — в src/main/ipc/handlers.ts, после registerTeamHandlers(ipcMain) (строка ~130):

registerReviewHandlers(ipcMain);

Шаг 4: Cleanup — в src/main/ipc/handlers.ts, функция removeIpcHandlers() (после removeTeamHandlers(ipcMain), строка ~155):

removeReviewHandlers(ipcMain);

Шаг 5: Import — в src/main/ipc/handlers.ts, добавить import:

import { initializeReviewHandlers, registerReviewHandlers, removeReviewHandlers } from './review';

6. Preload bridge + ElectronAPI типы

6a. Типы: src/shared/types/api.ts (MODIFY)

ВАЖНО: ElectronAPI интерфейс (строки ~406-519) типизирует window.electronAPI. Без добавления review — TypeScript не пропустит api.review.* вызовы.

// В src/shared/types/api.ts добавить:

import type { AgentChangeSet, TaskChangeSet, ChangeStats } from './review';

export interface ReviewAPI {
  getAgentChanges: (teamName: string, memberName: string) => Promise<AgentChangeSet>;
  getTaskChanges: (teamName: string, taskId: string) => Promise<TaskChangeSet>;
  getChangeStats: (teamName: string, memberName: string) => Promise<ChangeStats>;
}

// В ElectronAPI интерфейс добавить поле:
export interface ElectronAPI {
  // ... existing fields ...
  review: ReviewAPI;
}

6b. HttpAPIClient: src/renderer/api/httpClient.ts (MODIFY)

Для browser mode (SSH/remote) нужны заглушки:

// В HttpAPIClient class добавить:
review = {
  getAgentChanges: async () => { throw new Error('Review not available in browser mode'); },
  getTaskChanges: async () => { throw new Error('Review not available in browser mode'); },
  getChangeStats: async () => { throw new Error('Review not available in browser mode'); },
};

6c. Preload: src/preload/index.ts (MODIFY)

Добавить в electronAPI объект:

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)

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.

ВАЖНО: Также обновить src/renderer/store/types.ts:

import type { ChangeReviewSlice } from './slices/changeReviewSlice';

export type AppState = ProjectSlice &
  // ... existing slices ...
  UpdateSlice &
  ChangeReviewSlice;  // ← Phase 1 addition

Без этого useStore().fetchAgentChanges не будет доступен в TypeScript.

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/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)
src/main/ipc/handlers.ts MODIFY +10 (import + init + register + remove)
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/api/httpClient.ts MODIFY +8 (review stubs)
src/renderer/store/slices/changeReviewSlice.ts NEW 100
src/renderer/store/index.ts MODIFY +5
src/renderer/store/types.ts MODIFY +2 (import + AppState intersection)
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 (throwaway — заменяется в Phase 2 на CodeMirror)
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 + 10 MODIFY ~1,430

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/