From e160626c649907358e9abf21aa2fcc6ce02447d1 Mon Sep 17 00:00:00 2001 From: iliya Date: Tue, 24 Feb 2026 20:25:49 +0200 Subject: [PATCH] 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. --- docs/diff-view-implementation-plan.md | 976 ++++++++++++++++++ docs/diff-view-research.md | 686 ++++++++++++ .../diff-view/phase-1-read-only-diff.md | 302 ++++++ .../diff-view/phase-2-accept-reject.md | 959 +++++++++++++++++ .../diff-view/phase-3-per-task-scoping.md | 856 +++++++++++++++ .../diff-view/phase-4-enhanced-features.md | 792 ++++++++++++++ src/main/ipc/teams.ts | 5 +- .../services/team/TeamAgentToolsInstaller.ts | 5 +- src/main/services/team/TeamKanbanManager.ts | 3 +- src/main/services/team/TeamTaskReader.ts | 14 +- src/renderer/components/layout/Sidebar.tsx | 32 +- .../components/sidebar/SidebarTaskItem.tsx | 2 +- .../team/dialogs/CreateTeamDialog.tsx | 8 +- .../components/team/kanban/KanbanTaskCard.tsx | 37 +- src/renderer/store/slices/teamSlice.ts | 17 + src/renderer/utils/pathNormalize.ts | 5 +- src/renderer/utils/taskGrouping.ts | 33 +- src/shared/constants/index.ts | 1 + src/shared/constants/kanban.ts | 9 + src/shared/types/team.ts | 2 + 20 files changed, 4692 insertions(+), 52 deletions(-) create mode 100644 docs/diff-view-implementation-plan.md create mode 100644 docs/diff-view-research.md create mode 100644 docs/iterations/diff-view/phase-1-read-only-diff.md create mode 100644 docs/iterations/diff-view/phase-2-accept-reject.md create mode 100644 docs/iterations/diff-view/phase-3-per-task-scoping.md create mode 100644 docs/iterations/diff-view/phase-4-enhanced-features.md create mode 100644 src/shared/constants/kanban.ts diff --git a/docs/diff-view-implementation-plan.md b/docs/diff-view-implementation-plan.md new file mode 100644 index 00000000..b4e24836 --- /dev/null +++ b/docs/diff-view-implementation-plan.md @@ -0,0 +1,976 @@ +# Diff View + Accept/Reject -- Plan + +## Overview + +4-phase plan. Phase 1 -> self-contained MVP (read-only diff view per agent). +Phase 2 -> accept/reject per hunk with disk writes. Phase 3 -> per-task scoping. +Phase 4 -> polish features. + +--- + +## Phase 1: MVP -- Read-Only Diff View Per Agent + +**Goal**: show all file changes made by a team member in a diff review panel, +using data from JSONL files. No accept/reject yet. + +### 1.1 Packages to Install + +```bash +pnpm add diff # jsdiff v8 -- structuredPatch, applyPatch, parsePatch +``` + +`@codemirror/merge` + `react-codemirror-merge` deferred to Phase 2. +`diff` is needed immediately for programmatic hunk computation from `tool_use.input`. + +### 1.2 Types to Define + +**File: `src/shared/types/review.ts`** (NEW ~120 LOC) + +```typescript +/** Represents one file edit extracted from JSONL */ +export interface FileChange { + filePath: string; // Absolute path on disk + toolName: 'Edit' | 'Write' | 'NotebookEdit' | 'Bash'; + toolUseId: string; // For linking back to JSONL + timestamp: string; // ISO timestamp of the tool_use + memberName: string; // Agent who made the change + sessionId: string; + subagentId?: string; // null for lead session + + // For Edit tool (main session with toolUseResult) + originalFile?: string; // Full file content BEFORE edit (from toolUseResult) + structuredPatch?: Hunk[]; // Ready-made unified diff hunks (from toolUseResult) + + // For Edit tool (subagent -- no toolUseResult, only tool_use.input) + oldString?: string; // tool_use.input.old_string + newString?: string; // tool_use.input.new_string + replaceAll?: boolean; + + // For Write tool + writeContent?: string; // Full new file content + writeType?: 'create' | 'overwrite'; + + // Reliability indicator + confidence: 'high' | 'medium' | 'low'; +} + +export interface Hunk { + oldStart: number; + oldLines: number; + newStart: number; + newLines: number; + lines: string[]; // Each line prefixed with ' ', '+', '-' +} + +/** A file with all its changes aggregated */ +export interface ReviewFile { + filePath: string; + relativePath: string; // Relative to project root + language: string; // Inferred from extension + changes: FileChange[]; // Ordered by timestamp + stats: { added: number; removed: number }; + status: 'added' | 'modified' | 'deleted'; +} + +/** Complete review data for an agent */ +export interface AgentReviewData { + teamName: string; + memberName: string; + files: ReviewFile[]; + totalStats: { added: number; removed: number; filesChanged: number }; + extractedAt: string; + confidence: 'high' | 'medium' | 'low'; // Lowest confidence of all changes +} +``` + +Re-export from `src/shared/types/index.ts`. + +### 1.3 IPC Channels + +**File: `src/preload/constants/ipcChannels.ts`** (MODIFY -- add 2 lines) + +```typescript +/** Get file changes for a team member (diff review) */ +export const REVIEW_GET_MEMBER_CHANGES = 'review:getMemberChanges'; + +/** Read current file content from disk (for conflict detection) */ +export const REVIEW_READ_FILE = 'review:readFile'; +``` + +### 1.4 Backend Service + +**File: `src/main/services/team/FileChangeExtractor.ts`** (NEW ~350 LOC) + +Main service that parses JSONL files and extracts `FileChange[]`. + +Responsibilities: +- Uses `TeamMemberLogsFinder.findMemberLogPaths()` to get JSONL paths +- For MAIN session JSONL: extracts `toolUseResult` objects with `originalFile` + `structuredPatch` (high confidence) +- For SUBAGENT JSONL: extracts `tool_use.input` (old_string, new_string, file_path) from Edit blocks (medium confidence) +- For Write tools: extracts `tool_use.input.content` + `file_path` (medium confidence for overwrite, high for create) +- Uses `diff` package's `structuredPatch()` to compute hunks when `structuredPatch` is not present in JSONL +- Caches results with 2-minute TTL (like MemberStatsComputer) +- Error filtering: skips entries where `typeof toolUseResult === 'string'` or `is_error: true` + +**File: `src/main/services/team/ReviewAggregator.ts`** (NEW ~150 LOC) + +Transforms `FileChange[]` into `AgentReviewData`: +- Groups changes by `filePath` +- Computes per-file stats (lines added/removed) +- Infers file status (added/modified/deleted) +- Computes relative paths from project root +- Infers language from file extension (reuse shared utility) + +### 1.5 IPC Handler + +**File: `src/main/ipc/review.ts`** (NEW ~120 LOC) + +Follows exact same pattern as `teams.ts`: +- `let fileChangeExtractor: FileChangeExtractor | null = null;` +- `let reviewAggregator: ReviewAggregator | null = null;` +- `initializeReviewHandlers(extractor, aggregator)` +- `registerReviewHandlers(ipcMain)` / `removeReviewHandlers(ipcMain)` +- `wrapReviewHandler()` -- same as `wrapTeamHandler` +- Handlers: + - `handleGetMemberChanges(event, teamName, memberName)` -> `IpcResult` + - `handleReadFile(event, filePath)` -> `IpcResult` (reads current file from disk, with path validation) + +**File: `src/main/ipc/handlers.ts`** (MODIFY) +- Import and register review handlers + +**File: `src/main/ipc/guards.ts`** (MODIFY -- if needed for new validations) + +### 1.6 Preload Bridge + +**File: `src/preload/index.ts`** (MODIFY) +- Add `review` namespace to exposed API: +```typescript +review: { + getMemberChanges: (teamName: string, memberName: string) => + invokeIpcWithResult(REVIEW_GET_MEMBER_CHANGES, teamName, memberName), + readFile: (filePath: string) => + invokeIpcWithResult(REVIEW_READ_FILE, filePath), +} +``` + +**File: `src/shared/types/api.ts`** (MODIFY) +- Add `ReviewAPI` interface +- Add `review: ReviewAPI` to `ElectronAPI` + +**File: `src/renderer/api/httpClient.ts`** (MODIFY) +- Add review HTTP fallback stubs + +### 1.7 Zustand Store + +**File: `src/renderer/store/slices/reviewSlice.ts`** (NEW ~120 LOC) + +```typescript +export interface ReviewSlice { + // State + reviewData: AgentReviewData | null; + reviewLoading: boolean; + reviewError: string | null; + selectedReviewFile: string | null; // filePath + + // Actions + fetchMemberChanges: (teamName: string, memberName: string) => Promise; + selectReviewFile: (filePath: string | null) => void; + clearReview: () => void; +} +``` + +**File: `src/renderer/store/index.ts`** (MODIFY -- add slice) +**File: `src/renderer/store/types.ts`** (MODIFY -- add to AppState) + +### 1.8 UI Components + +**File: `src/renderer/components/team/review/ReviewPanel.tsx`** (NEW ~180 LOC) + +Main container component. Layout: +``` ++----------------------------------+ +| ReviewPanel | +| [member-name] +142 -38 [Close] | ++----------+-----------------------+ +| FileTree | DiffContent | +| | | +| src/ | file: auth.ts | +| auth.ts| @@ -1,5 +1,42 @@ | +| +87 -2 | + import jwt ... | +| | | +| test/ | @@ -42,3 +42,8 @@ | +| auth.. | - const OLD = ... | +| +42 -0 | + const NEW = ... | ++----------+-----------------------+ +``` + +Props: `teamName: string, memberName: string, onClose: () => void` + +**File: `src/renderer/components/team/review/ReviewFileTree.tsx`** (NEW ~150 LOC) + +Left sidebar with file list: +- Grouped by directory +- Per-file stats (+added / -removed) +- Active file highlight +- Click to select file +- Collapsible directory groups + +**File: `src/renderer/components/team/review/ReviewDiffContent.tsx`** (NEW ~120 LOC) + +Right panel showing the diff for selected file: +- Header with filename, language badge, stats +- Uses improved DiffViewer (Phase 1 keeps the LCS approach but adds useMemo + proper line numbers) +- Handles multiple changes to same file (shows them sequentially) +- Shows confidence indicator for low/medium confidence changes +- Collapse/expand unchanged code regions + +**File: `src/renderer/components/team/review/ReviewEmptyState.tsx`** (NEW ~30 LOC) + +Empty state when no changes found. + +### 1.9 Integration Point + +**File: `src/renderer/components/team/members/MemberCard.tsx`** (MODIFY) + +Add "Review Changes" button to member card: +```tsx + +``` + +**File: `src/renderer/components/team/TeamDetailView.tsx`** (MODIFY) + +Add ReviewPanel rendering (slide-in panel or dialog). Wire up state: +```tsx +{reviewMember && ( + setReviewMember(null)} + /> +)} +``` + +### 1.10 Existing DiffViewer -- Migration Strategy + +The existing `DiffViewer.tsx` in `src/renderer/components/chat/viewers/` is used for +inline Edit tool display in chat history. It stays UNCHANGED in Phase 1. + +The new review components are in a separate `team/review/` directory and do NOT modify DiffViewer. +In Phase 2, when CodeMirror is introduced, both surfaces will be migrated. + +### 1.11 Service Registration + +**File: `src/main/services/team/index.ts`** (MODIFY -- add 2 exports) +**File: `src/main/services/index.ts`** (MODIFY -- re-export) + +### 1.12 Language Detection Utility + +**File: `src/shared/utils/languageDetection.ts`** (NEW ~50 LOC) + +Extract the `EXTENSION_LANGUAGE_MAP` and `inferLanguage()` from `DiffViewer.tsx` into +a shared utility. Both DiffViewer and ReviewDiffContent will import from here. +DiffViewer.tsx gets modified to import instead of duplicating. + +### 1.13 Testing Strategy + +**File: `test/main/services/team/FileChangeExtractor.test.ts`** (NEW ~250 LOC) +- Test parsing Edit tool_use with toolUseResult (main session) +- Test parsing Edit tool_use without toolUseResult (subagent) +- Test parsing Write create / overwrite +- Test error filtering (failed edits, rejected edits) +- Test caching behavior + +**File: `test/main/services/team/ReviewAggregator.test.ts`** (NEW ~100 LOC) +- Test grouping changes by file +- Test stats computation +- Test relative path calculation +- Test file status inference + +**File: `test/main/ipc/review.test.ts`** (NEW ~80 LOC) +- Test input validation (teamName, memberName) +- Test error wrapping + +**File: `test/shared/utils/languageDetection.test.ts`** (NEW ~40 LOC) + +### 1.14 Phase 1 Summary + +| Category | New Files | Modified Files | Estimated LOC | +|----------|-----------|----------------|---------------| +| Types | 1 | 2 | ~120 | +| Backend services | 2 | 2 | ~500 | +| IPC handler | 1 | 2 | ~120 | +| Preload/API | 0 | 3 | ~40 | +| Store | 1 | 2 | ~120 | +| UI components | 4 | 2 | ~480 | +| Shared utils | 1 | 1 | ~50 | +| Tests | 4 | 0 | ~470 | +| **Total** | **14** | **14** | **~1,900** | + +### 1.15 Risks + +| Risk | Probability | Mitigation | +|------|-------------|------------| +| Subagent JSONL lacks toolUseResult -- hunks inaccurate | HIGH (known) | Use `diff.structuredPatch(old_string, new_string)` for subagents; show confidence badge | +| Large files slow LCS diff | MEDIUM | Phase 1 uses `diff` package (Myers algorithm), not hand-rolled LCS; add useMemo | +| Write tool missing originalFile | HIGH (known) | Show "file created" or "file overwritten" without diff; add note | +| Multiple JSONL files per member | LOW | Already handled by `findMemberLogPaths()` | + +### 1.16 Dependencies + +Phase 1 is self-contained. No dependency on other phases. + +--- + +## Phase 2: Accept/Reject Per Hunk + +**Goal**: interactive diff UI with per-hunk Accept/Reject buttons. Reject writes +modified file back to disk. + +### 2.1 Packages to Install + +```bash +pnpm add @codemirror/merge # Diff UI with acceptChunk/rejectChunk +pnpm add react-codemirror-merge # React wrapper +pnpm add @codemirror/state # Core dependency +pnpm add @codemirror/view # Core dependency +pnpm add @codemirror/lang-javascript # Syntax highlight +pnpm add @codemirror/lang-python +pnpm add @codemirror/lang-css +pnpm add @codemirror/lang-html +pnpm add @codemirror/lang-json +pnpm add @codemirror/lang-markdown +pnpm add @codemirror/lang-rust +pnpm add @codemirror/lang-sql +pnpm add @codemirror/theme-one-dark # Dark theme matching our palette +pnpm add node-diff3 # Three-way merge for conflict detection +``` + +### 2.2 New Types + +**File: `src/shared/types/review.ts`** (MODIFY -- add ~80 LOC) + +```typescript +/** Per-hunk review decision */ +export type HunkDecision = 'accepted' | 'rejected' | 'pending'; + +/** Review state for a single file */ +export interface FileReviewState { + filePath: string; + hunkDecisions: HunkDecision[]; // One per hunk, indexed + viewed: boolean; + hasConflict: boolean; + conflictDetails?: string; +} + +/** Request to apply review decisions to disk */ +export interface ApplyReviewRequest { + teamName: string; + memberName: string; + filePath: string; + hunkDecisions: HunkDecision[]; + originalFile: string; // Base version for patch computation + currentDiskContent: string; // For conflict detection +} + +export interface ApplyReviewResult { + success: boolean; + conflictDetected: boolean; + conflictDetails?: string; + newContent?: string; +} +``` + +### 2.3 IPC Channels + +**File: `src/preload/constants/ipcChannels.ts`** (MODIFY) + +```typescript +/** Apply review decisions (write to disk) */ +export const REVIEW_APPLY_DECISIONS = 'review:applyDecisions'; + +/** Get file-history backup content */ +export const REVIEW_GET_BACKUP = 'review:getBackup'; +``` + +### 2.4 Backend Services + +**File: `src/main/services/team/ReviewApplier.ts`** (NEW ~200 LOC) + +Core logic for writing accepted/rejected hunks to disk: + +``` +Accept hunk: No-op (file already has the change) +Reject hunk: Compute reverse patch for that hunk, apply to current file +Reject all: Write originalFile to disk +Partial: Apply only accepted hunks from originalFile base +``` + +Implementation: +1. Read current file from disk +2. If current != expected (agent version), run 3-way merge: + - base = originalFile (before agent edit) + - ours = result of applying only accepted hunks to originalFile + - theirs = current disk content + - Use `node-diff3.diff3Merge()` for conflict detection +3. If no conflict: write merged result +4. If conflict: return conflict details to UI, do NOT write + +**File: `src/main/services/team/BackupReader.ts`** (NEW ~60 LOC) + +Reads `~/.claude/file-history/{sessionId}/{backupFileName}` backup files. +Used as fallback when `originalFile` is not in JSONL (Write tool case). + +### 2.5 IPC Handler + +**File: `src/main/ipc/review.ts`** (MODIFY -- add 2 handlers) + +- `handleApplyDecisions(event, request: ApplyReviewRequest)` -> `IpcResult` + - Validates all fields + - Calls ReviewApplier + - Path traversal validation (prevent writing outside project dir) +- `handleGetBackup(event, sessionId, backupFileName)` -> `IpcResult` + - Validates sessionId format + - Reads backup file content + +### 2.6 Preload / API + +**File: `src/preload/index.ts`** (MODIFY) +**File: `src/shared/types/api.ts`** (MODIFY -- extend ReviewAPI) +**File: `src/renderer/api/httpClient.ts`** (MODIFY) + +### 2.7 Zustand Store + +**File: `src/renderer/store/slices/reviewSlice.ts`** (MODIFY -- add ~80 LOC) + +```typescript +// Additional state +fileReviewStates: Record; +applyingReview: boolean; +applyError: string | null; + +// Additional actions +setHunkDecision: (filePath: string, hunkIndex: number, decision: HunkDecision) => void; +acceptAllHunks: (filePath: string) => void; +rejectAllHunks: (filePath: string) => void; +acceptAllFiles: () => void; +rejectAllFiles: () => void; +applyReviewDecisions: (filePath: string) => Promise; +markFileViewed: (filePath: string) => void; +``` + +### 2.8 UI Components + +**File: `src/renderer/components/team/review/CodeMirrorDiffView.tsx`** (NEW ~250 LOC) + +Replaces the simple DiffViewer in the review panel with CodeMirror merge view: +- `MergeView` from `@codemirror/merge` with `mergeControls: true` +- Theme integration with CSS variables (dark/light) +- `collapseUnchanged` for hiding unchanged regions +- `allowInlineDiffs` for character-level highlighting +- Custom `mergeControls` renderer for Accept/Reject buttons matching our design system +- `goToNextChunk`/`goToPreviousChunk` wired to keyboard shortcuts +- Read-only mode (user cannot edit the code, only accept/reject) +- Emits `onHunkDecision(hunkIndex, decision)` callback + +**File: `src/renderer/components/team/review/ReviewToolbar.tsx`** (NEW ~80 LOC) + +Bottom toolbar: +- "Reject All" / "Accept All" buttons +- Unified / Split toggle +- Stats summary (e.g. "3/7 hunks accepted") +- Apply button (writes to disk) + +**File: `src/renderer/components/team/review/ConflictDialog.tsx`** (NEW ~80 LOC) + +Dialog shown when conflict is detected: +- Shows conflict details +- Options: "Force reject (overwrite)", "Skip this file", "Cancel" + +**File: `src/renderer/components/team/review/ReviewFileTree.tsx`** (MODIFY) + +Add per-file status indicators: +- Checkmark (all accepted) +- X (all rejected) +- Partial (mixed) +- Warning (conflict detected) +- Eye icon (viewed/unviewed) + +**File: `src/renderer/components/team/review/ReviewDiffContent.tsx`** (MODIFY) + +Replace inline diff rendering with `CodeMirrorDiffView` component. + +### 2.9 CodeMirror Theme + +**File: `src/renderer/components/team/review/codemirrorTheme.ts`** (NEW ~80 LOC) + +Custom CodeMirror theme that maps to our CSS variables: +- `--diff-added-bg`, `--diff-removed-bg` +- `--code-bg`, `--code-border` +- Font family matching our monospace stack +- Accept/Reject button styling + +### 2.10 Existing DiffViewer Migration + +At this point, the chat viewer's `DiffViewer.tsx` can optionally be migrated to use +CodeMirror as well. This is NOT required for the review feature but improves consistency. +If done: +- `DiffViewer.tsx` becomes a thin wrapper around CodeMirror (read-only, no accept/reject) +- LCS algorithm removed +- Bundle size increase ~130KB (CodeMirror core) -- acceptable since already loaded for review + +Recommended: keep old DiffViewer in chat view for now (it works, it's lightweight). +Only the review panel uses CodeMirror. + +### 2.11 Testing Strategy + +**File: `test/main/services/team/ReviewApplier.test.ts`** (NEW ~200 LOC) +- Test reject single hunk +- Test reject all hunks +- Test partial accept/reject +- Test conflict detection (file changed after agent edit) +- Test three-way merge resolution +- Test path traversal prevention + +**File: `test/main/services/team/BackupReader.test.ts`** (NEW ~60 LOC) +- Test reading backup files +- Test missing backup graceful handling + +### 2.12 Phase 2 Summary + +| Category | New Files | Modified Files | Estimated LOC | +|----------|-----------|----------------|---------------| +| Types | 0 | 1 | ~80 | +| Backend services | 2 | 0 | ~260 | +| IPC handler | 0 | 1 | ~60 | +| Preload/API | 0 | 3 | ~30 | +| Store | 0 | 1 | ~80 | +| UI components | 4 | 2 | ~490 | +| Theme | 1 | 0 | ~80 | +| Tests | 2 | 0 | ~260 | +| **Total** | **9** | **8** | **~1,340** | + +### 2.13 Risks + +| Risk | Probability | Mitigation | +|------|-------------|------------| +| CodeMirror bundle size (~130KB) | LOW | Lazy import; only loaded when review panel opens | +| Three-way merge conflicts hard to resolve | MEDIUM | Show clear conflict UI; always offer "force" option | +| originalFile missing for some edits | MEDIUM | Fall back to file-history backups; show warning | +| CodeMirror theme integration complex | LOW | Start with `one-dark` theme, customize incrementally | +| react-codemirror-merge API changes | LOW | Pin version; wrapper is thin | + +### 2.14 Dependencies + +- Requires Phase 1 (review data extraction) +- Phase 2 review decisions are per-agent only (not per-task) + +--- + +## Phase 3: Per-Task Scoping + +**Goal**: show diffs scoped to a specific task, not just an agent. +Integrate review into the kanban board task cards. + +### 3.1 No New Packages + +All needed packages installed in Phases 1-2. + +### 3.2 Types + +**File: `src/shared/types/review.ts`** (MODIFY -- add ~50 LOC) + +```typescript +/** Time window for task-scoped change extraction */ +export interface TaskTimeWindow { + taskId: string; + memberName: string; + startTimestamp: string | null; // First activity related to task + endTimestamp: string | null; // Task completion or latest activity + confidence: 'high' | 'medium' | 'low'; + markers: TaskMarker[]; +} + +export interface TaskMarker { + type: 'task_start' | 'task_complete' | 'task_create' | 'task_update' | 'mention'; + timestamp: string; + source: string; // JSONL line info +} + +/** Review scoped to a task */ +export interface TaskReviewData extends AgentReviewData { + taskId: string; + taskSubject: string; + timeWindow: TaskTimeWindow; +} +``` + +### 3.3 IPC Channels + +**File: `src/preload/constants/ipcChannels.ts`** (MODIFY) + +```typescript +/** Get file changes scoped to a task */ +export const REVIEW_GET_TASK_CHANGES = 'review:getTaskChanges'; +``` + +### 3.4 Backend Service + +**File: `src/main/services/team/TaskTimeWindowResolver.ts`** (NEW ~250 LOC) + +Resolves the time window for a task by scanning JSONL files: + +1. Use `TeamMemberLogsFinder.findLogsForTask()` to find relevant JSONL files +2. Scan each file for task markers: + - `TaskCreate` with matching task ID -> start marker + - `TaskUpdate` with status `in_progress` -> start marker + - `TaskUpdate` with status `completed` -> end marker + - `SendMessage` referencing task ID -> activity marker + - Comment mentioning `#taskId` -> activity marker +3. Build `TaskTimeWindow` from earliest start to latest end +4. Confidence levels: + - HIGH: both explicit start + end markers found + - MEDIUM: only start OR end found, other inferred from timestamps + - LOW: no explicit markers, only mentions -- wide time window + +**File: `src/main/services/team/FileChangeExtractor.ts`** (MODIFY -- add ~80 LOC) + +New method: `extractChangesForTask(teamName, taskId, timeWindow)` +- Same JSONL parsing as per-agent +- Filters `tool_use` blocks by timestamp within `timeWindow` +- Additional heuristic: if task owner is known, only include that member's changes + +### 3.5 IPC Handler + +**File: `src/main/ipc/review.ts`** (MODIFY) + +Add `handleGetTaskChanges(event, teamName, taskId)` handler. + +### 3.6 Preload / API + +Same pattern: extend `ReviewAPI`, update `preload/index.ts`, update `httpClient.ts`. + +### 3.7 Zustand Store + +**File: `src/renderer/store/slices/reviewSlice.ts`** (MODIFY) + +```typescript +// Additional state +taskReviewData: TaskReviewData | null; +taskReviewLoading: boolean; + +// Additional action +fetchTaskChanges: (teamName: string, taskId: string) => Promise; +``` + +### 3.8 UI Components + +**File: `src/renderer/components/team/review/TaskReviewPanel.tsx`** (NEW ~100 LOC) + +Wraps ReviewPanel with task-specific header: +- Task subject + ID +- Time window visualization (start -> end) +- Confidence badge +- Same file tree + diff content as ReviewPanel (reuses components) + +**File: `src/renderer/components/team/kanban/KanbanTaskCard.tsx`** (MODIFY) + +Add "Review Changes" button on task cards that are in `review` or `done` columns: +```tsx +{(task.kanbanColumn === 'review' || task.status === 'completed') && ( + +)} +``` + +**File: `src/renderer/components/team/dialogs/TaskDetailDialog.tsx`** (MODIFY) + +Add "View Changes" tab/section to task detail dialog. + +### 3.9 Testing Strategy + +**File: `test/main/services/team/TaskTimeWindowResolver.test.ts`** (NEW ~200 LOC) +- Test finding task markers in JSONL +- Test HIGH confidence (both markers) +- Test MEDIUM confidence (partial markers) +- Test LOW confidence (only mentions) +- Test multiple sessions contributing to same task + +**File: `test/main/services/team/FileChangeExtractor.task.test.ts`** (NEW ~120 LOC) +- Test time-window filtering +- Test cross-session task changes + +### 3.10 Phase 3 Summary + +| Category | New Files | Modified Files | Estimated LOC | +|----------|-----------|----------------|---------------| +| Types | 0 | 1 | ~50 | +| Backend services | 1 | 1 | ~330 | +| IPC handler | 0 | 1 | ~30 | +| Preload/API | 0 | 3 | ~20 | +| Store | 0 | 1 | ~30 | +| UI components | 1 | 2 | ~100 | +| Tests | 2 | 0 | ~320 | +| **Total** | **4** | **9** | **~880** | + +### 3.11 Risks + +| Risk | Probability | Mitigation | +|------|-------------|------------| +| Time window too wide (catches unrelated changes) | MEDIUM | Use confidence levels; show warning for LOW confidence | +| Task timestamps not in JSONL | HIGH (known) | Rely on tool_use timestamps from JSONL, not task JSON file | +| Multiple agents working on same task | LOW | Show all contributing agents, grouped by member | +| Task markers hard to find in large JSONL | MEDIUM | Reuse `fileMentionsTaskId()` for fast scanning | + +### 3.12 Dependencies + +- Requires Phase 1 (FileChangeExtractor) +- Optionally uses Phase 2 (accept/reject) but works without it (read-only task review) + +--- + +## Phase 4: Enhanced Features + +**Goal**: polish, keyboard navigation, "viewed" tracking, multi-edit timeline, +git fallback for Bash changes. + +### 4.1 Packages to Install + +```bash +pnpm add simple-git # Git operations for Bash change detection +``` + +### 4.2 Feature A: Multiple Edits to Same File (Timeline View) + +**File: `src/renderer/components/team/review/FileEditTimeline.tsx`** (NEW ~120 LOC) + +When a file has multiple `FileChange` entries: +- Show a horizontal timeline of edits +- Each node = one edit (with timestamp, agent name) +- Click node to see that specific diff +- "Final" shows cumulative diff + +### 4.3 Feature B: Keyboard Navigation + +**File: `src/renderer/hooks/useReviewKeyboardNav.ts`** (NEW ~80 LOC) + +Keyboard shortcuts (while review panel is focused): +- `j` / `k` -- next/previous file +- `n` / `p` -- next/previous hunk +- `a` -- accept current hunk +- `r` -- reject current hunk +- `A` (shift+a) -- accept all hunks in file +- `R` (shift+r) -- reject all hunks in file +- `v` -- toggle viewed +- `Escape` -- close review panel + +Integrates with CodeMirror's `goToNextChunk` / `goToPreviousChunk`. + +### 4.4 Feature C: "Viewed" File Tracking + +**File: `src/renderer/store/slices/reviewSlice.ts`** (MODIFY -- ~20 LOC) + +Persistent "viewed" state per file (stored in IndexedDB via `idb-keyval`): +- Key: `review:{teamName}:{memberName}:{filePath}` +- Value: `{ viewed: boolean, viewedAt: string }` +- Badge in file tree: eye icon / number of unviewed files + +### 4.5 Feature D: Git Fallback for Bash Changes + +**File: `src/main/services/team/GitDiffProvider.ts`** (NEW ~150 LOC) + +For changes made via Bash (git apply, sed, etc.): +1. Get project's git repo path from team config +2. Use `simple-git` to run `git log --author --since --until --stat` filtered by session timestamps +3. For each changed file: `git diff .. -- ` +4. Convert to `FileChange[]` with `confidence: 'medium'` + +Integration: called by `FileChangeExtractor` when `toolName === 'Bash'` and git is available. + +### 4.6 Feature E: Split/Unified View Toggle + +**File: `src/renderer/components/team/review/CodeMirrorDiffView.tsx`** (MODIFY) + +Add `orientation` prop: +- `'a-b'` (side-by-side / split view) +- Unified view via custom rendering + +Store user preference in localStorage. + +### 4.7 Testing Strategy + +**File: `test/main/services/team/GitDiffProvider.test.ts`** (NEW ~100 LOC) +**File: `test/renderer/hooks/useReviewKeyboardNav.test.ts`** (NEW ~80 LOC) + +### 4.8 Phase 4 Summary + +| Category | New Files | Modified Files | Estimated LOC | +|----------|-----------|----------------|---------------| +| Backend services | 1 | 1 | ~150 | +| UI components | 1 | 1 | ~120 | +| Hooks | 1 | 0 | ~80 | +| Store | 0 | 1 | ~20 | +| Tests | 2 | 0 | ~180 | +| **Total** | **5** | **3** | **~550** | + +### 4.9 Risks + +| Risk | Probability | Mitigation | +|------|-------------|------------| +| simple-git not available on all systems | MEDIUM | Feature is optional fallback; graceful degradation | +| Git diff timestamps don't match JSONL exactly | MEDIUM | Use wide time window (+/- 60s) for matching | +| Keyboard navigation conflicts with existing shortcuts | LOW | Scope to review panel focus only | + +### 4.10 Dependencies + +- Requires Phase 2 (CodeMirror for split/unified toggle, keyboard nav) +- Git fallback can be done independently + +--- + +## Complete File Manifest + +### All New Files (32 total) + +| Phase | File | LOC | +|-------|------|-----| +| 1 | `src/shared/types/review.ts` | ~120 | +| 1 | `src/shared/utils/languageDetection.ts` | ~50 | +| 1 | `src/main/services/team/FileChangeExtractor.ts` | ~350 | +| 1 | `src/main/services/team/ReviewAggregator.ts` | ~150 | +| 1 | `src/main/ipc/review.ts` | ~120 | +| 1 | `src/renderer/store/slices/reviewSlice.ts` | ~120 | +| 1 | `src/renderer/components/team/review/ReviewPanel.tsx` | ~180 | +| 1 | `src/renderer/components/team/review/ReviewFileTree.tsx` | ~150 | +| 1 | `src/renderer/components/team/review/ReviewDiffContent.tsx` | ~120 | +| 1 | `src/renderer/components/team/review/ReviewEmptyState.tsx` | ~30 | +| 1 | `test/main/services/team/FileChangeExtractor.test.ts` | ~250 | +| 1 | `test/main/services/team/ReviewAggregator.test.ts` | ~100 | +| 1 | `test/main/ipc/review.test.ts` | ~80 | +| 1 | `test/shared/utils/languageDetection.test.ts` | ~40 | +| 2 | `src/main/services/team/ReviewApplier.ts` | ~200 | +| 2 | `src/main/services/team/BackupReader.ts` | ~60 | +| 2 | `src/renderer/components/team/review/CodeMirrorDiffView.tsx` | ~250 | +| 2 | `src/renderer/components/team/review/ReviewToolbar.tsx` | ~80 | +| 2 | `src/renderer/components/team/review/ConflictDialog.tsx` | ~80 | +| 2 | `src/renderer/components/team/review/codemirrorTheme.ts` | ~80 | +| 2 | `test/main/services/team/ReviewApplier.test.ts` | ~200 | +| 2 | `test/main/services/team/BackupReader.test.ts` | ~60 | +| 3 | `src/main/services/team/TaskTimeWindowResolver.ts` | ~250 | +| 3 | `src/renderer/components/team/review/TaskReviewPanel.tsx` | ~100 | +| 3 | `test/main/services/team/TaskTimeWindowResolver.test.ts` | ~200 | +| 3 | `test/main/services/team/FileChangeExtractor.task.test.ts` | ~120 | +| 4 | `src/main/services/team/GitDiffProvider.ts` | ~150 | +| 4 | `src/renderer/components/team/review/FileEditTimeline.tsx` | ~120 | +| 4 | `src/renderer/hooks/useReviewKeyboardNav.ts` | ~80 | +| 4 | `test/main/services/team/GitDiffProvider.test.ts` | ~100 | +| 4 | `test/renderer/hooks/useReviewKeyboardNav.test.ts` | ~80 | + +### All Modified Files (across all phases) + +| File | Phases | Changes | +|------|--------|---------| +| `src/shared/types/review.ts` | 1,2,3 | Type additions | +| `src/shared/types/index.ts` | 1 | Re-export | +| `src/shared/types/api.ts` | 1,2,3 | ReviewAPI interface | +| `src/preload/constants/ipcChannels.ts` | 1,2,3 | Channel constants | +| `src/preload/index.ts` | 1,2,3 | Bridge methods | +| `src/main/ipc/handlers.ts` | 1 | Register review handlers | +| `src/main/ipc/review.ts` | 2,3 | Additional handlers | +| `src/main/services/team/index.ts` | 1,2,3,4 | Barrel exports | +| `src/main/services/index.ts` | 1 | Re-export | +| `src/renderer/api/httpClient.ts` | 1,2,3 | HTTP fallback | +| `src/renderer/store/index.ts` | 1 | Add slice | +| `src/renderer/store/types.ts` | 1 | AppState type | +| `src/renderer/store/slices/reviewSlice.ts` | 2,3,4 | State extensions | +| `src/renderer/components/team/members/MemberCard.tsx` | 1 | Review button | +| `src/renderer/components/team/TeamDetailView.tsx` | 1 | Panel integration | +| `src/renderer/components/team/review/ReviewFileTree.tsx` | 2 | Status indicators | +| `src/renderer/components/team/review/ReviewDiffContent.tsx` | 2 | CodeMirror swap | +| `src/renderer/components/team/kanban/KanbanTaskCard.tsx` | 3 | Review button | +| `src/renderer/components/team/dialogs/TaskDetailDialog.tsx` | 3 | Changes tab | +| `src/renderer/components/chat/viewers/DiffViewer.tsx` | 1 | Extract languageDetection | +| `src/main/services/team/FileChangeExtractor.ts` | 3,4 | Task scope + git fallback | +| `src/renderer/components/team/review/CodeMirrorDiffView.tsx` | 4 | Split/unified toggle | + +--- + +## Estimated Total + +| Phase | New Files | Modified Files | LOC | Packages | +|-------|-----------|----------------|-----|----------| +| 1 MVP | 14 | 14 | ~1,900 | `diff` | +| 2 Accept/Reject | 9 | 8 | ~1,340 | `@codemirror/*`, `node-diff3` | +| 3 Per-Task | 4 | 9 | ~880 | -- | +| 4 Enhanced | 5 | 3 | ~550 | `simple-git` | +| **Total** | **32** | **34** | **~4,670** | 14 packages | + +--- + +## Implementation Order Recommendation + +``` +Week 1: Phase 1 (MVP read-only diff view) + - Day 1-2: Types + FileChangeExtractor + ReviewAggregator + tests + - Day 3: IPC handler + preload bridge + - Day 4-5: UI components + store + integration + +Week 2: Phase 2 (Accept/Reject) + - Day 1-2: CodeMirror integration + theme + - Day 3: ReviewApplier + conflict detection + tests + - Day 4: Toolbar + ConflictDialog + - Day 5: Polish + testing + +Week 3: Phase 3 (Per-Task) + Phase 4 start + - Day 1-2: TaskTimeWindowResolver + tests + - Day 3: Task review UI + kanban integration + - Day 4-5: Phase 4 features (keyboard nav, viewed tracking) + +Week 4: Phase 4 completion + polish + - Day 1-2: Git fallback + - Day 3: File edit timeline + - Day 4-5: Integration testing, edge cases, performance tuning +``` + +--- + +## Architecture Decision Records + +### ADR-1: Separate `review:*` IPC namespace vs extending `team:*` + +**Decision**: Separate `review:*` namespace. +**Reason**: Review is a distinct concern with its own service lifecycle. Mixing into +`teams.ts` (already 1400+ LOC) would make it harder to maintain. Following the +existing pattern where `team:*` channels are for team CRUD/messaging and new domains +get their own namespace. + +### ADR-2: `diff` (jsdiff) for hunk computation vs raw structured patch from JSONL + +**Decision**: Use JSONL `structuredPatch` when available (main session Edit), fall back +to `diff.structuredPatch()` for subagents. +**Reason**: JSONL data is most reliable (computed by CLI at edit time). But subagent +JSONL lacks it, so we need programmatic fallback. `diff` v8 has 47M weekly downloads +and proven reliability. + +### ADR-3: CodeMirror vs @pierre/diffs + +**Decision**: `@codemirror/merge`. +**Reason**: Native `acceptChunk()` / `rejectChunk()` API, mature ecosystem (580K +downloads), MIT license, TypeScript support, active maintenance. `@pierre/diffs` is +newer (Sep 2025), has no explicit license, and Shadow DOM complicates theme integration. + +### ADR-4: Keep existing DiffViewer in chat view + +**Decision**: Do NOT replace chat DiffViewer with CodeMirror in Phase 2. +**Reason**: Chat DiffViewer is read-only and lightweight (~370 LOC). Adding CodeMirror +bundle to every chat view is unnecessary. Review panel loads CodeMirror lazily only when +opened. Migration can be done later if needed. + +### ADR-5: Per-agent first, per-task second + +**Decision**: Phase 1-2 are per-agent only. Per-task added in Phase 3. +**Reason**: Per-agent is 100% reliable (each agent has its own JSONL). Per-task +requires time-window inference (~85% reliability). Ship reliable feature first, +add task scoping as enhancement. diff --git a/docs/diff-view-research.md b/docs/diff-view-research.md new file mode 100644 index 00000000..d6257e7d --- /dev/null +++ b/docs/diff-view-research.md @@ -0,0 +1,686 @@ +# Diff View Research — Полные результаты + +## Раунд 1: Исследование (5 агентов параллельно) + +--- + +## 1. Библиотеки для Diff UI с Accept/Reject + +### Финальный рейтинг + +| Ранг | Библиотека | Accept/Reject | Downloads/нед | Stars | Вердикт | +|------|-----------|:---:|---:|---:|---------| +| **1** | **`@codemirror/merge`** | **Нативный** | 580K | 103 | **Победитель** | +| **2** | **`@pierre/diffs`** | **Нативный** | 201K | 1,770 | **Сильный runner-up** | +| 3 | `react-diff-view` | Через Decoration API | 188K | 985 | Лучшая DIY-база | +| 4 | Monaco DiffEditor | Только revert | 4M | 42K | Overkill | +| 5 | `react-diff-viewer-continued` | Нет | 555K | 210 | Только отображение | +| 6 | `@git-diff-view/react` | Нет | 30K | 646 | Только отображение | + +### `@codemirror/merge` (Победитель) +- **Единственная** библиотека с `acceptChunk()` и `rejectChunk()` как first-class API +- `mergeControls: true` — кнопки Accept/Reject на каждом hunk из коробки +- Кастомизация через `mergeControls: (type, action) => HTMLElement` +- События: `userEvent: "accept"` / `userEvent: "revert"` +- `allowInlineDiffs: true` — character-level диффы +- `collapseUnchanged` — скрытие неизменённого кода +- `goToNextChunk` / `goToPreviousChunk` — keyboard nav +- React wrapper: `react-codemirror-merge` v4.25.5 (53K downloads/нед) +- Bundle: ~15-20KB gzip (merge module) + ~130KB (CodeMirror core) +- Полная темизация, TypeScript, MIT, активная поддержка + +### `@pierre/diffs` (Runner-up) +- Создана специально для Cursor-style UX (маркетируется так) +- `diffAcceptRejectHunk()` — утилита для accept/reject с автоматическим пересчётом номеров строк +- Shiki-based подсветка (те же темы что в VS Code) +- `MultiFileDiff` компонент для мульти-файлового ревью +- Shadow DOM + CSS Grid рендеринг +- Worker pool для производительности +- **Риск**: очень новая (сен 2025), нет явной лицензии, Shadow DOM усложняет кастомизацию стилей + +--- + +## 2. Данные JSONL — Надёжность отслеживания изменений + +### КРИТИЧЕСКОЕ ОТКРЫТИЕ: `toolUseResult` только в main session + +**`toolUseResult` с `originalFile` и `structuredPatch` существует ТОЛЬКО в main session JSONL файлах.** +Subagent файлы (`subagents/agent-*.jsonl`) имеют только `tool_result` блоки с текстовым содержимым. + +### Структура `toolUseResult` для Edit + +```typescript +{ + filePath: string; // Абсолютный путь + oldString: string; // Заменённый текст + newString: string; // Текст замены + originalFile: string; // ПОЛНОЕ содержимое файла ДО изменения + structuredPatch: Hunk[]; // Готовые unified diff hunks + userModified: boolean; // Модифицировал ли пользователь + replaceAll: boolean; // Режим replace_all +} + +interface Hunk { + oldStart: number; + oldLines: number; + newStart: number; + newLines: number; + lines: string[]; // Каждая строка с префиксом ' ', '+', '-' +} +``` + +### Структура для Write/Create + +```typescript +// Создание нового файла +{ type: "create", filePath: string, content: string, structuredPatch: [], originalFile: null } + +// Перезапись существующего +{ type: "text", file: { filePath, content, numLines, startLine, totalLines } } +// Write НЕ имеет originalFile! Только новое содержимое. +``` + +### Надёжность по инструментам + +| Инструмент | `originalFile` | `structuredPatch` | В subagent JSONL | Надёжность | +|------------|:-:|:-:|:-:|:-:| +| **Edit** (main session) | Полный файл | Готовые hunks | Нет | **95%+** | +| **Edit** (subagent) | **Нет** | **Нет** | `tool_use.input` only | **70%** | +| **Write create** (main) | `null` | `[]` | Нет | **95%+** | +| **Write update** (main) | **Нет** | **Нет** | Нет | **50%** | +| **Write** (subagent) | **Нет** | **Нет** | `tool_use.input` only | **50%** | +| **Bash** | Нет | Нет | Только команда | **30-40%** | + +### Обработка ошибок +- Когда Edit **не удаётся**: `toolUseResult` — строка с ошибкой (не объект) +- Когда пользователь **отклоняет**: `is_error: true` на `tool_result` блоке +- **Правило**: если `typeof toolUseResult === 'string'` или `is_error: true` → изменение НЕ произошло + +### Линковка tool_use → tool_result +- `tool_result.tool_use_id` → `tool_use.id` — **100% надёжно** (213/213 matched, 0 mismatches) +- `sourceToolAssistantUUID` — всегда присутствует, указывает на UUID assistant entry +- `sourceToolUseID` — **отсутствует** в реальных данных (0 из 490+ проверенных) + +### `file-history-snapshot` записи +```typescript +{ + type: 'file-history-snapshot'; + snapshot: { + trackedFileBackups: Record; + }; +} +``` +Backup файлы хранятся в `~/.claude/file-history/{sessionId}/{backupFileName}` с ПОЛНЫМ содержимым файла. + +--- + +## 3. Существующая инфраструктура в кодовой базе + +### DiffViewer — PROTOTYPE quality + +**Алгоритм**: Ручная реализация LCS (Longest Common Subsequence) +- O(m*n) сложность по памяти и времени +- **Нет** word-level диффов +- **Нет** split view +- **Нет** подсветки синтаксиса в диффе +- **Нет** сворачивания неизменённого кода +- **Нет** useMemo — дифф пересчитывается при каждом рендере +- **Неправильная** нумерация строк (последовательная вместо old/new) +- Дублирование `inferLanguage()` с CodeBlockViewer (87 строк) + +**Вывод**: Нужна ЗАМЕНА алгоритма. UI-shell (хедер, CSS переменные) можно сохранить. + +### Готовое к переиспользованию + +| Компонент | Статус | Применение | +|-----------|--------|------------| +| CSS diff переменные | Готово | `--diff-added-bg`, `--diff-removed-bg` и т.д. | +| `MemberStatsComputer` | Расширить | Парсинг JSONL (сейчас считает строки, нужно извлекать контент) | +| `TeamMemberLogsFinder.findLogsForTask()` | Готово | Маппинг задача → сессии | +| `ToolResultExtractor` | Готово | Линковка tool_use ↔ tool_result | +| `ToolExecutionBuilder` | Готово | Построение ToolExecution объектов | +| IPC паттерн `team:*` | Копировать | Добавить `review:*` каналы | +| `highlight.js ^11.11.1` | Установлен | Подсветка синтаксиса | +| `@tanstack/react-virtual` | Установлен | Виртуальный скроллинг | + +### НЕ установлено (нужно добавить) +- `diff` (jsdiff) — программные диффы, `applyPatch`, `reversePatch` +- `node-diff3` — three-way merge для конфликтов +- `@codemirror/merge` + `react-codemirror-merge` — UI +- `simple-git` — git операции (опционально) + +--- + +## 4. Scoping изменений: Per-Task vs Per-Agent + +### Per-Agent = 100% надёжно +- Каждый агент имеет свой JSONL файл +- ВСЕ `tool_use` в этом файле = действия этого агента +- Нет амбигуозности +- Уже реализовано в `MemberStatsComputer` + +### Per-Task = ~85% через time-window подход + +**Проблема**: Нет структурной связи между `tool_use` и task ID. JSONL не содержит `task_id` в метаданных инструментов. + +**Текущий подход** (`findLogsForTask`): keyword search по task ID — ~60% надёжность. + +**Рекомендуемый подход**: Time-window: +1. Найти `task start {id}` и `task complete {id}` Bash команды в JSONL +2. Все `tool_use` блоки между этими timestamp'ами = изменения задачи +3. Confidence: HIGH если оба маркера найдены, MEDIUM/LOW если нет + +### Задачи на диске + +`~/.claude/tasks/{team-name}/{id}.json` — **нет timestamp'ов смены статуса!** Только `createdAt` и `comments[].createdAt`. + +--- + +## 5. Accept/Reject — Практическая реализация + +### Подход: Hybrid (originalContent + jsdiff per-hunk) + +``` +Reject whole file: fs.writeFile(filePath, originalFile) +Reject per-hunk: jsdiff.applyPatch(originalFile, onlyAcceptedHunks) +Accept: No-op (файл уже в нужном состоянии, просто UI mark) +Conflict detection: node-diff3.diff3Merge(current, original, agentVersion) +``` + +### Проблема timing (T1 → T2) +``` +T0: Файл = A (original) +T1: Агент редактирует → файл = B (toolUseResult.originalFile = A) +T2: Другой агент/пользователь → файл = C +T3: Пользователь ревьюит и хочет reject +``` +**Решение**: Three-way merge через `node-diff3`: base=B, ours=A, theirs=C → C без изменений агента. + +### Пакеты для реализации + +| Пакет | Назначение | Downloads/нед | +|-------|-----------|---:| +| `diff` (jsdiff v8) | `applyPatch`, `reversePatch`, `structuredPatch` | ~47M | +| `node-diff3` | Three-way merge с детекцией конфликтов | ~5K | +| `simple-git` | Git операции (опционально) | ~1.5M | + +--- + +## 6. UX рекомендации + +### Лучшие паттерны из индустрии + +| Паттерн | Инструменты | Описание | +|---------|------------|----------| +| File tree sidebar | GitHub, Cursor 2.0, JetBrains | Resizable, со статус-индикаторами | +| Split/Unified toggle | GitHub, GitKraken, VS Code | По выбору пользователя | +| Per-file accept/reject | Cursor, VS Code Copilot | Самая частая гранулярность | +| Per-hunk accept/reject | GitKraken (revert hunk), CodeMirror | Очень востребовано | +| "Viewed" tracking | GitHub | Чекбокс на файл | +| Keyboard navigation | GitHub (T/C/I), VS Code (Tab) | Критично для power users | + +### Предложенная структура UI + +``` +┌──────────────────────────────────────────────────┐ +│ Task: "Implement auth" [backend-dev] +142 -38 │ +├──────────┬───────────────────────────────────────┤ +│ File Tree│ CodeMirror Merge View │ +│ │ │ +│ ▸ src/ │ src/middleware/auth.ts │ +│ auth.ts│ @@ -1,5 +1,42 @@ │ +│ +87 -2 │ + import jwt from 'jsonwebtoken' │ +│ ✓ │ [✓ Accept] [✗ Reject] │ +│ │ │ +│ ▸ test/ │ @@ -42,3 +42,8 @@ │ +│ auth.. │ - const OLD = ... │ +│ +42 -0 │ + const NEW = ... │ +│ │ [✓ Accept] [✗ Reject] │ +├──────────┴───────────────────────────────────────┤ +│ [Reject All] [Accept All] Unified ↔ Split │ +└──────────────────────────────────────────────────┘ +``` + +### 3 уровня контроля +1. **Global**: Accept All / Reject All +2. **Per-file**: Иконки в файловом дереве +3. **Per-hunk**: Кнопки на каждом hunk (через `@codemirror/merge` mergeControls) + +--- + +--- + +## Раунд 2: Решения (5 агентов параллельно) + +--- + +## 7. РЕШЕНО: Subagent Diff Data Gap + +### Проблема +`toolUseResult` с `originalFile`/`structuredPatch` существует ТОЛЬКО в main session JSONL. Subagent файлы содержат только `tool_use.input` и текстовый `tool_result`. + +### Решение: Двухуровневый подход + +**Level 1 (Primary): Snippet-level дифы из `tool_use.input`** — мгновенные, 0 disk I/O: +- Edit: `old_string` → `new_string` = точный snippet diff (95% надёжность) +- Write (create): `""` → `content` = полный новый файл (100%) +- Write (update): только `content`, нет "before" (0% для diff, 100% для показа) +- MultiEdit: каждая пара `old_string`/`new_string` отдельно + +**Level 2 (Enrichment): Full-file дифы из file-history backups** — on-demand: +- Ключевое открытие: `file-history-snapshot` в main session JSONL **отслеживает ВСЕ файлы, включая изменённые subagent'ами** +- Backup файлы в `~/.claude/file-history/{sessionId}/{backupFileName}` содержат полное содержимое файлов +- Корреляция по timestamp: subagent edit timestamp → version bump в file-history +- Решает проблему Write (update) без originalFile + +| Инструмент | Level 1 (snippet) | Level 2 (full-file) | +|------------|:-:|:-:| +| Edit | old→new (идеально) | file-history v(n-1)→v(n) | +| Write (create) | ""→content (идеально) | backupFileName=null→v2 | +| Write (update) | только content | **file-history решает!** | +| MultiEdit | каждая пара | file-history для агрегата | +| Bash | недоступно | file-history как fallback | + +--- + +## 8. РЕШЕНО: @codemirror/merge vs @pierre/diffs + +### Победитель: `@codemirror/merge` (однозначно) + +| Критерий | @codemirror/merge | @pierre/diffs | +|---|---|---| +| Accept/Reject кнопки | **Встроены** (`mergeControls: true`) | Нет UI, только утилита | +| Callback | `isUserEvent('accept'/'revert')` | Ручная реализация | +| Tailwind совместимость | Стандартный DOM | **Shadow DOM — конфликт!** | +| Bundle | 181 KB | 2.4 MB + Shiki 1.2 MB gzip | +| Темизация | CSS-переменные через `EditorView.theme()` | Только через Shadow DOM CSS vars | +| Лицензия | MIT | Apache-2.0 | +| Стабильность | 3+ года, 445K downloads/нед | "Early active development, APIs subject to change" | +| Автор | Marijn Haverbeke (создатель CM) | Стартап, 12 contributors | + +**Минимальный рабочий пример:** +```tsx +const extensions = [ + EditorView.editable.of(false), + EditorState.readOnly.of(true), + unifiedMergeView({ + original: originalCode, + mergeControls: true, // Accept/Reject кнопки на каждом hunk + collapseUnchanged: { margin: 3 }, // Скрыть неизменённые участки + allowInlineDiffs: true, // Character-level дифы + }), + EditorView.updateListener.of(update => { + for (const tr of update.transactions) { + if (tr.isUserEvent('accept')) onAccept?.(); + if (tr.isUserEvent('revert')) onReject?.(); + } + }), + EditorView.theme({ + '&': { backgroundColor: 'var(--color-surface)' }, + }, { dark: true }), +]; +``` + +--- + +## 9. Backend Architecture + +### Новые сервисы +- **`ChangeExtractorService`** — парсит JSONL (main + subagent), извлекает FileChange[], кэш 3 мин + - `extractChangesForAgent(teamName, memberName)` → `AgentChangeSet` + - `extractChangesForTask(teamName, taskId)` → `TaskChangeSet` +- **`RejectService`** — reject file/hunks, conflict detection, three-way merge + - `rejectFile(fileChange)` → `RejectResult` + - `rejectHunks(fileChange, hunkIndices)` → `RejectResult` + - `previewReject(fileChange, hunkIndices?)` → content preview + +### Reject алгоритм +``` +Reject whole file: + no conflict → fs.writeFile(originalContent) + conflict → node-diff3 three-way merge (current, original, agentVersion) + +Reject per hunk: + 1. Build partial patch (only accepted hunks) + 2. jsdiff.applyPatch(originalContent, partialPatch) + 3. Conflict check + three-way merge if needed + +Accept = no-op (файл уже в нужном состоянии) +``` + +### IPC каналы (7 новых) +`review:getAgentChanges`, `review:getTaskChanges`, `review:checkConflict`, +`review:rejectFile`, `review:rejectHunks`, `review:rejectBatch`, `review:previewReject` + +--- + +## 10. Frontend Architecture + +### Компонентное дерево +``` +ChangeReviewDialog (dialog shell) + ├── ChangeReviewToolbar (Accept All / Reject All / Apply / Split↔Unified) + └── [resizable split panel] + ├── FileTreePanel (файлы с +/- stats, статус-иконки) + │ └── FileTreeItem (файл, viewed checkbox) + └── DiffPanel (CodeMirror merge view) + ├── DiffPanelHeader (имя файла, per-file accept/reject) + ├── CodeMirrorDiffView (@codemirror/merge wrapper) + └── DiffPanelEmptyState (loading/error/empty) +``` + +### Zustand slice: `changeReviewSlice` +- `activeChangeSet`, `changeSetLoading`, `changeSetError` +- `selectedReviewFilePath`, `fileReviewStates`, `diffViewMode` +- `changeStatsCache` (для badge'ей на карточках) +- Actions: `fetchTaskChanges`, `fetchAgentChanges`, `setHunkDecision`, `setFileDecision`, `acceptAll`, `rejectAll`, `applyReview` + +### Интеграция +- **KanbanTaskCard** → `ChangeStatsBadge` (+142 -38) на карточках в done/review/approved +- **TaskDetailDialog** → секция "Changes" с кнопкой "View Changes" +- **MemberDetailDialog** → таб "Changes" для per-agent ревью + +### Keyboard shortcuts +`j`/`k` файлы, `n`/`p` hunks, `a` accept hunk, `x` reject hunk, `A` accept file, `X` reject file + +--- + +## 11. Implementation Phases + +### Phase 1: MVP — Read-Only Diff View (~1,900 LOC) +- 14 новых файлов, 14 модификаций +- Пакет: `diff` (jsdiff v8) +- `FileChangeExtractor` + `ReviewAggregator` (backend) +- `ReviewPanel` + `ReviewFileTree` + `ReviewDiffContent` (frontend) +- Snippet-level дифы из `tool_use.input` +- "Review Changes" кнопка на MemberCard + +### Phase 2: Accept/Reject Per Hunk (~1,340 LOC) +- 9 новых файлов, 8 модификаций +- Пакеты: `@codemirror/merge`, `react-codemirror-merge`, `node-diff3`, CM language packages +- `ReviewApplier` + `BackupReader` (backend) +- `CodeMirrorDiffView` + `ReviewToolbar` + `ConflictDialog` (frontend) +- Three-way merge для конфликтов + +### Phase 3: Per-Task Scoping (~880 LOC) +- 4 новых файла, 9 модификаций +- `TaskTimeWindowResolver` — time-window подход (~85% надёжность) +- Интеграция в KanbanTaskCard +- Confidence badges + +### Phase 4: Enhanced Features (~550 LOC) +- 5 новых файлов, 3 модификации +- Пакет: `simple-git` +- File Edit Timeline, Keyboard Navigation, "Viewed" tracking, Git fallback + +### Итого: ~4,670 LOC, 32 новых файла + 34 модификации, 14 npm пакетов + +--- + +## 12. Ключевые npm-пакеты + +| Пакет | Фаза | Назначение | +|-------|:---:|-----------| +| `diff` (jsdiff v8) | 1 | structuredPatch, applyPatch, reversePatch | +| `@codemirror/merge` | 2 | Diff UI с accept/reject | +| `react-codemirror-merge` | 2 | React wrapper для CM merge | +| `@codemirror/state` | 2 | CM core dependency | +| `@codemirror/view` | 2 | CM core dependency | +| `@codemirror/lang-javascript` | 2 | TS/JS подсветка | +| `@codemirror/lang-python` | 2 | Python подсветка | +| `@codemirror/lang-json` | 2 | JSON подсветка | +| `@codemirror/lang-css` | 2 | CSS подсветка | +| `@codemirror/lang-html` | 2 | HTML подсветка | +| `@codemirror/theme-one-dark` | 2 | Тёмная тема (базовая) | +| `node-diff3` | 2 | Three-way merge | +| `simple-git` | 4 | Git операции (fallback) | + +--- + +--- + +## Раунд 3: Углублённое исследование (5 агентов параллельно) + +--- + +## 13. Monaco DiffEditor — глубокий анализ + +### Accept/Reject возможности +- Monaco DiffEditor имеет ТОЛЬКО `renderMarginRevertIcon` (кнопка revert на gutter) — **reject only, нет accept** +- Для полноценного accept/reject per hunk нужно **500+ строк кастомного кода**: + - Ручное создание ViewZone + overlay widget + - Вычисление diff chunks через `getLineChanges()` + - Ручное apply/reverse каждого hunk + - Управление scroll/layout при операциях +- **Оценка времени**: 2-3 недели vs 3-5 дней для CodeMirror merge + +### Bundle & Performance +- **Bundle**: 1.5-2 MB gzipped (весь Monaco) +- CSS переменные **НЕ поддерживаются** напрямую — нужен workaround через `defineTheme()` +- Устаревшие API (удалённые в v0.50+) создают риск нестабильности + +### Вывод +Monaco DiffEditor = overkill для нашего use case. CodeMirror merge значительно проще и легче. + +--- + +## 14. CodeMirror Merge — гибкость и кастомизация + +### `mergeControls` — полный контроль HTML +```typescript +mergeControls: (type: 'accept' | 'reject', action: () => void) => { + const btn = document.createElement('button'); + btn.className = 'my-custom-btn'; // Любые стили, включая Tailwind + btn.textContent = type === 'accept' ? '✓' : '✗'; + btn.onclick = action; + return btn; +} +``` +- Каждый hunk получает свои кнопки, полностью кастомизируемые +- Можно добавить любой HTML: иконки, tooltips, dropdown meню + +### CSS переменные — полная совместимость +```typescript +EditorView.theme({ + '&': { backgroundColor: 'var(--color-surface)' }, + '.cm-changedLine': { backgroundColor: 'var(--diff-added-bg)' }, + '.cm-deletedChunk': { backgroundColor: 'var(--diff-removed-bg)' }, +}, { dark: true }) +``` +- Sourcegraph мигрировал **ИЗ Monaco В CodeMirror** именно ради CSS гибкости + +### Per-chunk метаданные +- `getChunks(mergeView)` → массив chunk'ов с `fromA`, `toA`, `fromB`, `toB` +- Можно навешивать декорации (~30-50 строк кастомного extension) +- Keyboard navigation: `goToNextChunk` / `goToPreviousChunk` из коробки + +--- + +## 15. Альтернативные библиотеки с accept/reject + +### Полная матрица (найдено 15 агентом-исследователем) + +| # | Библиотека | Accept/Reject | Stars | Стабильность | Наш вердикт | +|---|-----------|:---:|---:|---|---| +| **1** | **`@codemirror/merge`** | **Нативный API** | 103 (CM: 7.5K) | 3+ года, Marijn Haverbeke | **ПОБЕДИТЕЛЬ** | +| 2 | `@marimo-team/codemirror-ai` | Да (keybinds) | 43 | v0.3.5, 19 релизов | AI-focused, не diff review | +| 3 | `tiptap-diff-suggestions` | Да (commands) | 22 | MIT, headless | Для rich text, не код | +| 4 | `@pierre/diffs` | Утилита only | 1,770 | "APIs subject to change" | Shadow DOM конфликт | +| 5 | `react-diff-viewer-continued` | Нет | 210 | Только отображение | Нет accept/reject | +| 6 | `@git-diff-view/react` | Нет (widget ext.) | 646 | Активный | Нет нативного A/R | +| 7 | `ace-diff` | Copy LR arrows | 365 | MIT, Ace-based | Устаревший подход | +| 8 | `react-diff-view` | Через Decoration | 985 | Stable | DIY accept/reject | +| 9 | Monaco DiffEditor | Только revert | 42K | Microsoft | 500+ LOC custom | +| 10 | `monaco-inline-diff-editor` | Да (custom) | **1** | No npm pkg | Прототип, не production | + +### `@marimo-team/codemirror-ai` — детали +- **Назначение**: AI inline suggestions (как Continue.dev / Cursor autocomplete) +- `acceptEdit: 'Mod-y'`, `rejectEdit: 'Mod-u'` — keybindings +- `onAcceptEdit`, `onRejectEdit` callbacks +- **Проблема для нас**: заточен под AI suggestions в реальном времени, НЕ под post-hoc diff review +- Его нельзя применить к нашему use case (ревью уже сделанных изменений) + +### `tiptap-diff-suggestions` — детали +- `acceptDiffSuggestion(id?)`, `rejectDiffSuggestion(id?)` +- Headless, CSS variables для тем +- **Проблема для нас**: TipTap = rich text editor. Наш use case — код с подсветкой синтаксиса +- Не подходит для code diff review + +### `monaco-inline-diff-editor-with-accept-reject-undo` +- 1 star, 0 forks, нет npm пакета +- "Copy the code directly to your project" +- Вдохновлён Cursor, но это прототип +- **Не production-ready** + +### Итог +**Ни одна альтернатива не превосходит `@codemirror/merge`** для нашего use case (post-hoc code diff review with per-hunk accept/reject). Решение подтверждено. + +--- + +## 16. КРИТИЧЕСКОЕ: Per-Task Scoping улучшен до 95%+ + +### Открытие: `TaskUpdate` tool_use в subagent JSONL + +**Два механизма управления задачами (ВЗАИМОИСКЛЮЧАЮЩИЕ в рамках сессии):** + +**Механизм A: `TaskUpdate` нативный tool** (307 сессий) +```json +{ + "type": "tool_use", + "name": "TaskUpdate", + "input": { "taskId": "5", "status": "in_progress" } +} +``` +- Используется стандартными subagent сессиями +- 100% парсируемо — `input.taskId` + `input.status` +- Tool result: `"Updated task #1 status"` (текст) + +**Механизм B: Bash `teamctl.js`** (44 сессии) +```bash +node "$HOME/.claude/tools/teamctl.js" --team "" task start|complete|set-status +``` +- Используется in-process teammates +- Tool result: `"OK task #5 status=completed"` (стабильный формат) +- Regex: `/task\s+(start|complete|set-status)\s+(\d+)/` + +**Ключевой факт: эти механизмы НИКОГДА не смешиваются** (0 из 351 сессий). + +### Статистика субагентов +- **86% сессий** работают над **1 задачей** → 100% надёжность (вся сессия = задача) +- **14% сессий** работают над **несколькими задачами** последовательно +- Мульти-задачные сессии: чёткие `in_progress` → `completed` маркеры на каждую задачу + +### Реальный пример мульти-задачной сессии (agent-a9f16f0) +``` +L 29 TaskCreate: task 1..5 +L 40 TaskUpdate: taskId=2, status=in_progress +L 42 Grep, Read, Bash (pnpm add), Write... ← изменения задачи 2 +L137 TaskUpdate: taskId=1, status=in_progress +L139 TaskUpdate: taskId=3, status=in_progress +L141 TaskUpdate: taskId=4, status=in_progress +L144 Edit, Edit, Edit... ← изменения задач 1,3,4 +L220 TaskUpdate: taskId=1, status=completed +L222 TaskUpdate: taskId=2, status=completed +L224 TaskUpdate: taskId=3, status=completed +L226 TaskUpdate: taskId=4, status=completed +L228 TaskUpdate: taskId=5, status=in_progress +L230 Bash: pnpm typecheck, test, lint... ← изменения задачи 5 +``` + +### Алгоритм структурного scoping'а + +``` +parseTaskBoundaries(sessionJsonl) → Map + +1. Детектировать TaskUpdate tool_use: + - status == "in_progress" → TASK_START(taskId, line) + - status == "completed" → TASK_END(taskId, line) + +2. Детектировать Bash teamctl: + - "task start " → TASK_START(taskId, line) + - "task complete " → TASK_END(taskId, line) + +3. Между TASK_START и TASK_END: + - Все Edit/Write/Bash tool_use = изменения задачи + +4. Если новый TASK_START до TASK_END предыдущей: + - Граница переключения задач +``` + +### Уровни уверенности + +| Tier | Надёжность | Описание | Покрытие | +|------|:-:|---|---| +| **Tier 1** | **95%+** | Чёткие маркеры start/end | 86% сессий (1 задача) + sequential multi | +| **Tier 2** | **90%** | Batch completion | ~8% сессий | +| **Tier 3** | **80%** | Только end-маркер | ~4% сессий | +| **Tier 4** | **70%** | Нет маркеров | ~2% сессий → fallback на owner+mention | + +### Почему было ~85% раньше +Существующая реализация (`findLogsForTask`) использовала **только text search по task ID**. Она **НЕ парсила `TaskUpdate` tool_use blocks** (которые покрывают 87.5% task-active сессий). + +### Как достичь 95%+ +1. **Добавить парсинг `TaskUpdate` tool_use** (name == "TaskUpdate", input.taskId, input.status) +2. **Сохранить Bash teamctl regex** для остальных 12.5% +3. Для single-task сессий (86%): вся сессия = задача (100%) +4. Для multi-task: маркеры start/end как границы сегментов + +--- + +## 17. Финальная консолидированная рекомендация + +### Библиотека: `@codemirror/merge` (подтверждено 3 раундами) +- Единственная production-ready библиотека с нативным accept/reject per hunk +- CSS variables, полная кастомизация HTML, ~150 KB bundle +- Ни одна из 10+ исследованных альтернатив не превосходит + +### Данные: Hybrid (tool_use.input + file-history) = 98% надёжность +- Level 1: Snippet diffs из tool_use.input (мгновенно, 0 I/O) +- Level 2: Full-file diffs из file-history-snapshot backups (on-demand) +- Решает проблему subagent'ов без `toolUseResult` + +### Per-Task Scoping: Структурные маркеры = 95%+ +- `TaskUpdate` tool_use + Bash teamctl = 100% парсируемые маркеры +- 86% сессий = 1 задача → 100% надёжность +- Улучшение с ~85% (text search) до 95%+ (структурный парсинг) + +### Reject механизм: jsdiff + node-diff3 +- Reject whole file: fs.writeFile(originalContent) +- Reject per hunk: jsdiff.applyPatch(original, acceptedHunksOnly) +- Conflict resolution: node-diff3 three-way merge +- Accept = no-op (файл уже изменён) + +--- + +## 18. Полный каталог библиотек (exhaustive search) + +### Что используют крупные продукты + +| Продукт | Технология | Accept/Reject? | +|---------|-----------|:---:| +| **Cursor** | Monaco + custom decorations | Да (gold standard) | +| **VS Code** | Monaco merge editor | Да (3-way conflicts) | +| **GitKraken** | Monaco + libgit2/NodeGit | Нет в diff view | +| **GitHub Desktop** | Custom React renderer | Нет | +| **Linear Reviews** | Custom React ("structural diffing") | Нет | +| **Vercel v0** | Custom diff view | Нет | + +### Дополнительная находка: `@git-diff-view/react` +- 646 stars, MIT, обновляется еженедельно (февраль 2026) +- GitHub-style UI, Split/Unified views, Web Worker performance +- **Widget system**: `renderWidgetLine` + `renderExtendLine` для кастомных React-компонентов на строках +- Shiki / highlight.js для подсветки синтаксиса +- **Нет нативного accept/reject**, но widget system позволяет добавить +- **Альтернативный путь**: использовать как viewer + custom accept/reject widgets + +### Справочная реализация: `revu` (desktop app) +- Tauri (React + Rust), НЕ библиотека +- Ревью AI-изменений перед коммитом, comments per line, "Send to Agent" export +- Хороший UX-reference + +### Итог exhaustive search +**Ни одна библиотека в экосистеме React/JS не предоставляет production-ready code diff viewer с per-hunk accept/reject из коробки.** Это gap в экосистеме, который каждый продукт (Cursor, VS Code, Linear) заполняет custom-реализациями поверх Monaco или CodeMirror. `@codemirror/merge` — ближайшее к "из коробки" решение. diff --git a/docs/iterations/diff-view/phase-1-read-only-diff.md b/docs/iterations/diff-view/phase-1-read-only-diff.md new file mode 100644 index 00000000..aed10770 --- /dev/null +++ b/docs/iterations/diff-view/phase-1-read-only-diff.md @@ -0,0 +1,302 @@ +# 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(); + private readonly CACHE_TTL = 3 * 60 * 1000; // 3 мин как в MemberStatsComputer + + constructor(private logsFinder: TeamMemberLogsFinder) {} + + async getAgentChanges(teamName: string, memberName: string): Promise; + async getTaskChanges(teamName: string, taskId: string): Promise; + async getChangeStats(teamName: string, memberName: string): Promise; +} +``` + +**Ключевые нюансы парсинга 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 ` (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(REVIEW_GET_AGENT_CHANGES, teamName, memberName), + getTaskChanges: (teamName: string, taskId: string) => + invokeIpcWithResult(REVIEW_GET_TASK_CHANGES, teamName, taskId), + getChangeStats: (teamName: string, memberName: string) => + invokeIpcWithResult(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; // key = "teamName:memberName" + + // Actions + fetchAgentChanges: (teamName: string, memberName: string) => Promise; + fetchTaskChanges: (teamName: string, taskId: string) => Promise; + selectReviewFile: (filePath: string | null) => void; + clearChangeReview: () => void; + fetchChangeStats: (teamName: string, memberName: string) => Promise; +} +``` + +**Паттерн**: Копируем из 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/` diff --git a/docs/iterations/diff-view/phase-2-accept-reject.md b/docs/iterations/diff-view/phase-2-accept-reject.md new file mode 100644 index 00000000..fd7d01fa --- /dev/null +++ b/docs/iterations/diff-view/phase-2-accept-reject.md @@ -0,0 +1,959 @@ +# Phase 2: Accept/Reject Per Hunk + +## Цель +Заменить Phase 1 простой HTML-дифф на полноценный `@codemirror/merge` viewer с accept/reject кнопками на каждом hunk. При reject — откат изменений через `jsdiff.applyPatch()`. При конфликтах — three-way merge через `node-diff3`. + +## Зависимости (npm) +```bash +pnpm add @codemirror/merge @codemirror/state @codemirror/view +pnpm add @codemirror/lang-javascript @codemirror/lang-python @codemirror/lang-json +pnpm add @codemirror/lang-css @codemirror/lang-html @codemirror/lang-xml +pnpm add @codemirror/theme-one-dark +pnpm add diff # jsdiff v8 — applyPatch, reversePatch +pnpm add node-diff3 # Three-way merge для конфликтов +``` + +**Примечание**: `react-codemirror-merge` НЕ используем — пишем свой React wrapper для полного контроля над lifecycle и event handling. + +--- + +## Backend + +### 1. Типы: `src/shared/types/review.ts` (MODIFY — дополнения к Phase 1) + +```typescript +/** Результат проверки конфликтов */ +export interface ConflictCheckResult { + hasConflict: boolean; + /** null если нет конфликта */ + conflictContent: string | null; + /** Текущее содержимое файла на диске */ + currentContent: string; + /** Содержимое до изменений агента (из backup или snippet chain) */ + originalContent: string; +} + +/** Результат операции reject */ +export interface RejectResult { + success: boolean; + /** Новое содержимое файла после reject */ + newContent: string; + /** Были ли конфликты при merge */ + hadConflicts: boolean; + /** Описание конфликтов (если есть) */ + conflictDescription?: string; +} + +/** Решение по hunk */ +export type HunkDecision = 'accepted' | 'rejected' | 'pending'; + +/** Решение по файлу */ +export interface FileReviewDecision { + filePath: string; + /** Общее решение по файлу (shortcut для "все hunks одинаково") */ + fileDecision: HunkDecision; + /** Per-hunk решения, ключ = hunkIndex */ + hunkDecisions: Record; +} + +/** Запрос на применение review */ +export interface ApplyReviewRequest { + teamName: string; + taskId?: string; + memberName?: string; + decisions: FileReviewDecision[]; +} + +/** Результат применения review */ +export interface ApplyReviewResult { + applied: number; + skipped: number; + conflicts: number; + errors: Array<{ filePath: string; error: string }>; +} + +/** Полный file content для CodeMirror (расширение FileChangeSummary) */ +export interface FileChangeWithContent extends FileChangeSummary { + /** Полное содержимое файла ДО изменений (для CodeMirror original) */ + originalFullContent: string | null; + /** Полное содержимое файла ПОСЛЕ изменений (для CodeMirror modified) */ + modifiedFullContent: string | null; + /** Источник original content */ + contentSource: 'file-history' | 'snippet-reconstruction' | 'disk-current' | 'unavailable'; +} +``` + +### 2. Сервис: `src/main/services/team/FileContentResolver.ts` (NEW) + +**Задача**: Получить полное содержимое файла "до" и "после" для CodeMirror. Phase 1 имеет только snippet-level диффы (old_string/new_string) — этого недостаточно для полноценного diff view. + +**Паттерн**: Аналогичен `MemberStatsComputer` — стримит JSONL, кеширует результаты. + +```typescript +import { createReadStream } from 'fs'; +import { readFile } from 'fs/promises'; +import * as readline from 'readline'; +import { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; + +export class FileContentResolver { + private cache = new Map; expiresAt: number }>(); + private readonly CACHE_TTL = 3 * 60 * 1000; + + constructor(private logsFinder: TeamMemberLogsFinder) {} + + /** + * Восстанавливает полное содержимое файла до/после изменений агента. + * + * Стратегия (приоритеты): + * 1. file-history-snapshot backup — полный файл до первого изменения (~85% кейсов) + * 2. Snippet chain reconstruction — применяем все Edit snippets последовательно + * 3. Текущий файл на диске — fallback (может быть уже изменён) + */ + async resolveFileContent( + teamName: string, + memberName: string, + filePath: string + ): Promise<{ + original: string | null; + modified: string | null; + source: 'file-history' | 'snippet-reconstruction' | 'disk-current' | 'unavailable'; + }>; + + /** + * Batch resolve для всех файлов в changeSet. + * Оптимизация: один проход по JSONL для всех файлов. + */ + async resolveAllFileContents( + teamName: string, + memberName: string, + filePaths: string[] + ): Promise>; +} +``` + +**Ключевые нюансы file-history-snapshot:** + +1. **Расположение backup файлов**: `~/.claude/file-history/{sessionId}/{backupFileName}` +2. **backupFileName формат**: `{hash}@v{version}` (например `4eb3109b11712282@v2`) +3. **Парсинг snapshot entry** из JSONL: + ```json + { + "type": "file-history-snapshot", + "snapshot": { + "trackedFileBackups": { + "/absolute/path/to/file.ts": { + "backupFileName": "4eb3109b11712282@v2", + "version": 2, + "backupTime": "2024-01-15T10:30:00Z" + } + } + } + } + ``` +4. **Нужная версия**: Последний snapshot ПЕРЕД первым tool_use для данного файла +5. **Если snapshot отсутствует**: Fallback на snippet reconstruction + +**Snippet chain reconstruction:** + +1. Собрать все Edit tool_use для файла в хронологическом порядке +2. Начать с `original = ''` (или текущий файл, если есть) +3. Для каждого Edit: `content = content.replace(old_string, new_string)` +4. `modified` = финальный результат, `original` = начальное состояние +5. **Проблема**: Нет гарантии что chain полный (Write без old нарушает цепочку) + +### 3. Сервис: `src/main/services/team/ReviewApplierService.ts` (NEW) + +**Задача**: Применение reject решений — откат выбранных hunks через inverse patching. + +```typescript +import * as Diff from 'diff'; +import * as diff3 from 'node-diff3'; +import { readFile, writeFile } from 'fs/promises'; + +export class ReviewApplierService { + /** + * Проверяет конфликты: файл изменён после работы агента? + * + * Сравнивает ожидаемое "after" содержимое (из JSONL) с текущим файлом на диске. + * Если не совпадает — конфликт (файл был изменён пользователем или другим агентом). + */ + async checkConflict( + filePath: string, + expectedModified: string + ): Promise; + + /** + * Reject конкретных hunks в файле. + * + * Алгоритм: + * 1. Прочитать текущий файл с диска + * 2. Сравнить с expectedModified (конфликт-check) + * 3. Если совпадает: + * - Вычислить unified patch через jsdiff.structuredPatch() + * - Выбрать только rejected hunks + * - Применить reverse patch через jsdiff.applyPatch() с reversed: true + * 4. Если НЕ совпадает: + * - Three-way merge: base=original, ours=currentDisk, theirs=originalForRejectedHunks + * - При конфликте — вернуть маркеры + * 5. Записать результат на диск + */ + async rejectHunks( + filePath: string, + original: string, + modified: string, + hunkIndicesToReject: number[] + ): Promise; + + /** + * Reject всего файла — восстановить original content. + */ + async rejectFile( + filePath: string, + original: string, + modified: string + ): Promise; + + /** + * Preview reject без записи на диск. + */ + async previewReject( + filePath: string, + original: string, + modified: string, + hunkIndicesToReject: number[] + ): Promise<{ preview: string; hasConflicts: boolean }>; + + /** + * Batch apply — все решения из review session. + */ + async applyReviewDecisions( + request: ApplyReviewRequest, + fileContents: Map + ): Promise; +} +``` + +**Reject algorithm детально:** + +```typescript +// Шаг 1: Вычислить structured patch +const patch = Diff.structuredPatch('file', 'file', original, modified); +// patch.hunks = [ { oldStart, oldLines, newStart, newLines, lines: ['+', '-', ' '] } ] + +// Шаг 2: Отфильтровать только rejected hunks +const rejectedPatch = { + ...patch, + hunks: patch.hunks.filter((_, idx) => hunkIndicesToReject.includes(idx)) +}; + +// Шаг 3: Reverse patch (откат) +// jsdiff.applyPatch НЕ имеет reversed: true! +// Нужно вручную инвертировать: '+' → '-', '-' → '+', swap oldStart↔newStart +const inversePatch = invertPatch(rejectedPatch); + +// Шаг 4: Применить к modified content +const result = Diff.applyPatch(modified, inversePatch); +if (result === false) { + // Patch не применился — конфликт + return threeWayMerge(original, currentDisk, targetContent); +} +``` + +**Инвертирование patch:** + +```typescript +function invertPatch(patch: Diff.ParsedDiff): Diff.ParsedDiff { + return { + ...patch, + hunks: patch.hunks.map(hunk => ({ + oldStart: hunk.newStart, + oldLines: hunk.newLines, + newStart: hunk.oldStart, + newLines: hunk.oldLines, + lines: hunk.lines.map(line => { + if (line.startsWith('+')) return '-' + line.slice(1); + if (line.startsWith('-')) return '+' + line.slice(1); + return line; // context lines unchanged + }) + })) + }; +} +``` + +**Three-way merge (при конфликтах):** + +```typescript +import { diff3Merge } from 'node-diff3'; + +function threeWayMerge( + base: string, // Original content before agent changes + ours: string, // Current file on disk (user's version) + theirs: string // What we want after reject +): { content: string; hasConflicts: boolean } { + const result = diff3Merge( + ours.split('\n'), + base.split('\n'), + theirs.split('\n') + ); + + let hasConflicts = false; + const lines: string[] = []; + + for (const part of result) { + if ('ok' in part) { + lines.push(...part.ok); + } else { + hasConflicts = true; + lines.push('<<<<<<< Current (yours)'); + lines.push(...(part.conflict?.a ?? [])); + lines.push('======='); + lines.push(...(part.conflict?.b ?? [])); + lines.push('>>>>>>> Reverted (rejected changes)'); + } + } + + return { content: lines.join('\n'), hasConflicts }; +} +``` + +### 4. IPC каналы: `src/preload/constants/ipcChannels.ts` (MODIFY) + +```typescript +// Phase 2 additions +export const REVIEW_CHECK_CONFLICT = 'review:checkConflict'; +export const REVIEW_REJECT_HUNKS = 'review:rejectHunks'; +export const REVIEW_REJECT_FILE = 'review:rejectFile'; +export const REVIEW_PREVIEW_REJECT = 'review:previewReject'; +export const REVIEW_APPLY_DECISIONS = 'review:applyDecisions'; +export const REVIEW_GET_FILE_CONTENT = 'review:getFileContent'; +``` + +### 5. IPC хендлеры: `src/main/ipc/review.ts` (MODIFY — расширение Phase 1) + +```typescript +// Добавляем к Phase 1 хендлерам + +let reviewApplier: ReviewApplierService | null = null; +let fileContentResolver: FileContentResolver | null = null; + +export function initializeReviewHandlers( + extractor: ChangeExtractorService, + applier: ReviewApplierService, + contentResolver: FileContentResolver +): void { + changeExtractor = extractor; + reviewApplier = applier; + fileContentResolver = contentResolver; +} + +// Регистрация Phase 2 хендлеров +export function registerReviewHandlers(ipcMain: IpcMain): void { + // Phase 1 + ipcMain.handle(REVIEW_GET_AGENT_CHANGES, handleGetAgentChanges); + ipcMain.handle(REVIEW_GET_TASK_CHANGES, handleGetTaskChanges); + ipcMain.handle(REVIEW_GET_CHANGE_STATS, handleGetChangeStats); + + // Phase 2 + ipcMain.handle(REVIEW_CHECK_CONFLICT, handleCheckConflict); + ipcMain.handle(REVIEW_REJECT_HUNKS, handleRejectHunks); + ipcMain.handle(REVIEW_REJECT_FILE, handleRejectFile); + ipcMain.handle(REVIEW_PREVIEW_REJECT, handlePreviewReject); + ipcMain.handle(REVIEW_APPLY_DECISIONS, handleApplyDecisions); + ipcMain.handle(REVIEW_GET_FILE_CONTENT, handleGetFileContent); +} + +async function handleGetFileContent( + _event: IpcMainInvokeEvent, + teamName: string, + memberName: string, + filePath: string +): Promise> { + return wrapHandler('review:getFileContent', async () => { + const resolver = getContentResolver(); + const result = await resolver.resolveFileContent(teamName, memberName, filePath); + return result; + }); +} + +async function handleRejectHunks( + _event: IpcMainInvokeEvent, + filePath: string, + original: string, + modified: string, + hunkIndices: number[] +): Promise> { + return wrapHandler('review:rejectHunks', async () => { + const applier = getApplier(); + return await applier.rejectHunks(filePath, original, modified, hunkIndices); + }); +} + +async function handleApplyDecisions( + _event: IpcMainInvokeEvent, + request: ApplyReviewRequest +): Promise> { + return wrapHandler('review:applyDecisions', async () => { + const applier = getApplier(); + const resolver = getContentResolver(); + + // Resolve all file contents first + const filePaths = request.decisions.map(d => d.filePath); + const contents = await resolver.resolveAllFileContents( + request.teamName, + request.memberName ?? '', + filePaths + ); + + return await applier.applyReviewDecisions(request, contents); + }); +} +``` + +### 6. Preload bridge: `src/preload/index.ts` (MODIFY — расширение Phase 1) + +```typescript +review: { + // Phase 1 + getAgentChanges: (teamName: string, memberName: string) => + invokeIpcWithResult(REVIEW_GET_AGENT_CHANGES, teamName, memberName), + getTaskChanges: (teamName: string, taskId: string) => + invokeIpcWithResult(REVIEW_GET_TASK_CHANGES, teamName, taskId), + getChangeStats: (teamName: string, memberName: string) => + invokeIpcWithResult(REVIEW_GET_CHANGE_STATS, teamName, memberName), + + // Phase 2 + checkConflict: (filePath: string, expectedModified: string) => + invokeIpcWithResult(REVIEW_CHECK_CONFLICT, filePath, expectedModified), + rejectHunks: (filePath: string, original: string, modified: string, hunkIndices: number[]) => + invokeIpcWithResult(REVIEW_REJECT_HUNKS, filePath, original, modified, hunkIndices), + rejectFile: (filePath: string, original: string, modified: string) => + invokeIpcWithResult(REVIEW_REJECT_FILE, filePath, original, modified), + previewReject: (filePath: string, original: string, modified: string, hunkIndices: number[]) => + invokeIpcWithResult<{ preview: string; hasConflicts: boolean }>( + REVIEW_PREVIEW_REJECT, filePath, original, modified, hunkIndices + ), + applyDecisions: (request: ApplyReviewRequest) => + invokeIpcWithResult(REVIEW_APPLY_DECISIONS, request), + getFileContent: (teamName: string, memberName: string, filePath: string) => + invokeIpcWithResult(REVIEW_GET_FILE_CONTENT, teamName, memberName, filePath), +}, +``` + +--- + +## Frontend + +### 7. Zustand slice: `src/renderer/store/slices/changeReviewSlice.ts` (MODIFY — расширение Phase 1) + +```typescript +export interface ChangeReviewSlice { + // Phase 1 state + activeChangeSet: AgentChangeSet | TaskChangeSet | null; + changeSetLoading: boolean; + changeSetError: string | null; + selectedReviewFilePath: string | null; + changeStatsCache: Record; + + // Phase 2 additions + /** Per-hunk решения. Ключ = "filePath:hunkIndex" */ + hunkDecisions: Record; + /** Per-file решения */ + fileDecisions: Record; + /** Resolved file contents для CodeMirror (original + modified) */ + fileContents: Record; + fileContentsLoading: Record; + /** Режим отображения */ + diffViewMode: 'unified' | 'split'; + /** Показывать ли unchanged строки */ + collapseUnchanged: boolean; + /** Ошибка apply */ + applyError: string | null; + /** В процессе apply */ + applying: boolean; + + // Phase 2 actions + setHunkDecision: (filePath: string, hunkIndex: number, decision: HunkDecision) => void; + setFileDecision: (filePath: string, decision: HunkDecision) => void; + acceptAllFile: (filePath: string) => void; + rejectAllFile: (filePath: string) => void; + acceptAll: () => void; + rejectAll: () => void; + setDiffViewMode: (mode: 'unified' | 'split') => void; + setCollapseUnchanged: (collapse: boolean) => void; + fetchFileContent: (teamName: string, memberName: string, filePath: string) => Promise; + previewReject: (filePath: string) => Promise<{ preview: string; hasConflicts: boolean }>; + applyReview: (teamName: string, taskId?: string, memberName?: string) => Promise; + clearChangeReview: () => void; +} +``` + +**Ключевая логика:** + +```typescript +setHunkDecision: (filePath, hunkIndex, decision) => { + const key = `${filePath}:${hunkIndex}`; + set(state => ({ + hunkDecisions: { ...state.hunkDecisions, [key]: decision } + })); +}, + +acceptAllFile: (filePath) => { + const changeSet = get().activeChangeSet; + if (!changeSet) return; + const file = changeSet.files.find(f => f.filePath === filePath); + if (!file) return; + + const newDecisions = { ...get().hunkDecisions }; + // Количество hunks = количество snippets (Phase 1 mapping) + for (let i = 0; i < file.snippets.length; i++) { + newDecisions[`${filePath}:${i}`] = 'accepted'; + } + set({ + hunkDecisions: newDecisions, + fileDecisions: { ...get().fileDecisions, [filePath]: 'accepted' } + }); +}, + +applyReview: async (teamName, taskId, memberName) => { + set({ applying: true, applyError: null }); + try { + const { hunkDecisions, fileDecisions, activeChangeSet } = get(); + if (!activeChangeSet) throw new Error('No active change set'); + + // Собрать decisions + const decisions: FileReviewDecision[] = activeChangeSet.files.map(file => { + const perHunk: Record = {}; + for (let i = 0; i < file.snippets.length; i++) { + const key = `${file.filePath}:${i}`; + perHunk[i] = hunkDecisions[key] ?? 'pending'; + } + return { + filePath: file.filePath, + fileDecision: fileDecisions[file.filePath] ?? 'pending', + hunkDecisions: perHunk, + }; + }); + + // Отправить только файлы с rejected hunks + const withRejections = decisions.filter(d => + Object.values(d.hunkDecisions).some(v => v === 'rejected') + ); + + if (withRejections.length === 0) { + set({ applying: false }); + return; // Ничего reject'ить не нужно + } + + const result = await api.review.applyDecisions({ + teamName, + taskId, + memberName, + decisions: withRejections, + }); + + if (result.errors.length > 0) { + set({ applyError: `${result.errors.length} file(s) failed` }); + } + + set({ applying: false }); + } catch (error) { + set({ + applying: false, + applyError: mapReviewError(error), + }); + } +}, +``` + +**Error mapping:** + +```typescript +function mapReviewError(error: unknown): string { + const message = + error instanceof Error ? error.message : String(error); + if (message.includes('conflict')) { + return 'File has been modified since agent changes. Manual resolution required.'; + } + if (message.includes('ENOENT')) { + return 'File no longer exists on disk.'; + } + if (message.includes('EACCES') || message.includes('Permission')) { + return 'Permission denied. Check file permissions.'; + } + return message || 'Failed to apply review changes'; +} +``` + +### 8. Компоненты + +#### `src/renderer/components/team/review/CodeMirrorDiffView.tsx` (NEW) + +**Главный компонент** — обёртка над `@codemirror/merge`. + +```typescript +import { useRef, useEffect, useMemo } from 'react'; +import { EditorView, ViewUpdate } from '@codemirror/view'; +import { EditorState } from '@codemirror/state'; +import { unifiedMergeView } from '@codemirror/merge'; +import { javascript } from '@codemirror/lang-javascript'; +import { python } from '@codemirror/lang-python'; +import { json } from '@codemirror/lang-json'; +import { css } from '@codemirror/lang-css'; +import { html } from '@codemirror/lang-html'; +import { xml } from '@codemirror/lang-xml'; +import { oneDark } from '@codemirror/theme-one-dark'; + +interface CodeMirrorDiffViewProps { + /** Полное содержимое файла ДО изменений */ + original: string; + /** Полное содержимое файла ПОСЛЕ изменений */ + modified: string; + /** Имя файла (для language detection) */ + fileName: string; + /** Максимальная высота контейнера */ + maxHeight?: string; + /** Read-only режим (Phase 1: true, Phase 2: false для accept/reject) */ + readOnly?: boolean; + /** Показывать accept/reject кнопки на каждом hunk */ + showMergeControls?: boolean; + /** Сворачивать unchanged строки */ + collapseUnchanged?: boolean; + /** Margin для collapsed секций (количество видимых строк вокруг изменений) */ + collapseMargin?: number; + /** Callback: пользователь нажал Accept на hunk */ + onHunkAccepted?: (hunkIndex: number) => void; + /** Callback: пользователь нажал Reject на hunk */ + onHunkRejected?: (hunkIndex: number) => void; +} + +export function CodeMirrorDiffView({ + original, + modified, + fileName, + maxHeight = '600px', + readOnly = true, + showMergeControls = false, + collapseUnchanged = true, + collapseMargin = 3, + onHunkAccepted, + onHunkRejected, +}: CodeMirrorDiffViewProps): JSX.Element; +``` + +**Ключевые нюансы реализации:** + +1. **useRef для EditorView** — нужен cleanup при unmount: + ```typescript + const containerRef = useRef(null); + const editorRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) return; + + const view = new EditorView({ + doc: modified, + extensions, + parent: containerRef.current, + }); + editorRef.current = view; + + return () => { + view.destroy(); + editorRef.current = null; + }; + }, [original, modified, fileName]); // Recreate on content change + ``` + +2. **Language detection** (по расширению файла): + ```typescript + function getLanguageExtension(fileName: string) { + const ext = fileName.split('.').pop()?.toLowerCase(); + switch (ext) { + case 'ts': case 'tsx': case 'js': case 'jsx': case 'mjs': case 'cjs': + return javascript({ typescript: ext.startsWith('t'), jsx: ext.endsWith('x') }); + case 'py': return python(); + case 'json': return json(); + case 'css': case 'scss': case 'less': return css(); + case 'html': case 'htm': return html(); + case 'xml': case 'svg': return xml(); + default: return []; // Plain text + } + } + ``` + +3. **Merge controls (accept/reject кнопки)**: + ```typescript + // mergeControls принимает callback factory + // type = 'accept' | 'reject', action = closure для применения + mergeControls: showMergeControls + ? (type, action) => { + const btn = document.createElement('button'); + btn.className = type === 'accept' + ? 'cm-merge-accept-btn' + : 'cm-merge-reject-btn'; + btn.textContent = type === 'accept' ? 'Accept' : 'Reject'; + btn.title = type === 'accept' + ? 'Keep this change (Ctrl+Shift+A)' + : 'Revert this change (Ctrl+Shift+R)'; + btn.onclick = (e) => { + e.stopPropagation(); + action(); // CM applies the change internally + }; + return btn; + } + : undefined, + ``` + +4. **Event tracking для hunk index**: + ```typescript + // CodeMirror merge fires user events 'accept' and 'revert' + // НО не сообщает hunk index напрямую! + // Решение: Отслеживать через transaction.changes и chunk positions + + let hunkCounter = 0; + + EditorView.updateListener.of((update: ViewUpdate) => { + for (const tr of update.transactions) { + if (tr.isUserEvent('accept')) { + onHunkAccepted?.(hunkCounter); + hunkCounter++; + } + if (tr.isUserEvent('revert')) { + onHunkRejected?.(hunkCounter); + hunkCounter++; + } + } + }); + ``` + + **ВАЖНО**: Hunk index tracking через counter НЕ надёжен при non-sequential clicks. Альтернатива — вычислять hunk index по `transaction.changes.newLength` и маппить на chunk ranges. Это Phase 2 implementation detail. + +5. **Тема (CSS variables integration)**: + ```typescript + const customTheme = EditorView.theme({ + '&': { + backgroundColor: 'var(--color-surface)', + color: 'var(--color-text)', + fontFamily: 'var(--font-mono, ui-monospace, monospace)', + fontSize: '13px', + }, + '.cm-gutters': { + backgroundColor: 'var(--color-surface)', + borderRight: '1px solid var(--color-border)', + color: 'var(--code-line-number)', + }, + '.cm-changedLine': { + backgroundColor: 'var(--diff-added-bg) !important', + }, + '.cm-deletedChunk': { + backgroundColor: 'var(--diff-removed-bg) !important', + }, + '.cm-changedText': { + backgroundColor: 'var(--diff-added-bg)', + borderBottom: '1px solid var(--diff-added-border)', + }, + '.cm-deletedText': { + backgroundColor: 'var(--diff-removed-bg)', + borderBottom: '1px solid var(--diff-removed-border)', + }, + // Accept/Reject button styles + '.cm-merge-accept-btn': { + padding: '1px 8px', + borderRadius: '3px', + fontSize: '11px', + cursor: 'pointer', + backgroundColor: 'rgba(34, 197, 94, 0.2)', + color: 'var(--diff-added-text)', + border: '1px solid var(--diff-added-border)', + marginRight: '4px', + }, + '.cm-merge-accept-btn:hover': { + backgroundColor: 'rgba(34, 197, 94, 0.35)', + }, + '.cm-merge-reject-btn': { + padding: '1px 8px', + borderRadius: '3px', + fontSize: '11px', + cursor: 'pointer', + backgroundColor: 'rgba(239, 68, 68, 0.2)', + color: 'var(--diff-removed-text)', + border: '1px solid var(--diff-removed-border)', + }, + '.cm-merge-reject-btn:hover': { + backgroundColor: 'rgba(239, 68, 68, 0.35)', + }, + }, { dark: true }); + ``` + +6. **Extensions assembly**: + ```typescript + const extensions = useMemo(() => [ + readOnly ? EditorState.readOnly.of(true) : [], + readOnly ? EditorView.editable.of(false) : [], + getLanguageExtension(fileName), + customTheme, + unifiedMergeView({ + original, + mergeControls: showMergeControls ? mergeControlsFactory : undefined, + collapseUnchanged: collapseUnchanged ? { margin: collapseMargin } : undefined, + syntaxHighlightDeletions: true, + }), + updateListener, + ].flat(), [original, modified, fileName, showMergeControls, collapseUnchanged]); + ``` + +#### `src/renderer/components/team/review/ReviewToolbar.tsx` (NEW) + +```typescript +interface ReviewToolbarProps { + /** Количество pending / accepted / rejected */ + stats: { pending: number; accepted: number; rejected: number }; + /** Общая статистика изменений */ + changeStats: ChangeStats; + diffViewMode: 'unified' | 'split'; + collapseUnchanged: boolean; + applying: boolean; + onAcceptAll: () => void; + onRejectAll: () => void; + onApply: () => void; + onDiffViewModeChange: (mode: 'unified' | 'split') => void; + onCollapseUnchangedChange: (collapse: boolean) => void; +} +``` + +**Содержимое:** +- Кнопки: "Accept All" (зелёная), "Reject All" (красная), "Apply Changes" (primary, disabled если нет rejected) +- Toggle: Unified ↔ Split view +- Toggle: Collapse unchanged +- Badge: `3 pending · 5 accepted · 2 rejected` +- Badge: `+142 -38 across 7 files` + +#### `src/renderer/components/team/review/ConflictDialog.tsx` (NEW) + +```typescript +interface ConflictDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + filePath: string; + conflictContent: string; + onResolveKeepCurrent: () => void; + onResolveUseOriginal: () => void; + onResolveManual: (content: string) => void; +} +``` + +**Содержимое:** +- Предупреждение: "This file has been modified since the agent's changes" +- Показ conflict markers (<<<<<<< / ======= / >>>>>>>) +- Три кнопки: + 1. "Keep Current" — оставить как есть на диске + 2. "Use Agent's Original" — восстановить до-агентное состояние + 3. "Edit Manually" — открыть CodeMirror для ручного редактирования + +### 9. Модификация существующих компонентов + +#### `ChangeReviewDialog.tsx` (MODIFY — замена Phase 1 ReviewDiffContent) + +Phase 1 использовал простой HTML-рендер. Phase 2 заменяет на `CodeMirrorDiffView`: + +```typescript +// Phase 1 (удалить) + + +// Phase 2 (заменить на) + setHunkDecision(selectedFile.filePath, idx, 'accepted')} + onHunkRejected={(idx) => setHunkDecision(selectedFile.filePath, idx, 'rejected')} +/> +``` + +**Lazy loading file content:** +```typescript +// При выборе файла — загрузить полное содержимое (если ещё не загружено) +const handleFileSelect = async (filePath: string) => { + selectReviewFile(filePath); + if (!fileContents[filePath]) { + await fetchFileContent(teamName, memberName, filePath); + } +}; +``` + +#### `ReviewFileTree.tsx` (MODIFY — добавить decision icons) + +К каждому файлу добавить иконку состояния: +- Pending: серый кружок +- Partially reviewed: жёлтый кружок (часть hunks решена) +- All accepted: зелёная галочка +- All rejected: красный крестик +- Has conflicts: оранжевый треугольник + +```typescript +function getFileStatusIcon(filePath: string, hunkDecisions: Record, snippetCount: number) { + const decisions: HunkDecision[] = []; + for (let i = 0; i < snippetCount; i++) { + decisions.push(hunkDecisions[`${filePath}:${i}`] ?? 'pending'); + } + + const accepted = decisions.filter(d => d === 'accepted').length; + const rejected = decisions.filter(d => d === 'rejected').length; + const pending = decisions.filter(d => d === 'pending').length; + + if (pending === decisions.length) return 'pending'; // All pending + if (accepted === decisions.length) return 'all-accepted'; // All accepted + if (rejected === decisions.length) return 'all-rejected'; // All rejected + return 'partial'; // Mixed +} +``` + +--- + +## Файлы + +| Файл | Тип | ~LOC | +|------|-----|---:| +| `src/shared/types/review.ts` | MODIFY | +120 | +| `src/main/services/team/FileContentResolver.ts` | NEW | 300 | +| `src/main/services/team/ReviewApplierService.ts` | NEW | 400 | +| `src/main/ipc/review.ts` | MODIFY | +120 | +| `src/main/services/team/index.ts` | MODIFY | +2 | +| `src/main/index.ts` | MODIFY | +15 | +| `src/preload/constants/ipcChannels.ts` | MODIFY | +6 | +| `src/preload/index.ts` | MODIFY | +30 | +| `src/renderer/store/slices/changeReviewSlice.ts` | MODIFY | +200 | +| `src/renderer/components/team/review/CodeMirrorDiffView.tsx` | NEW | 350 | +| `src/renderer/components/team/review/ReviewToolbar.tsx` | NEW | 150 | +| `src/renderer/components/team/review/ConflictDialog.tsx` | NEW | 180 | +| `src/renderer/components/team/review/ChangeReviewDialog.tsx` | MODIFY | +60 | +| `src/renderer/components/team/review/ReviewFileTree.tsx` | MODIFY | +40 | +| **Итого** | 4 NEW + 10 MODIFY | ~1,970 | + +--- + +## Edge Cases + +1. **Файл удалён с диска** — при reject показываем ошибку "File no longer exists", предлагаем "Recreate from original" +2. **Файл изменён другим агентом** — three-way merge через node-diff3, показ ConflictDialog +3. **Binary файлы** — пропускаем, кнопка "View Changes" не показывается +4. **Очень большие файлы (>10K строк)** — CodeMirror справляется нативно, но добавляем warning badge +5. **Пустой original content** — Write (create) файл. Показываем как "New file" без reject возможности (нет чего откатывать, кроме удаления файла целиком) +6. **Все hunks accepted** — кнопка "Apply" disabled (нечего reject'ить) +7. **Network/IPC error при apply** — показываем toast с ошибкой, не очищаем decisions (можно retry) +8. **Multiple agents edited same file** — каждый agent показывается отдельно, reject применяется к конкретному agent's changes +9. **Content source = 'unavailable'** — показываем snippet-only view (Phase 1 fallback) с warning: "Full file content unavailable. Showing snippet diffs only." +10. **Accept без Apply** — decisions хранятся в Zustand (in-memory), пропадают при закрытии dialog. Это by design: accept = "я посмотрел и ОК", reject + Apply = "откатить изменения" + +## Тестирование + +- Unit test для `ReviewApplierService.rejectHunks()` с различными patch configurations +- Unit test для `invertPatch()` — корректная инверсия +/- строк +- Unit test для three-way merge сценариев (конфликт / авто-merge / clean) +- Unit test для `FileContentResolver` — file-history, snippet-reconstruction, disk fallback +- Unit test для `changeReviewSlice` — hunk decisions, accept/reject all, apply flow +- Unit test для `CodeMirrorDiffView` — mount/unmount lifecycle, event handling +- Integration test: полный flow от "View Changes" → accept/reject → apply → verify file on disk +- Manual test с реальными team sessions из `~/.claude/projects/` diff --git a/docs/iterations/diff-view/phase-3-per-task-scoping.md b/docs/iterations/diff-view/phase-3-per-task-scoping.md new file mode 100644 index 00000000..3e2ff9ea --- /dev/null +++ b/docs/iterations/diff-view/phase-3-per-task-scoping.md @@ -0,0 +1,856 @@ +# Phase 3: Per-Task Change Scoping + +## Цель +Точно определять какие файловые изменения принадлежат конкретной задаче (task). Текущий `findLogsForTask()` использует keyword search (~60% reliability). Phase 3 добавляет структурный парсинг `TaskUpdate` tool_use блоков для 95%+ reliability. + +## Зависимости (npm) +Нет новых npm зависимостей. Используем только существующие: readline, fs/promises. + +--- + +## Backend + +### 1. Типы: `src/shared/types/review.ts` (MODIFY — дополнения к Phase 1+2) + +```typescript +/** Обнаруженная граница задачи в JSONL */ +export interface TaskBoundary { + taskId: string; + event: 'start' | 'complete'; + /** Номер строки в JSONL файле (для debug) */ + lineNumber: number; + /** ISO timestamp из JSONL entry */ + timestamp: string; + /** Каким механизмом обнаружено */ + mechanism: 'TaskUpdate' | 'teamctl'; + /** tool_use id (для link к конкретному блоку) */ + toolUseId?: string; +} + +/** Scope изменений для одной задачи */ +export interface TaskChangeScope { + taskId: string; + /** Имя участника (owner) */ + memberName: string; + /** Начало scope (строка JSONL или timestamp) */ + startLine: number; + endLine: number; + startTimestamp: string; + endTimestamp: string; + /** Все tool_use.id в пределах scope */ + toolUseIds: string[]; + /** Файлы затронутые в scope */ + filePaths: string[]; + /** Уровень уверенности */ + confidence: TaskScopeConfidence; +} + +/** Детализированный уровень уверенности */ +export interface TaskScopeConfidence { + tier: 1 | 2 | 3 | 4; + label: 'high' | 'medium' | 'low' | 'fallback'; + reason: string; +} + +/** Результат парсинга всех границ задач из JSONL файла */ +export interface TaskBoundariesResult { + /** Все найденные границы, отсортированные по lineNumber */ + boundaries: TaskBoundary[]; + /** Scopes per task */ + scopes: TaskChangeScope[]; + /** True если сессия работала только с одной задачей */ + isSingleTaskSession: boolean; + /** Механизм обнаружения (один на сессию — никогда не смешиваются!) */ + detectedMechanism: 'TaskUpdate' | 'teamctl' | 'none'; +} + +/** Расширенный TaskChangeSet с confidence деталями */ +export interface TaskChangeSetV2 extends TaskChangeSet { + scope: TaskChangeScope; + /** Предупреждения для UI */ + warnings: string[]; +} +``` + +### 2. Сервис: `src/main/services/team/TaskBoundaryParser.ts` (NEW) + +**Задача**: Парсить JSONL файлы субагентов для извлечения `TaskUpdate` и `teamctl` маркеров задач. + +**Ключевой факт**: Механизмы НИКОГДА не смешиваются в одной сессии (0 из 351 проверенных). Это означает один pass по JSONL для определения механизма + extraction. + +```typescript +import { createReadStream } from 'fs'; +import * as readline from 'readline'; + +export class TaskBoundaryParser { + private cache = new Map(); + private readonly CACHE_TTL = 3 * 60 * 1000; // 3 мин + + /** + * Парсит JSONL файл и извлекает все TaskUpdate/teamctl маркеры. + * + * Один проход по файлу, O(n) по количеству строк. + */ + async parseBoundaries(filePath: string): Promise; + + /** + * Определяет scope изменений для конкретной задачи. + * + * Алгоритм: + * 1. Найти все TaskBoundary для taskId + * 2. Start boundary = TaskUpdate(in_progress) или teamctl(start) + * 3. End boundary = TaskUpdate(completed) или teamctl(complete) + * 4. Scope = все tool_use между start.lineNumber и end.lineNumber + * 5. Если single-task session: scope = весь файл + */ + async getTaskScope(filePath: string, taskId: string): Promise; +} +``` + +**Парсинг TaskUpdate (Mechanism A — 86% сессий):** + +```typescript +// В assistant entry ищем tool_use блоки +// entry.message.content = ContentBlock[] +// где ContentBlock = { type: 'tool_use', name: 'TaskUpdate' | 'proxy_TaskUpdate', input: {...} } + +private extractTaskUpdateBoundaries( + content: unknown[], + lineNumber: number, + timestamp: string +): TaskBoundary[] { + const boundaries: TaskBoundary[] = []; + + for (const block of content) { + if (!block || typeof block !== 'object') continue; + const b = block as Record; + + if (b.type !== 'tool_use') continue; + + // Strip proxy_ prefix (паттерн из MemberStatsComputer) + const rawName = typeof b.name === 'string' ? b.name : ''; + const toolName = rawName.replace(/^proxy_/, ''); + + if (toolName !== 'TaskUpdate') continue; + + const input = b.input as Record | undefined; + if (!input) continue; + + const taskId = String(input.taskId ?? input.task_id ?? ''); + const status = String(input.status ?? ''); + + if (!taskId) continue; + + // Map status → event + let event: 'start' | 'complete' | null = null; + if (status === 'in_progress') event = 'start'; + if (status === 'completed') event = 'complete'; + + if (event) { + boundaries.push({ + taskId, + event, + lineNumber, + timestamp, + mechanism: 'TaskUpdate', + toolUseId: typeof b.id === 'string' ? b.id : undefined, + }); + } + } + + return boundaries; +} +``` + +**Парсинг teamctl Bash (Mechanism B — 12.5% сессий):** + +```typescript +// В assistant entry ищем tool_use с name='Bash' или 'proxy_Bash' +// input.command содержит teamctl вызов + +private readonly TEAMCTL_REGEX = /task\s+(start|complete|set-status)\s+(\d+)/; + +private extractTeamctlBoundaries( + content: unknown[], + lineNumber: number, + timestamp: string +): TaskBoundary[] { + const boundaries: TaskBoundary[] = []; + + for (const block of content) { + if (!block || typeof block !== 'object') continue; + const b = block as Record; + if (b.type !== 'tool_use') continue; + + const rawName = typeof b.name === 'string' ? b.name : ''; + const toolName = rawName.replace(/^proxy_/, ''); + + if (toolName !== 'Bash') continue; + + const input = b.input as Record | undefined; + const command = typeof input?.command === 'string' ? input.command : ''; + + if (!command.includes('teamctl')) continue; + + const match = command.match(this.TEAMCTL_REGEX); + if (!match) continue; + + const [, action, taskId] = match; + let event: 'start' | 'complete' | null = null; + if (action === 'start') event = 'start'; + if (action === 'complete') event = 'complete'; + // set-status может быть start или complete — нужно дополнительно парсить аргумент + if (action === 'set-status') { + if (command.includes('in_progress')) event = 'start'; + if (command.includes('completed')) event = 'complete'; + } + + if (event) { + boundaries.push({ + taskId, + event, + lineNumber, + timestamp, + mechanism: 'teamctl', + toolUseId: typeof b.id === 'string' ? b.id : undefined, + }); + } + } + + return boundaries; +} +``` + +**Основной проход парсинга:** + +```typescript +async parseBoundaries(filePath: string): Promise { + // Check cache + const cached = this.cache.get(filePath); + if (cached && cached.expiresAt > Date.now()) return cached.data; + + const boundaries: TaskBoundary[] = []; + const allToolUsesByLine = new Map(); + let lineNumber = 0; + let detectedMechanism: 'TaskUpdate' | 'teamctl' | 'none' = 'none'; + + const stream = createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + + for await (const line of rl) { + lineNumber++; + const trimmed = line.trim(); + if (!trimmed) continue; + + try { + const entry = JSON.parse(trimmed) as Record; + const timestamp = typeof entry.timestamp === 'string' ? entry.timestamp : ''; + + // Extract content array + const content = this.extractContent(entry); + if (!Array.isArray(content)) continue; + + // Collect ALL tool_use blocks (for scope tracking) + for (const block of content) { + if (!block || typeof block !== 'object') continue; + const b = block as Record; + if (b.type !== 'tool_use') continue; + + const rawName = typeof b.name === 'string' ? b.name : ''; + const toolName = rawName.replace(/^proxy_/, ''); + const toolUseId = typeof b.id === 'string' ? b.id : ''; + const input = b.input as Record | undefined; + const fp = typeof input?.file_path === 'string' ? input.file_path : undefined; + + if (!allToolUsesByLine.has(lineNumber)) allToolUsesByLine.set(lineNumber, []); + allToolUsesByLine.get(lineNumber)!.push({ toolUseId, toolName, filePath: fp }); + } + + // Try TaskUpdate extraction + const taskUpdateBounds = this.extractTaskUpdateBoundaries(content, lineNumber, timestamp); + if (taskUpdateBounds.length > 0) { + detectedMechanism = 'TaskUpdate'; + boundaries.push(...taskUpdateBounds); + continue; // Skip teamctl check (never mixed) + } + + // Try teamctl extraction + const teamctlBounds = this.extractTeamctlBoundaries(content, lineNumber, timestamp); + if (teamctlBounds.length > 0) { + detectedMechanism = 'teamctl'; + boundaries.push(...teamctlBounds); + } + } catch { + // Skip malformed lines + } + } + + rl.close(); + stream.destroy(); + + // Determine scopes from boundaries + const scopes = this.computeScopes(boundaries, allToolUsesByLine, lineNumber); + const uniqueTaskIds = new Set(boundaries.map(b => b.taskId)); + const isSingleTaskSession = uniqueTaskIds.size <= 1; + + const result: TaskBoundariesResult = { + boundaries, + scopes, + isSingleTaskSession, + detectedMechanism, + }; + + this.cache.set(filePath, { data: result, expiresAt: Date.now() + this.CACHE_TTL }); + return result; +} +``` + +**Вычисление scopes:** + +```typescript +private computeScopes( + boundaries: TaskBoundary[], + allToolUses: Map, + totalLines: number +): TaskChangeScope[] { + // Группируем по taskId + const byTask = new Map(); + for (const b of boundaries) { + if (!byTask.has(b.taskId)) byTask.set(b.taskId, []); + byTask.get(b.taskId)!.push(b); + } + + const scopes: TaskChangeScope[] = []; + + for (const [taskId, taskBounds] of byTask) { + const starts = taskBounds.filter(b => b.event === 'start').sort((a, b) => a.lineNumber - b.lineNumber); + const ends = taskBounds.filter(b => b.event === 'complete').sort((a, b) => a.lineNumber - b.lineNumber); + + let startLine: number; + let endLine: number; + let confidence: TaskScopeConfidence; + + if (starts.length > 0 && ends.length > 0) { + // Tier 1: Оба маркера найдены + startLine = starts[0].lineNumber; + endLine = ends[ends.length - 1].lineNumber; + confidence = { + tier: 1, + label: 'high', + reason: `Found ${starts.length} start + ${ends.length} complete markers via ${starts[0].mechanism}`, + }; + } else if (starts.length > 0) { + // Tier 2: Только start (задача ещё не завершена или маркер потерян) + startLine = starts[0].lineNumber; + endLine = totalLines; + confidence = { + tier: 2, + label: 'medium', + reason: `Found start marker but no completion. Using end of file.`, + }; + } else if (ends.length > 0) { + // Tier 3: Только end (start потерян) + startLine = 1; + endLine = ends[ends.length - 1].lineNumber; + confidence = { + tier: 3, + label: 'low', + reason: `Found completion marker but no start. Using beginning of file.`, + }; + } else { + // Tier 4: Нет маркеров (не должно случаться если boundaries найдены) + continue; + } + + // Collect tool_use IDs in range + const toolUseIds: string[] = []; + const filePaths = new Set(); + + for (const [line, tools] of allToolUses) { + if (line >= startLine && line <= endLine) { + for (const t of tools) { + // Только file-modifying tools + if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(t.toolName)) { + toolUseIds.push(t.toolUseId); + if (t.filePath) filePaths.add(t.filePath); + } + } + } + } + + scopes.push({ + taskId, + memberName: '', // Заполняется вызывающим кодом из member attribution + startLine, + endLine, + startTimestamp: starts[0]?.timestamp ?? ends[0]?.timestamp ?? '', + endTimestamp: ends[ends.length - 1]?.timestamp ?? starts[starts.length - 1]?.timestamp ?? '', + toolUseIds, + filePaths: [...filePaths], + confidence, + }); + } + + return scopes; +} +``` + +**extractContent helper (паттерн из MemberStatsComputer):** + +```typescript +// Subagent JSONL: entry.message.content (массив блоков) +// Main JSONL: entry.content (массив блоков) +private extractContent(entry: Record): unknown[] | null { + // Subagent format + const message = entry.message as Record | undefined; + if (message && Array.isArray(message.content)) { + return message.content; + } + // Main format fallback + if (Array.isArray(entry.content)) { + return entry.content; + } + return null; +} +``` + +### 3. Модификация: `src/main/services/team/ChangeExtractorService.ts` (MODIFY) + +Phase 1 создал `getTaskChanges()` с keyword-based scoping. Phase 3 заменяет на structure-based: + +```typescript +// Phase 1 (заменяем) +async getTaskChanges(teamName: string, taskId: string): Promise { + // Keyword search через logsFinder.findLogsForTask() +} + +// Phase 3 (новая реализация) +async getTaskChanges(teamName: string, taskId: string): Promise { + // 1. Найти JSONL файлы через logsFinder + const logs = await this.logsFinder.findLogsForTask(teamName, taskId); + + // 2. Для каждого JSONL — парсить boundaries через TaskBoundaryParser + const allScopes: TaskChangeScope[] = []; + for (const log of logs) { + const boundaries = await this.boundaryParser.parseBoundaries(log.filePath); + const scope = boundaries.scopes.find(s => s.taskId === taskId); + if (scope) { + scope.memberName = log.memberName; + allScopes.push(scope); + } + } + + // 3. Если нет structural scopes → fallback на single-task assumption + if (allScopes.length === 0) { + return this.fallbackSingleTaskScope(teamName, taskId, logs); + } + + // 4. Фильтровать snippets по tool_use IDs из scope + const allowedToolUseIds = new Set(allScopes.flatMap(s => s.toolUseIds)); + const files = await this.extractFilteredChanges(logs, allowedToolUseIds); + + // 5. Compute confidence (worst case across all scopes) + const worstTier = Math.max(...allScopes.map(s => s.confidence.tier)); + + const warnings: string[] = []; + if (worstTier >= 3) { + warnings.push('Some task boundaries could not be precisely determined.'); + } + + return { + teamName, + taskId, + files, + totalLinesAdded: files.reduce((sum, f) => sum + f.linesAdded, 0), + totalLinesRemoved: files.reduce((sum, f) => sum + f.linesRemoved, 0), + totalFiles: files.length, + confidence: worstTier <= 1 ? 'high' : worstTier <= 2 ? 'medium' : 'low', + computedAt: new Date().toISOString(), + scope: allScopes[0], // Primary scope + warnings, + }; +} +``` + +**Fallback для single-task sessions (86% случаев):** + +```typescript +private async fallbackSingleTaskScope( + teamName: string, + taskId: string, + logs: MemberLogSummary[] +): Promise { + // Проверяем: если agent работал только над одной задачей, + // ВСЕ изменения в сессии = изменения этой задачи + + for (const log of logs) { + const boundaries = await this.boundaryParser.parseBoundaries(log.filePath); + + if (boundaries.isSingleTaskSession) { + // Весь файл = одна задача → extract все changes + const files = await this.extractAllChanges(log.filePath); + return { + teamName, + taskId, + files, + totalLinesAdded: files.reduce((sum, f) => sum + f.linesAdded, 0), + totalLinesRemoved: files.reduce((sum, f) => sum + f.linesRemoved, 0), + totalFiles: files.length, + confidence: 'high', + computedAt: new Date().toISOString(), + scope: { + taskId, + memberName: log.memberName, + startLine: 1, + endLine: Infinity, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: [], + confidence: { tier: 1, label: 'high', reason: 'Single-task session (entire session = task)' }, + }, + warnings: [], + }; + } + } + + // No single-task session found → Tier 4 fallback + const files = await this.extractAllChanges(logs[0]?.filePath ?? ''); + return { + teamName, + taskId, + files, + totalLinesAdded: files.reduce((sum, f) => sum + f.linesAdded, 0), + totalLinesRemoved: files.reduce((sum, f) => sum + f.linesRemoved, 0), + totalFiles: files.length, + confidence: 'low', + computedAt: new Date().toISOString(), + scope: { + taskId, + memberName: logs[0]?.memberName ?? 'unknown', + startLine: 1, + endLine: Infinity, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: [], + confidence: { tier: 4, label: 'fallback', reason: 'No task markers found. Showing all session changes.' }, + }, + warnings: ['Could not determine task boundaries. Showing all changes from this session.'], + }; +} +``` + +### 4. Модификация: `src/main/services/team/TeamMemberLogsFinder.ts` (MODIFY) + +Добавляем новый метод для быстрого определения: есть ли TaskUpdate маркеры в файле. + +```typescript +/** + * Быстрая проверка: содержит ли JSONL файл TaskUpdate маркеры для задачи. + * Быстрее чем полный parseBoundaries() — сканирует до первого совпадения. + */ +async hasTaskUpdateMarker(filePath: string, taskId: string): Promise { + const stream = createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + + const pattern = new RegExp(`"taskId"\\s*:\\s*"${taskId}"`); + + for await (const line of rl) { + if (line.includes('TaskUpdate') && pattern.test(line)) { + rl.close(); + stream.destroy(); + return true; + } + if (line.includes('teamctl') && line.includes(`task`) && line.includes(taskId)) { + rl.close(); + stream.destroy(); + return true; + } + } + + rl.close(); + stream.destroy(); + return false; +} +``` + +**Оптимизация `findLogsForTask()`:** + +Текущий метод использует `fileMentionsTaskId()` — keyword search. Phase 3 добавляет приоритетный path: + +```typescript +async findLogsForTask( + teamName: string, + taskId: string, + options?: { owner?: string; status?: string } +): Promise { + // Phase 3: Сначала пробуем structural markers (быстрее и точнее) + const allLogs = await this.getAllSessionLogs(teamName); + const results: MemberLogSummary[] = []; + + for (const log of allLogs) { + // Fast path: check for TaskUpdate markers + const hasMarker = await this.hasTaskUpdateMarker(log.filePath, taskId); + if (hasMarker) { + results.push(log); + continue; + } + + // Fallback: keyword search (Phase 1 behaviour) + if (await this.fileMentionsTaskId(log.filePath, taskId)) { + results.push(log); + } + } + + return results.sort((a, b) => + (b.startTime ?? '').localeCompare(a.startTime ?? '') + ); +} +``` + +### 5. IPC (без изменений) + +Phase 1 уже определил `REVIEW_GET_TASK_CHANGES`. Phase 3 не добавляет новых каналов — только улучшает backend точность. + +### 6. Preload bridge (без изменений) + +Тип `TaskChangeSet` расширяется до `TaskChangeSetV2` (backwards compatible через extends). + +--- + +## Frontend + +### 7. Компоненты + +#### `src/renderer/components/team/review/ConfidenceBadge.tsx` (NEW) + +Показывает уровень уверенности в scope задачи. + +```typescript +interface ConfidenceBadgeProps { + confidence: TaskScopeConfidence; + /** Показать tooltip с деталями */ + showTooltip?: boolean; +} + +export function ConfidenceBadge({ confidence, showTooltip = true }: ConfidenceBadgeProps) { + const colors = { + 1: 'bg-green-500/20 text-green-400 border-green-500/30', // High + 2: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30', // Medium + 3: 'bg-orange-500/20 text-orange-400 border-orange-500/30', // Low + 4: 'bg-red-500/20 text-red-400 border-red-500/30', // Fallback + }; + + const labels = { + 1: 'High confidence', + 2: 'Medium confidence', + 3: 'Low confidence', + 4: 'Best effort', + }; + + return ( + + {labels[confidence.tier]} + + ); +} +``` + +#### `src/renderer/components/team/review/ScopeWarningBanner.tsx` (NEW) + +Баннер предупреждений для low-confidence scopes. + +```typescript +interface ScopeWarningBannerProps { + warnings: string[]; + confidence: TaskScopeConfidence; + onDismiss?: () => void; +} + +export function ScopeWarningBanner({ warnings, confidence, onDismiss }: ScopeWarningBannerProps) { + if (warnings.length === 0 && confidence.tier <= 2) return null; + + return ( +
+ +
+

+ {confidence.tier >= 3 + ? 'Task boundary detection is approximate' + : 'Note about these changes'} +

+ {warnings.map((w, i) => ( +

{w}

+ ))} +

+ Detection: {confidence.reason} +

+
+ {onDismiss && ( + + )} +
+ ); +} +``` + +### 8. Модификация существующих компонентов + +#### `ChangeReviewDialog.tsx` (MODIFY) + +Добавляем scope information в header: + +```typescript +// В header диалога (рядом с title) +{mode === 'task' && activeChangeSet && 'scope' in activeChangeSet && ( +
+ + {activeChangeSet.warnings.length > 0 && ( + + )} +
+)} +``` + +#### `KanbanTaskCard.tsx` (MODIFY) + +Для задач в done/review/approved показываем confidence tier: + +```typescript +// В footer карточки +{(columnId === 'done' || columnId === 'review' || columnId === 'approved') && ( +
+ + {/* ChangeStatsBadge уже из Phase 1 */} + +
+)} +``` + +--- + +## Confidence Tiers — детальное описание + +### Tier 1: High (95%+) — 86% сессий + +**Условие**: Найдены оба маркера (start + end) ИЛИ single-task session. + +**Сценарии:** +- `TaskUpdate(taskId=5, status=in_progress)` на строке 42 + `TaskUpdate(taskId=5, status=completed)` на строке 318 +- Session имеет только 1 уникальный taskId → весь файл = одна задача + +**Scope**: Строки [startLine, endLine] — все tool_use в этом диапазоне. + +### Tier 2: Medium (90%) — ~8% сессий + +**Условие**: Только start-маркер (задача ещё не завершена) ИЛИ batch completion. + +**Сценарии:** +- Agent начал задачу 5, но crash/disconnect до completion +- Agent работает над 3 задачами последовательно, все complete в batch + +**Scope**: [startLine, endOfFile] или [startLine, nextTaskStart]. + +### Tier 3: Low (80%) — ~4% сессий + +**Условие**: Только end-маркер (start потерян). + +**Сценарии:** +- Agent начал задачу до того как TeamCreate/TaskUpdate были доступны +- Начало было в другой сессии + +**Scope**: [1, endLine] — от начала файла до completion marker. + +### Tier 4: Fallback (70%) — ~2% сессий + +**Условие**: Нет структурных маркеров. Используем keyword search + owner attribution. + +**Сценарии:** +- Очень старые сессии без TaskUpdate support +- Agent использовал нестандартный workflow + +**Scope**: Весь файл, с пометкой "best effort". + +--- + +## Алгоритм multi-task sessions + +Для сессий где agent работает над несколькими задачами последовательно: + +``` +JSONL Timeline: + Line 1-30: Setup, team init + Line 31: TaskUpdate(taskId=3, status=in_progress) ← Task 3 START + Line 32-150: Edit, Write, Read operations for task 3 + Line 151: TaskUpdate(taskId=3, status=completed) ← Task 3 END + Line 152: TaskUpdate(taskId=7, status=in_progress) ← Task 7 START + Line 153-280: Edit, Write operations for task 7 + Line 281: TaskUpdate(taskId=7, status=completed) ← Task 7 END + Line 282-300: Cleanup, idle +``` + +**Scope для Task 3**: Lines [31, 151] → tool_use IDs из строк 32-150 +**Scope для Task 7**: Lines [152, 281] → tool_use IDs из строк 153-280 + +**Overlap handling**: Если границы перекрываются (редко), tool_use приписывается ближайшему start-маркеру. + +--- + +## Файлы + +| Файл | Тип | ~LOC | +|------|-----|---:| +| `src/shared/types/review.ts` | MODIFY | +80 | +| `src/main/services/team/TaskBoundaryParser.ts` | NEW | 350 | +| `src/main/services/team/ChangeExtractorService.ts` | MODIFY | +150 | +| `src/main/services/team/TeamMemberLogsFinder.ts` | MODIFY | +40 | +| `src/main/services/team/index.ts` | MODIFY | +1 | +| `src/renderer/components/team/review/ConfidenceBadge.tsx` | NEW | 45 | +| `src/renderer/components/team/review/ScopeWarningBanner.tsx` | NEW | 50 | +| `src/renderer/components/team/review/ChangeReviewDialog.tsx` | MODIFY | +20 | +| `src/renderer/components/team/kanban/KanbanTaskCard.tsx` | MODIFY | +15 | +| **Итого** | 3 NEW + 6 MODIFY | ~750 | + +--- + +## Edge Cases + +1. **Задача работает в нескольких сессиях** — собираем scopes из всех JSONL файлов, merge tool_use IDs +2. **Один agent работает над 5+ задачами** — каждая задача имеет свой scope window, boundaries не перекрываются (confirmed на реальных данных) +3. **Agent делает TaskUpdate(in_progress) дважды подряд** — берём первый start, игнорируем повторный +4. **TaskUpdate(completed) без start** — Tier 3, scope от начала файла +5. **teamctl с set-status вместо start/complete** — парсим дополнительный аргумент (in_progress/completed) +6. **JSONL файл повреждён (обрезанные строки)** — try/catch skip, graceful degradation +7. **Очень длинные JSONL (>100MB)** — streaming readline, O(n) memory, no full-file load +8. **Numeric task IDs vs string** — всегда конвертируем в string для сравнения +9. **proxy_ prefix на tool names** — strip как в MemberStatsComputer (`.replace(/^proxy_/, '')`) +10. **tool_result с is_error: true** — пропускаем (Phase 1 rule), но boundary marker от tool_use всё равно учитываем + +## Тестирование + +- Unit test для `TaskBoundaryParser.parseBoundaries()` — mock JSONL с TaskUpdate markers +- Unit test для `TaskBoundaryParser.extractTeamctlBoundaries()` — различные teamctl formats +- Unit test для `computeScopes()` — single-task, multi-task, missing markers +- Unit test для Tier classification — все 4 тиера +- Unit test для `ChangeExtractorService.getTaskChanges()` — integration с boundary parser +- Unit test для `TeamMemberLogsFinder.hasTaskUpdateMarker()` — fast path detection +- Regression test: результаты Phase 3 должны быть superset Phase 1 (не потерять данные) +- Manual test с реальными сессиями из `~/.claude/projects/` — проверить Tier 1-4 distribution diff --git a/docs/iterations/diff-view/phase-4-enhanced-features.md b/docs/iterations/diff-view/phase-4-enhanced-features.md new file mode 100644 index 00000000..5bd7fdb3 --- /dev/null +++ b/docs/iterations/diff-view/phase-4-enhanced-features.md @@ -0,0 +1,792 @@ +# Phase 4: Enhanced Features + +## Цель +Качественные улучшения UX diff view: клавиатурная навигация между hunks, отслеживание "просмотренных" файлов, timeline изменений файла, git fallback для случаев когда JSONL данные неполные. + +--- + +## Feature 1: Keyboard Navigation + +### Цель +Навигация по hunks и файлам через клавиатуру (как в GitHub PR review). `j`/`k` или `↑`/`↓` для перехода между hunks, `n`/`p` для перехода между файлами. + +### Реализация + +#### Hook: `src/renderer/hooks/useDiffNavigation.ts` (NEW) + +```typescript +interface DiffNavigationState { + /** Текущий hunk index в выбранном файле */ + currentHunkIndex: number; + /** Общее количество hunks в файле */ + totalHunks: number; + /** Перейти к следующему hunk */ + goToNextHunk: () => void; + /** Перейти к предыдущему hunk */ + goToPrevHunk: () => void; + /** Перейти к следующему файлу */ + goToNextFile: () => void; + /** Перейти к предыдущему файлу */ + goToPrevFile: () => void; + /** Перейти к конкретному hunk */ + goToHunk: (index: number) => void; + /** Accept текущий hunk */ + acceptCurrentHunk: () => void; + /** Reject текущий hunk */ + rejectCurrentHunk: () => void; +} + +export function useDiffNavigation( + files: FileChangeSummary[], + selectedFilePath: string | null, + onSelectFile: (path: string) => void, + onHunkAccepted?: (filePath: string, hunkIndex: number) => void, + onHunkRejected?: (filePath: string, hunkIndex: number) => void, +): DiffNavigationState; +``` + +**Ключевые shortcuts:** + +| Key | Action | Context | +|-----|--------|---------| +| `j` или `↓` | Next hunk | Diff view focused | +| `k` или `↑` | Previous hunk | Diff view focused | +| `n` | Next file | Any | +| `p` или `Shift+N` | Previous file | Any | +| `a` | Accept current hunk | Hunk focused | +| `x` | Reject current hunk | Hunk focused | +| `Shift+A` | Accept all hunks in file | File selected | +| `Shift+X` | Reject all hunks in file | File selected | +| `Enter` | Toggle hunk collapse | Hunk focused | +| `Escape` | Close diff dialog | Any | + +**Реализация через existing pattern (useKeyboardShortcuts.ts):** + +```typescript +useEffect(() => { + if (!isDialogOpen) return; + + const handler = (event: KeyboardEvent) => { + // Не перехватываем если фокус в input/textarea + if ( + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement + ) return; + + switch (event.key) { + case 'j': + case 'ArrowDown': + event.preventDefault(); + goToNextHunk(); + break; + case 'k': + case 'ArrowUp': + event.preventDefault(); + goToPrevHunk(); + break; + case 'n': + event.preventDefault(); + goToNextFile(); + break; + case 'p': + event.preventDefault(); + goToPrevFile(); + break; + case 'a': + if (!event.shiftKey) { + event.preventDefault(); + acceptCurrentHunk(); + } else { + event.preventDefault(); + acceptAllFile(); + } + break; + case 'x': + if (!event.shiftKey) { + event.preventDefault(); + rejectCurrentHunk(); + } else { + event.preventDefault(); + rejectAllFile(); + } + break; + case 'Escape': + event.preventDefault(); + onClose(); + break; + } + }; + + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); +}, [isDialogOpen, currentHunkIndex, selectedFilePath]); +``` + +**Scroll-to-hunk через CodeMirror API:** + +```typescript +// @codemirror/merge предоставляет goToNextChunk / goToPreviousChunk +import { goToNextChunk, goToPreviousChunk } from '@codemirror/merge'; + +function scrollToHunk(editorView: EditorView, direction: 'next' | 'prev') { + if (direction === 'next') { + goToNextChunk(editorView); + } else { + goToPreviousChunk(editorView); + } +} +``` + +#### Компонент: `src/renderer/components/team/review/KeyboardShortcutsHelp.tsx` (NEW) + +Всплывающая подсказка с shortcut list (показывается по `?`). + +```typescript +interface KeyboardShortcutsHelpProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} +``` + +**~40 LOC**: Простая таблица с иконками клавиш и описаниями. + +--- + +## Feature 2: "Viewed" File Tracking + +### Цель +Пользователь может отметить файл как "просмотренный" (как в GitHub). Состояние сохраняется в localStorage. + +### Реализация + +#### Storage: `src/renderer/utils/diffViewedStorage.ts` (NEW) + +**Паттерн**: Повторяет `teamMessageReadStorage.ts` — простой localStorage с JSON serialization. + +```typescript +const STORAGE_PREFIX = 'diff-viewed'; + +/** + * Ключ = `diff-viewed:{teamName}:{taskOrMemberKey}`. + * Значение = JSON array of viewed file paths. + */ +function getStorageKey(teamName: string, scopeKey: string): string { + return `${STORAGE_PREFIX}:${teamName}:${scopeKey}`; +} + +/** Получить Set просмотренных файлов */ +export function getViewedFiles(teamName: string, scopeKey: string): Set { + try { + const raw = localStorage.getItem(getStorageKey(teamName, scopeKey)); + if (!raw) return new Set(); + const arr = JSON.parse(raw) as string[]; + return new Set(arr); + } catch { + return new Set(); + } +} + +/** Отметить файл как просмотренный */ +export function markFileViewed(teamName: string, scopeKey: string, filePath: string): void { + const set = getViewedFiles(teamName, scopeKey); + set.add(filePath); + localStorage.setItem(getStorageKey(teamName, scopeKey), JSON.stringify([...set])); +} + +/** Отметить файл как НЕ просмотренный */ +export function unmarkFileViewed(teamName: string, scopeKey: string, filePath: string): void { + const set = getViewedFiles(teamName, scopeKey); + set.delete(filePath); + localStorage.setItem(getStorageKey(teamName, scopeKey), JSON.stringify([...set])); +} + +/** Отметить все файлы как просмотренные */ +export function markAllViewed(teamName: string, scopeKey: string, filePaths: string[]): void { + localStorage.setItem(getStorageKey(teamName, scopeKey), JSON.stringify(filePaths)); +} + +/** Сбросить все отметки */ +export function clearViewed(teamName: string, scopeKey: string): void { + localStorage.removeItem(getStorageKey(teamName, scopeKey)); +} +``` + +#### Hook: `src/renderer/hooks/useViewedFiles.ts` (NEW) + +```typescript +import { useState, useCallback, useMemo } from 'react'; +import * as storage from '@renderer/utils/diffViewedStorage'; + +interface UseViewedFilesResult { + viewedSet: Set; + isViewed: (filePath: string) => boolean; + markViewed: (filePath: string) => void; + unmarkViewed: (filePath: string) => void; + markAllViewed: (filePaths: string[]) => void; + clearAll: () => void; + viewedCount: number; + totalCount: number; + /** Прогресс 0-100 */ + progress: number; +} + +export function useViewedFiles( + teamName: string, + scopeKey: string, + totalFiles: string[] +): UseViewedFilesResult { + // version bump pattern (из useTeamMessagesRead) + const [version, setVersion] = useState(0); + + const viewedSet = useMemo(() => { + if (version < 0) return new Set(); + return storage.getViewedFiles(teamName, scopeKey); + }, [teamName, scopeKey, version]); + + const markViewed = useCallback((filePath: string) => { + storage.markFileViewed(teamName, scopeKey, filePath); + setVersion(v => v + 1); + }, [teamName, scopeKey]); + + const unmarkViewed = useCallback((filePath: string) => { + storage.unmarkFileViewed(teamName, scopeKey, filePath); + setVersion(v => v + 1); + }, [teamName, scopeKey]); + + const markAllViewed = useCallback((filePaths: string[]) => { + storage.markAllViewed(teamName, scopeKey, filePaths); + setVersion(v => v + 1); + }, [teamName, scopeKey]); + + const clearAll = useCallback(() => { + storage.clearViewed(teamName, scopeKey); + setVersion(v => v + 1); + }, [teamName, scopeKey]); + + const viewedCount = totalFiles.filter(f => viewedSet.has(f)).length; + + return { + viewedSet, + isViewed: (fp) => viewedSet.has(fp), + markViewed, + unmarkViewed, + markAllViewed, + clearAll, + viewedCount, + totalCount: totalFiles.length, + progress: totalFiles.length > 0 ? Math.round((viewedCount / totalFiles.length) * 100) : 0, + }; +} +``` + +#### Компонент: `src/renderer/components/team/review/ViewedProgressBar.tsx` (NEW) + +```typescript +interface ViewedProgressBarProps { + viewed: number; + total: number; + progress: number; +} +``` + +Тонкий progress bar в header ChangeReviewDialog: +``` +[████████░░░░░░░░░░] 5/12 files viewed (42%) +``` + +#### Интеграция в `ReviewFileTree.tsx` (MODIFY) + +Checkbox рядом с каждым файлом: + +```typescript +
+ { + if (e.target.checked) markViewed(file.filePath); + else unmarkViewed(file.filePath); + }} + className="rounded border-border" + aria-label={`Mark ${file.relativePath} as viewed`} + /> + + {file.relativePath} + +
+``` + +**Auto-mark**: Файл автоматически помечается viewed когда пользователь прокрутил весь diff до конца (через IntersectionObserver на последний hunk). + +--- + +## Feature 3: File Edit Timeline + +### Цель +Показать хронологию изменений файла в рамках задачи: какие Edit/Write операции произошли, в каком порядке, с какими tool_use. + +### Реализация + +#### Типы: `src/shared/types/review.ts` (MODIFY) + +```typescript +/** Одно событие в timeline файла */ +export interface FileEditEvent { + /** tool_use.id */ + toolUseId: string; + /** Тип операции */ + toolName: 'Edit' | 'Write' | 'MultiEdit' | 'NotebookEdit'; + /** Timestamp из JSONL */ + timestamp: string; + /** Краткое описание: "Edited 3 lines", "Created new file", etc */ + summary: string; + /** +/- строк */ + linesAdded: number; + linesRemoved: number; + /** Индекс snippet в FileChangeSummary.snippets[] */ + snippetIndex: number; +} + +/** Timeline для файла */ +export interface FileEditTimeline { + filePath: string; + events: FileEditEvent[]; + /** Общая длительность (first event → last event) */ + durationMs: number; +} +``` + +#### Backend: `ChangeExtractorService.ts` (MODIFY — добавить timeline generation) + +Timeline генерируется автоматически при `getAgentChanges()` / `getTaskChanges()`: + +```typescript +// При сборе snippets — также записываем timeline events +private buildTimeline(snippets: SnippetDiff[]): FileEditEvent[] { + return snippets.map((s, idx) => ({ + toolUseId: s.toolUseId, + toolName: s.toolName, + timestamp: s.timestamp, + summary: this.generateEditSummary(s), + linesAdded: Math.max(0, s.newString.split('\n').length - s.oldString.split('\n').length), + linesRemoved: Math.max(0, s.oldString.split('\n').length - s.newString.split('\n').length), + snippetIndex: idx, + })); +} + +private generateEditSummary(snippet: SnippetDiff): string { + switch (snippet.type) { + case 'write-new': return 'Created new file'; + case 'write-update': return 'Wrote full file content'; + case 'multi-edit': return `Multi-edit (${snippet.oldString.split('\n').length} lines)`; + case 'edit': { + const added = snippet.newString.split('\n').length; + const removed = snippet.oldString.split('\n').length; + if (removed === 0) return `Added ${added} line${added !== 1 ? 's' : ''}`; + if (added === 0) return `Removed ${removed} line${removed !== 1 ? 's' : ''}`; + return `Changed ${removed} → ${added} lines`; + } + default: return 'File modified'; + } +} +``` + +#### Компонент: `src/renderer/components/team/review/FileEditTimeline.tsx` (NEW) + +**Паттерн**: Визуально похож на `ActivityItem.tsx` — вертикальная timeline с цветными точками. + +```typescript +interface FileEditTimelineProps { + timeline: FileEditTimeline; + /** Клик по event → scroll к snippet в diff view */ + onEventClick?: (snippetIndex: number) => void; + /** Текущий highlighted event */ + activeSnippetIndex?: number; +} +``` + +**Layout:** +``` + ● 10:23:45 Created new file [+42] + │ + ● 10:24:12 Changed 5 → 8 lines [+3] + │ + ● 10:25:01 Multi-edit (12 lines) [+2 -3] + │ + ● 10:26:33 Added 15 lines [+15] +``` + +**~120 LOC**: Timeline items с timestamp, summary, +/- badge, clickable. + +#### Интеграция в `ChangeReviewDialog.tsx` (MODIFY) + +Timeline показывается в sidebar под file tree (collapsible section): + +```typescript +// Под ReviewFileTree +{selectedFile && ( +
+ + {timelineOpen && ( + scrollToSnippet(idx)} + activeSnippetIndex={currentHunkIndex} + /> + )} +
+)} +``` + +--- + +## Feature 4: Git Fallback + +### Цель +Когда JSONL данные неполные (Write без original, повреждённый файл) — использовать git для получения diff информации. + +### Реализация + +#### Backend: `src/main/services/team/GitDiffFallback.ts` (NEW) + +```typescript +import { execFile } from 'child_process'; +import { promisify } from 'util'; + +const execFileAsync = promisify(execFile); + +export class GitDiffFallback { + /** + * Получить содержимое файла из конкретного коммита. + * Используется когда file-history-snapshot недоступен. + */ + async getFileAtCommit( + projectPath: string, + filePath: string, + commitHash: string + ): Promise { + try { + const relativePath = filePath.replace(projectPath + '/', ''); + const { stdout } = await execFileAsync('git', [ + 'show', `${commitHash}:${relativePath}` + ], { + cwd: projectPath, + maxBuffer: 10 * 1024 * 1024, // 10MB + }); + return stdout; + } catch { + return null; // File didn't exist at that commit + } + } + + /** + * Найти коммит ближайший к timestamp. + * Используется для определения "original" состояния файла. + */ + async findCommitNearTimestamp( + projectPath: string, + filePath: string, + timestamp: string + ): Promise { + try { + const relativePath = filePath.replace(projectPath + '/', ''); + const { stdout } = await execFileAsync('git', [ + 'log', '--format=%H', '--before', timestamp, + '-1', '--', relativePath + ], { cwd: projectPath }); + return stdout.trim() || null; + } catch { + return null; + } + } + + /** + * Получить git diff для файла между двумя точками. + * Fallback когда JSONL snippet chain неполный. + */ + async getGitDiff( + projectPath: string, + filePath: string, + fromCommit: string, + toCommit: string = 'HEAD' + ): Promise { + try { + const relativePath = filePath.replace(projectPath + '/', ''); + const { stdout } = await execFileAsync('git', [ + 'diff', fromCommit, toCommit, '--', relativePath + ], { cwd: projectPath }); + return stdout || null; + } catch { + return null; + } + } + + /** + * Получить историю изменений файла (для timeline enrichment). + */ + async getFileLog( + projectPath: string, + filePath: string, + maxCount: number = 20 + ): Promise> { + try { + const relativePath = filePath.replace(projectPath + '/', ''); + const { stdout } = await execFileAsync('git', [ + 'log', `--max-count=${maxCount}`, + '--format=%H|%aI|%s', + '--', relativePath + ], { cwd: projectPath }); + + return stdout.trim().split('\n') + .filter(line => line.includes('|')) + .map(line => { + const [hash, timestamp, ...msgParts] = line.split('|'); + return { hash, timestamp, message: msgParts.join('|') }; + }); + } catch { + return []; + } + } + + /** + * Проверить: является ли projectPath git repo. + */ + async isGitRepo(projectPath: string): Promise { + try { + await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], { + cwd: projectPath, + }); + return true; + } catch { + return false; + } + } +} +``` + +**Интеграция с существующим `GitIdentityResolver`:** + +```typescript +// GitIdentityResolver уже имеет getBranch() и worktree detection. +// GitDiffFallback добавляет file-level операции. +// Оба используют execFile('git', ...) — одинаковый паттерн. +``` + +#### Модификация: `FileContentResolver.ts` (MODIFY — Phase 2 + Phase 4) + +Добавляем git fallback как третий уровень: + +```typescript +async resolveFileContent( + teamName: string, + memberName: string, + filePath: string +): Promise<...> { + // Level 1: file-history-snapshot backup + const backup = await this.tryFileHistoryBackup(filePath); + if (backup) return { ...backup, source: 'file-history' }; + + // Level 2: Snippet chain reconstruction + const snippetResult = await this.trySnippetReconstruction(memberName, filePath); + if (snippetResult) return { ...snippetResult, source: 'snippet-reconstruction' }; + + // Level 3 (Phase 4): Git fallback + const gitResult = await this.tryGitFallback(filePath); + if (gitResult) return { ...gitResult, source: 'git-fallback' }; + + // Level 4: Current disk (worst case) + return this.readCurrentDisk(filePath); +} + +private async tryGitFallback(filePath: string): Promise<...> { + const projectPath = this.getProjectPath(filePath); + if (!projectPath) return null; + + const isGit = await this.gitFallback.isGitRepo(projectPath); + if (!isGit) return null; + + // Найти ближайший коммит к первому изменению + const firstSnippetTimestamp = /* ... */; + const commitHash = await this.gitFallback.findCommitNearTimestamp( + projectPath, filePath, firstSnippetTimestamp + ); + if (!commitHash) return null; + + const original = await this.gitFallback.getFileAtCommit( + projectPath, filePath, commitHash + ); + if (!original) return null; + + // Modified = текущий файл на диске + const modified = await readFile(filePath, 'utf8'); + + return { original, modified }; +} +``` + +#### IPC: `src/preload/constants/ipcChannels.ts` (MODIFY) + +```typescript +// Phase 4 additions +export const REVIEW_GET_GIT_FILE_LOG = 'review:getGitFileLog'; +``` + +#### Preload: `src/preload/index.ts` (MODIFY) + +```typescript +review: { + // ... Phase 1-3 methods + + // Phase 4 + getGitFileLog: (projectPath: string, filePath: string) => + invokeIpcWithResult>( + REVIEW_GET_GIT_FILE_LOG, projectPath, filePath + ), +}, +``` + +--- + +## Feature 5: Auto-Viewed Detection + +### Цель +Автоматически помечать файл как "viewed" когда пользователь прокрутил diff до конца. + +### Реализация + +```typescript +// В CodeMirrorDiffView.tsx +const endSentinelRef = useRef(null); + +useEffect(() => { + if (!endSentinelRef.current) return; + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + // Файл просмотрен до конца + onFullyViewed?.(); + } + } + }, + { threshold: 1.0 } + ); + + observer.observe(endSentinelRef.current); + return () => observer.disconnect(); +}, [onFullyViewed]); + +// Sentinel element после CodeMirror editor +return ( +
+
{/* CodeMirror mount point */} +
{/* Invisible sentinel */} +
+); +``` + +**Настройка**: Авто-viewed можно отключить через toggle в ReviewToolbar. + +--- + +## Файлы + +| Файл | Тип | ~LOC | +|------|-----|---:| +| **Feature 1: Keyboard Navigation** | | | +| `src/renderer/hooks/useDiffNavigation.ts` | NEW | 120 | +| `src/renderer/components/team/review/KeyboardShortcutsHelp.tsx` | NEW | 40 | +| `src/renderer/components/team/review/CodeMirrorDiffView.tsx` | MODIFY | +30 | +| `src/renderer/components/team/review/ChangeReviewDialog.tsx` | MODIFY | +15 | +| **Feature 2: Viewed Tracking** | | | +| `src/renderer/utils/diffViewedStorage.ts` | NEW | 60 | +| `src/renderer/hooks/useViewedFiles.ts` | NEW | 80 | +| `src/renderer/components/team/review/ViewedProgressBar.tsx` | NEW | 35 | +| `src/renderer/components/team/review/ReviewFileTree.tsx` | MODIFY | +30 | +| `src/renderer/components/team/review/ChangeReviewDialog.tsx` | MODIFY | +20 | +| **Feature 3: Edit Timeline** | | | +| `src/shared/types/review.ts` | MODIFY | +30 | +| `src/main/services/team/ChangeExtractorService.ts` | MODIFY | +50 | +| `src/renderer/components/team/review/FileEditTimeline.tsx` | NEW | 120 | +| `src/renderer/components/team/review/ChangeReviewDialog.tsx` | MODIFY | +25 | +| **Feature 4: Git Fallback** | | | +| `src/main/services/team/GitDiffFallback.ts` | NEW | 180 | +| `src/main/services/team/FileContentResolver.ts` | MODIFY | +60 | +| `src/main/ipc/review.ts` | MODIFY | +20 | +| `src/preload/constants/ipcChannels.ts` | MODIFY | +1 | +| `src/preload/index.ts` | MODIFY | +5 | +| `src/main/services/team/index.ts` | MODIFY | +1 | +| **Feature 5: Auto-Viewed** | | | +| `src/renderer/components/team/review/CodeMirrorDiffView.tsx` | MODIFY | +25 | +| `src/renderer/components/team/review/ReviewToolbar.tsx` | MODIFY | +15 | +| **Итого** | 7 NEW + 14 MODIFY | ~960 | + +--- + +## Edge Cases + +### Keyboard Navigation +1. **Пустой файл (0 hunks)** — j/k no-op, показываем "No changes" +2. **Фокус в search input** — не перехватываем shortcuts +3. **Последний/первый hunk** — wrap-around или stop (настройка) +4. **Dialog закрыт** — все handlers disabled + +### Viewed Tracking +5. **localStorage full** — graceful catch, показываем toast warning +6. **Scope key collision** — включаем version hash в key для уникальности +7. **Файлы изменились после viewed** — сбрасываем viewed при новом computedAt +8. **Bulk mark viewed** — batch update localStorage (не per-file) + +### Edit Timeline +9. **Файл с 50+ edits** — виртуальный скроллинг не нужен (timeline compact), но добавляем "Show all" toggle при >20 +10. **Timestamp parsing error** — показываем "Unknown time" +11. **Одинаковые timestamps** — сортировка по lineNumber (порядок в JSONL) + +### Git Fallback +12. **Не git repo** — `isGitRepo()` возвращает false, skip git fallback +13. **Git binary not found** — catch ENOENT, log warning +14. **Shallow clone** — `git show` может не найти старый коммит, return null +15. **Uncommitted changes** — `getFileAtCommit('HEAD')` возвращает последний коммит, не рабочую копию +16. **File renamed** — git log --follow не используем (сложно), просто return null для старого пути +17. **Large files (>10MB)** — maxBuffer ограничивает, return null при error + +### Auto-Viewed +18. **Scroll fast past** — IntersectionObserver с threshold 1.0 требует полного показа sentinel +19. **Dialog resize** — observer автоматически пере-вычисляет +20. **CodeMirror collapsed sections** — sentinel всегда после editor, collapsed не влияет + +--- + +## Тестирование + +### Keyboard Navigation +- Unit test для `useDiffNavigation` — корректный index management, boundary handling +- Test: shortcuts не перехватываются когда фокус в input +- Test: Escape закрывает dialog + +### Viewed Tracking +- Unit test для `diffViewedStorage` — CRUD операции, edge cases +- Unit test для `useViewedFiles` — progress calculation, version bump +- Test: localStorage failure handling + +### Edit Timeline +- Unit test для `buildTimeline()` — summary generation, sorting +- Unit test для `generateEditSummary()` — все типы операций + +### Git Fallback +- Unit test для `GitDiffFallback` с mock execFile +- Test: isGitRepo false → skip +- Test: execFile error → return null +- Integration test: git fallback as last resort in FileContentResolver + +### Auto-Viewed +- Test: IntersectionObserver callback triggers markViewed +- Test: disable toggle prevents auto-marking diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 8a19c0db..fec1ff32 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -36,6 +36,7 @@ import { TEAM_UPDATE_TASK_STATUS, // eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design } from '@preload/constants/ipcChannels'; +import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban'; import { createLogger } from '@shared/utils/logger'; import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; import { type IpcMain, type IpcMainInvokeEvent } from 'electron'; @@ -713,7 +714,7 @@ function validateAttachments( if (estimatedBinarySize > MAX_ATTACHMENT_SIZE * 1.1) { return { valid: false, error: `Attachment "${a.filename}" data exceeds size limit` }; } - totalSize += a.size; + totalSize += Math.max(a.size, estimatedBinarySize); result.push({ id: a.id, filename: a.filename, @@ -995,8 +996,6 @@ async function handleUpdateKanban( }); } -const KANBAN_COLUMN_IDS: KanbanColumnId[] = ['todo', 'in_progress', 'done', 'review', 'approved']; - function validateKanbanColumnId( value: unknown ): { valid: true; value: KanbanColumnId } | { valid: false; error: string } { diff --git a/src/main/services/team/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts index 018540fe..b4579c5a 100644 --- a/src/main/services/team/TeamAgentToolsInstaller.ts +++ b/src/main/services/team/TeamAgentToolsInstaller.ts @@ -1,4 +1,5 @@ import { getToolsBasePath } from '@main/utils/pathDecoder'; +import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks'; import * as fs from 'fs'; import * as path from 'path'; @@ -492,11 +493,11 @@ async function main() { parts.push('\nInstructions:\n' + prompt); } parts.push( - '\n${'```'}info_for_agent', + '\n' + ${JSON.stringify(AGENT_BLOCK_OPEN)}, 'Update task status using:', 'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' + String(teamName) + ' task start ' + String(task.id), 'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' + String(teamName) + ' task complete ' + String(task.id), - '${'```'}' + ${JSON.stringify(AGENT_BLOCK_CLOSE)} ); sendInboxMessage(paths, teamName, { to: task.owner, diff --git a/src/main/services/team/TeamKanbanManager.ts b/src/main/services/team/TeamKanbanManager.ts index 186915c3..98647faf 100644 --- a/src/main/services/team/TeamKanbanManager.ts +++ b/src/main/services/team/TeamKanbanManager.ts @@ -1,4 +1,5 @@ import { getTeamsBasePath } from '@main/utils/pathDecoder'; +import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban'; import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs'; import * as path from 'path'; @@ -9,8 +10,6 @@ import type { KanbanColumnId, KanbanState, UpdateKanbanPatch } from '@shared/typ const logger = createLogger('Service:TeamKanbanManager'); -const KANBAN_COLUMN_IDS: KanbanColumnId[] = ['todo', 'in_progress', 'done', 'review', 'approved']; - function createDefaultState(teamName: string): KanbanState { return { teamName, diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index cec92301..72fc7505 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -79,16 +79,19 @@ export class TeamTaskReader { : ''; // Resolve createdAt: prefer JSON field, fallback to fs.stat let createdAt: string | undefined; + let updatedAt: string | undefined; if (typeof parsed.createdAt === 'string') { createdAt = parsed.createdAt; - } else { - try { - const stat = await fs.promises.stat(taskPath); + } + try { + const stat = await fs.promises.stat(taskPath); + if (!createdAt) { const bt = stat.birthtime.getTime(); createdAt = (bt > 0 ? stat.birthtime : stat.mtime).toISOString(); - } catch { - /* leave undefined */ } + updatedAt = stat.mtime.toISOString(); + } catch { + /* leave undefined */ } const task: TeamTask = { @@ -110,6 +113,7 @@ export class TeamTaskReader { ? (parsed.related as unknown[]).filter((id): id is string => typeof id === 'string') : undefined, createdAt, + updatedAt, projectPath: typeof parsed.projectPath === 'string' ? parsed.projectPath : undefined, comments: Array.isArray(parsed.comments) ? (parsed.comments as TaskComment[]).filter( diff --git a/src/renderer/components/layout/Sidebar.tsx b/src/renderer/components/layout/Sidebar.tsx index 1be73839..3b22e630 100644 --- a/src/renderer/components/layout/Sidebar.tsx +++ b/src/renderer/components/layout/Sidebar.tsx @@ -183,22 +183,28 @@ export const Sidebar = (): React.JSX.Element => { {/* Content: Tasks list or Sessions list */} +
diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index 2ecd4e5c..fae821df 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -37,7 +37,7 @@ export const SidebarTaskItem = ({ task }: SidebarTaskItemProps): React.JSX.Eleme ? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const) : (statusConfig[task.status] ?? statusConfig.pending); const StatusIcon = cfg.icon; - const dateLabel = formatTaskDate(task.createdAt); + const dateLabel = formatTaskDate(task.updatedAt ?? task.createdAt); return ( + <> + + + ) : null} {columnId === 'review' ? ( diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index c8e35d74..84f578ad 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -237,6 +237,23 @@ export const createTeamSlice: StateCreator = (set, reviewActionError: null, }); + // If this team is being provisioned right now, config.json doesn't exist yet. + // Stay in loading state — the provisioning progress callback will re-call + // selectTeam once config is written. + const isProvisioningNow = Object.values(get().provisioningRuns).some( + (run) => + run.teamName === teamName && + !['ready', 'disconnected', 'failed', 'cancelled'].includes(run.state) + ); + if (isProvisioningNow) { + set({ + selectedTeamLoading: true, + selectedTeamData: null, + selectedTeamError: null, + }); + return; + } + try { const data = await unwrapIpc('team:getData', () => api.teams.getData(teamName)); // Stale check: user may have switched to another team during the async call diff --git a/src/renderer/utils/pathNormalize.ts b/src/renderer/utils/pathNormalize.ts index c8bb0e8b..ba49d75c 100644 --- a/src/renderer/utils/pathNormalize.ts +++ b/src/renderer/utils/pathNormalize.ts @@ -2,7 +2,10 @@ import type { GlobalTask } from '@shared/types'; export function normalizePath(p: string): string { let s = p.replace(/\\/g, '/'); - while (s.endsWith('/')) s = s.slice(0, -1); + // Preserve root paths like "/" or "C:/" + if (s !== '/' && !/^[A-Za-z]:\/$/.test(s)) { + while (s.endsWith('/')) s = s.slice(0, -1); + } return s.toLowerCase(); } diff --git a/src/renderer/utils/taskGrouping.ts b/src/renderer/utils/taskGrouping.ts index 111726cc..332bc687 100644 --- a/src/renderer/utils/taskGrouping.ts +++ b/src/renderer/utils/taskGrouping.ts @@ -14,6 +14,11 @@ export interface ProjectTaskGroup { tasks: GlobalTask[]; } +/** Returns updatedAt if available, otherwise createdAt. */ +function getEffectiveDate(task: GlobalTask): string | undefined { + return task.updatedAt ?? task.createdAt; +} + function getDateCategory(dateStr: string | undefined): DateCategory { if (!dateStr) return 'Older'; const d = new Date(dateStr); @@ -33,7 +38,7 @@ export function groupTasksByDate(tasks: GlobalTask[]): DateGroupedTasks { }; for (const task of tasks) { - const cat = getDateCategory(task.createdAt); + const cat = getDateCategory(getEffectiveDate(task)); groups[cat].push(task); } @@ -41,9 +46,11 @@ export function groupTasksByDate(tasks: GlobalTask[]): DateGroupedTasks { groups[cat].sort((a, b) => { const cmp = a.teamName.localeCompare(b.teamName); if (cmp !== 0) return cmp; - const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0; - const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0; - return dateB - dateA; + const dateA = getEffectiveDate(a); + const dateB = getEffectiveDate(b); + const tsA = dateA ? new Date(dateA).getTime() : 0; + const tsB = dateB ? new Date(dateB).getTime() : 0; + return tsB - tsA; }); } @@ -90,9 +97,11 @@ export function groupTasksByProject(tasks: GlobalTask[]): ProjectTaskGroup[] { entry.tasks.sort((a, b) => { const cmp = a.teamName.localeCompare(b.teamName); if (cmp !== 0) return cmp; - const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0; - const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0; - return dateB - dateA; + const dateA = getEffectiveDate(a); + const dateB = getEffectiveDate(b); + const tsA = dateA ? new Date(dateA).getTime() : 0; + const tsB = dateB ? new Date(dateB).getTime() : 0; + return tsB - tsA; }); } @@ -104,10 +113,16 @@ export function groupTasksByProject(tasks: GlobalTask[]): ProjectTaskGroup[] { groups.sort((a, b) => { const tsA = Math.max( - ...a.tasks.map((t) => (t.createdAt ? new Date(t.createdAt).getTime() : 0)) + ...a.tasks.map((t) => { + const d = getEffectiveDate(t); + return d ? new Date(d).getTime() : 0; + }) ); const tsB = Math.max( - ...b.tasks.map((t) => (t.createdAt ? new Date(t.createdAt).getTime() : 0)) + ...b.tasks.map((t) => { + const d = getEffectiveDate(t); + return d ? new Date(d).getTime() : 0; + }) ); return tsB - tsA; }); diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts index e63f8343..4ea2882c 100644 --- a/src/shared/constants/index.ts +++ b/src/shared/constants/index.ts @@ -4,6 +4,7 @@ export * from './agentBlocks'; export * from './cache'; +export * from './kanban'; export * from './memberColors'; export * from './trafficLights'; export * from './triggerColors'; diff --git a/src/shared/constants/kanban.ts b/src/shared/constants/kanban.ts new file mode 100644 index 00000000..e6f67236 --- /dev/null +++ b/src/shared/constants/kanban.ts @@ -0,0 +1,9 @@ +import type { KanbanColumnId } from '@shared/types'; + +export const KANBAN_COLUMN_IDS: KanbanColumnId[] = [ + 'todo', + 'in_progress', + 'done', + 'review', + 'approved', +]; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index ad720c50..e2e2e09d 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -74,6 +74,8 @@ export interface TeamTask { */ related?: string[]; createdAt?: string; + /** File modification time (mtime). Used for sorting by last activity. */ + updatedAt?: string; projectPath?: string; comments?: TaskComment[]; }