feat: implement comprehensive audit and fix plans for diff view feature

- Added detailed audit report for the diff view feature, covering line count reliability, hunk parsing, and UI rendering.
- Introduced fix plans addressing critical bugs, including cache TTL adjustments, memory safeguards for LCS, and unified line counting methods.
- Enhanced notification handling for team messages, integrating native OS notifications for improved user experience.
- Updated various components and services to support new features and optimizations, ensuring better performance and reliability in the diff viewing process.
This commit is contained in:
iliya 2026-02-26 19:28:31 +02:00
parent d096b6b19a
commit d97a757f24
19 changed files with 1417 additions and 38 deletions

View file

@ -0,0 +1,536 @@
# Diff View Feature — Full Audit Report
Date: 2026-02-26
Verified: 2026-02-26 (4 parallel agents cross-checked every bug against actual source code)
Comprehensive audit of the changes/diff viewing feature covering line count reliability,
hunk parsing, stat-to-hunk consistency, and UI rendering.
---
## Architecture Overview
The app uses **two distinct diff strategies**:
1. **Chat Viewer Diffs** (read-only, simple display):
- `DiffViewer.tsx` — custom LCS-based line-by-line diff
- Pure string comparison, NO hunk structure
2. **Team Review Diffs** (interactive, hunk-aware):
- CodeMirror Merge plugin (`@codemirror/merge`)
- `ReviewDiffContent.tsx` — uses `diffLines` from `diff` package
- `CodeMirrorDiffView.tsx` — full merge view with hunk navigation
- `ReviewApplierService.ts` — applies/rejects hunks to disk
### Three independent stat computation paths:
| Service | Algorithm | Used For |
|---------|-----------|----------|
| `ChangeExtractorService` | `diffLines()` from npm `diff` | Team member changes (file badges) |
| `MemberStatsComputer` | `split('\n').length` arithmetic | Session analytics |
| `FileContentResolver` | `diffLines()` from npm `diff` | Full file content diffs (CodeMirror) |
---
## Evaluation Summary
| # | Bug | Real? | Confidence | Status |
|---|-----|-------|------------|--------|
| 1 | Two conflicting line-counting methods | **YES** | 9/10 | Open |
| 2 | Write never counts removals | **YES** | 10/10 | Open |
| 3 | Trailing newline off-by-one | **PARTIAL** | 6/10 | Resolves with #1 |
| 4 | FileContentResolver overwrites stats | **PARTIAL** | 5/10 | Design issue |
| 5 | computeHunkIndexAtPos → 0 fallback | **YES** | 10/10 | Open |
| 6 | Hunk ≠ snippet mapping | **YES** | 9/10 | Open |
| 7 | indexOf duplicates in rejection | **YES** | 10/10 | Open |
| 8 | Skeleton flash after save | **FIXED** | 9/10 | Done |
| 9 | CRLF → false diffs | **YES** | 9/10 | Open |
| 10 | OOM on large files (LCS) | **YES** | 8/10 | Open |
| 11 | Race condition disk vs cache | **YES** | 8/10 | Open |
| 12 | Empty string inconsistency | **YES** | 7/10 | Resolves with #1 |
| 13 | Bash estimation ~30-40% | **PARTIAL** | 6/10 | Design limitation |
| 14 | Echo escape handling wrong | **YES** | 8/10 | Open |
| 15 | portionCollapse edge case | **PARTIAL** | 5/10 | Needs testing |
| 16 | No-newline-at-EOF hidden | **YES** | 8/10 | Open |
| 17 | Three-way merge labels | **NO** | 9/10 | False positive |
| 18 | Zero-change files invisible | **YES** | 8/10 | Open |
| 19 | Viewed threshold mismatch | **YES** | 9/10 | Open |
| 20 | useEffect no deps array | **YES** | 10/10 | Open |
| 21 | Hunk count ≠ snippet count | **YES** | 8/10 | Open |
| 22 | Toolbar off-screen narrow viewport | **YES** | 7/10 | Open |
| 23 | Deleted files not marked | **YES** | 8/10 | Open |
| 24 | write-update reconstruction null | **YES** | 9/10 | Open |
| 25 | Bash relative paths | **YES** | 9/10 | Open |
| 26 | Empty line → space | **YES** | 7/10 | Open |
| 27 | No keyboard nav in tree | **YES** | 8/10 | Feature |
| 28 | Collapse state not persisted | **YES** | 9/10 | Open |
| 29 | No stats summary | **NO** | 3/10 | Feature request |
| 30 | Binary files not detected | **YES** | 8/10 | Open |
| 31 | No max file size | **YES** | 7/10 | Open |
| 32 | Whitespace changes not distinguished | **PARTIAL** | 6/10 | Optional feature |
**Totals**: 24 real bugs, 3 false positives (#4, #17, #29), 5 partial/design (#3, #13, #15, #32, #4), 1 fixed (#8)
---
## CRITICAL BUGS
### 1. Two Conflicting Line-Counting Methods
**Real bug: YES — Confidence: 9/10**
**Impact**: Line count badges in file tree may not match actual hunks in editor.
**Details**:
- `ChangeExtractorService` (`src/main/services/team/ChangeExtractorService.ts:463-473`) uses `diffLines()` — semantic line diffing
- `MemberStatsComputer` (`src/main/services/team/MemberStatsComputer.ts:193-196`) uses naive `split('\n').length``newLines - oldLines`
Example divergence:
```
File: 10 lines rewritten completely (same line count)
diffLines(): added=10, removed=10 (correct — all lines changed)
split arithmetic: added=0, removed=0 (wrong — same line count)
```
**Best fix**: Create unified line-counting utility using `diffLines()` as source of truth. Replace `MemberStatsComputer`'s arithmetic with shared utility.
**Risk**: Line count numbers will change post-fix; must test edge cases.
### 2. Write Operations Never Count Removals
**Real bug: YES — Confidence: 10/10**
**Location**: `MemberStatsComputer.ts:204-214`
```typescript
if (toolName === 'Write') {
const writeContent = typeof input.content === 'string' ? input.content : '';
if (writeContent) {
const fileAdded = writeContent.split('\n').length;
linesAdded += fileAdded;
addFileLines(input.file_path, fileAdded, 0); // Always 0 removals!
}
}
```
**Impact**: Write replacing 100-line file with 50-line file shows `+50 / -0` instead of accurate counts.
**Best fix**: Use `FileContentResolver` to access original state before Write, calculate true delta.
**Risk**: Requires coordination with FileContentResolver; may introduce coupling.
### 3. Trailing Newline Off-by-One
**Real bug: PARTIAL — Confidence: 6/10**
Resolves automatically when #1 is fixed (migration to `diffLines()`). Within MemberStatsComputer's own logic, the delta arithmetic is roughly self-consistent (both old and new over-count by 1, so the difference is correct). The issue is only visible when comparing MemberStatsComputer output against ChangeExtractorService output.
### 4. FileContentResolver Overwrites Stats
**Real bug: PARTIAL (design issue) — Confidence: 5/10**
`FileContentResolver.getFileContent()` recalculates stats from full content using `diffLines()`, overwriting input stats. This is actually MORE ACCURATE than snippet-based counts. The "overwrite" is intentional improvement, not a bug. The inconsistency is that file tree badges (pre-CM load) use snippet counts, while CodeMirror view uses recalculated counts.
**Verdict**: Not a code bug. Design choice with minor visual inconsistency during loading.
### 5. `computeHunkIndexAtPos` Returns 0 as Fallback
**Real bug: YES — Confidence: 10/10**
**Location**: `CodeMirrorDiffView.tsx:129-143`
```typescript
function computeHunkIndexAtPos(state: EditorState, pos: number): number {
const chunks = getChunks(state);
if (!chunks) return 0;
let index = 0;
for (const chunk of chunks.chunks) {
if (pos >= chunk.fromB && pos <= chunk.toB) {
return index;
}
index++;
}
return 0; // ← Always returns first hunk if no match!
}
```
**Impact**: Clicking Accept/Reject when cursor is between hunks applies action to the FIRST hunk, not the nearest one. Confirmed: callers at lines 416-420 and 432-435 trust this value unconditionally.
**Best fix**: Find nearest chunk by minimum distance: `Math.min(|pos - chunk.fromB|, |pos - chunk.toB|)` for each chunk, return index of nearest.
**Alternative**: Return -1 for "no match" and require caller handling.
**Risk**: Need tie-breaking rule when cursor is equidistant from two chunks.
### 6. Hunk Index ≠ Snippet Index (False 1:1 Assumption)
**Real bug: YES — Confidence: 9/10**
**Location**: `ReviewApplierService.ts:337-342`
```typescript
const snippetsToReject = hunkIndices
.filter((idx) => idx >= 0 && idx < validSnippets.length)
.map((idx) => validSnippets[idx]);
```
**Problem**: Assumes hunk #N corresponds to snippet #N. But:
- Multiple Edit calls can merge into one hunk in structuredPatch
- One Write call can produce multiple hunks
- MultiEdit creates 1 snippet with multiple logical changes
**Best fix**: Build hunk-to-snippet mapping using position matching. For each hunk, find snippets whose newString appears in that hunk region. Store `hunkIndex -> Set<snippetIndices>`.
**Risk**: Complex implementation, requires re-running diff analysis.
### 7. Snippet Rejection via indexOf — Duplicate Content Bug
**Real bug: YES — Confidence: 10/10**
**Location**: `ReviewApplierService.ts:353`
```typescript
const pos = content.indexOf(snippet.newString);
```
`indexOf()` finds FIRST occurrence only. If identical code patterns exist elsewhere in the file, rejection corrupts the wrong section.
**Best fix**: Position-aware matching: calculate approximate line/column of original edit, search for newString near that position (±5 lines tolerance), require context match.
**Risk**: More complex logic, false negatives if context too strict.
### 8. Skeleton Flash After File Save — FIXED
**Location**: `changeReviewSlice.ts:649-666`
After saving, `fileContents[filePath]` was deleted from cache, causing `hasContent = false` → skeleton placeholder shown until lazy re-fetch completes.
**Fix applied**: Instead of deleting, update `modifiedFullContent` with saved content in-place. `contentSource` set to `'disk-current'`.
---
## HIGH PRIORITY BUGS
### 9. CRLF Line Endings → False Diffs
**Real bug: YES — Confidence: 9/10**
**Location**: `DiffViewer.tsx:297`
```typescript
const oldLines = oldString.split('\n');
```
Windows files with `\r\n` leave trailing `\r` on each line. Every line shows as "changed" even if content is identical.
**Fix**: Use `split(/\r?\n/)` or normalize before diffing.
**Risk**: Very low. Standard regex, no side effects.
### 10. OOM on Large Files (DiffViewer LCS)
**Real bug: YES — Confidence: 8/10**
**Location**: `DiffViewer.tsx:50-68`
LCS algorithm is O(m×n) space. Two 5000-line files = 25M matrix entries ≈ 100MB RAM.
No safeguards, no fallback for large files.
**Best fix**: Add size check: if `m * n > MAX_CELLS` (e.g., 1M), fallback to `diffLines()` from npm `diff` package.
**Risk**: Fallback produces different visual output (semantic vs LCS). Need to test.
### 11. Race Condition: File Disk State vs Cache
**Real bug: YES — Confidence: 8/10**
Stats and hunks come from SEPARATE sources:
- Stats: JSONL tool_use blocks (snapshot at parse time)
- Hunks: current file on disk (read at view time)
3-minute cache TTL on both `ChangeExtractorService` and `FileContentResolver`.
If file changes on disk between fetches, stats and hunks desynchronize.
**No cache invalidation** on FileWatcher events → caches stay stale until TTL expires.
**Best fix**: Hook FileWatcher to evict caches when files change. Or reduce TTL to 30s.
**Risk**: Must ensure invalidation doesn't create new race conditions.
### 12. Empty String Handling Inconsistency
**Real bug: YES — Confidence: 7/10**
**Location**: `MemberStatsComputer.ts:193`
Empty string `''` is falsy → returns 0. But `''.split('\n').length === 1`.
**Resolves with #1** — migrating to `diffLines()` handles this correctly.
### 13. Bash Line Estimation Covers ~30-40% of Patterns
**Real bug: PARTIAL — Confidence: 6/10**
**Location**: `MemberStatsComputer.ts:314-416`
**Verdict**: Fundamental limitation, not a code bug. The JSONL only stores command strings, not execution output. Without running the shell, accurate counting is impossible. Code comments acknowledge this. Best approach: document limitation in UI with tooltip.
### 14. Echo Escape Sequence Handling Wrong
**Real bug: YES — Confidence: 8/10**
**Location**: `MemberStatsComputer.ts:371`
```typescript
added += content.split('\\n').length;
```
Splits on literal `\\n` in quoted string. But `echo "line1\nline2"` does NOT expand `\n` without `-e` flag. Counter is wrong for standard echo.
**Best fix**: Check for `-e` flag before splitting on `\\n`. Without `-e`, treat as single line.
**Risk**: Hacky logic, any change may break other cases. Conservative: don't count echo lines at all.
---
## MEDIUM PRIORITY BUGS
### 15. portionCollapse Line Position Edge Case
**Real bug: PARTIAL — Confidence: 5/10**
**Location**: `portionCollapse.ts:140`
Rare edge case at exact line boundaries. Needs unit tests with edge cases to confirm.
**Verdict**: Low probability, needs testing before fixing.
### 16. No-Newline-At-End-Of-File Not Shown
**Real bug: YES — Confidence: 8/10**
**Location**: `ReviewDiffContent.tsx:46-48`
```typescript
const lines = part.value.replace(/\n$/, '').split('\n');
```
Strips trailing newline. If original has no final newline but modified adds one, diff shows them as identical.
**Best fix**: Add visual indicator for no-newline-at-EOF.
**Risk**: Need to update rendering without breaking existing layout.
### 17. Three-Way Merge Labels Confusing — FALSE POSITIVE
**Confidence: 9/10 that this is NOT a bug**
Labels correctly follow diff3 semantics: `<<<<<<< current` = disk state, `>>>>>>> original` = pre-change state. This is standard and correct. The audit was wrong.
### 18. Zero-Change Files Invisible
**Real bug: YES — Confidence: 8/10**
**Location**: `ChangeStatsBadge.tsx`
```typescript
if (linesAdded === 0 && linesRemoved === 0) return null;
```
Files modified with equal adds/removes (e.g., 5 lines rewritten) show no badge. Missing `modified` boolean flag.
**Best fix**: Add `modified: boolean` flag to `FileChangeSummary`. Show neutral badge for zero-net-change files.
**Risk**: Requires data structure change, but straightforward.
### 19. Viewed File Threshold Mismatch
**Real bug: YES — Confidence: 9/10**
- `FileSectionDiff.tsx`: `threshold: 0.85`
- `CodeMirrorDiffView.tsx`: `threshold: 1.0`
**Best fix**: Standardize on 0.85 everywhere. One-line change.
**Risk**: None.
### 20. Missing Dependency Array in useEffect
**Real bug: YES — Confidence: 10/10**
**Location**: `FileSectionDiff.tsx:50-56`
```typescript
useEffect(() => {
if (localEditorViewRef.current) {
onEditorViewReady(file.filePath, localEditorViewRef.current);
}
}); // ← No dependency array! Runs EVERY render.
```
**Best fix**: Add `[file.filePath, onEditorViewReady]` dependency array.
**Risk**: Low. Need to ensure `onEditorViewReady` is memoized with useCallback.
### 21. Hunk Counts Mismatch in UI
**Real bug: YES — Confidence: 8/10**
`snippets.length` used as fallback before CodeMirror loads, then replaced by `chunks.length`. User sees progress jump (e.g., "1 of 1" → "1 of 5").
**Best fix**: Pre-compute chunk count from `diffLines()` at load time, not from snippet count.
**Risk**: Medium — adds computation step, but improves correctness.
### 22. Merge Toolbar Off-Screen in Narrow Viewport
**Real bug: YES — Confidence: 7/10**
Buttons at `insetInlineEnd: '8px'` get clipped in narrow viewports.
**Best fix**: CSS-only: use `insetInlineStart` or add `maxWidth: '100%'` overflow handling.
**Risk**: None. CSS-only change.
### 23. Deleted Files Not Marked
**Real bug: YES — Confidence: 8/10**
No `isDeleted` flag in `FileChangeSummary`. Deleted files show as regular changes.
**Best fix**: Add `isDeleted: boolean` field. Set when original has content and modified is empty.
**Risk**: Medium — requires data structure change.
### 24. Snippet Reconstruction Returns null for Write-Update
**Real bug: YES — Confidence: 9/10**
**Location**: `FileContentResolver.ts:392-394`
`write-update` can't be reconstructed because JSONL doesn't include `oldString`. Falls back to disk-current which may differ from actual original.
**Best fix**: Store original content when detecting write-update during extraction.
**Risk**: Medium — requires data structure enrichment.
### 25. Bash Relative Paths Not Captured
**Real bug: YES — Confidence: 9/10**
**Location**: `MemberStatsComputer.ts:373-376`
Only captures absolute paths (`startsWith('/')`). Misses relative paths.
**Best fix**: Remove `startsWith('/')` check, just validate non-empty string.
**Risk**: Very low. 1-line fix.
---
## LOW PRIORITY
### 26. DiffViewer Empty Line Rendering
**Real bug: YES — Confidence: 7/10**
```typescript
{line.content || ' '}
```
Empty lines show as single space. Can't distinguish from space-only lines.
**Best fix**: `{line.content ?? ' '}` (only use space if truly undefined).
**Risk**: None. 1-line fix.
### 27. No Keyboard Navigation in File Tree
**Real bug: YES — Confidence: 8/10**
Mouse-only navigation. No WAI-ARIA tree widget support.
**Verdict**: UX feature, ~150 LOC. Not a correctness bug.
### 28. Folder Collapse State Not Persisted
**Real bug: YES — Confidence: 9/10**
State lost on dialog close. Uses `useState` with fresh `Set()`.
**Best fix**: Persist to localStorage or Zustand store.
**Risk**: Low-medium. localStorage has size limits but file tree data is tiny.
### 29. No Diff Stats Summary at Top — FALSE POSITIVE
**Confidence: 3/10 that this is a bug**
Missing feature, not a bug. File-by-file stats are visible. Aggregate summary would be nice UX but isn't a correctness issue.
### 30. Binary Files Not Detected
**Real bug: YES — Confidence: 8/10**
`diffLines()` called on binary content without check. Can cause corrupted display.
**Best fix**: Check for null bytes (`content.includes('\0')`) before diffing. Skip binary files.
**Risk**: Very low. Simple guard.
### 31. No Maximum File Size Handling
**Real bug: YES — Confidence: 7/10**
No progress indicator for large files. Browser freezes for 2-3s on 1MB+ files.
**Best fix**: Add size check, show "file too large" fallback for >5MB.
**Risk**: Low. Graceful degradation.
### 32. Whitespace-Only Changes Not Distinguished
**Real bug: PARTIAL — Confidence: 6/10**
No visual distinction between content vs whitespace-only changes.
**Verdict**: Optional enhancement. Add `ignoreWhitespace` toggle. Not a correctness bug.
---
## Race Condition Severity Matrix
| Scenario | Probability | Severity |
|----------|-------------|----------|
| Disk modification between stat cache and hunk fetch | Medium | High |
| File deletion after stats cached | Low-Medium | High |
| JSONL appended while parsing | Low | Medium |
| Git repo state change (checkout, rebase) | Low | Medium |
| Snippet reconstruction chain broken | Medium | High |
---
## Fix Categories
### Safe to Fix Now (isolated, low-risk, clear approach)
- **#5**: computeHunkIndexAtPos → nearest hunk (local function change)
- **#9**: CRLF normalization `split(/\r?\n/)` (1-line regex change)
- **#19**: Threshold 0.85 vs 1.0 → standardize (1-line constant)
- **#20**: useEffect dependency array (1-line addition)
- **#25**: Bash relative paths (remove `startsWith('/')` check)
- **#26**: Empty line rendering (`||` → `??`)
### Need More Research Before Fixing (multi-file, complex, risky)
- **#1**: Unify line counting (affects 3 services, all consumers)
- **#2**: Write removals (needs original file content during stats)
- **#6**: Hunk↔snippet mapping (algorithm redesign)
- **#7**: indexOf → position-aware matching (could break existing logic)
- **#10**: OOM safeguard (need to test fallback visual consistency)
- **#11**: Cache invalidation (FileWatcher integration complexity)
- **#23**: Deleted files flag (data structure change, migration)
- **#24**: write-update reconstruction (data enrichment needed)
### Feature Requests / Won't Fix
- **#13**: Bash estimation — fundamental limitation
- **#17**: Three-way labels — NOT a bug
- **#27**: Keyboard nav — UX feature
- **#29**: Stats summary — UX feature
- **#32**: Whitespace toggle — optional enhancement
---
## Key Files Reference
| File | Role |
|------|------|
| `src/main/services/team/ChangeExtractorService.ts` | Parses JSONL, aggregates per-file changes |
| `src/main/services/team/MemberStatsComputer.ts` | Session analytics, line counting |
| `src/main/services/team/FileContentResolver.ts` | Full file content resolution |
| `src/main/services/team/ReviewApplierService.ts` | Apply/reject hunks, save files |
| `src/renderer/components/chat/viewers/DiffViewer.tsx` | Simple LCS diff viewer |
| `src/renderer/components/team/review/CodeMirrorDiffView.tsx` | Advanced CodeMirror merge view |
| `src/renderer/components/team/review/ReviewDiffContent.tsx` | Fallback snippet-based diff |
| `src/renderer/components/team/review/FileSectionDiff.tsx` | Diff section wrapper |
| `src/renderer/components/team/review/FileSectionHeader.tsx` | File header with Save button |
| `src/renderer/components/team/review/ContinuousScrollView.tsx` | Scroll container, skeleton logic |
| `src/renderer/components/team/review/portionCollapse.ts` | Smart collapse of unchanged regions |
| `src/renderer/components/team/review/ReviewFileTree.tsx` | File tree with stats badges |
| `src/renderer/store/slices/changeReviewSlice.ts` | Store: file contents, save, decisions |
| `src/renderer/hooks/useDiffNavigation.ts` | Keyboard navigation between hunks |
| `src/shared/types/review.ts` | Shared types: FileChangeSummary, FileChangeWithContent |

View file

@ -0,0 +1,587 @@
# Diff View — Detailed Fix Plans from Deep Research
Date: 2026-02-26
Source: 4 parallel research agents analyzing actual source code
---
## Fix #11: Cache TTL 3min → 30sec
**Confidence: 10/10**
**Effort: 2 lines**
### Files to Change
1. `src/main/services/team/ChangeExtractorService.ts:40`
```typescript
// OLD
private readonly CACHE_TTL = 3 * 60 * 1000; // 3 мин
// NEW
private readonly CACHE_TTL = 30 * 1000; // 30 sec
```
2. `src/main/services/team/FileContentResolver.ts:32`
```typescript
// OLD
private readonly cacheTtl = 3 * 60 * 1000; // 3 мин
// NEW
private readonly cacheTtl = 30 * 1000; // 30 sec
```
### Cache Architecture Details
Both services use `Map<string, CacheEntry>` with TTL:
- `ChangeExtractorService`: key = `${teamName}:${memberName}`, stores `AgentChangeSet` + `mtime` + `expiresAt`
- `FileContentResolver`: key = file path, stores `original | modified | source` + `expiresAt`
- `ChangeExtractorService` stores file `mtime` but NEVER uses it for validation
- `FileContentResolver` has `invalidateFile(filePath)` but only called in ONE place: `review.ts:265` after save
### FileWatcher Coverage
FileWatcher ALREADY watches the right directories:
- `~/.claude/projects/` (JSONL session files)
- `~/.claude/todos/` (todo JSON files)
- `~/.claude/teams/` (team config files)
- `~/.claude/tasks/` (task JSON files)
But NO service-level cache invalidation hooks exist beyond the single `invalidateFile()` call.
### Thundering Herd Risk: NONE
- Each cache entry expires independently, staggered by client refresh timing
- 30sec = 120 cache refreshes/hour per user, negligible CPU
- Each team member has separate cache key; concurrent misses don't cascade
### Future Phase (Optional): FileWatcher Integration
Would require:
1. Add `ChangeExtractorService` and `FileContentResolver` to ServiceContext
2. Wire FileWatcher events to precise cache invalidation
3. Map `${teamName}:${memberName}` cache keys to affected files
4. Complex wiring, not worth it until TTL proves insufficient
---
## Fix #10: OOM Safeguard for DiffViewer LCS
**Confidence: 9/10**
**Effort: ~30 LOC**
### Memory Analysis
LCS matrix: `(m+1) × (n+1)` entries, each number = ~8 bytes in V8:
- 1000×1000 = 1M entries ≈ 8MB ✓ Safe
- 3000×3000 = 9M entries ≈ 72MB ⚠️ Manageable
- 5000×5000 = 25M entries ≈ 200MB ✗ Dangerous
- 10000×10000 = 100M entries ≈ 800MB ✗ OOM
**Recommended threshold: `MAX_CELLS = 1_000_000`** (~1000×1000 lines)
### `diffLines()` Return Format
From npm `diff` package:
```typescript
Array<{
value: string; // The actual line(s) + newline
count?: number; // Number of lines
added?: boolean; // true = new lines
removed?: boolean; // true = removed lines
// If neither added/removed: unchanged context lines
}>
```
### DiffLine Type (DiffViewer)
```typescript
interface DiffLine {
type: 'removed' | 'added' | 'context';
content: string;
lineNumber: number;
}
```
### Implementation
**Import to add** (DiffViewer.tsx top):
```typescript
import { diffLines as semanticDiffLines } from 'diff';
```
**New constant**:
```typescript
/** Max LCS matrix cells before falling back to semantic diff.
* 1M cells ≈ 8MB RAM — safe for all platforms. */
const MAX_LCS_CELLS = 1_000_000;
```
**Fallback function**:
```typescript
/**
* Fallback diff using semantic line-diffing from npm `diff` package.
* Used when LCS matrix would exceed memory threshold.
* Output format matches LCS-based generateDiff().
*/
function generateDiffFallback(oldLines: string[], newLines: string[]): DiffLine[] {
const oldText = oldLines.join('\n');
const newText = newLines.join('\n');
const changes = semanticDiffLines(oldText, newText);
const result: DiffLine[] = [];
let lineNumber = 1;
for (const change of changes) {
// Split change value into individual lines, removing trailing newline
const changeLines = change.value.replace(/\r?\n$/, '').split(/\r?\n/);
for (const content of changeLines) {
if (change.added) {
result.push({ type: 'added', content, lineNumber: lineNumber++ });
} else if (change.removed) {
result.push({ type: 'removed', content, lineNumber: lineNumber++ });
} else {
result.push({ type: 'context', content, lineNumber: lineNumber++ });
}
}
}
return result;
}
```
**Modified `generateDiff()`**:
```typescript
function generateDiff(oldLines: string[], newLines: string[]): DiffLine[] {
// Fallback to semantic diffing for large files to prevent OOM
if (oldLines.length * newLines.length > MAX_LCS_CELLS) {
return generateDiffFallback(oldLines, newLines);
}
// Original LCS-based algorithm
const matrix = computeLCSMatrix(oldLines, newLines);
// ... rest unchanged ...
}
```
### Visual Behavior Difference
| File Pair | Strategy | Visual Quality |
|-----------|----------|---------------|
| < 1000×1000 | LCS | Precise character-level alignment |
| > 1000×1000 | Semantic | Groups consecutive changes differently, but correct |
The fallback is semantically correct but may group consecutive changes differently.
For most real code diffs, the visual difference is negligible.
### Precedent in Codebase
`ReviewDiffContent.tsx` already uses `diffLines()` successfully:
```typescript
const diffResult = diffLines(original ?? '', modified ?? '');
```
---
## Fix #1+#2: Unified Line Counting
**Confidence: 7/10**
**Effort: ~4-6 hours**
### Current State: 3 Independent Algorithms
#### MemberStatsComputer (`src/main/services/team/MemberStatsComputer.ts`)
**Edit** (lines 189-202):
```typescript
const oldLines = oldStr ? oldStr.split('\n').length : 0;
const newLines = newStr ? newStr.split('\n').length : 0;
const fileAdded = newLines > oldLines ? newLines - oldLines : 0;
const fileRemoved = oldLines > newLines ? oldLines - newLines : 0;
```
- WRONG when content changes but line count stays same
**Write** (lines 204-214):
```typescript
const fileAdded = writeContent.split('\n').length;
linesAdded += fileAdded;
addFileLines(input.file_path, fileAdded, 0); // Always removals = 0!
```
- NEVER counts removals
**MultiEdit** (lines 216-229):
- Same pattern as Write: only additions, no removals
**Bash** (lines 232-243):
- Heuristic `estimateBashLinesChanged()` (~30-40% coverage)
#### ChangeExtractorService (`src/main/services/team/ChangeExtractorService.ts`)
**countLines** (lines 463-473): Uses `diffLines()` — CORRECT
**buildTimeline** (lines 426-427): Uses `split('\n').length` — INCONSISTENT with own countLines!
#### FileContentResolver (`src/main/services/team/FileContentResolver.ts`)
Lines 141-156: Uses `diffLines()` — CORRECT
### Who Consumes These Counts
| Source | Consumer | UI |
|--------|----------|-----|
| MemberStatsComputer | MemberStatsTab.tsx | Session analytics "+X / -Y" |
| ChangeExtractorService | ChangeStatsBadge.tsx | File tree badges |
| ChangeExtractorService | ReviewApplierService.ts | Diff hunks |
| FileContentResolver | CodeMirrorDiffView.tsx | Full file diff display |
### Proposed Fix
**Phase 1: Create UnifiedLineCounter**
```typescript
// src/main/services/team/UnifiedLineCounter.ts
import { diffLines } from 'diff';
export class UnifiedLineCounter {
static countLines(oldStr: string, newStr: string): { added: number; removed: number } {
if (!oldStr && !newStr) return { added: 0, removed: 0 };
const changes = diffLines(oldStr, newStr);
let added = 0;
let removed = 0;
for (const c of changes) {
if (c.added) added += c.count ?? 0;
if (c.removed) removed += c.count ?? 0;
}
return { added, removed };
}
}
```
**Phase 2: Migrate MemberStatsComputer Edit**
```typescript
// Replace lines 189-202
const { added: fileAdded, removed: fileRemoved } = UnifiedLineCounter.countLines(oldStr, newStr);
```
**Phase 3: Fix Write Operations (Bug #2)**
Track file creation vs update during JSONL parse:
```typescript
const filesSeen = new Set<string>();
// In Write handler:
if (toolName === 'Write') {
const filePath = typeof input.file_path === 'string' ? input.file_path : '';
const writeContent = typeof input.content === 'string' ? input.content : '';
const isNewFile = !filesSeen.has(filePath);
filesSeen.add(filePath);
if (writeContent) {
if (isNewFile) {
// New file creation — all lines are additions
const { added } = UnifiedLineCounter.countLines('', writeContent);
linesAdded += added;
if (filePath) addFileLines(filePath, added, 0);
} else {
// File replacement — assume full rewrite (conservative estimate)
const { added } = UnifiedLineCounter.countLines('', writeContent);
linesAdded += added;
linesRemoved += added; // Assume ~same amount removed
if (filePath) addFileLines(filePath, added, added);
}
}
}
```
**Phase 4: Fix buildTimeline in ChangeExtractorService**
```typescript
// Replace lines 426-427
const { added, removed } = UnifiedLineCounter.countLines(s.oldString, s.newString);
// Use `added` and `removed` instead of split('\n').length arithmetic
```
**Phase 5: Keep Bash As-Is**
Fundamental limitation — command string has no execution output.
### Risk Assessment
| Risk | Level | Mitigation |
|------|-------|-----------|
| Write removals estimation inaccurate | HIGH | `filesSeen` Set tracks if file existed before; conservative "full rewrite" estimate |
| Line count numbers change in UI | MEDIUM | Expected — numbers become MORE accurate |
| Historical data shows different numbers | LOW | Accept as one-time correction |
| Circular dependency (MemberStatsComputer → FileContentResolver) | NONE | UnifiedLineCounter is independent utility |
### Open Questions
1. **Write-update without oldString**: `filesSeen` approach assumes sequential JSONL parsing. If messages are out of order, may misclassify. Need to verify JSONL ordering.
2. **Performance**: `diffLines()` is heavier than `split().length`. For sessions with 1000+ Edit calls, could add latency. Need to benchmark.
3. **Bash estimation**: Keep as-is or drop entirely? Current tooltip says "Approximate" — may be enough.
---
## Fix #6+#7: Hunk↔Snippet Mapping + indexOf
**Confidence: 5/10**
**Effort: ~150 LOC**
### Current Architecture Flow
```
CodeMirrorDiffView.tsx (lines 427, 443)
computeHunkIndexAtPos(state, pos) → hunkIndex: number
onRejectRef.current?.(idx) // onHunkRejected callback
IPC: team:applyReviewDecisions
ReviewApplierService.rejectHunks(filePath, original, modified, hunkIndices, snippets)
trySnippetLevelReject(modified, hunkIndices, snippets)
snippetsToReject = hunkIndices.map(idx => validSnippets[idx]) // ← BUG: 1:1 assumption
content.indexOf(snippet.newString) // ← BUG: first occurrence only
```
### Data Available in Snippets
From `ChangeExtractorService` (SnippetDiff type):
- `oldString` / `newString` — actual content
- `toolName` — Edit, Write, MultiEdit
- `toolUseId` — unique ID
- `timestamp` — when it happened
- `type` — 'edit' | 'write-new' | 'write-update' | 'multi-edit'
- `isError` — whether tool errored
- `replaceAll` — for Edit with replace_all flag
- **NO line numbers** — this is the core problem
### Data Available in Hunks
From `structuredPatch()` (npm `diff` package):
```typescript
interface StructuredPatchHunk {
oldStart: number; // Line number in original (1-based)
oldLines: number; // Line count in original
newStart: number; // Line number in modified (1-based)
newLines: number; // Line count in modified
lines: string[]; // Actual diff lines (+, -, space context)
}
```
From CodeMirror's `getChunks()`:
```typescript
chunks: {
fromA: number // Original doc character position
toA: number // Original doc character position
fromB: number // Modified doc character position
toB: number // Modified doc character position
}[]
```
### Proposed Fix: 3 Phases
#### Phase 1: `buildHunkToSnippetMapping()`
Build explicit mapping using content overlap detection:
```typescript
private buildHunkToSnippetMapping(
original: string,
modified: string,
hunkIndices: number[],
snippets: SnippetDiff[]
): Map<number, Set<number>> {
const patch = structuredPatch('file', 'file', original, modified);
if (!patch.hunks || patch.hunks.length === 0) return new Map();
const mapping = new Map<number, Set<number>>();
for (const hunkIdx of hunkIndices) {
if (hunkIdx < 0 || hunkIdx >= patch.hunks.length) continue;
const hunk = patch.hunks[hunkIdx];
const snippetSet = new Set<number>();
// Extract added/removed content from hunk
const addedLines = hunk.lines.filter(l => l.startsWith('+')).map(l => l.slice(1));
const removedLines = hunk.lines.filter(l => l.startsWith('-')).map(l => l.slice(1));
const addedContent = addedLines.join('\n');
const removedContent = removedLines.join('\n');
for (let sIdx = 0; sIdx < snippets.length; sIdx++) {
const snippet = snippets[sIdx];
if (snippet.isError) continue;
const matchesNew = addedContent.includes(snippet.newString);
const matchesOld = removedContent.includes(snippet.oldString);
if (snippet.type === 'write-new' || snippet.type === 'write-update') {
if (matchesNew) snippetSet.add(sIdx);
} else {
// For edits: require both old AND new match for higher confidence
if (matchesNew && matchesOld) {
snippetSet.add(sIdx);
} else if (matchesNew) {
snippetSet.add(sIdx); // Lower confidence fallback
}
}
}
mapping.set(hunkIdx, snippetSet);
}
return mapping;
}
```
#### Phase 2: Position-Aware `findSnippetPosition()`
Replace indexOf with context-aware search:
```typescript
private findSnippetPosition(
snippet: SnippetDiff,
content: string
): number {
const { newString, oldString } = snippet;
// Fast path: newString is unique in content
const firstPos = content.indexOf(newString);
if (firstPos === -1) return -1;
const lastPos = content.lastIndexOf(newString);
if (firstPos === lastPos) return firstPos; // Only one occurrence — safe
// Multiple occurrences — use oldString context to disambiguate
// Search for each occurrence and check if surrounding context matches oldString
const positions: number[] = [];
let searchStart = 0;
while (true) {
const pos = content.indexOf(newString, searchStart);
if (pos === -1) break;
positions.push(pos);
searchStart = pos + 1;
}
// For each candidate position, check if oldString context is nearby
if (oldString) {
for (const pos of positions) {
// Look for oldString within ±1000 chars of this position
// (in the original document, oldString would be at roughly the same position)
const nearbyStart = Math.max(0, pos - 1000);
const nearbyEnd = Math.min(content.length, pos + newString.length + 1000);
const nearby = content.substring(nearbyStart, nearbyEnd);
// If any unique token from oldString appears nearby, this is likely correct
const oldTokens = oldString.split(/\s+/).filter(t => t.length > 3);
const matchScore = oldTokens.filter(t => nearby.includes(t)).length;
if (matchScore > oldTokens.length * 0.5) {
return pos; // >50% of oldString tokens found nearby
}
}
}
// Last resort: return first position with warning
return firstPos;
}
```
#### Phase 3: Update `trySnippetLevelReject()` Signature
```typescript
// Pass `original` through to enable mapping and context matching
private trySnippetLevelReject(
modified: string,
hunkIndices: number[],
snippets: SnippetDiff[],
original: string // NEW parameter
): RejectResult | null {
const validSnippets = snippets.filter(s => !s.isError);
if (validSnippets.length === 0) return null;
// NEW: Build mapping instead of assuming 1:1
const hunkToSnippets = this.buildHunkToSnippetMapping(
original, modified, hunkIndices, validSnippets
);
// Collect all snippets to reject
const snippetIndices = new Set<number>();
for (const indices of hunkToSnippets.values()) {
indices.forEach(idx => snippetIndices.add(idx));
}
const snippetsToReject = Array.from(snippetIndices)
.map(idx => validSnippets[idx])
.filter(Boolean);
// NEW: Position-aware matching
const positioned = snippetsToReject
.map(snippet => ({
snippet,
pos: this.findSnippetPosition(snippet, modified)
}))
.filter(item => item.pos !== -1)
.sort((a, b) => b.pos - a.pos); // Descending for safe replacement
if (positioned.length !== snippetsToReject.length) {
return null; // Fallback to hunk-level
}
let content = modified;
for (const { snippet, pos } of positioned) {
if (snippet.type === 'write-new') continue;
if (snippet.replaceAll) {
content = content.split(snippet.newString).join(snippet.oldString);
} else {
content = content.substring(0, pos) + snippet.oldString +
content.substring(pos + snippet.newString.length);
}
}
return { success: true, newContent: content, hadConflicts: false };
}
```
### Edge Cases & Concerns
| Case | Current Behavior | After Fix |
|------|------------------|-----------|
| Multiple Edit → 1 Hunk | Assumes hunkIdx = snippetIdx (WRONG) | Content overlap mapping (CORRECT) |
| 1 Write → 2 Hunks | Maps to wrong snippet | Maps via content match |
| Duplicate code in file | Corrupts first occurrence | Context-aware disambiguation |
| Short snippet (1 line) | indexOf works | May still match wrong occurrence if context is ambiguous |
| No oldString context | N/A | Falls back to first indexOf match (same as before) |
| replaceAll snippets | Works (replaces all) | Still works (replaceAll logic unchanged) |
### Open Questions
1. **Short snippets**: If `newString` is `"return true;"` and appears 10 times — context matching may fail. Need hunk line range to narrow search.
2. **Performance**: `structuredPatch()` is called again in `buildHunkToSnippetMapping` (already called in `rejectHunks`). Should cache the patch result.
3. **MultiEdit**: Creates 1 snippet but may affect multiple non-contiguous regions. `buildHunkToSnippetMapping` should handle this but needs testing.
4. **Overlapping snippets**: Two snippets touching the same line range. Position-aware replacement from end (descending sort) handles this, but still fragile.
5. **`original` parameter**: Need to thread it through from all callers: `rejectHunks()`, `previewReject()`, `acceptHunks()`.
### Why Confidence is Only 5/10
The core issue is that **snippets have NO line numbers**. All matching is content-based (heuristic). For short/common snippets, disambiguation may fail. A truly robust fix would require:
1. Adding line number tracking to `SnippetDiff` during extraction
2. Or using `structuredPatch` bidirectionally to map hunks to file regions
Both are larger architectural changes that go beyond the current fix scope.
---
## Summary: Implementation Priority
| Fix | Confidence | Effort | Do When |
|-----|-----------|--------|---------|
| #11 Cache TTL | 10/10 | 2 lines | NOW |
| #10 OOM safeguard | 9/10 | ~30 LOC | NOW |
| #1+#2 Line counting | 7/10 | ~4-6h | After tests written |
| #6+#7 Hunk mapping | 5/10 | ~150 LOC | After architectural review |

View file

@ -33,7 +33,9 @@ import { existsSync } from 'fs';
import { join } from 'path';
import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
import { showTeamNativeNotification } from './ipc/teams';
import { HttpServer } from './services/infrastructure/HttpServer';
import { TeamInboxReader } from './services/team/TeamInboxReader';
import { getProjectsBasePath, getTodosBasePath } from './utils/pathDecoder';
import {
CliInstallerService,
@ -56,6 +58,74 @@ import type { TeamChangeEvent } from '@shared/types';
const logger = createLogger('App');
// --- Team message notification tracking ---
const teamInboxReader = new TeamInboxReader();
/** Track last-seen message count per inbox file to detect new messages. */
const inboxMessageCounts = new Map<string, number>();
/** Debounce per-inbox to avoid flooding during batch writes. */
const inboxNotifyTimers = new Map<string, ReturnType<typeof setTimeout>>();
const INBOX_NOTIFY_DEBOUNCE_MS = 500;
/** Messages sent from our UI (user_sent) — suppress notifications for these. */
const suppressedSources = new Set(['user_sent']);
async function notifyNewInboxMessages(teamName: string, detail: string): Promise<void> {
// detail is like "inboxes/carol.json" — extract member name
const match = /^inboxes\/(.+)\.json$/.exec(detail);
if (!match) return;
const memberName = match[1];
const key = `${teamName}:${memberName}`;
try {
const messages = await teamInboxReader.getMessagesFor(teamName, memberName);
const prevCount = inboxMessageCounts.get(key) ?? 0;
if (prevCount === 0) {
// First load — seed count, don't notify
inboxMessageCounts.set(key, messages.length);
return;
}
if (messages.length <= prevCount) {
inboxMessageCounts.set(key, messages.length);
return;
}
// Messages are sorted newest-first, so new ones are at the beginning
const newMessages = messages.slice(0, messages.length - prevCount);
inboxMessageCounts.set(key, messages.length);
// Resolve team display name
let teamDisplayName = teamName;
try {
const service = teamDataService;
if (service) {
const summary = await service.listTeams();
const team = summary.find((t) => t.teamName === teamName);
if (team?.displayName) teamDisplayName = team.displayName;
}
} catch {
// use teamName as fallback
}
for (const msg of newMessages) {
// Skip messages sent from our own UI
if (msg.source && suppressedSources.has(msg.source)) continue;
const fromLabel = msg.from || 'Unknown';
const toLabel = msg.to || memberName;
const summary = msg.summary || msg.text.slice(0, 60);
showTeamNativeNotification({
title: `${teamDisplayName}`,
subtitle: `${fromLabel}${toLabel}: ${summary}`,
body: msg.text,
});
}
} catch (error) {
logger.warn(`Failed to check inbox messages for ${key}:`, error);
}
}
// Window icon path for non-mac platforms.
const getWindowIconPath = (): string | undefined => {
const isDev = process.env.NODE_ENV === 'development';
@ -163,15 +233,33 @@ function wireFileWatcherEvents(context: ServiceContext): void {
}
httpServer?.broadcast('team-change', event);
// Auto-relay direct messages to live team lead process (no UI dependency).
// Process inbox change events — relay to lead + native OS notifications.
try {
if (!event || typeof event !== 'object') return;
const row = event as { type?: unknown; teamName?: unknown };
const row = event as { type?: unknown; teamName?: unknown; detail?: unknown };
if (row.type !== 'inbox') return;
if (typeof row.teamName !== 'string' || row.teamName.trim().length === 0) return;
const teamName = row.teamName.trim();
if (!teamProvisioningService.isTeamAlive(teamName)) return;
void teamProvisioningService.relayLeadInboxMessages(teamName).catch(() => undefined);
const detail = typeof row.detail === 'string' ? row.detail : '';
// Auto-relay direct messages to live team lead process (no UI dependency).
if (teamProvisioningService.isTeamAlive(teamName)) {
void teamProvisioningService.relayLeadInboxMessages(teamName).catch(() => undefined);
}
// Show native OS notification for new inbox messages (debounced per inbox).
if (detail.startsWith('inboxes/')) {
const timerKey = `${teamName}:${detail}`;
const existing = inboxNotifyTimers.get(timerKey);
if (existing) clearTimeout(existing);
inboxNotifyTimers.set(
timerKey,
setTimeout(() => {
inboxNotifyTimers.delete(timerKey);
void notifyNewInboxMessages(teamName, detail).catch(() => undefined);
}, INBOX_NOTIFY_DEBOUNCE_MS)
);
}
} catch {
// ignore
}

View file

@ -33,6 +33,7 @@ import {
TEAM_RESTORE_TASK,
TEAM_SEND_MESSAGE,
TEAM_SET_TASK_CLARIFICATION,
TEAM_SHOW_MESSAGE_NOTIFICATION,
TEAM_SOFT_DELETE_TASK,
TEAM_START_TASK,
TEAM_STOP,
@ -47,10 +48,11 @@ import {
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';
import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { ConfigManager } from '../services/infrastructure/ConfigManager';
import { NotificationManager } from '../services/infrastructure/NotificationManager';
import { gitIdentityResolver } from '../services/parsing/GitIdentityResolver';
@ -88,6 +90,7 @@ import type {
TeamData,
TeamLaunchRequest,
TeamLaunchResponse,
TeamMessageNotificationData,
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
TeamSummary,
@ -209,6 +212,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_RESTORE_TASK, handleRestoreTask);
ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks);
ipcMain.handle(TEAM_SET_TASK_CLARIFICATION, handleSetTaskClarification);
ipcMain.handle(TEAM_SHOW_MESSAGE_NOTIFICATION, handleShowMessageNotification);
logger.info('Team handlers registered');
}
@ -253,6 +257,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_RESTORE_TASK);
ipcMain.removeHandler(TEAM_GET_DELETED_TASKS);
ipcMain.removeHandler(TEAM_SET_TASK_CLARIFICATION);
ipcMain.removeHandler(TEAM_SHOW_MESSAGE_NOTIFICATION);
}
function getTeamDataService(): TeamDataService {
@ -1594,6 +1599,67 @@ async function handleKillProcess(
});
}
async function handleShowMessageNotification(
_event: IpcMainInvokeEvent,
data: unknown
): Promise<IpcResult<void>> {
if (!data || typeof data !== 'object') {
return { success: false, error: 'Invalid notification data' };
}
const d = data as TeamMessageNotificationData;
if (!d.teamDisplayName || !d.from || !d.body) {
return { success: false, error: 'Missing required fields (teamDisplayName, from, body)' };
}
showTeamNativeNotification({
title: d.teamDisplayName,
subtitle: d.summary ?? `${d.from}${d.to ?? 'team'}`,
body: d.body,
});
return { success: true, data: undefined };
}
/**
* Show a native OS notification for a team event.
* Respects user's notification settings (enabled, snoozed).
* Cross-platform: macOS, Linux, Windows via Electron Notification API.
*/
export function showTeamNativeNotification(opts: {
title: string;
subtitle?: string;
body: string;
}): void {
const config = ConfigManager.getInstance().getConfig();
if (!config.notifications.enabled) return;
if (config.notifications.snoozedUntil && Date.now() < config.notifications.snoozedUntil) return;
if (
typeof Notification === 'undefined' ||
typeof Notification.isSupported !== 'function' ||
!Notification.isSupported()
) {
return;
}
const notification = new Notification({
title: opts.title,
subtitle: opts.subtitle,
body: opts.body.slice(0, 300),
sound: config.notifications.soundEnabled ? 'default' : undefined,
});
notification.on('click', () => {
const windows = BrowserWindow.getAllWindows();
const mainWin = windows[0];
if (mainWin && !mainWin.isDestroyed()) {
mainWin.show();
mainWin.focus();
}
});
notification.show();
}
async function handleAddTaskComment(
_event: IpcMainInvokeEvent,
teamName: unknown,

View file

@ -37,7 +37,7 @@ interface LogFileRef {
export class ChangeExtractorService {
private cache = new Map<string, CacheEntry>();
private readonly CACHE_TTL = 3 * 60 * 1000; // 3 мин
private readonly CACHE_TTL = 30 * 1000; // 30 сек — shorter TTL to reduce stale data risk
constructor(
private readonly logsFinder: TeamMemberLogsFinder,

View file

@ -29,7 +29,7 @@ interface ContentCacheEntry {
*/
export class FileContentResolver {
private cache = new Map<string, ContentCacheEntry>();
private readonly cacheTtl = 3 * 60 * 1000; // 3 мин (same as ChangeExtractorService)
private readonly cacheTtl = 30 * 1000; // 30 сек — shorter TTL to reduce stale data risk
constructor(
private readonly logsFinder: TeamMemberLogsFinder,

View file

@ -371,7 +371,7 @@ export function estimateBashLinesChanged(command: string): BashLinesResult {
added += content.split('\\n').length;
}
const filePath = echoMatch[3];
if (filePath?.startsWith('/')) {
if (filePath?.trim()) {
files.push(filePath);
}
}

View file

@ -306,16 +306,16 @@ function buildTaskStatusProtocol(teamName: string): string {
- Typical flow:
a) Owner finishes work on #X task complete #X
b) Reviewer accepts review approve #X
10. CLARIFICATION PROTOCOL (IMPORTANT):
When you are blocked and need information to continue a task:
a) Do BOTH of these steps:
1) Send a message to your team lead via SendMessage explaining what you need.
2) Set the clarification flag on the task:
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-clarification <taskId> lead --from "<your-name>"
b) The flag is auto-cleared when the lead adds a task comment on your task.
10. CLARIFICATION PROTOCOL (CRITICAL MANDATORY):
When you are blocked and need information to continue a task, you MUST do BOTH steps below skipping the Bash command breaks the task board:
a) STEP 1 FIRST, set the clarification flag via Bash (this updates the task board):
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-clarification <taskId> lead --from "<your-name>"
b) STEP 2 THEN, send a message to your team lead via SendMessage explaining what you need.
IMPORTANT: Always run the Bash command BEFORE sending the message. The flag is what makes the task board show "needs clarification" without it, your request is invisible on the board.
c) The flag is auto-cleared when the lead adds a task comment on your task.
If the lead replies via SendMessage instead, clear the flag yourself once you have the answer:
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-clarification <taskId> clear --from "<your-name>"
c) Do NOT set clarification to "user" yourself only the team lead escalates to the user.
d) Do NOT set clarification to "user" yourself only the team lead escalates to the user.
Failure to follow this protocol means the task board will show incorrect status.`);
}
@ -347,12 +347,16 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string
`Notification policy:`,
`- The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task.`,
``,
`Clarification handling:`,
`Clarification handling (CRITICAL — MANDATORY for correct task board state):`,
`- When a teammate needs clarification (needsClarification: "lead"), reply via task comment (preferred — auto-clears the flag) or SendMessage.`,
`- If you reply via SendMessage instead of task comment, also clear the flag manually:`,
` node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-clarification <taskId> clear --from "${leadName}"`,
`- If you cannot answer and the user needs to decide, escalate to "user":`,
` node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-clarification <taskId> user --from "${leadName}"`,
`- If you cannot answer and the user needs to decide — ESCALATION PROTOCOL:`,
` 1) FIRST, set the flag to "user" via Bash (this updates the task board):`,
` node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-clarification <taskId> user --from "${leadName}"`,
` 2) THEN, send a message to "user" explaining the question.`,
` 3) THEN, reply to the teammate telling them to wait.`,
` IMPORTANT: Always run the Bash command BEFORE sending messages. Without the flag, the task board won't show that the task is blocked waiting for user input.`,
].join('\n')
);
}

View file

@ -309,6 +309,9 @@ export const TEAM_GET_DELETED_TASKS = 'team:getDeletedTasks';
/** Set needsClarification flag on a task */
export const TEAM_SET_TASK_CLARIFICATION = 'team:setTaskClarification';
/** Show native OS notification for a team message */
export const TEAM_SHOW_MESSAGE_NOTIFICATION = 'team:showMessageNotification';
// =============================================================================
// CLI Installer API Channels
// =============================================================================

View file

@ -69,6 +69,7 @@ import {
TEAM_RESTORE_TASK,
TEAM_SEND_MESSAGE,
TEAM_SET_TASK_CLARIFICATION,
TEAM_SHOW_MESSAGE_NOTIFICATION,
TEAM_SOFT_DELETE_TASK,
TEAM_START_TASK,
TEAM_STOP,
@ -161,6 +162,7 @@ import type {
TeamData,
TeamLaunchRequest,
TeamLaunchResponse,
TeamMessageNotificationData,
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
TeamSummary,
@ -710,6 +712,9 @@ const electronAPI: ElectronAPI = {
) => {
return invokeIpcWithResult<void>(TEAM_SET_TASK_CLARIFICATION, teamName, taskId, value);
},
showMessageNotification: async (data: TeamMessageNotificationData) => {
return invokeIpcWithResult<void>(TEAM_SHOW_MESSAGE_NOTIFICATION, data);
},
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
ipcRenderer.on(
TEAM_CHANGE,

View file

@ -796,6 +796,9 @@ export class HttpAPIClient implements ElectronAPI {
): Promise<void> => {
// Not available via HTTP client — no-op
},
showMessageNotification: async (): Promise<void> => {
// Not available via HTTP client — native notifications require Electron
},
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
return this.addEventListener('team-change', (data: unknown) =>
callback(null, data as TeamChangeEvent)

View file

@ -20,6 +20,7 @@ import {
} from '@renderer/constants/cssVariables';
import { getBaseName } from '@renderer/utils/pathUtils';
import { formatTokens } from '@shared/utils/tokenFormatting';
import { diffLines as semanticDiffLines } from 'diff';
import { Pencil } from 'lucide-react';
// =============================================================================
@ -41,9 +42,41 @@ interface DiffLine {
}
// =============================================================================
// Diff Algorithm (LCS-based)
// Diff Algorithm (LCS-based, with semantic fallback for large files)
// =============================================================================
/** Max LCS matrix cells before falling back to semantic diff.
* 1M cells 8MB RAM safe for all platforms. */
const MAX_LCS_CELLS = 1_000_000;
/**
* Fallback diff using semantic line-diffing from npm `diff` package.
* Used when LCS matrix would exceed memory threshold.
*/
function generateDiffFallback(oldLines: string[], newLines: string[]): DiffLine[] {
const oldText = oldLines.join('\n');
const newText = newLines.join('\n');
const changes = semanticDiffLines(oldText, newText);
const result: DiffLine[] = [];
let lineNumber = 1;
for (const change of changes) {
const changeLines = change.value.replace(/\r?\n$/, '').split(/\r?\n/);
for (const content of changeLines) {
if (change.added) {
result.push({ type: 'added', content, lineNumber: lineNumber++ });
} else if (change.removed) {
result.push({ type: 'removed', content, lineNumber: lineNumber++ });
} else {
result.push({ type: 'context', content, lineNumber: lineNumber++ });
}
}
}
return result;
}
/**
* Computes the Longest Common Subsequence matrix for two arrays of strings.
*/
@ -69,8 +102,13 @@ function computeLCSMatrix(oldLines: string[], newLines: string[]): number[][] {
/**
* Backtrack through LCS matrix to generate diff lines.
* Falls back to semantic diffing for large files to prevent OOM.
*/
function generateDiff(oldLines: string[], newLines: string[]): DiffLine[] {
if (oldLines.length * newLines.length > MAX_LCS_CELLS) {
return generateDiffFallback(oldLines, newLines);
}
const matrix = computeLCSMatrix(oldLines, newLines);
const result: DiffLine[] = [];
@ -276,7 +314,7 @@ const DiffLineRow: React.FC<DiffLineRowProps> = ({ line }): React.JSX.Element =>
</span>
{/* Content */}
<span className="flex-1 whitespace-pre" style={{ color: style.text }}>
{line.content || ' '}
{line.content ?? ' '}
</span>
</div>
);
@ -294,8 +332,8 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
tokenCount,
}): React.JSX.Element => {
// Compute diff
const oldLines = oldString.split('\n');
const newLines = newString.split('\n');
const oldLines = oldString.split(/\r?\n/);
const newLines = newString.split(/\r?\n/);
const diffLines = generateDiff(oldLines, newLines);
const stats = computeStats(diffLines);

View file

@ -125,21 +125,31 @@ function getAsyncLanguageDesc(fileName: string): LanguageDescription | null {
return LanguageDescription.matchFilename(languages, fileName);
}
/** Compute hunk index for the chunk at a given position (B-side / modified doc) */
/** Compute hunk index for the chunk at a given position (B-side / modified doc).
* If the position falls inside a chunk, returns that chunk's index.
* Otherwise returns the nearest chunk by distance (avoids defaulting to 0). */
function computeHunkIndexAtPos(state: EditorState, pos: number): number {
const chunks = getChunks(state);
if (!chunks) return 0;
if (!chunks || chunks.chunks.length === 0) return 0;
let nearestIndex = 0;
let nearestDist = Infinity;
let index = 0;
for (const chunk of chunks.chunks) {
// Only check B-side (modified doc) — editor positions correspond to B-side in unified view.
// A-side (fromA/toA) positions refer to the original document and are not editor positions.
// Exact match — position is inside this chunk
if (pos >= chunk.fromB && pos <= chunk.toB) {
return index;
}
// Track nearest chunk for fallback
const dist = Math.min(Math.abs(pos - chunk.fromB), Math.abs(pos - chunk.toB));
if (dist < nearestDist) {
nearestDist = dist;
nearestIndex = index;
}
index++;
}
return 0;
return nearestIndex;
}
/** Custom dark theme for diff view using CSS variables */
@ -773,7 +783,7 @@ export const CodeMirrorDiffView = ({
}
}
},
{ threshold: 1.0 }
{ threshold: 0.85 }
);
observer.observe(endSentinelRef.current);

View file

@ -53,7 +53,7 @@ export const FileSectionDiff = ({
if (localEditorViewRef.current) {
onEditorViewReady(file.filePath, localEditorViewRef.current);
}
});
}, [file.filePath, onEditorViewReady]);
// Auto-viewed sentinel observer
useEffect(() => {

View file

@ -655,9 +655,18 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
set((s) => {
const nextEdited = { ...s.editedContents };
delete nextEdited[filePath];
// Remove cached file content so next fetchFileContent re-reads from disk
// Update cached content in-place to avoid skeleton flash.
// Replace modifiedFullContent with saved version so CodeMirror
// reflects the new baseline without a full re-fetch cycle.
const nextContents = { ...s.fileContents };
delete nextContents[filePath];
const existing = nextContents[filePath];
if (existing) {
nextContents[filePath] = {
...existing,
modifiedFullContent: content,
contentSource: 'disk-current',
};
}
return { editedContents: nextEdited, fileContents: nextContents, applying: false };
});
} catch (error) {

View file

@ -29,6 +29,9 @@ import type {
import type { StateCreator } from 'zustand';
// --- Clarification notification tracking ---
// Native OS notifications for new inbox messages are handled in main process
// (main/index.ts → notifyNewInboxMessages). This renderer-side tracking only
// handles clarification-specific logic (e.g., marking tasks as needing user input).
const notifiedClarificationTaskKeys = new Set<string>();
let isFirstFetchAllTasks = true;
@ -49,11 +52,19 @@ function detectClarificationNotifications(oldTasks: GlobalTask[], newTasks: Glob
}
function fireClarificationNotification(task: GlobalTask): void {
if (typeof Notification === 'undefined') return;
if (Notification.permission !== 'granted') return;
new Notification('Clarification needed', {
body: `[${task.teamDisplayName}] Task #${task.id}: ${task.subject}`,
});
// Delegate to main process for native OS notification (cross-platform, no permission needed)
const latestComment = task.comments?.length ? task.comments[task.comments.length - 1] : undefined;
const body = latestComment?.text || task.description || `Task #${task.id}: ${task.subject}`;
void api.teams
?.showMessageNotification({
teamDisplayName: task.teamDisplayName,
from: latestComment?.author || 'team-lead',
to: 'user',
summary: `Clarification needed — Task #${task.id}`,
body,
})
.catch(() => undefined);
}
function mapSendMessageError(error: unknown): string {

View file

@ -46,6 +46,7 @@ import type {
TeamData,
TeamLaunchRequest,
TeamLaunchResponse,
TeamMessageNotificationData,
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
TeamSummary,
@ -440,6 +441,7 @@ export interface TeamsAPI {
softDeleteTask: (teamName: string, taskId: string) => Promise<void>;
restoreTask: (teamName: string, taskId: string) => Promise<void>;
getDeletedTasks: (teamName: string) => Promise<TeamTask[]>;
showMessageNotification: (data: TeamMessageNotificationData) => Promise<void>;
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void;
onProvisioningProgress: (
callback: (event: unknown, data: TeamProvisioningProgress) => void

View file

@ -374,3 +374,18 @@ export interface UpdateMemberRoleRequest {
name: string;
role: string | undefined;
}
/** Data sent from renderer to main for native OS team message notification. */
export interface TeamMessageNotificationData {
teamDisplayName: string;
/** Who sent the message. */
from: string;
/** Who received the message (member name or "user"). */
to?: string;
/** Short summary shown in subtitle. */
summary?: string;
/** Full message body — displayed as notification body (truncated to 300 chars). */
body: string;
/** Optional sender color for visual context. */
color?: string;
}

View file

@ -3,7 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('electron', () => ({
app: { getLocale: vi.fn(() => 'en'), getPath: vi.fn(() => '/tmp') },
Notification: vi.fn(),
Notification: Object.assign(vi.fn(), { isSupported: vi.fn(() => false) }),
BrowserWindow: { getAllWindows: vi.fn(() => []) },
}));
vi.mock('@preload/constants/ipcChannels', () => ({
@ -45,6 +46,7 @@ vi.mock('@preload/constants/ipcChannels', () => ({
TEAM_SOFT_DELETE_TASK: 'team:softDeleteTask',
TEAM_GET_DELETED_TASKS: 'team:getDeletedTasks',
TEAM_SET_TASK_CLARIFICATION: 'team:setTaskClarification',
TEAM_SHOW_MESSAGE_NOTIFICATION: 'team:showMessageNotification',
TEAM_RESTORE: 'team:restoreTeam',
TEAM_PERMANENTLY_DELETE: 'team:permanentlyDeleteTeam',
TEAM_RESTORE_TASK: 'team:restoreTask',