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:
@@ -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
@@ -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()
|
||||
|
||||
Generated
+1872
-1404
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user