- Removed the legacy teamctl CLI integration, including the associated source code and references in the main module. - Updated the package description to reflect the current functionality without legacy support. - Cleaned up build scripts by removing unnecessary executable permissions and legacy file handling. - Adjusted tests and documentation to remove references to the deprecated CLI.
21 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';
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:
- Структура 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": "..." }, ...] } - NotebookEdit — SKIP:
NotebookEditимеет другую структуру input (notebook_path,cell_number,new_source) — нетfile_path,old_string,new_string. Пропускаем при парсинге (toolName !== 'NotebookEdit'guard). НЕ включаем в SnippetDiff. - replace_all — при
replace_all: trueв Edit input:- В SnippetDiff записываем
replaceAll: true - При snippet chain reconstruction используем
content.replaceAll(oldString, newString)вместоcontent.replace() - При reject — нужно откатить ВСЕ вхождения, не только первое (см. Phase 2)
- В SnippetDiff записываем
- Пропуск ошибок —
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<string>errored tool_use_id из всех tool_result блоков. Второй pass — фильтровать tool_use по этому set.
- Фильтрация proxy_ префикса: Имена инструментов приходят как
proxy_Edit— нужно strip prefix (паттерн из MemberStatsComputer) - Подсчёт строк (через
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):
- Найти 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 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
- Файл редактировался несколько раз — показываем все 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/