mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-12 02:37:45 +08:00
Add E2E tests for internationalization across multiple pages
- Implemented navigation.spec.ts to test language switching and translation of navigation elements. - Created sessions-page.spec.ts to verify translations on the sessions page, including headers, status badges, and date formatting. - Developed settings-page.spec.ts to ensure settings page content is translated and persists across sessions. - Added skills-page.spec.ts to validate translations for skill categories, action buttons, and empty states.
This commit is contained in:
245
ccw/frontend/src/components/shared/ConversationCard.tsx
Normal file
245
ccw/frontend/src/components/shared/ConversationCard.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
// ========================================
|
||||
// ConversationCard Component
|
||||
// ========================================
|
||||
// Card component for displaying CLI execution history items
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
MoreVertical,
|
||||
Eye,
|
||||
Trash2,
|
||||
Copy,
|
||||
Clock,
|
||||
Timer,
|
||||
Hash,
|
||||
MessagesSquare,
|
||||
Folder,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/Dropdown';
|
||||
import type { CliExecution } from '@/lib/api';
|
||||
|
||||
export interface ConversationCardProps {
|
||||
/** Execution data */
|
||||
execution: CliExecution;
|
||||
/** Called when view action is triggered */
|
||||
onView?: (execution: CliExecution) => void;
|
||||
/** Called when delete action is triggered */
|
||||
onDelete?: (id: string) => void;
|
||||
/** Called when card is clicked */
|
||||
onClick?: (execution: CliExecution) => void;
|
||||
/** Optional className */
|
||||
className?: string;
|
||||
/** Disabled state for actions */
|
||||
actionsDisabled?: boolean;
|
||||
}
|
||||
|
||||
// Status configuration
|
||||
const statusConfig = {
|
||||
success: {
|
||||
variant: 'success' as const,
|
||||
icon: 'check-circle',
|
||||
},
|
||||
error: {
|
||||
variant: 'destructive' as const,
|
||||
icon: 'x-circle',
|
||||
},
|
||||
timeout: {
|
||||
variant: 'warning' as const,
|
||||
icon: 'clock',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Format duration to human readable string
|
||||
*/
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time ago string
|
||||
*/
|
||||
function getTimeAgo(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
if (seconds < 60) return 'just now';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* ConversationCard component for displaying CLI execution history
|
||||
*/
|
||||
export function ConversationCard({
|
||||
execution,
|
||||
onView,
|
||||
onDelete,
|
||||
onClick,
|
||||
className,
|
||||
actionsDisabled = false,
|
||||
}: ConversationCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const status = statusConfig[execution.status] || statusConfig.error;
|
||||
|
||||
const handleCopyId = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(execution.id);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
console.error('Failed to copy ID');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
// Don't trigger if clicking on dropdown
|
||||
if ((e.target as HTMLElement).closest('[data-radix-popper-content-wrapper]')) {
|
||||
return;
|
||||
}
|
||||
onClick?.(execution);
|
||||
};
|
||||
|
||||
const handleAction = (
|
||||
e: React.MouseEvent,
|
||||
action: 'view' | 'delete' | 'copy'
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
switch (action) {
|
||||
case 'view':
|
||||
onView?.(execution);
|
||||
break;
|
||||
case 'delete':
|
||||
onDelete?.(execution.id);
|
||||
break;
|
||||
case 'copy':
|
||||
handleCopyId(e);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'group cursor-pointer transition-all duration-200 hover:shadow-md',
|
||||
className
|
||||
)}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Main content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Header row */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{execution.tool}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{execution.mode || 'analysis'}
|
||||
</Badge>
|
||||
{execution.turn_count && execution.turn_count > 1 && (
|
||||
<Badge variant="info" className="gap-1 text-xs">
|
||||
<MessagesSquare className="h-3 w-3" />
|
||||
{execution.turn_count}
|
||||
</Badge>
|
||||
)}
|
||||
{execution.sourceDir && execution.sourceDir !== '.' && (
|
||||
<Badge variant="outline" className="gap-1 text-xs">
|
||||
<Folder className="h-3 w-3" />
|
||||
{execution.sourceDir}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant={status.variant} className="gap-1 text-xs ml-auto">
|
||||
{status.icon === 'check-circle' && '✓'}
|
||||
{status.icon === 'x-circle' && '✗'}
|
||||
{status.icon === 'clock' && '⏱'}
|
||||
{execution.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Prompt preview */}
|
||||
<p className="text-sm text-foreground line-clamp-2 mb-2">
|
||||
{execution.prompt_preview}
|
||||
</p>
|
||||
|
||||
{/* Meta info */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{getTimeAgo(execution.timestamp)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Timer className="h-3 w-3" />
|
||||
{formatDuration(execution.duration_ms)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 font-mono" title={execution.id}>
|
||||
<Hash className="h-3 w-3" />
|
||||
{execution.id.substring(0, 8)}...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
<span className="sr-only">{formatMessage({ id: 'common.aria.actions' })}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={(e) => handleAction(e, 'copy')}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
{copied
|
||||
? formatMessage({ id: 'history.actions.copied' })
|
||||
: formatMessage({ id: 'history.actions.copyId' })}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => handleAction(e, 'view')}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
{formatMessage({ id: 'history.actions.view' })}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => handleAction(e, 'delete')}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{formatMessage({ id: 'history.actions.delete' })}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user