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:
parent
2b6f9cf9cd
commit
e160626c64
20 changed files with 4692 additions and 52 deletions
976
docs/diff-view-implementation-plan.md
Normal file
976
docs/diff-view-implementation-plan.md
Normal 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
686
docs/diff-view-research.md
Normal 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` — ближайшее к "из коробки" решение.
|
||||
302
docs/iterations/diff-view/phase-1-read-only-diff.md
Normal file
302
docs/iterations/diff-view/phase-1-read-only-diff.md
Normal 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/`
|
||||
959
docs/iterations/diff-view/phase-2-accept-reject.md
Normal file
959
docs/iterations/diff-view/phase-2-accept-reject.md
Normal 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/`
|
||||
856
docs/iterations/diff-view/phase-3-per-task-scoping.md
Normal file
856
docs/iterations/diff-view/phase-3-per-task-scoping.md
Normal 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
|
||||
792
docs/iterations/diff-view/phase-4-enhanced-features.md
Normal file
792
docs/iterations/diff-view/phase-4-enhanced-features.md
Normal 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
|
||||
|
|
@ -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 } {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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' ? (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
export * from './agentBlocks';
|
||||
export * from './cache';
|
||||
export * from './kanban';
|
||||
export * from './memberColors';
|
||||
export * from './trafficLights';
|
||||
export * from './triggerColors';
|
||||
|
|
|
|||
9
src/shared/constants/kanban.ts
Normal file
9
src/shared/constants/kanban.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import type { KanbanColumnId } from '@shared/types';
|
||||
|
||||
export const KANBAN_COLUMN_IDS: KanbanColumnId[] = [
|
||||
'todo',
|
||||
'in_progress',
|
||||
'done',
|
||||
'review',
|
||||
'approved',
|
||||
];
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue