- 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.
14 KiB
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:
- Структура entry:
obj.message.content— массив блоков (в отличие от main session гдеobj.content) - Edit tool_use.input:
{ "file_path": "/abs/path", "old_string": "...", "new_string": "...", "replace_all": false } - 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")
- Write (create) — файл раньше не существовал. Определяем: если
- MultiEdit tool_use.input:
{ "file_path": "/abs/path", "edits": [{ "old_string": "...", "new_string": "..." }, ...] } - Пропуск ошибок: Следующий за tool_use блок
tool_resultсis_error: true→ пропускаем этот tool_use - Фильтрация proxy_ префикса: Имена инструментов приходят как
proxy_Edit— нужно strip prefix (паттерн из MemberStatsComputer) - Подсчёт строк:
linesAdded = newString.split('\n').length - oldString.split('\n').length(для добавленных), аналогично для removed
Task scoping (для getTaskChanges):
- Найти JSONL файлы агента через
logsFinder.findLogsForTask(teamName, taskId) - Парсить файлы, ища маркеры
TaskUpdatetool_use:input.taskId === taskId && input.status === 'in_progress'→ началоinput.taskId === taskId && input.status === 'completed'→ конец
- Альтернативно: Bash teamctl
task start|complete <id>(regex) - Все tool_use Edit/Write между start и end маркерами = изменения задачи
- Если 86% кейс (1 задача в сессии): вся сессия = задача
Confidence scoring:
high: Найдены оба маркера (start + end) ИЛИ single-task sessionmedium: Найден только 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
- Файл редактировался несколько раз — показываем все snippets в хронологическом порядке
- Write (update) без old_string — показываем только новое содержимое с пометкой "Full file content"
- MultiEdit — каждая пара old_string/new_string отдельным snippet
- Ошибка парсинга JSONL — graceful degradation, показываем то что смогли распарсить
- Пустой changeSet — "No file changes detected" empty state
- Очень длинные файлы — виртуальный скроллинг через
@tanstack/react-virtual(уже установлен) - Binary файлы — пропускаем, не показываем дифф
Тестирование
- Unit test для
ChangeExtractorService.parseFile()с моковым JSONL - Unit test для task scoping (TaskUpdate маркеры)
- Unit test для
ChangeStatsBadgeрендеринга - Ручное тестирование на реальных team sessions из
~/.claude/projects/