3086233f87
* auto-claude: subtask-1-1 - Relocate status badges from header to metadata section
- Move status badges (stuck, incomplete, archived, execution phase, status, review reason) from the title row to the metadata badges section below the description
- Simplify header to show only title with full width
- Prepend status badges before category/impact/complexity badges in metadata section
- Add safe optional chaining for metadata property access to prevent runtime errors
- Update outer condition to allow rendering metadata section even without task.metadata
* auto-claude: subtask-1-1 - Restructure TaskCard header: Remove flex wrapper around title, make title standalone with full width
* fix: Add localization for security severity badge label
- Add translation key 'metadata.severity' to en/tasks.json and fr/tasks.json
- Update TaskCard.tsx to use t('metadata.severity') instead of hardcoded 'severity' string
- Ensures proper i18n support for security severity badges
Fixes QA feedback: 'Make sure localization work on code changed in this task'
🤖 Generated with Claude Code
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
* docs: Update implementation plan with QA fix session 1
- Document localization fix for security severity badge
- Mark all subtasks as completed
- Set ready_for_qa_revalidation to true
🤖 Generated with Claude Code
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
* restoring package-lock.json
* Merge develop: bring in latest changes
Includes performance optimizations, new features, and various improvements from develop branch.
* resolve: package-lock.json conflict (kept develop version)
Merge conflict resolution: accepted develop branch version of package-lock.json
to maintain consistency with updated dependencies.
* resolve: TaskCard.tsx conflict (merged layout + performance)
Merge conflict resolution: combined both changes:
- Our feature: title full width, badges below description in combined section
- Develop: performance optimizations (memo, useMemo, useCallback, useRef)
* fix: TerminalGrid.tsx react-resizable-panels imports
Fixed incorrect imports from react-resizable-panels:
- Group → PanelGroup
- Separator → PanelResizeHandle
- orientation → direction
* fix: revert TerminalGrid to use correct react-resizable-panels v4 API
v4.2.0 uses Group/Separator/orientation, not PanelGroup/PanelResizeHandle/direction
---------
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
490 lines
18 KiB
TypeScript
490 lines
18 KiB
TypeScript
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';
|
|
import { Badge } from './ui/badge';
|
|
import { Button } from './ui/button';
|
|
import { cn, formatRelativeTime, sanitizeMarkdownForDisplay } from '../lib/utils';
|
|
import { PhaseProgressIndicator } from './PhaseProgressIndicator';
|
|
import {
|
|
TASK_CATEGORY_LABELS,
|
|
TASK_CATEGORY_COLORS,
|
|
TASK_COMPLEXITY_COLORS,
|
|
TASK_COMPLEXITY_LABELS,
|
|
TASK_IMPACT_COLORS,
|
|
TASK_IMPACT_LABELS,
|
|
TASK_PRIORITY_COLORS,
|
|
TASK_PRIORITY_LABELS,
|
|
EXECUTION_PHASE_LABELS,
|
|
EXECUTION_PHASE_BADGE_COLORS
|
|
} from '../../shared/constants';
|
|
import { startTask, stopTask, checkTaskRunning, recoverStuckTask, isIncompleteHumanReview, archiveTasks } from '../stores/task-store';
|
|
import type { Task, TaskCategory, ReviewReason } from '../../shared/types';
|
|
|
|
// Category icon mapping
|
|
const CategoryIcon: Record<TaskCategory, typeof Zap> = {
|
|
feature: Target,
|
|
bug_fix: Bug,
|
|
refactoring: Wrench,
|
|
documentation: FileCode,
|
|
security: Shield,
|
|
performance: Gauge,
|
|
ui_ux: Palette,
|
|
infrastructure: Wrench,
|
|
testing: FileCode
|
|
};
|
|
|
|
interface TaskCardProps {
|
|
task: Task;
|
|
onClick: () => void;
|
|
}
|
|
|
|
// 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 any subtask statuses changed (compare all subtasks)
|
|
prevTask.subtasks.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;
|
|
const hasActiveExecution = executionPhase && executionPhase !== 'idle' && executionPhase !== 'complete' && executionPhase !== 'failed';
|
|
|
|
// 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 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 5s grace period (increased from 2s)
|
|
stuckCheckRef.current.timeout = setTimeout(performStuckCheck, 5000);
|
|
|
|
// Periodic re-check every 30 seconds (reduced frequency from 15s)
|
|
stuckCheckRef.current.interval = setInterval(performStuckCheck, 30000);
|
|
|
|
return () => {
|
|
if (stuckCheckRef.current.timeout) {
|
|
clearTimeout(stuckCheckRef.current.timeout);
|
|
}
|
|
if (stuckCheckRef.current.interval) {
|
|
clearInterval(stuckCheckRef.current.interval);
|
|
}
|
|
};
|
|
}, [task.id, isRunning, performStuckCheck]);
|
|
|
|
// Add visibility change handler to re-validate on focus (debounced)
|
|
useEffect(() => {
|
|
let debounceTimeout: NodeJS.Timeout | null = null;
|
|
|
|
const handleVisibilityChange = () => {
|
|
if (document.visibilityState === 'visible' && isRunning) {
|
|
// 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);
|
|
};
|
|
}, [isRunning, performStuckCheck]);
|
|
|
|
const handleStartStop = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
if (isRunning && !isStuck) {
|
|
stopTask(task.id);
|
|
} else {
|
|
startTask(task.id);
|
|
}
|
|
};
|
|
|
|
const handleRecover = async (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
setIsRecovering(true);
|
|
// Auto-restart the task after recovery (no need to click Start again)
|
|
const result = await recoverStuckTask(task.id, { autoRestart: true });
|
|
if (result.success) {
|
|
setIsStuck(false);
|
|
}
|
|
setIsRecovering(false);
|
|
};
|
|
|
|
const handleArchive = async (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
const result = await archiveTasks(task.projectId, [task.id]);
|
|
if (!result.success) {
|
|
console.error('[TaskCard] Failed to archive task:', task.id, result.error);
|
|
}
|
|
};
|
|
|
|
const getStatusBadgeVariant = (status: string) => {
|
|
switch (status) {
|
|
case 'in_progress':
|
|
return 'info';
|
|
case 'ai_review':
|
|
return 'warning';
|
|
case 'human_review':
|
|
return 'purple';
|
|
case 'done':
|
|
return 'success';
|
|
default:
|
|
return 'secondary';
|
|
}
|
|
};
|
|
|
|
const getStatusLabel = (status: string) => {
|
|
switch (status) {
|
|
case 'in_progress':
|
|
return t('labels.running');
|
|
case 'ai_review':
|
|
return t('labels.aiReview');
|
|
case 'human_review':
|
|
return t('labels.needsReview');
|
|
case 'done':
|
|
return t('status.complete');
|
|
default:
|
|
return t('labels.pending');
|
|
}
|
|
};
|
|
|
|
const getReviewReasonLabel = (reason?: ReviewReason): { label: string; variant: 'success' | 'destructive' | 'warning' } | null => {
|
|
if (!reason) return null;
|
|
switch (reason) {
|
|
case 'completed':
|
|
return { label: t('reviewReason.completed'), variant: 'success' };
|
|
case 'errors':
|
|
return { label: t('reviewReason.hasErrors'), variant: 'destructive' };
|
|
case 'qa_rejected':
|
|
return { label: t('reviewReason.qaIssues'), variant: 'warning' };
|
|
case 'plan_review':
|
|
return { label: t('reviewReason.approvePlan'), variant: 'warning' };
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const reviewReasonInfo = task.status === 'human_review' ? getReviewReasonLabel(task.reviewReason) : null;
|
|
|
|
const isArchived = !!task.metadata?.archivedAt;
|
|
|
|
return (
|
|
<Card
|
|
className={cn(
|
|
'card-surface task-card-enhanced cursor-pointer',
|
|
isRunning && !isStuck && 'ring-2 ring-primary border-primary task-running-pulse',
|
|
isStuck && 'ring-2 ring-warning border-warning task-stuck-pulse',
|
|
isArchived && 'opacity-60 hover:opacity-80'
|
|
)}
|
|
onClick={onClick}
|
|
>
|
|
<CardContent className="p-4">
|
|
{/* Title - full width, no wrapper */}
|
|
<h3
|
|
className="font-semibold text-sm text-foreground line-clamp-2 leading-snug"
|
|
title={task.title}
|
|
>
|
|
{task.title}
|
|
</h3>
|
|
|
|
{/* Description - sanitized to handle markdown content (memoized) */}
|
|
{sanitizedDescription && (
|
|
<p className="mt-2 text-xs text-muted-foreground line-clamp-2">
|
|
{sanitizedDescription}
|
|
</p>
|
|
)}
|
|
|
|
{/* Metadata badges */}
|
|
{(task.metadata || isStuck || isIncomplete || hasActiveExecution || reviewReasonInfo) && (
|
|
<div className="mt-2.5 flex flex-wrap gap-1.5">
|
|
{/* Stuck indicator - highest priority */}
|
|
{isStuck && (
|
|
<Badge
|
|
variant="outline"
|
|
className="text-[10px] px-1.5 py-0.5 flex items-center gap-1 bg-warning/10 text-warning border-warning/30 badge-priority-urgent"
|
|
>
|
|
<AlertTriangle className="h-2.5 w-2.5" />
|
|
{t('labels.stuck')}
|
|
</Badge>
|
|
)}
|
|
{/* Incomplete indicator - task in human_review but no subtasks completed */}
|
|
{isIncomplete && !isStuck && (
|
|
<Badge
|
|
variant="outline"
|
|
className="text-[10px] px-1.5 py-0.5 flex items-center gap-1 bg-orange-500/10 text-orange-400 border-orange-500/30"
|
|
>
|
|
<AlertTriangle className="h-2.5 w-2.5" />
|
|
{t('labels.incomplete')}
|
|
</Badge>
|
|
)}
|
|
{/* Archived indicator - task has been released */}
|
|
{task.metadata?.archivedAt && (
|
|
<Badge
|
|
variant="outline"
|
|
className="text-[10px] px-1.5 py-0.5 flex items-center gap-1 bg-muted text-muted-foreground border-border"
|
|
>
|
|
<Archive className="h-2.5 w-2.5" />
|
|
{t('status.archived')}
|
|
</Badge>
|
|
)}
|
|
{/* Execution phase badge - shown when actively running */}
|
|
{hasActiveExecution && executionPhase && !isStuck && !isIncomplete && (
|
|
<Badge
|
|
variant="outline"
|
|
className={cn(
|
|
'text-[10px] px-1.5 py-0.5 flex items-center gap-1',
|
|
EXECUTION_PHASE_BADGE_COLORS[executionPhase]
|
|
)}
|
|
>
|
|
<Loader2 className="h-2.5 w-2.5 animate-spin" />
|
|
{EXECUTION_PHASE_LABELS[executionPhase]}
|
|
</Badge>
|
|
)}
|
|
{/* Status badge - hide when execution phase badge is showing */}
|
|
{!hasActiveExecution && (
|
|
<Badge
|
|
variant={isStuck ? 'warning' : isIncomplete ? 'warning' : getStatusBadgeVariant(task.status)}
|
|
className="text-[10px] px-1.5 py-0.5"
|
|
>
|
|
{isStuck ? t('labels.needsRecovery') : isIncomplete ? t('labels.needsResume') : getStatusLabel(task.status)}
|
|
</Badge>
|
|
)}
|
|
{/* Review reason badge - explains why task needs human review */}
|
|
{reviewReasonInfo && !isStuck && !isIncomplete && (
|
|
<Badge
|
|
variant={reviewReasonInfo.variant}
|
|
className="text-[10px] px-1.5 py-0.5"
|
|
>
|
|
{reviewReasonInfo.label}
|
|
</Badge>
|
|
)}
|
|
{/* Category badge with icon */}
|
|
{task.metadata?.category && (
|
|
<Badge
|
|
variant="outline"
|
|
className={cn('text-[10px] px-1.5 py-0', TASK_CATEGORY_COLORS[task.metadata.category])}
|
|
>
|
|
{CategoryIcon[task.metadata.category] && (
|
|
(() => {
|
|
const Icon = CategoryIcon[task.metadata.category!];
|
|
return <Icon className="h-2.5 w-2.5 mr-0.5" />;
|
|
})()
|
|
)}
|
|
{TASK_CATEGORY_LABELS[task.metadata.category]}
|
|
</Badge>
|
|
)}
|
|
{/* Impact badge - high visibility for important tasks */}
|
|
{task.metadata?.impact && (task.metadata.impact === 'high' || task.metadata.impact === 'critical') && (
|
|
<Badge
|
|
variant="outline"
|
|
className={cn('text-[10px] px-1.5 py-0', TASK_IMPACT_COLORS[task.metadata.impact])}
|
|
>
|
|
{TASK_IMPACT_LABELS[task.metadata.impact]}
|
|
</Badge>
|
|
)}
|
|
{/* Complexity badge */}
|
|
{task.metadata?.complexity && (
|
|
<Badge
|
|
variant="outline"
|
|
className={cn('text-[10px] px-1.5 py-0', TASK_COMPLEXITY_COLORS[task.metadata.complexity])}
|
|
>
|
|
{TASK_COMPLEXITY_LABELS[task.metadata.complexity]}
|
|
</Badge>
|
|
)}
|
|
{/* Priority badge - only show urgent/high */}
|
|
{task.metadata?.priority && (task.metadata.priority === 'urgent' || task.metadata.priority === 'high') && (
|
|
<Badge
|
|
variant="outline"
|
|
className={cn('text-[10px] px-1.5 py-0', TASK_PRIORITY_COLORS[task.metadata.priority])}
|
|
>
|
|
{TASK_PRIORITY_LABELS[task.metadata.priority]}
|
|
</Badge>
|
|
)}
|
|
{/* Security severity - always show */}
|
|
{task.metadata?.securitySeverity && (
|
|
<Badge
|
|
variant="outline"
|
|
className={cn('text-[10px] px-1.5 py-0', TASK_IMPACT_COLORS[task.metadata.securitySeverity])}
|
|
>
|
|
{task.metadata.securitySeverity} {t('metadata.severity')}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Progress section - Phase-aware with animations */}
|
|
{(task.subtasks.length > 0 || hasActiveExecution || isRunning || isStuck) && (
|
|
<div className="mt-4">
|
|
<PhaseProgressIndicator
|
|
phase={executionPhase}
|
|
subtasks={task.subtasks}
|
|
isStuck={isStuck}
|
|
isRunning={isRunning}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Footer */}
|
|
<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>{relativeTime}</span>
|
|
</div>
|
|
|
|
{/* Action buttons */}
|
|
{isStuck ? (
|
|
<Button
|
|
variant="warning"
|
|
size="sm"
|
|
className="h-7 px-2.5"
|
|
onClick={handleRecover}
|
|
disabled={isRecovering}
|
|
>
|
|
{isRecovering ? (
|
|
<>
|
|
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
|
|
{t('labels.recovering')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<RotateCcw className="mr-1.5 h-3 w-3" />
|
|
{t('actions.recover')}
|
|
</>
|
|
)}
|
|
</Button>
|
|
) : isIncomplete ? (
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
className="h-7 px-2.5"
|
|
onClick={handleStartStop}
|
|
>
|
|
<Play className="mr-1.5 h-3 w-3" />
|
|
{t('actions.resume')}
|
|
</Button>
|
|
) : task.status === 'done' && !task.metadata?.archivedAt ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 px-2.5 hover:bg-muted-foreground/10"
|
|
onClick={handleArchive}
|
|
title={t('tooltips.archiveTask')}
|
|
>
|
|
<Archive className="mr-1.5 h-3 w-3" />
|
|
{t('actions.archive')}
|
|
</Button>
|
|
) : (task.status === 'backlog' || task.status === 'in_progress') && (
|
|
<Button
|
|
variant={isRunning ? 'destructive' : 'default'}
|
|
size="sm"
|
|
className="h-7 px-2.5"
|
|
onClick={handleStartStop}
|
|
>
|
|
{isRunning ? (
|
|
<>
|
|
<Square className="mr-1.5 h-3 w-3" />
|
|
{t('actions.stop')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Play className="mr-1.5 h-3 w-3" />
|
|
{t('actions.start')}
|
|
</>
|
|
)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}, taskCardPropsAreEqual);
|