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.
This commit is contained in:
iliya 2026-02-24 20:25:49 +02:00 committed by Илия
parent 2b6f9cf9cd
commit e160626c64
20 changed files with 4692 additions and 52 deletions

View file

@ -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<T>()` -- same as `wrapTeamHandler`
- Handlers:
- `handleGetMemberChanges(event, teamName, memberName)` -> `IpcResult<AgentReviewData>`
- `handleReadFile(event, filePath)` -> `IpcResult<string>` (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<void>;
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
<button onClick={() => openReviewPanel(memberName)}>
<GitCompareArrows className="size-4" /> Review
</button>
```
**File: `src/renderer/components/team/TeamDetailView.tsx`** (MODIFY)
Add ReviewPanel rendering (slide-in panel or dialog). Wire up state:
```tsx
{reviewMember && (
<ReviewPanel
teamName={teamName}
memberName={reviewMember}
onClose={() => 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<ApplyReviewResult>`
- Validates all fields
- Calls ReviewApplier
- Path traversal validation (prevent writing outside project dir)
- `handleGetBackup(event, sessionId, backupFileName)` -> `IpcResult<string>`
- 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<string, FileReviewState>;
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<ApplyReviewResult>;
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<void>;
```
### 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') && (
<button onClick={() => openTaskReview(task.id)}>
<GitCompareArrows className="size-3.5" /> Changes
</button>
)}
```
**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 <before-sha>..<after-sha> -- <file>`
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.

686
docs/diff-view-research.md Normal file
View file

@ -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<string, {
backupFileName: string | null; // e.g. "4eb3109b11712282@v2"
version: number;
backupTime: string;
}>;
};
}
```
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 "<team>" task start|complete|set-status <id>
```
- Используется 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<taskId, tool_use_ids[]>
1. Детектировать TaskUpdate tool_use:
- status == "in_progress" → TASK_START(taskId, line)
- status == "completed" → TASK_END(taskId, line)
2. Детектировать Bash teamctl:
- "task start <id>" → TASK_START(taskId, line)
- "task complete <id>" → 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` — ближайшее к "из коробки" решение.

View file

@ -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<string, { data: AgentChangeSet; expiresAt: number }>();
private readonly CACHE_TTL = 3 * 60 * 1000; // 3 мин как в MemberStatsComputer
constructor(private logsFinder: TeamMemberLogsFinder) {}
async getAgentChanges(teamName: string, memberName: string): Promise<AgentChangeSet>;
async getTaskChanges(teamName: string, taskId: string): Promise<TaskChangeSet>;
async getChangeStats(teamName: string, memberName: string): Promise<ChangeStats>;
}
```
**Ключевые нюансы парсинга subagent JSONL:**
1. **Структура entry**: `obj.message.content` — массив блоков (в отличие от main session где `obj.content`)
2. **Edit tool_use.input**:
```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 <id>` (regex)
4. Все tool_use Edit/Write между start и end маркерами = изменения задачи
5. Если 86% кейс (1 задача в сессии): вся сессия = задача
**Confidence scoring:**
- `high`: Найдены оба маркера (start + end) ИЛИ single-task session
- `medium`: Найден только end-маркер
- `low`: Нет маркеров, используем fallback (owner + text search)
### 3. IPC каналы: `src/preload/constants/ipcChannels.ts` (MODIFY)
Добавить 3 канала:
```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<AgentChangeSet>(REVIEW_GET_AGENT_CHANGES, teamName, memberName),
getTaskChanges: (teamName: string, taskId: string) =>
invokeIpcWithResult<TaskChangeSet>(REVIEW_GET_TASK_CHANGES, teamName, taskId),
getChangeStats: (teamName: string, memberName: string) =>
invokeIpcWithResult<ChangeStats>(REVIEW_GET_CHANGE_STATS, teamName, memberName),
},
```
---
## Frontend
### 7. Zustand slice: `src/renderer/store/slices/changeReviewSlice.ts` (NEW)
```typescript
export interface ChangeReviewSlice {
// State
activeChangeSet: AgentChangeSet | TaskChangeSet | null;
changeSetLoading: boolean;
changeSetError: string | null;
selectedReviewFilePath: string | null;
changeStatsCache: Record<string, ChangeStats>; // key = "teamName:memberName"
// Actions
fetchAgentChanges: (teamName: string, memberName: string) => Promise<void>;
fetchTaskChanges: (teamName: string, taskId: string) => Promise<void>;
selectReviewFile: (filePath: string | null) => void;
clearChangeReview: () => void;
fetchChangeStats: (teamName: string, memberName: string) => Promise<void>;
}
```
**Паттерн**: Копируем из teamSlice — loading/error/data + async actions с try/catch.
Зарегистрировать в `src/renderer/store/index.ts` как новый slice.
### 8. Компоненты
#### `src/renderer/components/team/review/ChangeReviewDialog.tsx` (NEW)
- **Dialog shell**: Полноэкранный overlay (или большой dialog)
- Открывается из KanbanTaskCard или MemberDetailDialog
- Props: `open`, `onOpenChange`, `teamName`, `mode: 'agent' | 'task'`, `memberName?`, `taskId?`
- При открытии вызывает `fetchAgentChanges` или `fetchTaskChanges`
- Содержит resizable split panel:
- Слева: `ReviewFileTree`
- Справа: `ReviewDiffContent`
#### `src/renderer/components/team/review/ReviewFileTree.tsx` (NEW)
- Список файлов из `activeChangeSet.files`
- Каждый файл показывает: имя, +N -M badge, иконку статуса
- Клик выбирает файл → `selectReviewFile(filePath)`
- Группировка по директориям (tree view)
- Выделение активного файла
#### `src/renderer/components/team/review/ReviewDiffContent.tsx` (NEW)
- Показывает диффы для выбранного файла
- Phase 1: простой HTML-рендер (old_string красным, new_string зелёным)
- Использует `jsdiff.diffLines()` для вычисления unified diff из old_string/new_string
- Подсветка синтаксиса через существующий `highlight.js` (уже установлен)
- CSS переменные: `--diff-added-bg`, `--diff-removed-bg` и т.д. (уже есть в index.css)
- Если файл имеет несколько snippets — показываем все последовательно с разделителями
#### `src/renderer/components/team/review/ChangeStatsBadge.tsx` (NEW)
- Маленький inline badge: `+142 -38`
- Зелёный для добавленных, красный для удалённых
- Используется в KanbanTaskCard и MemberCard
### 9. Интеграция в существующие компоненты
#### `KanbanTaskCard.tsx` (MODIFY)
- Добавить `ChangeStatsBadge` рядом с subject (для задач в done/review/approved)
- Добавить кнопку "View Changes" (иконка `FileCode` или `GitCompare` из lucide)
- Клик открывает `ChangeReviewDialog` с `mode: 'task'`
#### `TeamDetailView.tsx` (MODIFY)
- Добавить рендер `ChangeReviewDialog` (один инстанс на уровне TeamDetailView)
- State: `reviewDialogState: { open: boolean; mode: 'agent' | 'task'; memberName?: string; taskId?: string }`
- Прокинуть callback `onViewChanges` в KanbanBoard → KanbanTaskCard
---
## Файлы
| Файл | Тип | ~LOC |
|------|-----|---:|
| `src/shared/types/review.ts` | NEW | 80 |
| `src/main/services/team/ChangeExtractorService.ts` | NEW | 350 |
| `src/main/ipc/review.ts` | NEW | 80 |
| `src/main/services/team/index.ts` | MODIFY | +1 |
| `src/main/index.ts` | MODIFY | +10 |
| `src/preload/constants/ipcChannels.ts` | MODIFY | +3 |
| `src/preload/index.ts` | MODIFY | +10 |
| `src/renderer/store/slices/changeReviewSlice.ts` | NEW | 100 |
| `src/renderer/store/index.ts` | MODIFY | +5 |
| `src/renderer/components/team/review/ChangeReviewDialog.tsx` | NEW | 150 |
| `src/renderer/components/team/review/ReviewFileTree.tsx` | NEW | 180 |
| `src/renderer/components/team/review/ReviewDiffContent.tsx` | NEW | 250 |
| `src/renderer/components/team/review/ChangeStatsBadge.tsx` | NEW | 40 |
| `src/renderer/components/team/kanban/KanbanTaskCard.tsx` | MODIFY | +30 |
| `src/renderer/components/team/TeamDetailView.tsx` | MODIFY | +40 |
| **Итого** | 8 NEW + 7 MODIFY | ~1,330 |
---
## Edge Cases
1. **Файл редактировался несколько раз** — показываем все snippets в хронологическом порядке
2. **Write (update) без old_string** — показываем только новое содержимое с пометкой "Full file content"
3. **MultiEdit** — каждая пара old_string/new_string отдельным snippet
4. **Ошибка парсинга JSONL** — graceful degradation, показываем то что смогли распарсить
5. **Пустой changeSet** — "No file changes detected" empty state
6. **Очень длинные файлы** — виртуальный скроллинг через `@tanstack/react-virtual` (уже установлен)
7. **Binary файлы** — пропускаем, не показываем дифф
## Тестирование
- Unit test для `ChangeExtractorService.parseFile()` с моковым JSONL
- Unit test для task scoping (TaskUpdate маркеры)
- Unit test для `ChangeStatsBadge` рендеринга
- Ручное тестирование на реальных team sessions из `~/.claude/projects/`

View file

@ -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<number, HunkDecision>;
}
/** Запрос на применение 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<string, { data: Map<string, FileVersions>; 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<Map<string, FileChangeWithContent>>;
}
```
**Ключевые нюансы 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<ConflictCheckResult>;
/**
* 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<RejectResult>;
/**
* Reject всего файла — восстановить original content.
*/
async rejectFile(
filePath: string,
original: string,
modified: string
): Promise<RejectResult>;
/**
* 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<string, FileChangeWithContent>
): Promise<ApplyReviewResult>;
}
```
**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<IpcResult<FileChangeWithContent>> {
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<IpcResult<RejectResult>> {
return wrapHandler('review:rejectHunks', async () => {
const applier = getApplier();
return await applier.rejectHunks(filePath, original, modified, hunkIndices);
});
}
async function handleApplyDecisions(
_event: IpcMainInvokeEvent,
request: ApplyReviewRequest
): Promise<IpcResult<ApplyReviewResult>> {
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<AgentChangeSet>(REVIEW_GET_AGENT_CHANGES, teamName, memberName),
getTaskChanges: (teamName: string, taskId: string) =>
invokeIpcWithResult<TaskChangeSet>(REVIEW_GET_TASK_CHANGES, teamName, taskId),
getChangeStats: (teamName: string, memberName: string) =>
invokeIpcWithResult<ChangeStats>(REVIEW_GET_CHANGE_STATS, teamName, memberName),
// Phase 2
checkConflict: (filePath: string, expectedModified: string) =>
invokeIpcWithResult<ConflictCheckResult>(REVIEW_CHECK_CONFLICT, filePath, expectedModified),
rejectHunks: (filePath: string, original: string, modified: string, hunkIndices: number[]) =>
invokeIpcWithResult<RejectResult>(REVIEW_REJECT_HUNKS, filePath, original, modified, hunkIndices),
rejectFile: (filePath: string, original: string, modified: string) =>
invokeIpcWithResult<RejectResult>(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<ApplyReviewResult>(REVIEW_APPLY_DECISIONS, request),
getFileContent: (teamName: string, memberName: string, filePath: string) =>
invokeIpcWithResult<FileChangeWithContent>(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<string, ChangeStats>;
// Phase 2 additions
/** Per-hunk решения. Ключ = "filePath:hunkIndex" */
hunkDecisions: Record<string, HunkDecision>;
/** Per-file решения */
fileDecisions: Record<string, HunkDecision>;
/** Resolved file contents для CodeMirror (original + modified) */
fileContents: Record<string, FileChangeWithContent>;
fileContentsLoading: Record<string, boolean>;
/** Режим отображения */
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<void>;
previewReject: (filePath: string) => Promise<{ preview: string; hasConflicts: boolean }>;
applyReview: (teamName: string, taskId?: string, memberName?: string) => Promise<void>;
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<number, HunkDecision> = {};
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<HTMLDivElement>(null);
const editorRef = useRef<EditorView | null>(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 (удалить)
<ReviewDiffContent snippets={selectedFile.snippets} />
// Phase 2 (заменить на)
<CodeMirrorDiffView
original={fileContent?.originalFullContent ?? ''}
modified={fileContent?.modifiedFullContent ?? ''}
fileName={selectedFile.relativePath}
showMergeControls={true}
collapseUnchanged={collapseUnchanged}
onHunkAccepted={(idx) => 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<string, HunkDecision>, 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/`

View file

@ -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<string, { data: TaskBoundariesResult; expiresAt: number }>();
private readonly CACHE_TTL = 3 * 60 * 1000; // 3 мин
/**
* Парсит JSONL файл и извлекает все TaskUpdate/teamctl маркеры.
*
* Один проход по файлу, O(n) по количеству строк.
*/
async parseBoundaries(filePath: string): Promise<TaskBoundariesResult>;
/**
* Определяет 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<TaskChangeScope | null>;
}
```
**Парсинг 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<string, unknown>;
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<string, unknown> | 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<string, unknown>;
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<string, unknown> | 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<TaskBoundariesResult> {
// Check cache
const cached = this.cache.get(filePath);
if (cached && cached.expiresAt > Date.now()) return cached.data;
const boundaries: TaskBoundary[] = [];
const allToolUsesByLine = new Map<number, { toolUseId: string; toolName: string; filePath?: string }[]>();
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<string, unknown>;
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<string, unknown>;
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<string, unknown> | 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<number, { toolUseId: string; toolName: string; filePath?: string }[]>,
totalLines: number
): TaskChangeScope[] {
// Группируем по taskId
const byTask = new Map<string, TaskBoundary[]>();
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<string>();
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<string, unknown>): unknown[] | null {
// Subagent format
const message = entry.message as Record<string, unknown> | 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<TaskChangeSet> {
// Keyword search через logsFinder.findLogsForTask()
}
// Phase 3 (новая реализация)
async getTaskChanges(teamName: string, taskId: string): Promise<TaskChangeSetV2> {
// 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<TaskChangeSetV2> {
// Проверяем: если 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<boolean> {
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<MemberLogSummary[]> {
// 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 (
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs border ${colors[confidence.tier]}`}
title={showTooltip ? confidence.reason : undefined}
>
{labels[confidence.tier]}
</span>
);
}
```
#### `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 (
<div className="flex items-start gap-2 p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20 text-sm">
<AlertTriangle className="w-4 h-4 text-yellow-400 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="font-medium text-yellow-300">
{confidence.tier >= 3
? 'Task boundary detection is approximate'
: 'Note about these changes'}
</p>
{warnings.map((w, i) => (
<p key={i} className="text-text-secondary mt-1">{w}</p>
))}
<p className="text-text-muted mt-1 text-xs">
Detection: {confidence.reason}
</p>
</div>
{onDismiss && (
<button onClick={onDismiss} className="text-text-muted hover:text-text">
<X className="w-4 h-4" />
</button>
)}
</div>
);
}
```
### 8. Модификация существующих компонентов
#### `ChangeReviewDialog.tsx` (MODIFY)
Добавляем scope information в header:
```typescript
// В header диалога (рядом с title)
{mode === 'task' && activeChangeSet && 'scope' in activeChangeSet && (
<div className="flex items-center gap-2">
<ConfidenceBadge confidence={activeChangeSet.scope.confidence} />
{activeChangeSet.warnings.length > 0 && (
<ScopeWarningBanner
warnings={activeChangeSet.warnings}
confidence={activeChangeSet.scope.confidence}
/>
)}
</div>
)}
```
#### `KanbanTaskCard.tsx` (MODIFY)
Для задач в done/review/approved показываем confidence tier:
```typescript
// В footer карточки
{(columnId === 'done' || columnId === 'review' || columnId === 'approved') && (
<div className="flex items-center gap-2 mt-2">
<button
onClick={(e) => {
e.stopPropagation();
onViewChanges?.(task.id);
}}
className="flex items-center gap-1 text-xs text-text-muted hover:text-text transition-colors"
>
<FileCode className="w-3.5 h-3.5" />
View Changes
</button>
{/* ChangeStatsBadge уже из Phase 1 */}
<ChangeStatsBadge stats={taskChangeStats} />
</div>
)}
```
---
## 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

View file

@ -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<string> {
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<string>;
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<string>();
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
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={isViewed(file.filePath)}
onChange={(e) => {
if (e.target.checked) markViewed(file.filePath);
else unmarkViewed(file.filePath);
}}
className="rounded border-border"
aria-label={`Mark ${file.relativePath} as viewed`}
/>
<span className={isViewed(file.filePath) ? 'text-text-muted line-through' : 'text-text'}>
{file.relativePath}
</span>
</div>
```
**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 && (
<div className="border-t border-border pt-3">
<button
onClick={() => setTimelineOpen(!timelineOpen)}
className="flex items-center gap-1 text-xs text-text-secondary hover:text-text w-full"
>
<Clock className="w-3.5 h-3.5" />
Edit Timeline ({selectedTimeline.events.length})
<ChevronDown className={`w-3 h-3 transition-transform ${timelineOpen ? 'rotate-180' : ''}`} />
</button>
{timelineOpen && (
<FileEditTimeline
timeline={selectedTimeline}
onEventClick={(idx) => scrollToSnippet(idx)}
activeSnippetIndex={currentHunkIndex}
/>
)}
</div>
)}
```
---
## 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<string | null> {
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<string | null> {
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<string | null> {
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<Array<{ hash: string; timestamp: string; message: string }>> {
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<boolean> {
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<Array<{ hash: string; timestamp: string; message: string }>>(
REVIEW_GET_GIT_FILE_LOG, projectPath, filePath
),
},
```
---
## Feature 5: Auto-Viewed Detection
### Цель
Автоматически помечать файл как "viewed" когда пользователь прокрутил diff до конца.
### Реализация
```typescript
// В CodeMirrorDiffView.tsx
const endSentinelRef = useRef<HTMLDivElement>(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 (
<div>
<div ref={containerRef} /> {/* CodeMirror mount point */}
<div ref={endSentinelRef} className="h-1" /> {/* Invisible sentinel */}
</div>
);
```
**Настройка**: Авто-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

View file

@ -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 } {

View file

@ -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,

View file

@ -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,

View file

@ -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(

View file

@ -183,22 +183,28 @@ export const Sidebar = (): React.JSX.Element => {
{/* Content: Tasks list or Sessions list */}
<div
id={sidebarTab === 'tasks' ? 'sidebar-tasks-panel' : 'sidebar-sessions-panel'}
id="sidebar-tasks-panel"
role="tabpanel"
aria-labelledby={`sidebar-tab-${sidebarTab}`}
aria-labelledby="sidebar-tab-tasks"
hidden={sidebarTab !== 'tasks'}
className="min-w-0 flex-1 overflow-hidden"
>
{sidebarTab === 'tasks' ? (
<GlobalTaskList
hideHeader
filters={taskFilters}
onFiltersChange={setTaskFilters}
filtersPopoverOpen={taskFiltersPopoverOpen}
onFiltersPopoverOpenChange={setTaskFiltersPopoverOpen}
/>
) : (
<DateGroupedSessions />
)}
<GlobalTaskList
hideHeader
filters={taskFilters}
onFiltersChange={setTaskFilters}
filtersPopoverOpen={taskFiltersPopoverOpen}
onFiltersPopoverOpenChange={setTaskFiltersPopoverOpen}
/>
</div>
<div
id="sidebar-sessions-panel"
role="tabpanel"
aria-labelledby="sidebar-tab-sessions"
hidden={sidebarTab !== 'sessions'}
className="min-w-0 flex-1 overflow-hidden"
>
<DateGroupedSessions />
</div>
</div>

View file

@ -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 (
<button

View file

@ -422,14 +422,12 @@ export const CreateTeamDialog = ({
if (!open || !isDev || initialData) {
return;
}
if (teamName.trim().length === 0) {
setTeamName(DEV_DEFAULT_TEAM.teamName);
}
setTeamName((prev) => (prev.trim().length === 0 ? DEV_DEFAULT_TEAM.teamName : prev));
if (descriptionDraft.value.trim().length === 0) {
descriptionDraft.setValue(DEV_DEFAULT_TEAM.description);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- dev default, intentional deps
}, [open, isDev, teamName, initialData]);
// eslint-disable-next-line react-hooks/exhaustive-deps -- dev defaults applied once on open
}, [open]);
useEffect(() => {
if (cwdMode !== 'project') {

View file

@ -259,17 +259,32 @@ export const KanbanTaskCard = ({
) : null}
{columnId === 'done' ? (
<Button
variant="outline"
size="sm"
aria-label={`Request review for task ${task.id}`}
onClick={(e) => {
e.stopPropagation();
onRequestReview(task.id);
}}
>
Request Review
</Button>
<>
<Button
variant="outline"
size="sm"
className="gap-1 border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
aria-label={`Approve task ${task.id}`}
onClick={(e) => {
e.stopPropagation();
onApprove(task.id);
}}
>
<CheckCircle2 size={12} />
Approve
</Button>
<Button
variant="outline"
size="sm"
aria-label={`Request review for task ${task.id}`}
onClick={(e) => {
e.stopPropagation();
onRequestReview(task.id);
}}
>
Request Review
</Button>
</>
) : null}
{columnId === 'review' ? (

View file

@ -237,6 +237,23 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (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

View file

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

View file

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

View file

@ -4,6 +4,7 @@
export * from './agentBlocks';
export * from './cache';
export * from './kanban';
export * from './memberColors';
export * from './trafficLights';
export * from './triggerColors';

View file

@ -0,0 +1,9 @@
import type { KanbanColumnId } from '@shared/types';
export const KANBAN_COLUMN_IDS: KanbanColumnId[] = [
'todo',
'in_progress',
'done',
'review',
'approved',
];

View file

@ -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[];
}