fix: resolve frontend lag and update dependencies (#526)

* perf: fix frontend lag with batched IPC events and optimized store updates

Critical performance fixes addressing 2-5s UI lag during task execution:

Frontend optimizations:
- Batch IPC log events (100+/sec → 6/sec batched updates)
- Add batchAppendLogs to task-store for efficient log appending
- Only set updatedAt on phase changes, not every progress tick
- Memoize sanitizeMarkdownForDisplay and formatRelativeTime in TaskCard
- Add React.memo with custom comparators to DroppableColumn
- Use IntersectionObserver to pause animations when cards not visible
- Reduce debug logging verbosity

Backend optimizations:
- Add project index caching with 5-minute TTL in client.py
- Batch StatusManager file writes with threading.Timer debounce

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore(deps): update all frontend dependencies including major versions

Updates all frontend dependencies to latest versions:
- react-resizable-panels 3.0.6 → 4.2.0 (breaking API change)
- globals 16.5.0 → 17.0.0
- lucide-react 0.560.0 → 0.562.0
- zod 4.2.1 → 4.3.4
- Plus other minor/patch updates

Updates TerminalGrid.tsx for react-resizable-panels v4 API:
- PanelGroup → Group
- PanelResizeHandle → Separator
- direction → orientation
- Removed order prop
- Changed div wrappers to React.Fragment for proper resize handling

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* debug: add extensive logging for PR review worktree creation

Adds debug print statements to troubleshoot worktree creation:
- _create_pr_worktree(): logs project_dir, worktree_dir, head_sha,
  fetch result, worktree add result, and final creation status
- review(): logs context.head_sha, context.head_branch, resolved
  head_sha, and worktree creation attempt/result
- _cleanup_pr_worktree(): logs cleanup calls and path existence

Also updates worktree path from .auto-claude/pr-review-worktrees/
to .auto-claude/github/pr/worktrees/ for better organization.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: make debug logging conditional on DEBUG=true env var

Wraps all [PRReview] DEBUG prints in `if DEBUG_MODE:` checks so they
only output when DEBUG=true is set in the environment. This matches
the frontend's debug mode system.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address PR review findings for thread safety and error handling

Fixes 8 issues from Auto Claude PR Review:
- Add try-catch for writeFileSync calls in execution-handlers.ts
- Add threading.Lock for debounced write timer in status.py
- Add threading.Lock for project index cache in client.py
- Clear batchTimeout on unmount in useIpc.ts
- Remove isVisible from IntersectionObserver deps in PhaseProgressIndicator.tsx
- Create stable onClick handlers via useMemo Map in KanbanBoard.tsx
- Remove unused imports (useCallback, useRef)

These changes prevent race conditions, memory leaks, and unnecessary
re-renders that were causing app lag and potential crashes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(core): address race conditions and thread safety issues

Fixes PR review findings and CodeQL security alerts:
- status.py: Move status mutation inside lock to prevent race conditions
- client.py: Add double-checked locking for project cache updates
- useIpc.ts: Fix stale closure risk with module-level storeActionsRef,
  change console.log to console.warn for ESLint compliance
- execution-handlers.ts: Add atomicWriteFileSync and safeReadFileSync
  helpers to prevent TOCTOU file system race conditions (CodeQL HIGH)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(core): complete thread safety and add JSON parse error handling

Addresses remaining PR review findings:
- status.py: Add _write_lock protection to all 7 status mutation methods
  (update, set_active, set_inactive, update_subtasks, update_phase,
  update_workers, update_session) for consistent thread safety
- execution-handlers.ts: Wrap JSON.parse in try-catch after safeReadFileSync
  to handle corrupted plan files gracefully

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(status): capture status snapshot inside lock to prevent race condition

Move `to_dict()` call inside `_write_lock` to ensure consistent snapshot.
Previously, the lock was released before calling `to_dict()`, allowing
concurrent modifications via `update()`, `set_active()`, etc. to produce
inconsistent state where some fields reflect old values and others new.

* fix(pr-review): detect new commits after force push and fix cache race condition

Three bugs were preventing follow-up reviews from triggering after new commits:

1. Force push detection: When a force push made the old reviewed commit
   unreachable, the GitHub comparison API would fail and the error handler
   incorrectly returned hasNewCommits: false. Now returns true if SHAs differ.

2. Cache race condition: setPRReviewResult() always cleared newCommitsCheck,
   causing a race where the cache was cleared before the new commit check
   could populate it during refresh. Added preserveNewCommitsCheck option.

3. State sync: PRDetail's useEffect only synced when initialNewCommitsCheck
   was not undefined, missing updates from null to a value. Now always syncs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-02 10:50:35 +01:00
committed by GitHub
parent 3db02c5d64
commit 30f7951a53
17 changed files with 3032 additions and 1732 deletions
+99 -2
View File
@@ -15,10 +15,107 @@ single source of truth for phase-aware tool and MCP server configuration.
import json
import logging
import os
import threading
import time
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# =============================================================================
# Project Index Cache
# =============================================================================
# Caches project index and capabilities to avoid reloading on every create_client() call.
# This significantly reduces the time to create new agent sessions.
_PROJECT_INDEX_CACHE: dict[str, tuple[dict[str, Any], dict[str, bool], float]] = {}
_CACHE_TTL_SECONDS = 300 # 5 minute TTL
_CACHE_LOCK = threading.Lock() # Protects _PROJECT_INDEX_CACHE access
def _get_cached_project_data(
project_dir: Path,
) -> tuple[dict[str, Any], dict[str, bool]]:
"""
Get project index and capabilities with caching.
Args:
project_dir: Path to the project directory
Returns:
Tuple of (project_index, project_capabilities)
"""
key = str(project_dir.resolve())
now = time.time()
debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
# Check cache with lock
with _CACHE_LOCK:
if key in _PROJECT_INDEX_CACHE:
cached_index, cached_capabilities, cached_time = _PROJECT_INDEX_CACHE[key]
cache_age = now - cached_time
if cache_age < _CACHE_TTL_SECONDS:
if debug:
print(
f"[ClientCache] Cache HIT for project index (age: {cache_age:.1f}s / TTL: {_CACHE_TTL_SECONDS}s)"
)
logger.debug(f"Using cached project index for {project_dir}")
return cached_index, cached_capabilities
elif debug:
print(
f"[ClientCache] Cache EXPIRED for project index (age: {cache_age:.1f}s > TTL: {_CACHE_TTL_SECONDS}s)"
)
# Cache miss or expired - load fresh data (outside lock to avoid blocking)
load_start = time.time()
logger.debug(f"Loading project index for {project_dir}")
project_index = load_project_index(project_dir)
project_capabilities = detect_project_capabilities(project_index)
if debug:
load_duration = (time.time() - load_start) * 1000
print(
f"[ClientCache] Cache MISS - loaded project index in {load_duration:.1f}ms"
)
# Store in cache with lock - use double-checked locking pattern
# Re-check if another thread populated the cache while we were loading
with _CACHE_LOCK:
if key in _PROJECT_INDEX_CACHE:
cached_index, cached_capabilities, cached_time = _PROJECT_INDEX_CACHE[key]
cache_age = time.time() - cached_time
if cache_age < _CACHE_TTL_SECONDS:
# Another thread already cached valid data while we were loading
if debug:
print(
"[ClientCache] Cache was populated by another thread, using cached data"
)
return cached_index, cached_capabilities
# Either no cache entry or it's expired - store our fresh data
_PROJECT_INDEX_CACHE[key] = (project_index, project_capabilities, time.time())
return project_index, project_capabilities
def invalidate_project_cache(project_dir: Path | None = None) -> None:
"""
Invalidate the project index cache.
Args:
project_dir: Specific project to invalidate, or None to clear all
"""
with _CACHE_LOCK:
if project_dir is None:
_PROJECT_INDEX_CACHE.clear()
logger.debug("Cleared all project index cache entries")
else:
key = str(project_dir.resolve())
if key in _PROJECT_INDEX_CACHE:
del _PROJECT_INDEX_CACHE[key]
logger.debug(f"Invalidated project index cache for {project_dir}")
from agents.tools_pkg import (
CONTEXT7_TOOLS,
ELECTRON_TOOLS,
@@ -396,8 +493,8 @@ def create_client(
# Load project capabilities for dynamic MCP tool selection
# This enables context-aware tool injection based on project type
project_index = load_project_index(project_dir)
project_capabilities = detect_project_capabilities(project_index)
# Uses caching to avoid reloading on every create_client() call
project_index, project_capabilities = _get_cached_project_data(project_dir)
# Load per-project MCP configuration from .auto-claude/.env
mcp_config = load_project_mcp_config(project_dir)
@@ -63,8 +63,8 @@ logger = logging.getLogger(__name__)
# Check if debug mode is enabled
DEBUG_MODE = os.environ.get("DEBUG", "").lower() in ("true", "1", "yes")
# Directory for PR review worktrees (project-local, same filesystem)
PR_WORKTREE_DIR = ".auto-claude/pr-review-worktrees"
# Directory for PR review worktrees (inside github/pr for consistency)
PR_WORKTREE_DIR = ".auto-claude/github/pr/worktrees"
class ParallelOrchestratorReviewer:
@@ -137,16 +137,40 @@ class ParallelOrchestratorReviewer:
"""
worktree_name = f"pr-{pr_number}-{uuid.uuid4().hex[:8]}"
worktree_dir = self.project_dir / PR_WORKTREE_DIR
if DEBUG_MODE:
print(f"[PRReview] DEBUG: project_dir={self.project_dir}", flush=True)
print(f"[PRReview] DEBUG: worktree_dir={worktree_dir}", flush=True)
print(f"[PRReview] DEBUG: head_sha={head_sha}", flush=True)
worktree_dir.mkdir(parents=True, exist_ok=True)
worktree_path = worktree_dir / worktree_name
if DEBUG_MODE:
print(f"[PRReview] DEBUG: worktree_path={worktree_path}", flush=True)
print(
f"[PRReview] DEBUG: worktree_dir exists={worktree_dir.exists()}",
flush=True,
)
# Fetch the commit if not available locally (handles fork PRs)
subprocess.run(
fetch_result = subprocess.run(
["git", "fetch", "origin", head_sha],
cwd=self.project_dir,
capture_output=True,
text=True,
timeout=60,
)
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: fetch returncode={fetch_result.returncode}",
flush=True,
)
if fetch_result.stderr:
print(
f"[PRReview] DEBUG: fetch stderr={fetch_result.stderr[:200]}",
flush=True,
)
# Create detached worktree at the PR commit
result = subprocess.run(
@@ -156,11 +180,31 @@ class ParallelOrchestratorReviewer:
text=True,
)
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: worktree add returncode={result.returncode}",
flush=True,
)
if result.stderr:
print(
f"[PRReview] DEBUG: worktree add stderr={result.stderr[:200]}",
flush=True,
)
if result.stdout:
print(
f"[PRReview] DEBUG: worktree add stdout={result.stdout[:200]}",
flush=True,
)
if result.returncode != 0:
raise RuntimeError(f"Failed to create worktree: {result.stderr}")
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: worktree created, exists={worktree_path.exists()}",
flush=True,
)
logger.info(f"[PRReview] Created worktree at {worktree_path}")
print(f"[PRReview] Created worktree at {worktree_path.name}", flush=True)
return worktree_path
def _cleanup_pr_worktree(self, worktree_path: Path) -> None:
@@ -169,19 +213,42 @@ class ParallelOrchestratorReviewer:
Args:
worktree_path: Path to the worktree to remove
"""
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: _cleanup_pr_worktree called with {worktree_path}",
flush=True,
)
if not worktree_path or not worktree_path.exists():
if DEBUG_MODE:
print(
"[PRReview] DEBUG: worktree path doesn't exist, skipping cleanup",
flush=True,
)
return
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: Attempting to remove worktree at {worktree_path}",
flush=True,
)
# Try 1: git worktree remove
result = subprocess.run(
["git", "worktree", "remove", "--force", str(worktree_path)],
cwd=self.project_dir,
capture_output=True,
text=True,
)
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: worktree remove returncode={result.returncode}",
flush=True,
)
if result.returncode == 0:
logger.info(f"[PRReview] Cleaned up worktree: {worktree_path.name}")
print(f"[PRReview] Cleaned up worktree: {worktree_path.name}", flush=True)
return
# Try 2: shutil.rmtree fallback
@@ -229,7 +296,11 @@ class ParallelOrchestratorReviewer:
cwd=self.project_dir,
capture_output=True,
)
print(f"[PRReview] Cleaned up {stale_count} stale worktree(s)", flush=True)
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: Cleaned up {stale_count} stale worktree(s)",
flush=True,
)
def _define_specialist_agents(self) -> dict[str, AgentDefinition]:
"""
@@ -535,7 +606,21 @@ The SDK will run invoked agents in parallel automatically.
# Create temporary worktree at PR head commit for isolated review
# This ensures agents read from the correct PR state, not the current checkout
head_sha = context.head_sha or context.head_branch
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: context.head_sha='{context.head_sha}'",
flush=True,
)
print(
f"[PRReview] DEBUG: context.head_branch='{context.head_branch}'",
flush=True,
)
print(f"[PRReview] DEBUG: resolved head_sha='{head_sha}'", flush=True)
if not head_sha:
if DEBUG_MODE:
print("[PRReview] DEBUG: No head_sha - using fallback", flush=True)
logger.warning(
"[ParallelOrchestrator] No head_sha available, using current checkout"
)
@@ -546,12 +631,28 @@ The SDK will run invoked agents in parallel automatically.
else self.project_dir
)
else:
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: Creating worktree for head_sha={head_sha}",
flush=True,
)
try:
worktree_path = self._create_pr_worktree(
head_sha, context.pr_number
)
project_root = worktree_path
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: Using worktree as "
f"project_root={project_root}",
flush=True,
)
except RuntimeError as e:
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: Worktree creation FAILED: {e}",
flush=True,
)
logger.warning(
f"[ParallelOrchestrator] Worktree creation failed, "
f"using current checkout: {e}"
+128 -34
View File
@@ -6,6 +6,7 @@ Build status tracking and status file management for ccstatusline integration.
"""
import json
import threading
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
@@ -104,10 +105,16 @@ class BuildStatus:
class StatusManager:
"""Manages the .auto-claude-status file for ccstatusline integration."""
# Class-level debounce delay (ms) for batched writes
_WRITE_DEBOUNCE_MS = 50
def __init__(self, project_dir: Path):
self.project_dir = Path(project_dir)
self.status_file = self.project_dir / ".auto-claude-status"
self._status = BuildStatus()
self._write_pending = False
self._write_timer: threading.Timer | None = None
self._write_lock = threading.Lock() # Protects _write_pending and _write_timer
def read(self) -> BuildStatus:
"""Read current status from file."""
@@ -122,38 +129,114 @@ class StatusManager:
except (OSError, json.JSONDecodeError):
return BuildStatus()
def write(self, status: BuildStatus = None) -> None:
"""Write status to file."""
if status:
self._status = status
self._status.last_update = datetime.now().isoformat()
def _do_write(self) -> None:
"""Perform the actual file write."""
import os
import time
debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
write_start = time.time()
with self._write_lock:
self._write_pending = False
self._write_timer = None
# Update timestamp inside lock to prevent race conditions
self._status.last_update = datetime.now().isoformat()
# Capture consistent snapshot while holding lock
status_dict = self._status.to_dict()
try:
with open(self.status_file, "w") as f:
json.dump(self._status.to_dict(), f, indent=2)
json.dump(status_dict, f, indent=2)
if debug:
write_duration = (time.time() - write_start) * 1000
print(
f"[StatusManager] Batched write completed in {write_duration:.2f}ms"
)
except OSError as e:
print(warning(f"Could not write status file: {e}"))
def _schedule_write(self) -> None:
"""Schedule a debounced write to batch multiple updates."""
import os
debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
with self._write_lock:
if self._write_timer is not None:
self._write_timer.cancel()
if debug:
print(
"[StatusManager] Cancelled pending write, batching with new update"
)
self._write_pending = True
self._write_timer = threading.Timer(
self._WRITE_DEBOUNCE_MS / 1000.0, self._do_write
)
self._write_timer.start()
if debug:
print(
f"[StatusManager] Scheduled batched write in {self._WRITE_DEBOUNCE_MS}ms"
)
def write(self, status: BuildStatus = None, immediate: bool = False) -> None:
"""Write status to file.
Args:
status: Optional status to set before writing
immediate: If True, write immediately without debouncing
"""
# Protect status assignment with lock to prevent race conditions
with self._write_lock:
if status:
self._status = status
if immediate:
# Cancel any pending debounced write
with self._write_lock:
if self._write_timer is not None:
self._write_timer.cancel()
self._write_timer = None
self._do_write()
else:
self._schedule_write()
def flush(self) -> None:
"""Force any pending writes to complete immediately."""
with self._write_lock:
should_write = self._write_pending
if self._write_timer is not None:
self._write_timer.cancel()
self._write_timer = None
if should_write:
self._do_write()
def update(self, **kwargs) -> None:
"""Update specific status fields."""
for key, value in kwargs.items():
if hasattr(self._status, key):
setattr(self._status, key, value)
with self._write_lock:
for key, value in kwargs.items():
if hasattr(self._status, key):
setattr(self._status, key, value)
self.write()
def set_active(self, spec: str, state: BuildState) -> None:
"""Mark build as active."""
self._status.active = True
self._status.spec = spec
self._status.state = state
self._status.session_started = datetime.now().isoformat()
self.write()
"""Mark build as active. Writes immediately for visibility."""
with self._write_lock:
self._status.active = True
self._status.spec = spec
self._status.state = state
self._status.session_started = datetime.now().isoformat()
self.write(immediate=True)
def set_inactive(self) -> None:
"""Mark build as inactive."""
self._status.active = False
self._status.state = BuildState.IDLE
self.write()
"""Mark build as inactive. Writes immediately for visibility."""
with self._write_lock:
self._status.active = False
self._status.state = BuildState.IDLE
self.write(immediate=True)
def update_subtasks(
self,
@@ -163,37 +246,48 @@ class StatusManager:
failed: int = None,
) -> None:
"""Update subtask progress."""
if completed is not None:
self._status.subtasks_completed = completed
if total is not None:
self._status.subtasks_total = total
if in_progress is not None:
self._status.subtasks_in_progress = in_progress
if failed is not None:
self._status.subtasks_failed = failed
with self._write_lock:
if completed is not None:
self._status.subtasks_completed = completed
if total is not None:
self._status.subtasks_total = total
if in_progress is not None:
self._status.subtasks_in_progress = in_progress
if failed is not None:
self._status.subtasks_failed = failed
self.write()
def update_phase(self, current: str, phase_id: int = 0, total: int = 0) -> None:
"""Update current phase."""
self._status.phase_current = current
self._status.phase_id = phase_id
self._status.phase_total = total
with self._write_lock:
self._status.phase_current = current
self._status.phase_id = phase_id
self._status.phase_total = total
self.write()
def update_workers(self, active: int, max_workers: int = None) -> None:
"""Update worker count."""
self._status.workers_active = active
if max_workers is not None:
self._status.workers_max = max_workers
with self._write_lock:
self._status.workers_active = active
if max_workers is not None:
self._status.workers_max = max_workers
self.write()
def update_session(self, number: int) -> None:
"""Update session number."""
self._status.session_number = number
with self._write_lock:
self._status.session_number = number
self.write()
def clear(self) -> None:
"""Remove status file."""
# Cancel any pending writes
with self._write_lock:
if self._write_timer is not None:
self._write_timer.cancel()
self._write_timer = None
self._write_pending = False
if self.status_file.exists():
try:
self.status_file.unlink()
+1872 -1404
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -81,13 +81,13 @@
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"i18next": "^25.7.3",
"lucide-react": "^0.560.0",
"lucide-react": "^0.562.0",
"motion": "^12.23.26",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-i18next": "^16.5.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.6",
"react-resizable-panels": "^4.2.0",
"remark-gfm": "^4.0.1",
"semver": "^7.7.3",
"tailwind-merge": "^3.4.0",
@@ -117,7 +117,7 @@
"eslint": "^9.39.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"globals": "^16.5.0",
"globals": "^17.0.0",
"husky": "^9.1.7",
"jsdom": "^27.3.0",
"lint-staged": "^16.2.7",
@@ -1341,25 +1341,32 @@ export function registerPRHandlers(
return { hasNewCommits: false, newCommitCount: 0 };
}
// Fetch PR data to get current HEAD (before try block so it's accessible in catch)
let currentHeadSha: string;
try {
// Get PR data to find current HEAD
const prData = (await githubFetch(
config.token,
`/repos/${config.repo}/pulls/${prNumber}`
)) as { head: { sha: string }; commits: number };
currentHeadSha = prData.head.sha;
} catch (error) {
debugLog('Error fetching PR data', { prNumber, error: error instanceof Error ? error.message : error });
return { hasNewCommits: false, newCommitCount: 0 };
}
const currentHeadSha = prData.head.sha;
if (reviewedCommitSha === currentHeadSha) {
return {
hasNewCommits: false,
newCommitCount: 0,
lastReviewedCommit: reviewedCommitSha,
currentHeadCommit: currentHeadSha,
hasCommitsAfterPosting: false,
};
}
// Early return if SHAs match - no new commits
if (reviewedCommitSha === currentHeadSha) {
return {
hasNewCommits: false,
newCommitCount: 0,
lastReviewedCommit: reviewedCommitSha,
currentHeadCommit: currentHeadSha,
hasCommitsAfterPosting: false,
};
}
// Try to get detailed comparison
try {
// Get comparison to count new commits
const comparison = (await githubFetch(
config.token,
@@ -1396,8 +1403,21 @@ export function registerPRHandlers(
hasCommitsAfterPosting,
};
} catch (error) {
debugLog('Error checking new commits', { prNumber, error: error instanceof Error ? error.message : error });
return { hasNewCommits: false, newCommitCount: 0 };
// Comparison failed (e.g., force push made old commit unreachable)
// Since we already verified SHAs differ, treat as having new commits
debugLog('Comparison failed but SHAs differ - likely force push, treating as new commits', {
prNumber,
reviewedCommitSha,
currentHeadSha,
error: error instanceof Error ? error.message : error
});
return {
hasNewCommits: true,
newCommitCount: 1, // Unknown count due to force push
lastReviewedCommit: reviewedCommitSha,
currentHeadCommit: currentHeadSha,
hasCommitsAfterPosting: true, // Assume yes for force push scenarios
};
}
});
@@ -2,7 +2,7 @@ import { ipcMain, BrowserWindow } from 'electron';
import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir } from '../../../shared/constants';
import type { IPCResult, TaskStartOptions, TaskStatus } from '../../../shared/types';
import path from 'path';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from 'fs';
import { spawnSync } from 'child_process';
import { AgentManager } from '../../agent';
import { fileWatcher } from '../../file-watcher';
@@ -16,6 +16,43 @@ import {
createPlanIfNotExists
} from './plan-file-utils';
/**
* Atomic file write to prevent TOCTOU race conditions.
* Writes to a temporary file first, then atomically renames to target.
* This ensures the target file is never in an inconsistent state.
*/
function atomicWriteFileSync(filePath: string, content: string): void {
const tempPath = `${filePath}.${process.pid}.tmp`;
try {
writeFileSync(tempPath, content, 'utf-8');
renameSync(tempPath, filePath);
} catch (error) {
// Clean up temp file if rename failed
try {
unlinkSync(tempPath);
} catch {
// Ignore cleanup errors
}
throw error;
}
}
/**
* Safe file read that handles missing files without TOCTOU issues.
* Returns null if file doesn't exist or can't be read.
*/
function safeReadFileSync(filePath: string): string | null {
try {
return readFileSync(filePath, 'utf-8');
} catch (error) {
// ENOENT (file not found) is expected, other errors should be logged
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error(`[safeReadFileSync] Error reading ${filePath}:`, error);
}
return null;
}
}
/**
* Helper function to check subtask completion status
*/
@@ -175,24 +212,44 @@ export function registerTaskExecutionHandlers(
);
}
// CRITICAL: Persist status to implementation_plan.json to prevent status flip-flop
// When getTasks() is called (on refresh), it reads status from the plan file.
// Without persisting here, the old status (e.g., 'human_review') would override
// the in-memory 'in_progress' status, causing the task to flip back and forth.
// Uses shared utility for consistency with agent-events-handlers.ts
const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN);
const persisted = persistPlanStatusSync(planPath, 'in_progress');
if (persisted) {
console.warn('[TASK_START] Updated plan status to: in_progress');
}
// Note: Plan file may not exist yet for new tasks - that's fine (persistPlanStatusSync handles ENOENT)
// Notify status change
// Notify status change IMMEDIATELY (don't wait for file write)
// This provides instant UI feedback while file persistence happens in background
const ipcSentAt = Date.now();
mainWindow.webContents.send(
IPC_CHANNELS.TASK_STATUS_CHANGE,
taskId,
'in_progress'
);
const DEBUG = process.env.DEBUG === 'true';
if (DEBUG) {
console.log(`[TASK_START] IPC sent immediately for task ${taskId}, deferring file persistence`);
}
// CRITICAL: Persist status to implementation_plan.json to prevent status flip-flop
// When getTasks() is called (on refresh), it reads status from the plan file.
// Without persisting here, the old status (e.g., 'human_review') would override
// the in-memory 'in_progress' status, causing the task to flip back and forth.
// Uses shared utility for consistency with agent-events-handlers.ts
// NOTE: This is now async and non-blocking for better UI responsiveness
const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN);
setImmediate(async () => {
const persistStart = Date.now();
try {
const persisted = await persistPlanStatus(planPath, 'in_progress');
if (persisted) {
console.warn('[TASK_START] Updated plan status to: in_progress');
}
if (DEBUG) {
const delay = persistStart - ipcSentAt;
const duration = Date.now() - persistStart;
console.log(`[TASK_START] File persistence: delayed ${delay}ms after IPC, completed in ${duration}ms`);
}
} catch (err) {
console.error('[TASK_START] Failed to persist plan status:', err);
}
});
// Note: Plan file may not exist yet for new tasks - that's fine (persistPlanStatus handles ENOENT)
}
);
@@ -200,23 +257,13 @@ export function registerTaskExecutionHandlers(
* Stop a task
*/
ipcMain.on(IPC_CHANNELS.TASK_STOP, (_, taskId: string) => {
const DEBUG = process.env.DEBUG === 'true';
agentManager.killTask(taskId);
fileWatcher.unwatch(taskId);
// Find task and project to update the plan file
const { task, project } = findTaskAndProject(taskId);
if (task && project) {
// Persist status to implementation_plan.json to prevent status flip-flop on refresh
// Uses shared utility for consistency with agent-events-handlers.ts
const planPath = getPlanPath(project, task);
const persisted = persistPlanStatusSync(planPath, 'backlog');
if (persisted) {
console.warn('[TASK_STOP] Updated plan status to backlog');
}
// Note: File not found is expected for tasks without a plan file (persistPlanStatusSync handles ENOENT)
}
// Notify status change IMMEDIATELY for instant UI feedback
const ipcSentAt = Date.now();
const mainWindow = getMainWindow();
if (mainWindow) {
mainWindow.webContents.send(
@@ -225,6 +272,37 @@ export function registerTaskExecutionHandlers(
'backlog'
);
}
if (DEBUG) {
console.log(`[TASK_STOP] IPC sent immediately for task ${taskId}, deferring file persistence`);
}
// Find task and project to update the plan file (async, non-blocking)
const { task, project } = findTaskAndProject(taskId);
if (task && project) {
// Persist status to implementation_plan.json to prevent status flip-flop on refresh
// Uses shared utility for consistency with agent-events-handlers.ts
// NOTE: This is now async and non-blocking for better UI responsiveness
const planPath = getPlanPath(project, task);
setImmediate(async () => {
const persistStart = Date.now();
try {
const persisted = await persistPlanStatus(planPath, 'backlog');
if (persisted) {
console.warn('[TASK_STOP] Updated plan status to backlog');
}
if (DEBUG) {
const delay = persistStart - ipcSentAt;
const duration = Date.now() - persistStart;
console.log(`[TASK_STOP] File persistence: delayed ${delay}ms after IPC, completed in ${duration}ms`);
}
} catch (err) {
console.error('[TASK_STOP] Failed to persist plan status:', err);
}
});
// Note: File not found is expected for tasks without a plan file (persistPlanStatus handles ENOENT)
}
});
/**
@@ -261,10 +339,15 @@ export function registerTaskExecutionHandlers(
if (approved) {
// Write approval to QA report
const qaReportPath = path.join(specDir, AUTO_BUILD_PATHS.QA_REPORT);
writeFileSync(
qaReportPath,
`# QA Review\n\nStatus: APPROVED\n\nReviewed at: ${new Date().toISOString()}\n`
);
try {
writeFileSync(
qaReportPath,
`# QA Review\n\nStatus: APPROVED\n\nReviewed at: ${new Date().toISOString()}\n`
);
} catch (error) {
console.error('[TASK_REVIEW] Failed to write QA report:', error);
return { success: false, error: 'Failed to write QA report file' };
}
const mainWindow = getMainWindow();
if (mainWindow) {
@@ -320,10 +403,15 @@ export function registerTaskExecutionHandlers(
console.warn('[TASK_REVIEW] Writing QA fix request to:', fixRequestPath);
console.warn('[TASK_REVIEW] hasWorktree:', hasWorktree, 'worktreePath:', worktreePath);
writeFileSync(
fixRequestPath,
`# QA Fix Request\n\nStatus: REJECTED\n\n## Feedback\n\n${feedback || 'No feedback provided'}\n\nCreated at: ${new Date().toISOString()}\n`
);
try {
writeFileSync(
fixRequestPath,
`# QA Fix Request\n\nStatus: REJECTED\n\n## Feedback\n\n${feedback || 'No feedback provided'}\n\nCreated at: ${new Date().toISOString()}\n`
);
} catch (error) {
console.error('[TASK_REVIEW] Failed to write QA fix request:', error);
return { success: false, error: 'Failed to write QA fix request file' };
}
// Restart QA process - use worktree path if it exists, otherwise main project
// The QA process needs to run where the implementation_plan.json with completed subtasks is
@@ -597,10 +685,19 @@ export function registerTaskExecutionHandlers(
try {
// Read the plan to analyze subtask progress
// Using safe read to avoid TOCTOU race conditions
let plan: Record<string, unknown> | null = null;
if (existsSync(planPath)) {
const planContent = readFileSync(planPath, 'utf-8');
plan = JSON.parse(planContent);
const planContent = safeReadFileSync(planPath);
if (planContent) {
try {
plan = JSON.parse(planContent);
} catch (parseError) {
console.error('[Recovery] Failed to parse plan file as JSON:', parseError);
return {
success: false,
error: 'Plan file contains invalid JSON. The file may be corrupted.'
};
}
}
// Determine the target status intelligently based on subtask progress
@@ -646,7 +743,16 @@ export function registerTaskExecutionHandlers(
// Just update status in plan file (project store reads from file, no separate update needed)
plan.status = 'human_review';
plan.planStatus = 'review';
writeFileSync(planPath, JSON.stringify(plan, null, 2));
try {
// Use atomic write to prevent TOCTOU race conditions
atomicWriteFileSync(planPath, JSON.stringify(plan, null, 2));
} catch (writeError) {
console.error('[Recovery] Failed to write plan file:', writeError);
return {
success: false,
error: 'Failed to write plan file'
};
}
return {
success: true,
@@ -691,7 +797,16 @@ export function registerTaskExecutionHandlers(
}
}
writeFileSync(planPath, JSON.stringify(plan, null, 2));
try {
// Use atomic write to prevent TOCTOU race conditions
atomicWriteFileSync(planPath, JSON.stringify(plan, null, 2));
} catch (writeError) {
console.error('[Recovery] Failed to write plan file:', writeError);
return {
success: false,
error: 'Failed to write plan file during recovery'
};
}
}
// Stop file watcher if it was watching this task
@@ -742,7 +857,14 @@ export function registerTaskExecutionHandlers(
if (plan) {
plan.status = 'in_progress';
plan.planStatus = 'in_progress';
writeFileSync(planPath, JSON.stringify(plan, null, 2));
try {
// Use atomic write to prevent TOCTOU race conditions
atomicWriteFileSync(planPath, JSON.stringify(plan, null, 2));
} catch (writeError) {
console.error('[Recovery] Failed to write plan file for restart:', writeError);
// Continue with restart attempt even if file write fails
// The plan status will be updated by the agent when it starts
}
}
// Start the task execution
@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react';
import { useState, useMemo, memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useViewState } from '../contexts/ViewStateContext';
import {
@@ -44,6 +44,55 @@ interface DroppableColumnProps {
onArchiveAll?: () => void;
}
/**
* Compare two tasks arrays for meaningful changes.
* Returns true if tasks are equivalent (should skip re-render).
*/
function tasksAreEquivalent(prevTasks: Task[], nextTasks: Task[]): boolean {
if (prevTasks.length !== nextTasks.length) return false;
if (prevTasks === nextTasks) return true;
// Compare by ID and fields that affect rendering
for (let i = 0; i < prevTasks.length; i++) {
const prev = prevTasks[i];
const next = nextTasks[i];
if (
prev.id !== next.id ||
prev.status !== next.status ||
prev.executionProgress?.phase !== next.executionProgress?.phase ||
prev.updatedAt !== next.updatedAt
) {
return false;
}
}
return true;
}
/**
* Custom comparator for DroppableColumn memo.
*/
function droppableColumnPropsAreEqual(
prevProps: DroppableColumnProps,
nextProps: DroppableColumnProps
): boolean {
// Quick checks first
if (prevProps.status !== nextProps.status) return false;
if (prevProps.isOver !== nextProps.isOver) return false;
if (prevProps.onTaskClick !== nextProps.onTaskClick) return false;
if (prevProps.onAddClick !== nextProps.onAddClick) return false;
if (prevProps.onArchiveAll !== nextProps.onArchiveAll) return false;
// Deep compare tasks
const tasksEqual = tasksAreEquivalent(prevProps.tasks, nextProps.tasks);
// Only log when re-rendering (reduces noise)
if (window.DEBUG && !tasksEqual) {
console.log(`[DroppableColumn] Re-render: ${nextProps.status} column (${nextProps.tasks.length} tasks)`);
}
return tasksEqual;
}
// Empty state content for each column
const getEmptyStateContent = (status: TaskStatus, t: (key: string) => string): { icon: React.ReactNode; message: string; subtext?: string } => {
switch (status) {
@@ -85,13 +134,35 @@ const getEmptyStateContent = (status: TaskStatus, t: (key: string) => string): {
}
};
function DroppableColumn({ status, tasks, onTaskClick, isOver, onAddClick, onArchiveAll }: DroppableColumnProps) {
const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskClick, isOver, onAddClick, onArchiveAll }: DroppableColumnProps) {
const { t } = useTranslation('tasks');
const { setNodeRef } = useDroppable({
id: status
});
const taskIds = tasks.map((t) => t.id);
// Memoize taskIds to prevent SortableContext from re-rendering unnecessarily
const taskIds = useMemo(() => tasks.map((t) => t.id), [tasks]);
// Create stable onClick handlers for each task to prevent unnecessary re-renders
const onClickHandlers = useMemo(() => {
const handlers = new Map<string, () => void>();
tasks.forEach((task) => {
handlers.set(task.id, () => onTaskClick(task));
});
return handlers;
}, [tasks, onTaskClick]);
// Memoize task card elements to prevent recreation on every render
const taskCards = useMemo(() => {
if (tasks.length === 0) return null;
return tasks.map((task) => (
<SortableTaskCard
key={task.id}
task={task}
onClick={onClickHandlers.get(task.id)!}
/>
));
}, [tasks, onClickHandlers]);
const getColumnBorderColor = (): string => {
switch (status) {
@@ -194,13 +265,7 @@ function DroppableColumn({ status, tasks, onTaskClick, isOver, onAddClick, onArc
)}
</div>
) : (
tasks.map((task) => (
<SortableTaskCard
key={task.id}
task={task}
onClick={() => onTaskClick(task)}
/>
))
taskCards
)}
</div>
</SortableContext>
@@ -208,7 +273,7 @@ function DroppableColumn({ status, tasks, onTaskClick, isOver, onAddClick, onArc
</div>
</div>
);
}
}, droppableColumnPropsAreEqual);
export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick }: KanbanBoardProps) {
const { t } = useTranslation('tasks');
@@ -1,5 +1,6 @@
import { motion, AnimatePresence } from 'motion/react';
import { useTranslation } from 'react-i18next';
import { memo, useRef, useState, useEffect } from 'react';
import { cn } from '../lib/utils';
import type { ExecutionPhase, TaskLogs, Subtask } from '../../shared/types';
@@ -39,8 +40,10 @@ const PHASE_LABEL_KEYS: Record<ExecutionPhase, string> = {
* - Planning/Validation: Shows animated activity bar with entry count
* - Coding: Shows subtask-based percentage progress
* - Stuck: Shows warning state with interrupted animation
*
* Performance: Uses IntersectionObserver to pause animations when not visible
*/
export function PhaseProgressIndicator({
export const PhaseProgressIndicator = memo(function PhaseProgressIndicator({
phase: rawPhase,
subtasks,
phaseLogs,
@@ -50,6 +53,35 @@ export function PhaseProgressIndicator({
}: PhaseProgressIndicatorProps) {
const { t } = useTranslation('tasks');
const phase = rawPhase || 'idle';
const containerRef = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(true);
const prevVisibleRef = useRef(true);
// Use IntersectionObserver to pause animations when component is not visible
useEffect(() => {
const element = containerRef.current;
if (!element) return;
const observer = new IntersectionObserver(
([entry]) => {
const nowVisible = entry.isIntersecting;
if (prevVisibleRef.current !== nowVisible && window.DEBUG) {
console.log(`[PhaseProgress] Visibility changed: ${prevVisibleRef.current} -> ${nowVisible}, animations ${nowVisible ? 'resumed' : 'paused'}`);
}
prevVisibleRef.current = nowVisible;
setIsVisible(nowVisible);
},
{ threshold: 0.1 }
);
observer.observe(element);
return () => observer.disconnect();
}, []);
// Only animate when visible and running
const shouldAnimate = isVisible && isRunning && !isStuck;
// Calculate subtask-based progress (for coding phase)
const completedSubtasks = subtasks.filter((c) => c.status === 'completed').length;
@@ -77,26 +109,26 @@ export function PhaseProgressIndicator({
const activeEntries = getActivePhaseEntries();
return (
<div className={cn('space-y-1.5', className)}>
<div ref={containerRef} className={cn('space-y-1.5', className)}>
{/* Progress label row */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{isStuck ? t('execution.labels.interrupted') : showSubtaskProgress ? t('execution.labels.progress') : phaseLabel}
</span>
{/* Activity indicator dot for non-coding phases */}
{/* Activity indicator dot for non-coding phases - only animate when visible */}
{isRunning && !isStuck && isIndeterminatePhase && (
<motion.div
className={cn('h-1.5 w-1.5 rounded-full', colors.color)}
animate={{
animate={shouldAnimate ? {
scale: [1, 1.5, 1],
opacity: [1, 0.5, 1],
}}
transition={{
} : { scale: 1, opacity: 1 }}
transition={shouldAnimate ? {
duration: 1,
repeat: Infinity,
ease: 'easeInOut',
}}
} : undefined}
/>
)}
</div>
@@ -122,13 +154,13 @@ export function PhaseProgressIndicator({
>
<AnimatePresence mode="wait">
{isStuck ? (
// Stuck/Interrupted state - pulsing warning bar
// Stuck/Interrupted state - pulsing warning bar (only animate when visible)
<motion.div
key="stuck"
className="absolute inset-0 bg-warning/40"
initial={{ opacity: 0 }}
animate={{ opacity: [0.3, 0.6, 0.3] }}
transition={{ duration: 2, repeat: Infinity, ease: 'easeInOut' }}
animate={isVisible ? { opacity: [0.3, 0.6, 0.3] } : { opacity: 0.45 }}
transition={isVisible ? { duration: 2, repeat: Infinity, ease: 'easeInOut' } : undefined}
/>
) : showSubtaskProgress ? (
// Determinate progress for coding phase
@@ -139,8 +171,8 @@ export function PhaseProgressIndicator({
animate={{ width: `${subtaskProgress}%` }}
transition={{ duration: 0.5, ease: 'easeOut' }}
/>
) : isRunning && isIndeterminatePhase ? (
// Indeterminate animated progress for planning/validation
) : shouldAnimate && isIndeterminatePhase ? (
// Indeterminate animated progress for planning/validation (only when visible)
<motion.div
key="indeterminate"
className={cn('absolute h-full w-1/3 rounded-full', colors.color)}
@@ -153,6 +185,12 @@ export function PhaseProgressIndicator({
ease: 'easeInOut',
}}
/>
) : isRunning && isIndeterminatePhase && !isVisible ? (
// Static placeholder when not visible but running
<motion.div
key="indeterminate-static"
className={cn('absolute h-full w-1/3 rounded-full left-1/3', colors.color)}
/>
) : totalSubtasks > 0 ? (
// Static progress based on subtasks (when not running)
<motion.div
@@ -169,37 +207,44 @@ export function PhaseProgressIndicator({
{/* Subtask indicators (only show when subtasks exist) */}
{totalSubtasks > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{subtasks.slice(0, 10).map((subtask, index) => (
<motion.div
key={subtask.id || `subtask-${index}`}
className={cn(
'h-2 w-2 rounded-full',
subtask.status === 'completed' && 'bg-success',
subtask.status === 'in_progress' && 'bg-info',
subtask.status === 'failed' && 'bg-destructive',
subtask.status === 'pending' && 'bg-muted-foreground/30'
)}
initial={{ scale: 0, opacity: 0 }}
animate={{
scale: 1,
opacity: 1,
...(subtask.status === 'in_progress' && {
boxShadow: [
'0 0 0 0 rgba(var(--info), 0.4)',
'0 0 0 4px rgba(var(--info), 0)',
],
}),
}}
transition={{
scale: { delay: index * 0.03, duration: 0.2 },
opacity: { delay: index * 0.03, duration: 0.2 },
boxShadow: subtask.status === 'in_progress'
? { duration: 1, repeat: Infinity, ease: 'easeOut' }
: undefined,
}}
title={`${subtask.title || subtask.id}: ${subtask.status}`}
/>
))}
{subtasks.slice(0, 10).map((subtask, index) => {
const isInProgress = subtask.status === 'in_progress';
const shouldPulse = isInProgress && isVisible;
return (
<motion.div
key={subtask.id || `subtask-${index}`}
className={cn(
'h-2 w-2 rounded-full',
subtask.status === 'completed' && 'bg-success',
isInProgress && 'bg-info',
subtask.status === 'failed' && 'bg-destructive',
subtask.status === 'pending' && 'bg-muted-foreground/30'
)}
initial={{ scale: 0, opacity: 0 }}
animate={{
scale: 1,
opacity: 1,
// Only animate boxShadow when visible to save GPU cycles
...(shouldPulse && {
boxShadow: [
'0 0 0 0 rgba(var(--info), 0.4)',
'0 0 0 4px rgba(var(--info), 0)',
],
}),
}}
transition={{
scale: { delay: index * 0.03, duration: 0.2 },
opacity: { delay: index * 0.03, duration: 0.2 },
// Only repeat animation when visible
boxShadow: shouldPulse
? { duration: 1, repeat: Infinity, ease: 'easeOut' }
: undefined,
}}
title={`${subtask.title || subtask.id}: ${subtask.status}`}
/>
);
})}
{totalSubtasks > 10 && (
<span key="overflow-count" className="text-[10px] text-muted-foreground font-medium ml-0.5">
+{totalSubtasks - 10}
@@ -210,21 +255,23 @@ export function PhaseProgressIndicator({
{/* Phase steps indicator (shows overall flow) */}
{(isRunning || phase !== 'idle') && (
<PhaseStepsIndicator currentPhase={phase} isStuck={isStuck} />
<PhaseStepsIndicator currentPhase={phase} isStuck={isStuck} isVisible={isVisible} />
)}
</div>
);
}
});
/**
* Mini phase steps indicator showing the overall flow
*/
function PhaseStepsIndicator({
const PhaseStepsIndicator = memo(function PhaseStepsIndicator({
currentPhase,
isStuck,
isVisible = true,
}: {
currentPhase: ExecutionPhase;
isStuck: boolean;
isVisible?: boolean;
}) {
const { t } = useTranslation('tasks');
@@ -252,6 +299,8 @@ function PhaseStepsIndicator({
<div className="flex items-center gap-1 mt-2">
{phases.map((phase, index) => {
const state = getPhaseState(phase.key);
const shouldAnimate = state === 'active' && !isStuck && isVisible;
return (
<div key={phase.key} className="flex items-center">
<motion.div
@@ -263,16 +312,8 @@ function PhaseStepsIndicator({
state === 'failed' && 'bg-destructive/10 text-destructive',
state === 'pending' && 'bg-muted text-muted-foreground'
)}
animate={
state === 'active' && !isStuck
? { opacity: [1, 0.6, 1] }
: { opacity: 1 }
}
transition={
state === 'active' && !isStuck
? { duration: 1.5, repeat: Infinity, ease: 'easeInOut' }
: undefined
}
animate={shouldAnimate ? { opacity: [1, 0.6, 1] } : { opacity: 1 }}
transition={shouldAnimate ? { duration: 1.5, repeat: Infinity, ease: 'easeInOut' } : undefined}
>
{state === 'complete' && (
<svg className="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -294,4 +335,4 @@ function PhaseStepsIndicator({
})}
</div>
);
}
});
@@ -1,3 +1,4 @@
import { memo, useCallback } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { TaskCard } from './TaskCard';
@@ -9,7 +10,20 @@ interface SortableTaskCardProps {
onClick: () => void;
}
export function SortableTaskCard({ task, onClick }: SortableTaskCardProps) {
// Custom comparator - only re-render when task or onClick actually changed
function sortableTaskCardPropsAreEqual(
prevProps: SortableTaskCardProps,
nextProps: SortableTaskCardProps
): boolean {
// TaskCard has its own memo, so we just need to check reference equality
// for the task object and onClick handler
return (
prevProps.task === nextProps.task &&
prevProps.onClick === nextProps.onClick
);
}
export const SortableTaskCard = memo(function SortableTaskCard({ task, onClick }: SortableTaskCardProps) {
const {
attributes,
listeners,
@@ -27,6 +41,11 @@ export function SortableTaskCard({ task, onClick }: SortableTaskCardProps) {
zIndex: isDragging ? 50 : undefined
};
// Memoize onClick to prevent unnecessary TaskCard re-renders
const handleClick = useCallback(() => {
onClick();
}, [onClick]);
return (
<div
ref={setNodeRef}
@@ -39,7 +58,7 @@ export function SortableTaskCard({ task, onClick }: SortableTaskCardProps) {
{...attributes}
{...listeners}
>
<TaskCard task={task} onClick={onClick} />
<TaskCard task={task} onClick={handleClick} />
</div>
);
}
}, sortableTaskCardPropsAreEqual);
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef, useCallback, memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Play, Square, Clock, Zap, Target, Shield, Gauge, Palette, FileCode, Bug, Wrench, Loader2, AlertTriangle, RotateCcw, Archive } from 'lucide-react';
import { Card, CardContent } from './ui/card';
@@ -39,10 +39,58 @@ interface TaskCardProps {
onClick: () => void;
}
export function TaskCard({ task, onClick }: TaskCardProps) {
// Custom comparator for React.memo - only re-render when relevant task data changes
function taskCardPropsAreEqual(prevProps: TaskCardProps, nextProps: TaskCardProps): boolean {
const prevTask = prevProps.task;
const nextTask = nextProps.task;
// Fast path: same reference
if (prevTask === nextTask && prevProps.onClick === nextProps.onClick) {
return true;
}
// Compare only the fields that affect rendering
const isEqual = (
prevTask.id === nextTask.id &&
prevTask.status === nextTask.status &&
prevTask.title === nextTask.title &&
prevTask.description === nextTask.description &&
prevTask.updatedAt === nextTask.updatedAt &&
prevTask.reviewReason === nextTask.reviewReason &&
prevTask.executionProgress?.phase === nextTask.executionProgress?.phase &&
prevTask.executionProgress?.phaseProgress === nextTask.executionProgress?.phaseProgress &&
prevTask.subtasks.length === nextTask.subtasks.length &&
prevTask.metadata?.category === nextTask.metadata?.category &&
prevTask.metadata?.complexity === nextTask.metadata?.complexity &&
prevTask.metadata?.archivedAt === nextTask.metadata?.archivedAt &&
// Check if subtask statuses changed (quick check on first few)
prevTask.subtasks.slice(0, 5).every((s, i) => s.status === nextTask.subtasks[i]?.status)
);
// Only log when actually re-rendering (reduces noise significantly)
if (window.DEBUG && !isEqual) {
const changes: string[] = [];
if (prevTask.status !== nextTask.status) changes.push(`status: ${prevTask.status} -> ${nextTask.status}`);
if (prevTask.executionProgress?.phase !== nextTask.executionProgress?.phase) {
changes.push(`phase: ${prevTask.executionProgress?.phase} -> ${nextTask.executionProgress?.phase}`);
}
if (prevTask.subtasks.length !== nextTask.subtasks.length) {
changes.push(`subtasks: ${prevTask.subtasks.length} -> ${nextTask.subtasks.length}`);
}
console.log(`[TaskCard] Re-render: ${prevTask.id} | ${changes.join(', ') || 'other fields'}`);
}
return isEqual;
}
export const TaskCard = memo(function TaskCard({ task, onClick }: TaskCardProps) {
const { t } = useTranslation('tasks');
const [isStuck, setIsStuck] = useState(false);
const [isRecovering, setIsRecovering] = useState(false);
const stuckCheckRef = useRef<{ timeout: NodeJS.Timeout | null; interval: NodeJS.Timeout | null }>({
timeout: null,
interval: null
});
const isRunning = task.status === 'in_progress';
const executionPhase = task.executionProgress?.phase;
@@ -51,49 +99,85 @@ export function TaskCard({ task, onClick }: TaskCardProps) {
// Check if task is in human_review but has no completed subtasks (crashed/incomplete)
const isIncomplete = isIncompleteHumanReview(task);
// Memoize expensive computations to avoid running on every render
const sanitizedDescription = useMemo(
() => task.description ? sanitizeMarkdownForDisplay(task.description, 150) : null,
[task.description]
);
// Memoize relative time (recalculates only when updatedAt changes)
const relativeTime = useMemo(
() => formatRelativeTime(task.updatedAt),
[task.updatedAt]
);
// Memoized stuck check function to avoid recreating on every render
const performStuckCheck = useCallback(() => {
// Use requestIdleCallback for non-blocking check when available
const doCheck = () => {
checkTaskRunning(task.id).then((actuallyRunning) => {
setIsStuck(!actuallyRunning);
});
};
if ('requestIdleCallback' in window) {
(window as Window & { requestIdleCallback: (cb: () => void) => void }).requestIdleCallback(doCheck);
} else {
doCheck();
}
}, [task.id]);
// Check if task is stuck (status says in_progress but no actual process)
// Add a grace period to avoid false positives during process spawn
// Add a longer grace period to avoid false positives during process spawn
useEffect(() => {
if (!isRunning) {
setIsStuck(false);
// Clear any pending checks
if (stuckCheckRef.current.timeout) {
clearTimeout(stuckCheckRef.current.timeout);
stuckCheckRef.current.timeout = null;
}
if (stuckCheckRef.current.interval) {
clearInterval(stuckCheckRef.current.interval);
stuckCheckRef.current.interval = null;
}
return;
}
// Initial check after 2s grace period
const initialTimeout = setTimeout(() => {
checkTaskRunning(task.id).then((actuallyRunning) => {
setIsStuck(!actuallyRunning);
});
}, 2000);
// Initial check after 5s grace period (increased from 2s)
stuckCheckRef.current.timeout = setTimeout(performStuckCheck, 5000);
// Periodic re-check every 15 seconds
const recheckInterval = setInterval(() => {
checkTaskRunning(task.id).then((actuallyRunning) => {
setIsStuck(!actuallyRunning);
});
}, 15000);
// Periodic re-check every 30 seconds (reduced frequency from 15s)
stuckCheckRef.current.interval = setInterval(performStuckCheck, 30000);
return () => {
clearTimeout(initialTimeout);
clearInterval(recheckInterval);
if (stuckCheckRef.current.timeout) {
clearTimeout(stuckCheckRef.current.timeout);
}
if (stuckCheckRef.current.interval) {
clearInterval(stuckCheckRef.current.interval);
}
};
}, [task.id, isRunning]);
}, [task.id, isRunning, performStuckCheck]);
// Add visibility change handler to re-validate on focus
// Add visibility change handler to re-validate on focus (debounced)
useEffect(() => {
let debounceTimeout: NodeJS.Timeout | null = null;
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible' && isRunning) {
checkTaskRunning(task.id).then((actuallyRunning) => {
setIsStuck(!actuallyRunning);
});
// Debounce visibility checks to avoid rapid re-checks
if (debounceTimeout) clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(performStuckCheck, 500);
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
if (debounceTimeout) clearTimeout(debounceTimeout);
};
}, [task.id, isRunning]);
}, [isRunning, performStuckCheck]);
const handleStartStop = (e: React.MouseEvent) => {
e.stopPropagation();
@@ -257,10 +341,10 @@ export function TaskCard({ task, onClick }: TaskCardProps) {
</div>
</div>
{/* Description - sanitized to handle markdown content */}
{task.description && (
{/* Description - sanitized to handle markdown content (memoized) */}
{sanitizedDescription && (
<p className="mt-2 text-xs text-muted-foreground line-clamp-2">
{sanitizeMarkdownForDisplay(task.description, 150)}
{sanitizedDescription}
</p>
)}
@@ -337,7 +421,7 @@ export function TaskCard({ task, onClick }: TaskCardProps) {
<div className="mt-4 flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
<span>{formatRelativeTime(task.updatedAt)}</span>
<span>{relativeTime}</span>
</div>
{/* Action buttons */}
@@ -406,4 +490,4 @@ export function TaskCard({ task, onClick }: TaskCardProps) {
</CardContent>
</Card>
);
}
}, taskCardPropsAreEqual);
@@ -1,8 +1,8 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
PanelGroup,
Group,
Panel,
PanelResizeHandle,
Separator,
} from 'react-resizable-panels';
import {
DndContext,
@@ -403,14 +403,14 @@ export function TerminalGrid({ projectPath, onNewTaskClick, isActive = false }:
"flex-1 overflow-hidden p-2 transition-all duration-300 ease-out",
fileExplorerOpen && "pr-0"
)}>
<PanelGroup direction="vertical" className="h-full">
<Group orientation="vertical" className="h-full">
{terminalRows.map((row, rowIndex) => (
<div key={rowIndex} className="contents">
<Panel id={`row-${rowIndex}`} order={rowIndex} defaultSize={100 / terminalRows.length} minSize={15}>
<PanelGroup direction="horizontal" className="h-full">
<React.Fragment key={rowIndex}>
<Panel id={`row-${rowIndex}`} defaultSize={100 / terminalRows.length} minSize={15}>
<Group orientation="horizontal" className="h-full">
{row.map((terminal, colIndex) => (
<div key={terminal.id} className="contents">
<Panel id={terminal.id} order={colIndex} defaultSize={100 / row.length} minSize={20}>
<React.Fragment key={terminal.id}>
<Panel id={terminal.id} defaultSize={100 / row.length} minSize={20}>
<div className="h-full p-1">
<Terminal
id={terminal.id}
@@ -426,18 +426,18 @@ export function TerminalGrid({ projectPath, onNewTaskClick, isActive = false }:
</div>
</Panel>
{colIndex < row.length - 1 && (
<PanelResizeHandle className="w-1 hover:bg-primary/30 transition-colors" />
<Separator className="w-1 hover:bg-primary/30 transition-colors" />
)}
</div>
</React.Fragment>
))}
</PanelGroup>
</Group>
</Panel>
{rowIndex < terminalRows.length - 1 && (
<PanelResizeHandle className="h-1 hover:bg-primary/30 transition-colors" />
<Separator className="h-1 hover:bg-primary/30 transition-colors" />
)}
</div>
</React.Fragment>
))}
</PanelGroup>
</Group>
</div>
{/* File explorer panel (slides from right, pushes content) */}
@@ -99,11 +99,10 @@ export function PRDetail({
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
const logsLoadedRef = useRef(false);
// Sync with store's newCommitsCheck when it changes (e.g., when switching PRs)
// Sync with store's newCommitsCheck when it changes (e.g., when switching PRs or after refresh)
// Always sync to keep local state in sync with store, including null values
useEffect(() => {
if (initialNewCommitsCheck !== undefined) {
setNewCommitsCheck(initialNewCommitsCheck);
}
setNewCommitsCheck(initialNewCommitsCheck ?? null);
}, [initialNewCommitsCheck]);
// Sync local postedFindingIds with reviewResult.postedFindingIds when it changes
@@ -112,7 +112,8 @@ export function useGitHubPRs(projectId?: string): UseGitHubPRsResult {
const reviewResult = await window.electronAPI.github.getPRReview(projectId, pr.number);
if (reviewResult) {
// Update store with the loaded result
usePRReviewStore.getState().setPRReviewResult(projectId, reviewResult);
// Preserve newCommitsCheck during preload to avoid race condition with new commits check
usePRReviewStore.getState().setPRReviewResult(projectId, reviewResult, { preserveNewCommitsCheck: true });
return { prNumber: pr.number, reviewResult };
}
} else {
@@ -181,7 +182,8 @@ export function useGitHubPRs(projectId?: string): UseGitHubPRsResult {
window.electronAPI.github.getPRReview(projectId, prNumber).then(result => {
if (result) {
// Update store with the loaded result
usePRReviewStore.getState().setPRReviewResult(projectId, result);
// Preserve newCommitsCheck when loading existing review from disk
usePRReviewStore.getState().setPRReviewResult(projectId, result, { preserveNewCommitsCheck: true });
}
});
}
@@ -248,7 +250,8 @@ export function useGitHubPRs(projectId?: string): UseGitHubPRsResult {
// Reload review result to get updated postedAt and finding status
const result = await window.electronAPI.github.getPRReview(projectId, prNumber);
if (result) {
usePRReviewStore.getState().setPRReviewResult(projectId, result);
// Preserve newCommitsCheck - posting doesn't change whether there are new commits
usePRReviewStore.getState().setPRReviewResult(projectId, result, { preserveNewCommitsCheck: true });
}
}
return success;
+130 -10
View File
@@ -1,9 +1,116 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { unstable_batchedUpdates } from 'react-dom';
import { useTaskStore } from '../stores/task-store';
import { useRoadmapStore } from '../stores/roadmap-store';
import { useRateLimitStore } from '../stores/rate-limit-store';
import type { ImplementationPlan, TaskStatus, RoadmapGenerationStatus, Roadmap, ExecutionProgress, RateLimitInfo, SDKRateLimitInfo } from '../../shared/types';
/**
* Batched update queue for IPC events.
* Collects updates within a 16ms window (one frame) and flushes them together.
* This prevents multiple sequential re-renders when multiple IPC events arrive.
*/
interface BatchedUpdate {
status?: TaskStatus;
progress?: ExecutionProgress;
plan?: ImplementationPlan;
logs?: string[]; // Batched log lines
queuedAt?: number; // For debug timing
}
/**
* Store action references type for batch flushing.
*/
interface StoreActions {
updateTaskStatus: (taskId: string, status: TaskStatus) => void;
updateExecutionProgress: (taskId: string, progress: ExecutionProgress) => void;
updateTaskFromPlan: (taskId: string, plan: ImplementationPlan) => void;
batchAppendLogs: (taskId: string, logs: string[]) => void;
}
/**
* Module-level batch state.
*
* DESIGN NOTE: These module-level variables are intentionally shared across all hook instances.
* This is acceptable because:
* 1. There's only one Zustand store instance (singleton pattern)
* 2. The app has a single main window that uses this hook
* 3. Batching IPC updates at module level ensures all events within a frame are coalesced
*
* The storeActionsRef pattern ensures we always have the latest action references when
* flushing, avoiding stale closure issues from component re-renders.
*/
const batchQueue = new Map<string, BatchedUpdate>();
let batchTimeout: NodeJS.Timeout | null = null;
let storeActionsRef: StoreActions | null = null;
function flushBatch(): void {
if (batchQueue.size === 0 || !storeActionsRef) return;
const flushStart = performance.now();
const updateCount = batchQueue.size;
let totalUpdates = 0;
let totalLogs = 0;
// Capture current actions reference to avoid stale closures during batch processing
const actions = storeActionsRef;
// Batch all React updates together
unstable_batchedUpdates(() => {
batchQueue.forEach((updates, taskId) => {
// Apply updates in order: plan first (has most data), then status, then progress, then logs
if (updates.plan) {
actions.updateTaskFromPlan(taskId, updates.plan);
totalUpdates++;
}
if (updates.status) {
actions.updateTaskStatus(taskId, updates.status);
totalUpdates++;
}
if (updates.progress) {
actions.updateExecutionProgress(taskId, updates.progress);
totalUpdates++;
}
// Batch append all logs at once (instead of one state update per log line)
if (updates.logs && updates.logs.length > 0) {
actions.batchAppendLogs(taskId, updates.logs);
totalLogs += updates.logs.length;
totalUpdates++;
}
});
});
if (window.DEBUG) {
const flushDuration = performance.now() - flushStart;
console.warn(`[IPC Batch] Flushed ${totalUpdates} updates (${totalLogs} logs) for ${updateCount} tasks in ${flushDuration.toFixed(2)}ms`);
}
batchQueue.clear();
batchTimeout = null;
}
function queueUpdate(taskId: string, update: BatchedUpdate): void {
const existing = batchQueue.get(taskId) || {};
// For logs, accumulate rather than replace
let mergedLogs = existing.logs;
if (update.logs) {
mergedLogs = [...(existing.logs || []), ...update.logs];
}
batchQueue.set(taskId, {
...existing,
...update,
logs: mergedLogs,
queuedAt: existing.queuedAt || performance.now()
});
// Schedule flush after 16ms (one frame at 60fps)
if (!batchTimeout) {
batchTimeout = setTimeout(flushBatch, 16);
}
}
/**
* Hook to set up IPC event listeners for task updates
*/
@@ -12,18 +119,24 @@ export function useIpcListeners(): void {
const updateTaskStatus = useTaskStore((state) => state.updateTaskStatus);
const updateExecutionProgress = useTaskStore((state) => state.updateExecutionProgress);
const appendLog = useTaskStore((state) => state.appendLog);
const batchAppendLogs = useTaskStore((state) => state.batchAppendLogs);
const setError = useTaskStore((state) => state.setError);
// Update module-level store actions reference for batch flushing
// This ensures flushBatch() always has access to current action implementations
storeActionsRef = { updateTaskStatus, updateExecutionProgress, updateTaskFromPlan, batchAppendLogs };
useEffect(() => {
// Set up listeners
// Set up listeners with batched updates
const cleanupProgress = window.electronAPI.onTaskProgress(
(taskId: string, plan: ImplementationPlan) => {
updateTaskFromPlan(taskId, plan);
queueUpdate(taskId, { plan });
}
);
const cleanupError = window.electronAPI.onTaskError(
(taskId: string, error: string) => {
// Errors are not batched - show immediately
setError(`Task ${taskId}: ${error}`);
appendLog(taskId, `[ERROR] ${error}`);
}
@@ -31,19 +144,20 @@ export function useIpcListeners(): void {
const cleanupLog = window.electronAPI.onTaskLog(
(taskId: string, log: string) => {
appendLog(taskId, log);
// Logs are now batched to reduce state updates (was causing 100+ updates/sec)
queueUpdate(taskId, { logs: [log] });
}
);
const cleanupStatus = window.electronAPI.onTaskStatusChange(
(taskId: string, status: TaskStatus) => {
updateTaskStatus(taskId, status);
queueUpdate(taskId, { status });
}
);
const cleanupExecutionProgress = window.electronAPI.onTaskExecutionProgress(
(taskId: string, progress: ExecutionProgress) => {
updateExecutionProgress(taskId, progress);
queueUpdate(taskId, { progress });
}
);
@@ -58,7 +172,7 @@ export function useIpcListeners(): void {
(projectId: string, status: RoadmapGenerationStatus) => {
// Debug logging
if (window.DEBUG) {
console.log('[Roadmap] Progress update:', {
console.warn('[Roadmap] Progress update:', {
projectId,
currentProjectId: useRoadmapStore.getState().currentProjectId,
phase: status.phase,
@@ -77,7 +191,7 @@ export function useIpcListeners(): void {
(projectId: string, roadmap: Roadmap) => {
// Debug logging
if (window.DEBUG) {
console.log('[Roadmap] Generation complete:', {
console.warn('[Roadmap] Generation complete:', {
projectId,
currentProjectId: useRoadmapStore.getState().currentProjectId,
featuresCount: roadmap.features?.length || 0,
@@ -122,7 +236,7 @@ export function useIpcListeners(): void {
(projectId: string) => {
// Debug logging
if (window.DEBUG) {
console.log('[Roadmap] Generation stopped:', {
console.warn('[Roadmap] Generation stopped:', {
projectId,
currentProjectId: useRoadmapStore.getState().currentProjectId
});
@@ -168,6 +282,12 @@ export function useIpcListeners(): void {
// Cleanup on unmount
return () => {
// Flush any pending batched updates before cleanup
if (batchTimeout) {
clearTimeout(batchTimeout);
flushBatch();
batchTimeout = null;
}
cleanupProgress();
cleanupError();
cleanupLog();
@@ -180,7 +300,7 @@ export function useIpcListeners(): void {
cleanupRateLimit();
cleanupSDKRateLimit();
};
}, [updateTaskFromPlan, updateTaskStatus, updateExecutionProgress, appendLog, setError]);
}, [updateTaskFromPlan, updateTaskStatus, updateExecutionProgress, appendLog, batchAppendLogs, setError]);
}
/**
@@ -30,7 +30,7 @@ interface PRReviewStoreState {
startPRReview: (projectId: string, prNumber: number) => void;
startFollowupReview: (projectId: string, prNumber: number) => void;
setPRReviewProgress: (projectId: string, progress: PRReviewProgress) => void;
setPRReviewResult: (projectId: string, result: PRReviewResult) => void;
setPRReviewResult: (projectId: string, result: PRReviewResult, options?: { preserveNewCommitsCheck?: boolean }) => void;
setPRReviewError: (projectId: string, prNumber: number, error: string) => void;
setNewCommitsCheck: (projectId: string, prNumber: number, check: NewCommitsCheck) => void;
clearPRReview: (projectId: string, prNumber: number) => void;
@@ -114,7 +114,7 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
};
}),
setPRReviewResult: (projectId: string, result: PRReviewResult) => set((state) => {
setPRReviewResult: (projectId: string, result: PRReviewResult, options?: { preserveNewCommitsCheck?: boolean }) => set((state) => {
const key = `${projectId}:${result.prNumber}`;
const existing = state.prReviews[key];
return {
@@ -129,7 +129,8 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
previousResult: existing?.previousResult ?? null,
error: result.error ?? null,
// Clear new commits check when review completes (it was just reviewed)
newCommitsCheck: null
// BUT preserve it during preload/refresh to avoid race condition
newCommitsCheck: options?.preserveNewCommitsCheck ? (existing?.newCommitsCheck ?? null) : null
}
}
};
+160 -94
View File
@@ -15,6 +15,7 @@ interface TaskState {
updateTaskFromPlan: (taskId: string, plan: ImplementationPlan) => void;
updateExecutionProgress: (taskId: string, progress: Partial<ExecutionProgress>) => void;
appendLog: (taskId: string, log: string) => void;
batchAppendLogs: (taskId: string, logs: string[]) => void;
selectTask: (taskId: string | null) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
@@ -25,6 +26,35 @@ interface TaskState {
getTasksByStatus: (status: TaskStatus) => Task[];
}
/**
* Helper to find task index by id or specId.
* Returns -1 if not found.
*/
function findTaskIndex(tasks: Task[], taskId: string): number {
return tasks.findIndex((t) => t.id === taskId || t.specId === taskId);
}
/**
* Helper to update a single task efficiently.
* Uses slice instead of map to avoid iterating all tasks.
*/
function updateTaskAtIndex(tasks: Task[], index: number, updater: (task: Task) => Task): Task[] {
if (index < 0 || index >= tasks.length) return tasks;
const updatedTask = updater(tasks[index]);
// If the task reference didn't change, return original array
if (updatedTask === tasks[index]) {
return tasks;
}
// Create new array with only the changed task replaced
const newTasks = [...tasks];
newTasks[index] = updatedTask;
return newTasks;
}
export const useTaskStore = create<TaskState>((set, get) => ({
tasks: [],
selectedTaskId: null,
@@ -39,121 +69,157 @@ export const useTaskStore = create<TaskState>((set, get) => ({
})),
updateTask: (taskId, updates) =>
set((state) => ({
tasks: state.tasks.map((t) =>
t.id === taskId || t.specId === taskId ? { ...t, ...updates } : t
)
})),
set((state) => {
const index = findTaskIndex(state.tasks, taskId);
if (index === -1) return state;
return {
tasks: updateTaskAtIndex(state.tasks, index, (t) => ({ ...t, ...updates }))
};
}),
updateTaskStatus: (taskId, status) =>
set((state) => ({
tasks: state.tasks.map((t) => {
if (t.id !== taskId && t.specId !== taskId) return t;
set((state) => {
const index = findTaskIndex(state.tasks, taskId);
if (index === -1) return state;
// Determine execution progress based on status transition
let executionProgress = t.executionProgress;
return {
tasks: updateTaskAtIndex(state.tasks, index, (t) => {
// Determine execution progress based on status transition
let executionProgress = t.executionProgress;
if (status === 'backlog') {
// When status goes to backlog, reset execution progress to idle
// This ensures the planning/coding animation stops when task is stopped
executionProgress = { phase: 'idle' as ExecutionPhase, phaseProgress: 0, overallProgress: 0 };
} else if (status === 'in_progress' && !t.executionProgress?.phase) {
// When starting a task and no phase is set yet, default to planning
// This prevents the "no active phase" UI state during startup race condition
executionProgress = { phase: 'planning' as ExecutionPhase, phaseProgress: 0, overallProgress: 0 };
}
if (status === 'backlog') {
// When status goes to backlog, reset execution progress to idle
// This ensures the planning/coding animation stops when task is stopped
executionProgress = { phase: 'idle' as ExecutionPhase, phaseProgress: 0, overallProgress: 0 };
} else if (status === 'in_progress' && !t.executionProgress?.phase) {
// When starting a task and no phase is set yet, default to planning
// This prevents the "no active phase" UI state during startup race condition
executionProgress = { phase: 'planning' as ExecutionPhase, phaseProgress: 0, overallProgress: 0 };
}
return { ...t, status, executionProgress, updatedAt: new Date() };
})
})),
return { ...t, status, executionProgress, updatedAt: new Date() };
})
};
}),
updateTaskFromPlan: (taskId, plan) =>
set((state) => ({
tasks: state.tasks.map((t) => {
if (t.id !== taskId && t.specId !== taskId) return t;
set((state) => {
const index = findTaskIndex(state.tasks, taskId);
if (index === -1) return state;
const subtasks: Subtask[] = plan.phases.flatMap((phase) =>
phase.subtasks.map((subtask) => ({
id: subtask.id,
title: subtask.description,
description: subtask.description,
status: subtask.status,
files: [],
verification: subtask.verification as Subtask['verification']
}))
);
return {
tasks: updateTaskAtIndex(state.tasks, index, (t) => {
const subtasks: Subtask[] = plan.phases.flatMap((phase) =>
phase.subtasks.map((subtask) => ({
id: subtask.id,
title: subtask.description,
description: subtask.description,
status: subtask.status,
files: [],
verification: subtask.verification as Subtask['verification']
}))
);
const allCompleted = subtasks.every((s) => s.status === 'completed');
const anyFailed = subtasks.some((s) => s.status === 'failed');
const anyInProgress = subtasks.some((s) => s.status === 'in_progress');
const anyCompleted = subtasks.some((s) => s.status === 'completed');
const allCompleted = subtasks.every((s) => s.status === 'completed');
const anyFailed = subtasks.some((s) => s.status === 'failed');
const anyInProgress = subtasks.some((s) => s.status === 'in_progress');
const anyCompleted = subtasks.some((s) => s.status === 'completed');
let status: TaskStatus = t.status;
let reviewReason: ReviewReason | undefined = t.reviewReason;
let status: TaskStatus = t.status;
let reviewReason: ReviewReason | undefined = t.reviewReason;
// RACE CONDITION FIX: Don't let stale plan data override status during active execution
const activePhases: ExecutionPhase[] = ['planning', 'coding', 'qa_review', 'qa_fixing'];
const isInActivePhase = t.executionProgress?.phase && activePhases.includes(t.executionProgress.phase);
// RACE CONDITION FIX: Don't let stale plan data override status during active execution
const activePhases: ExecutionPhase[] = ['planning', 'coding', 'qa_review', 'qa_fixing'];
const isInActivePhase = t.executionProgress?.phase && activePhases.includes(t.executionProgress.phase);
if (!isInActivePhase) {
if (allCompleted) {
status = 'ai_review';
} else if (anyFailed) {
status = 'human_review';
reviewReason = 'errors';
} else if (anyInProgress || anyCompleted) {
status = 'in_progress';
if (!isInActivePhase) {
if (allCompleted) {
status = 'ai_review';
} else if (anyFailed) {
status = 'human_review';
reviewReason = 'errors';
} else if (anyInProgress || anyCompleted) {
status = 'in_progress';
}
}
}
return {
...t,
title: plan.feature || t.title,
subtasks,
status,
reviewReason,
updatedAt: new Date()
};
})
})),
return {
...t,
title: plan.feature || t.title,
subtasks,
status,
reviewReason,
updatedAt: new Date()
};
})
};
}),
updateExecutionProgress: (taskId, progress) =>
set((state) => ({
tasks: state.tasks.map((t) => {
if (t.id !== taskId && t.specId !== taskId) return t;
set((state) => {
const index = findTaskIndex(state.tasks, taskId);
if (index === -1) return state;
const existingProgress = t.executionProgress || {
phase: 'idle' as ExecutionPhase,
phaseProgress: 0,
overallProgress: 0,
sequenceNumber: 0
};
return {
tasks: updateTaskAtIndex(state.tasks, index, (t) => {
const existingProgress = t.executionProgress || {
phase: 'idle' as ExecutionPhase,
phaseProgress: 0,
overallProgress: 0,
sequenceNumber: 0
};
const incomingSeq = progress.sequenceNumber ?? 0;
const currentSeq = existingProgress.sequenceNumber ?? 0;
if (incomingSeq > 0 && currentSeq > 0 && incomingSeq < currentSeq) {
return t;
}
const incomingSeq = progress.sequenceNumber ?? 0;
const currentSeq = existingProgress.sequenceNumber ?? 0;
if (incomingSeq > 0 && currentSeq > 0 && incomingSeq < currentSeq) {
return t; // Skip out-of-order update
}
return {
...t,
executionProgress: {
...existingProgress,
...progress
},
updatedAt: new Date()
};
})
})),
// Only update updatedAt on phase transitions (not on every progress tick)
// This prevents unnecessary re-renders from the memo comparator
const phaseChanged = progress.phase && progress.phase !== existingProgress.phase;
return {
...t,
executionProgress: {
...existingProgress,
...progress
},
// Only set updatedAt on phase changes to reduce re-renders
...(phaseChanged ? { updatedAt: new Date() } : {})
};
})
};
}),
appendLog: (taskId, log) =>
set((state) => ({
tasks: state.tasks.map((t) =>
t.id === taskId || t.specId === taskId
? { ...t, logs: [...(t.logs || []), log] }
: t
)
})),
set((state) => {
const index = findTaskIndex(state.tasks, taskId);
if (index === -1) return state;
return {
tasks: updateTaskAtIndex(state.tasks, index, (t) => ({
...t,
logs: [...(t.logs || []), log]
}))
};
}),
// Batch append multiple logs at once (single state update instead of N updates)
batchAppendLogs: (taskId, logs) =>
set((state) => {
if (logs.length === 0) return state;
const index = findTaskIndex(state.tasks, taskId);
if (index === -1) return state;
return {
tasks: updateTaskAtIndex(state.tasks, index, (t) => ({
...t,
logs: [...(t.logs || []), ...logs]
}))
};
}),
selectTask: (taskId) => set({ selectedTaskId: taskId }),