Files
Aperant/apps/frontend/src/renderer/components/TaskCard.tsx
T
Vinícius Santos 3086233f87 Improving Task Card Title Readability (#461)
* 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>
2026-01-02 16:20:58 +01:00

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);