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

14 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' | '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, извлекает контент из блоков.

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. Пропуск ошибок: Следующий за 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 канала:

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

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.

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/