Files
Aperant/apps/frontend/src/renderer/components/PhaseProgressIndicator.tsx
T
Mitsu f843811292 feat: add i18n internationalization system (#248)
* Add multilingual support and i18n integration

- Implemented i18n framework using `react-i18next` for translation management.
- Added support for English and French languages with translation files.
- Integrated language selector into settings.
- Updated all text strings in UI components to use translation keys.
- Ensured smooth language switching with live updates.

* Migrate remaining hard-coded strings to i18n system

- TaskCard: status labels, review reasons, badges, action buttons
- PhaseProgressIndicator: execution phases, progress labels
- KanbanBoard: drop zone, show archived, tooltips
- CustomModelModal: dialog title, description, labels
- ProactiveSwapListener: account switch notifications
- AgentProfileSelector: phase labels, custom configuration
- GeneralSettings: agent framework option

Added translation keys for en/fr locales in tasks.json, common.json,
and settings.json for complete i18n coverage.

* Add i18n support to dialogs and settings components

- AddFeatureDialog: form labels, validation messages, buttons
- AddProjectModal: dialog steps, form fields, actions
- RateLimitIndicator: rate limit notifications
- RateLimitModal: account switching, upgrade prompts
- AdvancedSettings: updates and notifications sections
- ThemeSettings: theme selection labels
- Updated dialogs.json locales (en/fr)

* Fix truncated 'ready' message in dialogs locales

* Fix backlog terminology in i18n locales

Change "Planning"/"Planification" to standard PM term "Backlog"

* Migrate settings navigation and integration labels to i18n

- AppSettings: nav items, section titles, buttons
- IntegrationSettings: Claude accounts, auto-switch, API keys labels
- Added settings nav/projectSections/integrations translation keys
- Added buttons.saving to common translations

* Migrate AgentProfileSettings and Sidebar init dialog to i18n

- AgentProfileSettings: migrate phase config labels, section title,
  description, and all hardcoded strings to settings namespace
- Sidebar: migrate init dialog strings to dialogs namespace with
  common buttons from common namespace
- Add new translation keys for agent profile settings and update dialog

* Migrate AppSettings navigation labels to i18n

- Add useTranslation hook to AppSettings.tsx
- Replace hardcoded section labels with dynamic translations
- Add projectSections translations for project settings nav
- Add rerunWizardDescription translation key

* Add explicit typing to notificationItems array

Import NotificationSettings type and use keyof to properly type
the notification item keys, removing manual type assertion.
2025-12-24 17:37:31 +01:00

297 lines
11 KiB
TypeScript

import { motion, AnimatePresence } from 'motion/react';
import { useTranslation } from 'react-i18next';
import { cn } from '../lib/utils';
import type { ExecutionPhase, TaskLogs, Subtask } from '../../shared/types';
interface PhaseProgressIndicatorProps {
phase?: ExecutionPhase;
subtasks: Subtask[];
phaseLogs?: TaskLogs | null;
isStuck?: boolean;
isRunning?: boolean;
className?: string;
}
// Phase display configuration (colors only - labels are translated)
const PHASE_COLORS: Record<ExecutionPhase, { color: string; bgColor: string }> = {
idle: { color: 'bg-muted-foreground', bgColor: 'bg-muted' },
planning: { color: 'bg-amber-500', bgColor: 'bg-amber-500/20' },
coding: { color: 'bg-info', bgColor: 'bg-info/20' },
qa_review: { color: 'bg-purple-500', bgColor: 'bg-purple-500/20' },
qa_fixing: { color: 'bg-orange-500', bgColor: 'bg-orange-500/20' },
complete: { color: 'bg-success', bgColor: 'bg-success/20' },
failed: { color: 'bg-destructive', bgColor: 'bg-destructive/20' },
};
// Phase label translation keys
const PHASE_LABEL_KEYS: Record<ExecutionPhase, string> = {
idle: 'execution.phases.idle',
planning: 'execution.phases.planning',
coding: 'execution.phases.coding',
qa_review: 'execution.phases.reviewing',
qa_fixing: 'execution.phases.fixing',
complete: 'execution.phases.complete',
failed: 'execution.phases.failed',
};
/**
* Smart progress indicator that adapts based on execution phase:
* - Planning/Validation: Shows animated activity bar with entry count
* - Coding: Shows subtask-based percentage progress
* - Stuck: Shows warning state with interrupted animation
*/
export function PhaseProgressIndicator({
phase = 'idle',
subtasks,
phaseLogs,
isStuck = false,
isRunning = false,
className,
}: PhaseProgressIndicatorProps) {
const { t } = useTranslation('tasks');
// Calculate subtask-based progress (for coding phase)
const completedSubtasks = subtasks.filter((c) => c.status === 'completed').length;
const totalSubtasks = subtasks.length;
const subtaskProgress = totalSubtasks > 0 ? Math.round((completedSubtasks / totalSubtasks) * 100) : 0;
// Get log entry counts for activity indication
const planningEntries = phaseLogs?.phases?.planning?.entries?.length || 0;
const codingEntries = phaseLogs?.phases?.coding?.entries?.length || 0;
const validationEntries = phaseLogs?.phases?.validation?.entries?.length || 0;
// Determine which phase log to show activity for
const getActivePhaseEntries = () => {
if (phase === 'planning') return planningEntries;
if (phase === 'qa_review' || phase === 'qa_fixing') return validationEntries;
return codingEntries;
};
// Determine if we should show indeterminate (activity) vs determinate (%) progress
const isIndeterminatePhase = phase === 'planning' || phase === 'qa_review' || phase === 'qa_fixing';
const showSubtaskProgress = phase === 'coding' || (totalSubtasks > 0 && !isIndeterminatePhase);
const colors = PHASE_COLORS[phase] || PHASE_COLORS.idle;
const phaseLabel = t(PHASE_LABEL_KEYS[phase] || PHASE_LABEL_KEYS.idle);
const activeEntries = getActivePhaseEntries();
return (
<div 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 */}
{isRunning && !isStuck && isIndeterminatePhase && (
<motion.div
className={cn('h-1.5 w-1.5 rounded-full', colors.color)}
animate={{
scale: [1, 1.5, 1],
opacity: [1, 0.5, 1],
}}
transition={{
duration: 1,
repeat: Infinity,
ease: 'easeInOut',
}}
/>
)}
</div>
<span className="text-xs font-medium text-foreground">
{showSubtaskProgress ? (
`${subtaskProgress}%`
) : activeEntries > 0 ? (
<span className="text-muted-foreground">
{activeEntries} {activeEntries === 1 ? t('execution.labels.entry') : t('execution.labels.entries')}
</span>
) : (
'—'
)}
</span>
</div>
{/* Progress bar */}
<div
className={cn(
'relative h-1.5 w-full overflow-hidden rounded-full',
isStuck ? 'bg-warning/20' : 'bg-border'
)}
>
<AnimatePresence mode="wait">
{isStuck ? (
// Stuck/Interrupted state - pulsing warning bar
<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' }}
/>
) : showSubtaskProgress ? (
// Determinate progress for coding phase
<motion.div
key="determinate"
className={cn('h-full rounded-full', colors.color)}
initial={{ width: 0 }}
animate={{ width: `${subtaskProgress}%` }}
transition={{ duration: 0.5, ease: 'easeOut' }}
/>
) : isRunning && isIndeterminatePhase ? (
// Indeterminate animated progress for planning/validation
<motion.div
key="indeterminate"
className={cn('absolute h-full w-1/3 rounded-full', colors.color)}
animate={{
x: ['-100%', '400%'],
}}
transition={{
duration: 1.5,
repeat: Infinity,
ease: 'easeInOut',
}}
/>
) : totalSubtasks > 0 ? (
// Static progress based on subtasks (when not running)
<motion.div
key="static"
className={cn('h-full rounded-full', colors.color)}
initial={{ width: 0 }}
animate={{ width: `${subtaskProgress}%` }}
transition={{ duration: 0.5, ease: 'easeOut' }}
/>
) : null}
</AnimatePresence>
</div>
{/* 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}`}
/>
))}
{totalSubtasks > 10 && (
<span key="overflow-count" className="text-[10px] text-muted-foreground font-medium ml-0.5">
+{totalSubtasks - 10}
</span>
)}
</div>
)}
{/* Phase steps indicator (shows overall flow) */}
{(isRunning || phase !== 'idle') && (
<PhaseStepsIndicator currentPhase={phase} isStuck={isStuck} />
)}
</div>
);
}
/**
* Mini phase steps indicator showing the overall flow
*/
function PhaseStepsIndicator({
currentPhase,
isStuck,
}: {
currentPhase: ExecutionPhase;
isStuck: boolean;
}) {
const { t } = useTranslation('tasks');
const phases: { key: ExecutionPhase; labelKey: string }[] = [
{ key: 'planning', labelKey: 'execution.shortPhases.plan' },
{ key: 'coding', labelKey: 'execution.shortPhases.code' },
{ key: 'qa_review', labelKey: 'execution.shortPhases.qa' },
];
const getPhaseState = (phaseKey: ExecutionPhase) => {
const phaseOrder = ['planning', 'coding', 'qa_review', 'qa_fixing', 'complete'];
const currentIndex = phaseOrder.indexOf(currentPhase);
const phaseIndex = phaseOrder.indexOf(phaseKey);
if (currentPhase === 'failed') return 'failed';
if (currentPhase === 'complete') return 'complete';
if (phaseKey === currentPhase || (phaseKey === 'qa_review' && currentPhase === 'qa_fixing')) {
return isStuck ? 'stuck' : 'active';
}
if (phaseIndex < currentIndex) return 'complete';
return 'pending';
};
return (
<div className="flex items-center gap-1 mt-2">
{phases.map((phase, index) => {
const state = getPhaseState(phase.key);
return (
<div key={phase.key} className="flex items-center">
<motion.div
className={cn(
'flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-medium',
state === 'complete' && 'bg-success/10 text-success',
state === 'active' && 'bg-primary/10 text-primary',
state === 'stuck' && 'bg-warning/10 text-warning',
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
}
>
{state === 'complete' && (
<svg className="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
{t(phase.labelKey)}
</motion.div>
{index < phases.length - 1 && (
<div
className={cn(
'w-2 h-px mx-0.5',
getPhaseState(phases[index + 1].key) !== 'pending' ? 'bg-success/50' : 'bg-border'
)}
/>
)}
</div>
);
})}
</div>
);
}