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:
catlog22
2026-01-30 22:54:21 +08:00
parent e78e95049b
commit 81725c94b1
150 changed files with 25341 additions and 1448 deletions

View File

@@ -4,6 +4,7 @@
// Manage custom slash commands with search/filter
import { useState, useMemo } from 'react';
import { useIntl } from 'react-intl';
import {
Terminal,
Search,
@@ -35,6 +36,8 @@ interface CommandCardProps {
}
function CommandCard({ command, isExpanded, onToggleExpand, onCopy }: CommandCardProps) {
const { formatMessage } = useIntl();
return (
<Card className="overflow-hidden">
{/* Header */}
@@ -59,7 +62,7 @@ function CommandCard({ command, isExpanded, onToggleExpand, onCopy }: CommandCar
)}
</div>
<p className="text-sm text-muted-foreground mt-1">
{command.description}
{command.description || formatMessage({ id: 'commands.card.noDescription' })}
</p>
</div>
</div>
@@ -107,7 +110,7 @@ function CommandCard({ command, isExpanded, onToggleExpand, onCopy }: CommandCar
<div>
<div className="flex items-center gap-2 text-sm font-medium text-foreground mb-2">
<Code className="w-4 h-4" />
Usage
{formatMessage({ id: 'commands.card.usage' })}
</div>
<div className="p-3 bg-background rounded-md font-mono text-sm overflow-x-auto">
<code>{command.usage}</code>
@@ -120,7 +123,7 @@ function CommandCard({ command, isExpanded, onToggleExpand, onCopy }: CommandCar
<div>
<div className="flex items-center gap-2 text-sm font-medium text-foreground mb-2">
<BookOpen className="w-4 h-4" />
Examples
{formatMessage({ id: 'commands.card.examples' })}
</div>
<div className="space-y-2">
{command.examples.map((example, idx) => (
@@ -151,6 +154,7 @@ function CommandCard({ command, isExpanded, onToggleExpand, onCopy }: CommandCar
// ========== Main Page Component ==========
export function CommandsManagerPage() {
const { formatMessage } = useIntl();
const [searchQuery, setSearchQuery] = useState('');
const [categoryFilter, setCategoryFilter] = useState<string>('all');
const [sourceFilter, setSourceFilter] = useState<string>('all');
@@ -217,20 +221,20 @@ export function CommandsManagerPage() {
<div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
<Terminal className="w-6 h-6 text-primary" />
Commands Manager
{formatMessage({ id: 'commands.title' })}
</h1>
<p className="text-muted-foreground mt-1">
Manage custom slash commands for Claude Code
{formatMessage({ id: 'commands.description' })}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
Refresh
{formatMessage({ id: 'common.actions.refresh' })}
</Button>
<Button>
<Plus className="w-4 h-4 mr-2" />
New Command
{formatMessage({ id: 'commands.actions.create' })}
</Button>
</div>
</div>
@@ -242,28 +246,28 @@ export function CommandsManagerPage() {
<Terminal className="w-5 h-5 text-primary" />
<span className="text-2xl font-bold">{totalCount}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">Total Commands</p>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'common.stats.totalCommands' })}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2">
<Code className="w-5 h-5 text-info" />
<span className="text-2xl font-bold">{builtinCount}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">Built-in</p>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'commands.source.builtin' })}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2">
<Plus className="w-5 h-5 text-success" />
<span className="text-2xl font-bold">{customCount}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">Custom</p>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'commands.source.custom' })}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2">
<Tag className="w-5 h-5 text-warning" />
<span className="text-2xl font-bold">{categories.length}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">Categories</p>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'common.stats.categories' })}</p>
</Card>
</div>
@@ -272,7 +276,7 @@ export function CommandsManagerPage() {
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search commands by name, description, or alias..."
placeholder={formatMessage({ id: 'commands.filters.searchPlaceholder' })}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
@@ -281,10 +285,10 @@ export function CommandsManagerPage() {
<div className="flex gap-2">
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Category" />
<SelectValue placeholder={formatMessage({ id: 'commands.filters.category' })} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
<SelectItem value="all">{formatMessage({ id: 'commands.filters.allCategories' })}</SelectItem>
{categories.map((cat) => (
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
))}
@@ -292,12 +296,12 @@ export function CommandsManagerPage() {
</Select>
<Select value={sourceFilter} onValueChange={setSourceFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Source" />
<SelectValue placeholder={formatMessage({ id: 'commands.filters.source' })} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Sources</SelectItem>
<SelectItem value="builtin">Built-in</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
<SelectItem value="all">{formatMessage({ id: 'commands.filters.allSources' })}</SelectItem>
<SelectItem value="builtin">{formatMessage({ id: 'commands.source.builtin' })}</SelectItem>
<SelectItem value="custom">{formatMessage({ id: 'commands.source.custom' })}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -306,10 +310,10 @@ export function CommandsManagerPage() {
{/* Expand/Collapse All */}
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={expandAll}>
Expand All
{formatMessage({ id: 'commands.actions.expandAll' })}
</Button>
<Button variant="ghost" size="sm" onClick={collapseAll}>
Collapse All
{formatMessage({ id: 'commands.actions.collapseAll' })}
</Button>
</div>
@@ -323,9 +327,9 @@ export function CommandsManagerPage() {
) : commands.length === 0 ? (
<Card className="p-8 text-center">
<Terminal className="w-12 h-12 mx-auto text-muted-foreground/50" />
<h3 className="mt-4 text-lg font-medium text-foreground">No commands found</h3>
<h3 className="mt-4 text-lg font-medium text-foreground">{formatMessage({ id: 'commands.emptyState.title' })}</h3>
<p className="mt-2 text-muted-foreground">
Try adjusting your search or filters.
{formatMessage({ id: 'commands.emptyState.message' })}
</p>
</Card>
) : (

View File

@@ -0,0 +1,334 @@
// ========================================
// CLI Endpoints Page
// ========================================
// Manage LiteLLM endpoints, custom CLI endpoints, and CLI wrapper endpoints
import { useState } from 'react';
import { useIntl } from 'react-intl';
import {
Plug,
Plus,
Search,
RefreshCw,
Power,
PowerOff,
Edit,
Trash2,
ChevronDown,
ChevronUp,
Zap,
Code,
Layers,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select';
import { useCliEndpoints, useToggleCliEndpoint } from '@/hooks';
import type { CliEndpoint } from '@/lib/api';
import { cn } from '@/lib/utils';
// ========== Endpoint Card Component ==========
interface EndpointCardProps {
endpoint: CliEndpoint;
isExpanded: boolean;
onToggleExpand: () => void;
onToggle: (endpointId: string, enabled: boolean) => void;
onEdit: (endpoint: CliEndpoint) => void;
onDelete: (endpointId: string) => void;
}
function EndpointCard({ endpoint, isExpanded, onToggleExpand, onToggle, onEdit, onDelete }: EndpointCardProps) {
const { formatMessage } = useIntl();
const typeConfig = {
litellm: { icon: Zap, color: 'text-blue-600', label: 'cliEndpoints.type.litellm' },
custom: { icon: Code, color: 'text-purple-600', label: 'cliEndpoints.type.custom' },
wrapper: { icon: Layers, color: 'text-orange-600', label: 'cliEndpoints.type.wrapper' },
};
const config = typeConfig[endpoint.type];
const Icon = config.icon;
return (
<Card className={cn('overflow-hidden', !endpoint.enabled && 'opacity-60')}>
{/* Header */}
<div
className="p-4 cursor-pointer hover:bg-muted/50 transition-colors"
onClick={onToggleExpand}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-3">
<div className={cn(
'p-2 rounded-lg',
endpoint.enabled ? 'bg-primary/10' : 'bg-muted'
)}>
<Icon className={cn(
'w-5 h-5',
endpoint.enabled ? config.color : 'text-muted-foreground'
)} />
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">
{endpoint.name}
</span>
<Badge variant="outline" className="text-xs">
{formatMessage({ id: config.label })}
</Badge>
{endpoint.enabled && (
<Badge variant="outline" className="text-xs text-green-600">
{formatMessage({ id: 'cliEndpoints.status.enabled' })}
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground mt-1">
{formatMessage({ id: 'cliEndpoints.id' })}: {endpoint.id}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
onToggle(endpoint.id, !endpoint.enabled);
}}
>
{endpoint.enabled ? <Power className="w-4 h-4 text-green-600" /> : <PowerOff className="w-4 h-4" />}
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
onEdit(endpoint);
}}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
onDelete(endpoint.id);
}}
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-muted-foreground" />
) : (
<ChevronDown className="w-5 h-5 text-muted-foreground" />
)}
</div>
</div>
</div>
{/* Expanded Content */}
{isExpanded && (
<div className="border-t border-border p-4 space-y-3 bg-muted/30">
{/* Config display */}
<div>
<p className="text-xs text-muted-foreground mb-2">{formatMessage({ id: 'cliEndpoints.config' })}</p>
<div className="bg-background p-3 rounded-md font-mono text-sm overflow-x-auto">
<pre>{JSON.stringify(endpoint.config, null, 2)}</pre>
</div>
</div>
</div>
)}
</Card>
);
}
// ========== Main Page Component ==========
export function EndpointsPage() {
const { formatMessage } = useIntl();
const [searchQuery, setSearchQuery] = useState('');
const [typeFilter, setTypeFilter] = useState<'all' | 'litellm' | 'custom' | 'wrapper'>('all');
const [expandedEndpoints, setExpandedEndpoints] = useState<Set<string>>(new Set());
const {
endpoints,
litellmEndpoints,
customEndpoints,
totalCount,
enabledCount,
isLoading,
isFetching,
refetch,
} = useCliEndpoints();
const { toggleEndpoint } = useToggleCliEndpoint();
const toggleExpand = (endpointId: string) => {
setExpandedEndpoints((prev) => {
const next = new Set(prev);
if (next.has(endpointId)) {
next.delete(endpointId);
} else {
next.add(endpointId);
}
return next;
});
};
const handleToggle = (endpointId: string, enabled: boolean) => {
toggleEndpoint(endpointId, enabled);
};
const handleDelete = (endpointId: string) => {
if (confirm(formatMessage({ id: 'cliEndpoints.deleteConfirm' }, { id: endpointId }))) {
// TODO: Implement delete functionality
console.log('Delete endpoint:', endpointId);
}
};
const handleEdit = (endpoint: CliEndpoint) => {
// TODO: Implement edit dialog
console.log('Edit endpoint:', endpoint);
};
// Filter endpoints by search query and type
const filteredEndpoints = (() => {
let filtered = endpoints;
if (typeFilter !== 'all') {
filtered = filtered.filter((e) => e.type === typeFilter);
}
if (searchQuery) {
const searchLower = searchQuery.toLowerCase();
filtered = filtered.filter((e) =>
e.name.toLowerCase().includes(searchLower) ||
e.id.toLowerCase().includes(searchLower)
);
}
return filtered;
})();
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
<Plug className="w-6 h-6 text-primary" />
{formatMessage({ id: 'cliEndpoints.title' })}
</h1>
<p className="text-muted-foreground mt-1">
{formatMessage({ id: 'cliEndpoints.description' })}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
{formatMessage({ id: 'common.actions.refresh' })}
</Button>
<Button>
<Plus className="w-4 h-4 mr-2" />
{formatMessage({ id: 'cliEndpoints.actions.add' })}
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="p-4">
<div className="flex items-center gap-2">
<Plug className="w-5 h-5 text-primary" />
<span className="text-2xl font-bold">{totalCount}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'cliEndpoints.stats.total' })}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2">
<Power className="w-5 h-5 text-green-600" />
<span className="text-2xl font-bold">{enabledCount}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'cliEndpoints.stats.enabled' })}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2">
<Zap className="w-5 h-5 text-blue-600" />
<span className="text-2xl font-bold">{litellmEndpoints.length}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'cliEndpoints.type.litellm' })}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2">
<Code className="w-5 h-5 text-purple-600" />
<span className="text-2xl font-bold">{customEndpoints.length}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'cliEndpoints.type.custom' })}</p>
</Card>
</div>
{/* Filters and Search */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder={formatMessage({ id: 'cliEndpoints.filters.searchPlaceholder' })}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Select value={typeFilter} onValueChange={(v: typeof typeFilter) => setTypeFilter(v)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder={formatMessage({ id: 'cliEndpoints.filters.type' })} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{formatMessage({ id: 'cliEndpoints.filters.allTypes' })}</SelectItem>
<SelectItem value="litellm">{formatMessage({ id: 'cliEndpoints.type.litellm' })}</SelectItem>
<SelectItem value="custom">{formatMessage({ id: 'cliEndpoints.type.custom' })}</SelectItem>
<SelectItem value="wrapper">{formatMessage({ id: 'cliEndpoints.type.wrapper' })}</SelectItem>
</SelectContent>
</Select>
</div>
{/* Endpoints List */}
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-24 bg-muted animate-pulse rounded-lg" />
))}
</div>
) : filteredEndpoints.length === 0 ? (
<Card className="p-8 text-center">
<Plug className="w-12 h-12 mx-auto text-muted-foreground/50" />
<h3 className="mt-4 text-lg font-medium text-foreground">{formatMessage({ id: 'cliEndpoints.emptyState.title' })}</h3>
<p className="mt-2 text-muted-foreground">
{formatMessage({ id: 'cliEndpoints.emptyState.message' })}
</p>
</Card>
) : (
<div className="space-y-3">
{filteredEndpoints.map((endpoint) => (
<EndpointCard
key={endpoint.id}
endpoint={endpoint}
isExpanded={expandedEndpoints.has(endpoint.id)}
onToggleExpand={() => toggleExpand(endpoint.id)}
onToggle={handleToggle}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
);
}
export default EndpointsPage;

View File

@@ -0,0 +1,364 @@
// ========================================
// FixSessionPage Component
// ========================================
// Fix session detail page for displaying fix session tasks
import * as React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useIntl } from 'react-intl';
import {
ArrowLeft,
Wrench,
CheckCircle,
XCircle,
Clock,
File,
Loader2,
} from 'lucide-react';
import { useSessions } from '@/hooks/useSessions';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Card, CardContent } from '@/components/ui/Card';
type TaskStatusFilter = 'all' | 'pending' | 'in_progress' | 'fixed' | 'failed';
interface FixTask {
task_id: string;
id?: string;
title?: string;
status: 'pending' | 'in_progress' | 'completed';
result?: 'fixed' | 'failed';
file?: string;
line?: number;
finding_title?: string;
dimension?: string;
attempts?: number;
commit_hash?: string;
}
/**
* FixSessionPage component - Display fix session tasks and progress
*/
export function FixSessionPage() {
const { sessionId } = useParams<{ sessionId: string }>();
const navigate = useNavigate();
const { formatMessage } = useIntl();
const { filteredSessions, isLoading, error, refetch } = useSessions({
filter: { location: 'all' },
});
const [statusFilter, setStatusFilter] = React.useState<TaskStatusFilter>('all');
// Find session
const session = React.useMemo(
() => filteredSessions.find((s) => s.session_id === sessionId),
[filteredSessions, sessionId]
);
const tasks = React.useMemo(() => {
if (!session?.tasks) return [];
return session.tasks as FixTask[];
}, [session?.tasks]);
// Calculate statistics
const stats = React.useMemo(() => {
const total = tasks.length;
const fixed = tasks.filter((t) => t.status === 'completed' && t.result === 'fixed').length;
const failed = tasks.filter((t) => t.status === 'completed' && t.result === 'failed').length;
const pending = tasks.filter((t) => t.status === 'pending').length;
const inProgress = tasks.filter((t) => t.status === 'in_progress').length;
const completed = fixed + failed;
const percentComplete = total > 0 ? Math.round((completed / total) * 100) : 0;
return { total, fixed, failed, pending, inProgress, completed, percentComplete };
}, [tasks]);
// Filter tasks
const filteredTasks = React.useMemo(() => {
if (statusFilter === 'all') return tasks;
if (statusFilter === 'fixed') {
return tasks.filter((t) => t.status === 'completed' && t.result === 'fixed');
}
if (statusFilter === 'failed') {
return tasks.filter((t) => t.status === 'completed' && t.result === 'failed');
}
return tasks.filter((t) => t.status === statusFilter);
}, [tasks, statusFilter]);
// Get status badge props
const getStatusBadge = (task: FixTask) => {
if (task.status === 'completed') {
if (task.result === 'fixed') {
return { variant: 'success' as const, label: formatMessage({ id: 'fixSession.status.fixed' }), icon: CheckCircle };
}
if (task.result === 'failed') {
return { variant: 'destructive' as const, label: formatMessage({ id: 'fixSession.status.failed' }), icon: XCircle };
}
}
if (task.status === 'in_progress') {
return { variant: 'warning' as const, label: formatMessage({ id: 'fixSession.status.inProgress' }), icon: Loader2 };
}
return { variant: 'secondary' as const, label: formatMessage({ id: 'fixSession.status.pending' }), icon: Clock };
};
const handleBack = () => {
navigate('/sessions');
};
const handleFilterChange = (filter: TaskStatusFilter) => {
setStatusFilter(filter);
};
// Loading state
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" disabled>
<ArrowLeft className="h-4 w-4 mr-2" />
{formatMessage({ id: 'common.back' })}
</Button>
<div className="h-8 w-48 rounded bg-muted animate-pulse" />
</div>
<div className="grid grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-24 rounded-lg bg-muted animate-pulse" />
))}
</div>
</div>
);
}
// Error state
if (error) {
return (
<div className="flex items-center gap-2 p-4 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive">
<XCircle className="h-5 w-5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium">{formatMessage({ id: 'common.errors.loadFailed' })}</p>
<p className="text-xs mt-0.5">{error.message}</p>
</div>
<Button variant="outline" size="sm" onClick={() => refetch()}>
{formatMessage({ id: 'common.actions.retry' })}
</Button>
</div>
);
}
// Session not found
if (!session) {
return (
<div className="flex flex-col items-center justify-center py-16 px-4">
<Wrench className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'fixSession.notFound.title' })}
</h3>
<p className="text-sm text-muted-foreground mb-4">
{formatMessage({ id: 'fixSession.notFound.message' })}
</p>
<Button onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
{formatMessage({ id: 'common.back' })}
</Button>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
{formatMessage({ id: 'common.back' })}
</Button>
<div className="flex-1">
<h1 className="text-2xl font-semibold text-foreground">{session.session_id}</h1>
{session.title && (
<p className="text-sm text-muted-foreground mt-0.5">{session.title}</p>
)}
</div>
<Badge variant="warning">
<Wrench className="h-3 w-3 mr-1" />
Fix
</Badge>
</div>
{/* Progress Section */}
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Wrench className="h-5 w-5" />
{formatMessage({ id: 'fixSession.progress.title' })}
</h3>
<Badge variant="secondary">{session.phase || 'Execution'}</Badge>
</div>
{/* Progress Bar */}
<div className="mb-2">
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${stats.percentComplete}%` }}
/>
</div>
</div>
<div className="text-sm text-muted-foreground mb-6">
<strong>{stats.completed}</strong>/{stats.total} {formatMessage({ id: 'common.tasks' })} (
{stats.percentComplete}%)
</div>
{/* Summary Cards */}
<div className="grid grid-cols-4 gap-4">
<div className="text-center p-4 bg-background rounded-lg border">
<div className="text-2xl font-semibold text-foreground">{stats.total}</div>
<div className="text-sm text-muted-foreground">{formatMessage({ id: 'fixSession.stats.total' })}</div>
</div>
<div className="text-center p-4 bg-background rounded-lg border border-success/30 bg-success/5">
<div className="text-2xl font-semibold text-success">{stats.fixed}</div>
<div className="text-sm text-muted-foreground">{formatMessage({ id: 'fixSession.stats.fixed' })}</div>
</div>
<div className="text-center p-4 bg-background rounded-lg border border-destructive/30 bg-destructive/5">
<div className="text-2xl font-semibold text-destructive">{stats.failed}</div>
<div className="text-sm text-muted-foreground">{formatMessage({ id: 'fixSession.stats.failed' })}</div>
</div>
<div className="text-center p-4 bg-background rounded-lg border">
<div className="text-2xl font-semibold text-foreground">{stats.pending}</div>
<div className="text-sm text-muted-foreground">{formatMessage({ id: 'fixSession.stats.pending' })}</div>
</div>
</div>
</CardContent>
</Card>
{/* Tasks Section */}
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<File className="h-5 w-5" />
{formatMessage({ id: 'fixSession.tasks.title' })}
</h3>
<div className="flex gap-1">
{[
{ key: 'all' as const, label: formatMessage({ id: 'fixSession.filter.all' }) },
{ key: 'pending' as const, label: formatMessage({ id: 'fixSession.filter.pending' }) },
{ key: 'in_progress' as const, label: formatMessage({ id: 'fixSession.filter.inProgress' }) },
{ key: 'fixed' as const, label: formatMessage({ id: 'fixSession.filter.fixed' }) },
{ key: 'failed' as const, label: formatMessage({ id: 'fixSession.filter.failed' }) },
].map((filter) => (
<Button
key={filter.key}
variant={statusFilter === filter.key ? 'default' : 'outline'}
size="sm"
onClick={() => handleFilterChange(filter.key)}
>
{filter.label}
</Button>
))}
</div>
</div>
{/* Tasks List */}
{filteredTasks.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<File className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'fixSession.empty.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'fixSession.empty.message' })}
</p>
</div>
) : (
<div className="grid gap-3">
{filteredTasks.map((task) => {
const statusBadge = getStatusBadge(task);
const StatusIcon = statusBadge.icon;
return (
<Card
key={task.task_id || task.id}
className={`hover:shadow-sm transition-shadow ${
task.status === 'completed' && task.result === 'failed'
? 'border-destructive/30 bg-destructive/5'
: ''
}`}
>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-muted-foreground">
{task.task_id || task.id || 'N/A'}
</span>
<Badge variant={statusBadge.variant} className="gap-1">
<StatusIcon className="h-3 w-3" />
{statusBadge.label}
</Badge>
</div>
<h4 className="font-medium text-foreground text-sm">
{task.title || formatMessage({ id: 'fixSession.task.untitled' })}
</h4>
{task.finding_title && (
<p className="text-sm text-muted-foreground mt-1">{task.finding_title}</p>
)}
{task.file && (
<div className="flex items-center gap-1 mt-2 text-xs text-muted-foreground">
<File className="h-3 w-3" />
{task.file}
{task.line && `:${task.line}`}
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-1 text-xs">
{task.dimension && (
<span className="px-2 py-0.5 bg-muted rounded text-muted-foreground">
{task.dimension}
</span>
)}
{task.attempts && task.attempts > 1 && (
<span className="px-2 py-0.5 bg-muted rounded text-muted-foreground">
{formatMessage({ id: 'fixSession.task.attempts' }, { count: task.attempts })}
</span>
)}
{task.commit_hash && (
<span className="px-2 py-0.5 bg-primary-light text-primary rounded font-mono">
{task.commit_hash.substring(0, 7)}
</span>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Session Info */}
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground p-4 bg-background rounded-lg border">
<div>
<span className="font-medium">{formatMessage({ id: 'fixSession.info.created' })}:</span>{' '}
{new Date(session.created_at).toLocaleString()}
</div>
{session.updated_at && (
<div>
<span className="font-medium">{formatMessage({ id: 'fixSession.info.updated' })}:</span>{' '}
{new Date(session.updated_at).toLocaleString()}
</div>
)}
{session.description && (
<div className="w-full">
<span className="font-medium">{formatMessage({ id: 'fixSession.info.description' })}:</span>{' '}
{session.description}
</div>
)}
</div>
</div>
);
}
export default FixSessionPage;

View File

@@ -14,6 +14,7 @@ import {
Terminal,
} from 'lucide-react';
import { Link } from 'react-router-dom';
import { useIntl } from 'react-intl';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
@@ -53,16 +54,18 @@ const helpSections: HelpSection[] = [
];
export function HelpPage() {
const { formatMessage } = useIntl();
return (
<div className="max-w-4xl mx-auto space-y-8">
{/* Page Header */}
<div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
<HelpCircle className="w-6 h-6 text-primary" />
Help & Documentation
{formatMessage({ id: 'help.title' })}
</h1>
<p className="text-muted-foreground mt-1">
Learn how to use CCW Dashboard and get the most out of your workflows
{formatMessage({ id: 'help.description' })}
</p>
</div>
@@ -182,19 +185,19 @@ export function HelpPage() {
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-foreground">
Need more help?
{formatMessage({ id: 'help.support.title' })}
</h3>
<p className="text-muted-foreground mt-1 mb-4">
Check the project documentation or reach out for support.
{formatMessage({ id: 'help.support.description' })}
</p>
<div className="flex gap-3">
<Button variant="outline" size="sm">
<Book className="w-4 h-4 mr-2" />
Documentation
{formatMessage({ id: 'help.support.documentation' })}
</Button>
<Button variant="outline" size="sm">
<Video className="w-4 h-4 mr-2" />
Tutorials
{formatMessage({ id: 'help.support.tutorials' })}
</Button>
</div>
</div>

View File

@@ -0,0 +1,314 @@
// ========================================
// HistoryPage Component
// ========================================
// CLI execution history page with filtering and bulk actions
import * as React from 'react';
import { useIntl } from 'react-intl';
import {
Terminal,
SearchX,
RefreshCw,
Trash2,
AlertTriangle,
Search,
X,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useHistory } from '@/hooks/useHistory';
import { ConversationCard } from '@/components/shared/ConversationCard';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/Dialog';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuLabel,
} from '@/components/ui/Dropdown';
/**
* HistoryPage component - Display CLI execution history
*/
export function HistoryPage() {
const { formatMessage } = useIntl();
const [searchQuery, setSearchQuery] = React.useState('');
const [toolFilter, setToolFilter] = React.useState<string | undefined>(undefined);
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
const [deleteType, setDeleteType] = React.useState<'single' | 'tool' | 'all' | null>(null);
const [deleteTarget, setDeleteTarget] = React.useState<string | null>(null);
const {
executions,
isLoading,
isFetching,
error,
refetch,
deleteExecution,
deleteByTool,
deleteAll,
isDeleting,
} = useHistory({
filter: { search: searchQuery || undefined, tool: toolFilter },
});
const tools = React.useMemo(() => {
const toolSet = new Set(executions.map((e) => e.tool));
return Array.from(toolSet).sort();
}, [executions]);
// Filter handlers
const handleClearSearch = () => {
setSearchQuery('');
};
const handleClearFilters = () => {
setSearchQuery('');
setToolFilter(undefined);
};
const hasActiveFilters = searchQuery.length > 0 || toolFilter !== undefined;
// Delete handlers
const handleDeleteClick = (id: string) => {
setDeleteType('single');
setDeleteTarget(id);
setDeleteDialogOpen(true);
};
const handleDeleteByTool = (tool: string) => {
setDeleteType('tool');
setDeleteTarget(tool);
setDeleteDialogOpen(true);
};
const handleDeleteAll = () => {
setDeleteType('all');
setDeleteTarget(null);
setDeleteDialogOpen(true);
};
const handleConfirmDelete = async () => {
try {
if (deleteType === 'single' && deleteTarget) {
await deleteExecution(deleteTarget);
} else if (deleteType === 'tool' && deleteTarget) {
await deleteByTool(deleteTarget);
} else if (deleteType === 'all') {
await deleteAll();
}
setDeleteDialogOpen(false);
setDeleteType(null);
setDeleteTarget(null);
} catch (err) {
console.error('Failed to delete:', err);
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">
{formatMessage({ id: 'history.title' })}
</h1>
<p className="text-sm text-muted-foreground mt-1">
{formatMessage({ id: 'history.description' })}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
>
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />
{formatMessage({ id: 'common.actions.refresh' })}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Trash2 className="h-4 w-4 mr-2" />
{formatMessage({ id: 'history.deleteOptions' })}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{formatMessage({ id: 'history.deleteBy' })}</DropdownMenuLabel>
<DropdownMenuSeparator />
{tools.map((tool) => (
<DropdownMenuItem key={tool} onClick={() => handleDeleteByTool(tool)}>
{formatMessage({ id: 'history.deleteAllTool' }, { tool })}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleDeleteAll}
className="text-destructive focus:text-destructive"
>
<AlertTriangle className="mr-2 h-4 w-4" />
{formatMessage({ id: 'history.deleteAll' })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Error alert */}
{error && (
<div className="flex items-center gap-2 p-4 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive">
<Terminal className="h-5 w-5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium">{formatMessage({ id: 'common.errors.loadFailed' })}</p>
<p className="text-xs mt-0.5">{error.message}</p>
</div>
<Button variant="outline" size="sm" onClick={() => refetch()}>
{formatMessage({ id: 'common.actions.retry' })}
</Button>
</div>
)}
{/* Filters */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
{/* Search input */}
<div className="flex-1 max-w-sm relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={formatMessage({ id: 'history.searchPlaceholder' })}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-9"
/>
{searchQuery && (
<button
onClick={handleClearSearch}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Tool filter */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2 min-w-[160px] justify-between">
{toolFilter || formatMessage({ id: 'history.filterAllTools' })}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuItem onClick={() => setToolFilter(undefined)}>
{formatMessage({ id: 'history.filterAllTools' })}
</DropdownMenuItem>
<DropdownMenuSeparator />
{tools.map((tool) => (
<DropdownMenuItem
key={tool}
onClick={() => setToolFilter(tool)}
className={toolFilter === tool ? 'bg-accent' : ''}
>
{tool}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Clear filters */}
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={handleClearFilters}>
{formatMessage({ id: 'common.actions.clearFilters' })}
</Button>
)}
</div>
{/* Executions list */}
{isLoading ? (
<div className="grid gap-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-28 rounded-lg bg-muted animate-pulse" />
))}
</div>
) : executions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 px-4">
<SearchX className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{hasActiveFilters
? formatMessage({ id: 'history.empty.filtered' })
: formatMessage({ id: 'history.empty.title' })}
</h3>
<p className="text-sm text-muted-foreground text-center">
{hasActiveFilters
? formatMessage({ id: 'history.empty.filteredMessage' })
: formatMessage({ id: 'history.empty.message' })}
</p>
{hasActiveFilters && (
<Button variant="outline" onClick={handleClearFilters} className="mt-4">
{formatMessage({ id: 'common.actions.clearFilters' })}
</Button>
)}
</div>
) : (
<div className="grid gap-3">
{executions.map((execution) => (
<ConversationCard
key={execution.id}
execution={execution}
onDelete={handleDeleteClick}
actionsDisabled={isDeleting}
/>
))}
</div>
)}
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{deleteType === 'all'
? formatMessage({ id: 'history.dialog.deleteAllTitle' })
: formatMessage({ id: 'history.dialog.deleteTitle' })}
</DialogTitle>
<DialogDescription>
{deleteType === 'all' && formatMessage({ id: 'history.dialog.deleteAllMessage' })}
{deleteType === 'tool' &&
formatMessage({ id: 'history.dialog.deleteToolMessage' }, { tool: deleteTarget })}
{deleteType === 'single' &&
formatMessage({ id: 'history.dialog.deleteMessage' })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
disabled={isDeleting}
>
{formatMessage({ id: 'common.actions.cancel' })}
</Button>
<Button
variant="destructive"
onClick={handleConfirmDelete}
disabled={isDeleting}
>
{isDeleting
? formatMessage({ id: 'common.status.deleting' })
: formatMessage({ id: 'common.actions.delete' })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export default HistoryPage;

View File

@@ -5,6 +5,7 @@
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import { useIntl } from 'react-intl';
import {
FolderKanban,
ListChecks,
@@ -22,58 +23,59 @@ import { SessionCard, SessionCardSkeleton } from '@/components/shared/SessionCar
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils';
// Stat card configuration
const statCards = [
{
key: 'activeSessions',
title: 'Active Sessions',
icon: FolderKanban,
variant: 'primary' as const,
getValue: (stats: { activeSessions: number }) => stats.activeSessions,
},
{
key: 'totalTasks',
title: 'Total Tasks',
icon: ListChecks,
variant: 'info' as const,
getValue: (stats: { totalTasks: number }) => stats.totalTasks,
},
{
key: 'completedTasks',
title: 'Completed',
icon: CheckCircle2,
variant: 'success' as const,
getValue: (stats: { completedTasks: number }) => stats.completedTasks,
},
{
key: 'pendingTasks',
title: 'Pending',
icon: Clock,
variant: 'warning' as const,
getValue: (stats: { pendingTasks: number }) => stats.pendingTasks,
},
{
key: 'failedTasks',
title: 'Failed',
icon: XCircle,
variant: 'danger' as const,
getValue: (stats: { failedTasks: number }) => stats.failedTasks,
},
{
key: 'todayActivity',
title: "Today's Activity",
icon: Activity,
variant: 'default' as const,
getValue: (stats: { todayActivity: number }) => stats.todayActivity,
},
];
/**
* HomePage component - Dashboard overview with statistics and recent sessions
*/
export function HomePage() {
const { formatMessage } = useIntl();
const navigate = useNavigate();
// Stat card configuration
const statCards = React.useMemo(() => [
{
key: 'activeSessions',
title: formatMessage({ id: 'home.stats.activeSessions' }),
icon: FolderKanban,
variant: 'primary' as const,
getValue: (stats: { activeSessions: number }) => stats.activeSessions,
},
{
key: 'totalTasks',
title: formatMessage({ id: 'home.stats.totalTasks' }),
icon: ListChecks,
variant: 'info' as const,
getValue: (stats: { totalTasks: number }) => stats.totalTasks,
},
{
key: 'completedTasks',
title: formatMessage({ id: 'home.stats.completedTasks' }),
icon: CheckCircle2,
variant: 'success' as const,
getValue: (stats: { completedTasks: number }) => stats.completedTasks,
},
{
key: 'pendingTasks',
title: formatMessage({ id: 'home.stats.pendingTasks' }),
icon: Clock,
variant: 'warning' as const,
getValue: (stats: { pendingTasks: number }) => stats.pendingTasks,
},
{
key: 'failedTasks',
title: formatMessage({ id: 'common.status.failed' }),
icon: XCircle,
variant: 'danger' as const,
getValue: (stats: { failedTasks: number }) => stats.failedTasks,
},
{
key: 'todayActivity',
title: formatMessage({ id: 'common.stats.todayActivity' }),
icon: Activity,
variant: 'default' as const,
getValue: (stats: { todayActivity: number }) => stats.todayActivity,
},
], [formatMessage]);
// Fetch dashboard stats
const {
stats,
@@ -126,9 +128,9 @@ export function HomePage() {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Dashboard</h1>
<h1 className="text-2xl font-semibold text-foreground">{formatMessage({ id: 'home.title' })}</h1>
<p className="text-sm text-muted-foreground mt-1">
Overview of your workflow sessions and tasks
{formatMessage({ id: 'home.description' })}
</p>
</div>
<Button
@@ -138,7 +140,7 @@ export function HomePage() {
disabled={isFetching}
>
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />
Refresh
{formatMessage({ id: 'common.actions.refresh' })}
</Button>
</div>
@@ -147,20 +149,20 @@ export function HomePage() {
<div className="flex items-center gap-2 p-4 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive">
<AlertCircle className="h-5 w-5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium">Failed to load dashboard data</p>
<p className="text-sm font-medium">{formatMessage({ id: 'home.errors.loadFailed' })}</p>
<p className="text-xs mt-0.5">
{(statsError || sessionsError)?.message || 'An unexpected error occurred'}
{(statsError || sessionsError)?.message || formatMessage({ id: 'common.errors.unknownError' })}
</p>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh}>
Retry
{formatMessage({ id: 'home.errors.retry' })}
</Button>
</div>
)}
{/* Stats Grid */}
<section>
<h2 className="text-lg font-medium text-foreground mb-4">Statistics</h2>
<h2 className="text-lg font-medium text-foreground mb-4">{formatMessage({ id: 'home.sections.statistics' })}</h2>
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-6">
{isLoading
? // Loading skeletons
@@ -182,9 +184,9 @@ export function HomePage() {
{/* Recent Sessions */}
<section>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-medium text-foreground">Recent Sessions</h2>
<h2 className="text-lg font-medium text-foreground">{formatMessage({ id: 'home.sections.recentSessions' })}</h2>
<Button variant="link" size="sm" onClick={handleViewAllSessions}>
View All
{formatMessage({ id: 'common.actions.viewAll' })}
</Button>
</div>
@@ -199,9 +201,9 @@ export function HomePage() {
// Empty state
<div className="flex flex-col items-center justify-center py-12 px-4 border border-dashed border-border rounded-lg">
<FolderKanban className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-1">No sessions yet</h3>
<h3 className="text-lg font-medium text-foreground mb-1">{formatMessage({ id: 'home.emptyState.noSessions.title' })}</h3>
<p className="text-sm text-muted-foreground text-center max-w-sm">
Start a new workflow session to track your development tasks and progress.
{formatMessage({ id: 'home.emptyState.noSessions.message' })}
</p>
</div>
) : (

View File

@@ -0,0 +1,310 @@
// ========================================
// CLI Installations Page
// ========================================
// Manage CCW CLI tool installations (install, upgrade, uninstall)
import { useState } from 'react';
import { useIntl } from 'react-intl';
import {
Download,
Upload,
Trash2,
Search,
RefreshCw,
CheckCircle,
XCircle,
AlertCircle,
Package,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select';
import {
useCliInstallations,
useInstallCliTool,
useUninstallCliTool,
useUpgradeCliTool,
} from '@/hooks';
import type { CliInstallation } from '@/lib/api';
import { cn } from '@/lib/utils';
// ========== Installation Card Component ==========
interface InstallationCardProps {
installation: CliInstallation;
onInstall: (toolName: string) => void;
onUninstall: (toolName: string) => void;
onUpgrade: (toolName: string) => void;
isInstalling: boolean;
isUninstalling: boolean;
isUpgrading: boolean;
}
function InstallationCard({
installation,
onInstall,
onUninstall,
onUpgrade,
isInstalling,
isUninstalling,
isUpgrading,
}: InstallationCardProps) {
const { formatMessage } = useIntl();
const statusConfig = {
active: { icon: CheckCircle, color: 'text-green-600', label: 'cliInstallations.status.active' },
inactive: { icon: XCircle, color: 'text-muted-foreground', label: 'cliInstallations.status.inactive' },
error: { icon: AlertCircle, color: 'text-destructive', label: 'cliInstallations.status.error' },
};
const config = statusConfig[installation.status];
const StatusIcon = config.icon;
const isLoading = isInstalling || isUninstalling || isUpgrading;
return (
<Card className={cn('p-4', !installation.installed && 'opacity-60')}>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<div className={cn(
'p-2 rounded-lg',
installation.installed ? 'bg-primary/10' : 'bg-muted'
)}>
<Package className={cn(
'w-5 h-5',
installation.installed ? 'text-primary' : 'text-muted-foreground'
)} />
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">
{installation.name}
</span>
{installation.version && (
<Badge variant="outline" className="text-xs">
v{installation.version}
</Badge>
)}
<Badge variant="outline" className={cn('text-xs', config.color)}>
<StatusIcon className="w-3 h-3 mr-1" />
{formatMessage({ id: config.label })}
</Badge>
{installation.installed && (
<Badge variant="outline" className="text-xs text-green-600">
{formatMessage({ id: 'cliInstallations.installed' })}
</Badge>
)}
</div>
{installation.path && (
<p className="text-xs text-muted-foreground mt-1 font-mono">
{installation.path}
</p>
)}
{installation.lastChecked && (
<p className="text-xs text-muted-foreground mt-1">
{formatMessage({ id: 'cliInstallations.lastChecked' })}: {new Date(installation.lastChecked).toLocaleString()}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{installation.installed ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => onUpgrade(installation.name)}
disabled={isLoading}
>
<Upload className="w-4 h-4 mr-1" />
{formatMessage({ id: 'cliInstallations.actions.upgrade' })}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onUninstall(installation.name)}
disabled={isLoading}
className="text-destructive hover:text-destructive"
>
<Trash2 className="w-4 h-4 mr-1" />
{formatMessage({ id: 'cliInstallations.actions.uninstall' })}
</Button>
</>
) : (
<Button
variant="default"
size="sm"
onClick={() => onInstall(installation.name)}
disabled={isLoading}
>
<Download className="w-4 h-4 mr-1" />
{formatMessage({ id: 'cliInstallations.actions.install' })}
</Button>
)}
</div>
</div>
</Card>
);
}
// ========== Main Page Component ==========
export function InstallationsPage() {
const { formatMessage } = useIntl();
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'installed' | 'not-installed'>('all');
const {
installations,
totalCount,
installedCount,
isLoading,
isFetching,
refetch,
} = useCliInstallations();
const { installTool, isInstalling } = useInstallCliTool();
const { uninstallTool, isUninstalling } = useUninstallCliTool();
const { upgradeTool, isUpgrading } = useUpgradeCliTool();
const handleInstall = (toolName: string) => {
installTool(toolName);
};
const handleUninstall = (toolName: string) => {
if (confirm(formatMessage({ id: 'cliInstallations.uninstallConfirm' }, { name: toolName }))) {
uninstallTool(toolName);
}
};
const handleUpgrade = (toolName: string) => {
upgradeTool(toolName);
};
// Filter installations by search query and status
const filteredInstallations = (() => {
let filtered = installations;
if (statusFilter === 'installed') {
filtered = filtered.filter((i) => i.installed);
} else if (statusFilter === 'not-installed') {
filtered = filtered.filter((i) => !i.installed);
}
if (searchQuery) {
const searchLower = searchQuery.toLowerCase();
filtered = filtered.filter((i) =>
i.name.toLowerCase().includes(searchLower) ||
(i.version && i.version.toLowerCase().includes(searchLower))
);
}
return filtered;
})();
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
<Package className="w-6 h-6 text-primary" />
{formatMessage({ id: 'cliInstallations.title' })}
</h1>
<p className="text-muted-foreground mt-1">
{formatMessage({ id: 'cliInstallations.description' })}
</p>
</div>
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
{formatMessage({ id: 'common.actions.refresh' })}
</Button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<Card className="p-4">
<div className="flex items-center gap-2">
<Package className="w-5 h-5 text-primary" />
<span className="text-2xl font-bold">{totalCount}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'cliInstallations.stats.total' })}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-600" />
<span className="text-2xl font-bold">{installedCount}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'cliInstallations.stats.installed' })}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2">
<XCircle className="w-5 h-5 text-muted-foreground" />
<span className="text-2xl font-bold">{totalCount - installedCount}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'cliInstallations.stats.available' })}</p>
</Card>
</div>
{/* Filters and Search */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder={formatMessage({ id: 'cliInstallations.filters.searchPlaceholder' })}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Select value={statusFilter} onValueChange={(v: typeof statusFilter) => setStatusFilter(v)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder={formatMessage({ id: 'cliInstallations.filters.status' })} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{formatMessage({ id: 'cliInstallations.filters.all' })}</SelectItem>
<SelectItem value="installed">{formatMessage({ id: 'cliInstallations.filters.installed' })}</SelectItem>
<SelectItem value="not-installed">{formatMessage({ id: 'cliInstallations.filters.notInstalled' })}</SelectItem>
</SelectContent>
</Select>
</div>
{/* Installations List */}
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-20 bg-muted animate-pulse rounded-lg" />
))}
</div>
) : filteredInstallations.length === 0 ? (
<Card className="p-8 text-center">
<Package className="w-12 h-12 mx-auto text-muted-foreground/50" />
<h3 className="mt-4 text-lg font-medium text-foreground">{formatMessage({ id: 'cliInstallations.emptyState.title' })}</h3>
<p className="mt-2 text-muted-foreground">
{formatMessage({ id: 'cliInstallations.emptyState.message' })}
</p>
</Card>
) : (
<div className="space-y-3">
{filteredInstallations.map((installation) => (
<InstallationCard
key={installation.name}
installation={installation}
onInstall={handleInstall}
onUninstall={handleUninstall}
onUpgrade={handleUpgrade}
isInstalling={isInstalling}
isUninstalling={isUninstalling}
isUpgrading={isUpgrading}
/>
))}
</div>
)}
</div>
);
}
export default InstallationsPage;

View File

@@ -4,6 +4,7 @@
// Track and manage project issues with drag-drop queue
import { useState, useMemo } from 'react';
import { useIntl } from 'react-intl';
import {
AlertCircle,
Plus,
@@ -41,6 +42,7 @@ interface NewIssueDialogProps {
}
function NewIssueDialog({ open, onOpenChange, onSubmit, isCreating }: NewIssueDialogProps) {
const { formatMessage } = useIntl();
const [title, setTitle] = useState('');
const [context, setContext] = useState('');
const [priority, setPriority] = useState<Issue['priority']>('medium');
@@ -59,56 +61,56 @@ function NewIssueDialog({ open, onOpenChange, onSubmit, isCreating }: NewIssueDi
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Issue</DialogTitle>
<DialogTitle>{formatMessage({ id: 'issues.createDialog.title' })}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
<div>
<label className="text-sm font-medium text-foreground">Title</label>
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'issues.createDialog.labels.title' })}</label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Issue title..."
placeholder={formatMessage({ id: 'issues.createDialog.placeholders.title' })}
className="mt-1"
required
/>
</div>
<div>
<label className="text-sm font-medium text-foreground">Context (optional)</label>
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'issues.createDialog.labels.context' })}</label>
<textarea
value={context}
onChange={(e) => setContext(e.target.value)}
placeholder="Describe the issue..."
placeholder={formatMessage({ id: 'issues.createDialog.placeholders.context' })}
className="mt-1 w-full min-h-[100px] p-3 bg-background border border-input rounded-md text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="text-sm font-medium text-foreground">Priority</label>
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'issues.createDialog.labels.priority' })}</label>
<Select value={priority} onValueChange={(v) => setPriority(v as Issue['priority'])}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="critical">Critical</SelectItem>
<SelectItem value="low">{formatMessage({ id: 'issues.priority.low' })}</SelectItem>
<SelectItem value="medium">{formatMessage({ id: 'issues.priority.medium' })}</SelectItem>
<SelectItem value="high">{formatMessage({ id: 'issues.priority.high' })}</SelectItem>
<SelectItem value="critical">{formatMessage({ id: 'issues.priority.critical' })}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
{formatMessage({ id: 'issues.createDialog.buttons.cancel' })}
</Button>
<Button type="submit" disabled={isCreating || !title.trim()}>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
{formatMessage({ id: 'issues.createDialog.buttons.creating' })}
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
Create Issue
{formatMessage({ id: 'issues.createDialog.buttons.create' })}
</>
)}
</Button>
@@ -138,6 +140,8 @@ function IssueList({
onIssueDelete,
onStatusChange,
}: IssueListProps) {
const { formatMessage } = useIntl();
if (isLoading) {
return (
<div className="space-y-3">
@@ -152,9 +156,9 @@ function IssueList({
return (
<Card className="p-8 text-center">
<AlertCircle className="w-12 h-12 mx-auto text-muted-foreground/50" />
<h3 className="mt-4 text-lg font-medium text-foreground">No issues found</h3>
<h3 className="mt-4 text-lg font-medium text-foreground">{formatMessage({ id: 'issues.emptyState.title' })}</h3>
<p className="mt-2 text-muted-foreground">
Create a new issue or adjust your filters.
{formatMessage({ id: 'issues.emptyState.message' })}
</p>
</Card>
);
@@ -179,6 +183,7 @@ function IssueList({
// ========== Main Page Component ==========
export function IssueManagerPage() {
const { formatMessage } = useIntl();
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [priorityFilter, setPriorityFilter] = useState<PriorityFilter>('all');
@@ -238,24 +243,24 @@ export function IssueManagerPage() {
<div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
<AlertCircle className="w-6 h-6 text-primary" />
Issue Manager
{formatMessage({ id: 'issues.title' })}
</h1>
<p className="text-muted-foreground mt-1">
Track and manage project issues and bugs
{formatMessage({ id: 'issues.description' })}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
Refresh
{formatMessage({ id: 'common.actions.refresh' })}
</Button>
<Button variant="outline">
<Github className="w-4 h-4 mr-2" />
Pull from GitHub
{formatMessage({ id: 'issues.actions.github' })}
</Button>
<Button onClick={() => setIsNewIssueOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
New Issue
{formatMessage({ id: 'issues.actions.create' })}
</Button>
</div>
</div>
@@ -267,28 +272,28 @@ export function IssueManagerPage() {
<AlertCircle className="w-5 h-5 text-info" />
<span className="text-2xl font-bold">{openCount}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">Open Issues</p>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'common.status.openIssues' })}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2">
<Clock className="w-5 h-5 text-warning" />
<span className="text-2xl font-bold">{issuesByStatus.in_progress?.length || 0}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">In Progress</p>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'issues.status.inProgress' })}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-destructive" />
<span className="text-2xl font-bold">{criticalCount}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">Critical</p>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'issues.priority.critical' })}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-success" />
<span className="text-2xl font-bold">{issuesByStatus.resolved?.length || 0}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">Resolved</p>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'issues.status.resolved' })}</p>
</Card>
</div>
@@ -297,7 +302,7 @@ export function IssueManagerPage() {
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search issues..."
placeholder={formatMessage({ id: 'common.actions.searchIssues' })}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
@@ -306,26 +311,26 @@ export function IssueManagerPage() {
<div className="flex gap-2">
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Status" />
<SelectValue placeholder={formatMessage({ id: 'common.status.label' })} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="open">Open</SelectItem>
<SelectItem value="in_progress">In Progress</SelectItem>
<SelectItem value="resolved">Resolved</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
<SelectItem value="all">{formatMessage({ id: 'issues.filters.all' })}</SelectItem>
<SelectItem value="open">{formatMessage({ id: 'issues.status.open' })}</SelectItem>
<SelectItem value="in_progress">{formatMessage({ id: 'issues.status.inProgress' })}</SelectItem>
<SelectItem value="resolved">{formatMessage({ id: 'issues.status.resolved' })}</SelectItem>
<SelectItem value="closed">{formatMessage({ id: 'issues.status.closed' })}</SelectItem>
</SelectContent>
</Select>
<Select value={priorityFilter} onValueChange={(v) => setPriorityFilter(v as PriorityFilter)}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Priority" />
<SelectValue placeholder={formatMessage({ id: 'issues.priority.label' })} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Priority</SelectItem>
<SelectItem value="critical">Critical</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="all">{formatMessage({ id: 'issues.filters.byPriority' })}</SelectItem>
<SelectItem value="critical">{formatMessage({ id: 'issues.priority.critical' })}</SelectItem>
<SelectItem value="high">{formatMessage({ id: 'issues.priority.high' })}</SelectItem>
<SelectItem value="medium">{formatMessage({ id: 'issues.priority.medium' })}</SelectItem>
<SelectItem value="low">{formatMessage({ id: 'issues.priority.low' })}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -338,7 +343,7 @@ export function IssueManagerPage() {
size="sm"
onClick={() => setStatusFilter('all')}
>
All ({statusCounts.all})
{formatMessage({ id: 'issues.filters.all' })} ({statusCounts.all})
</Button>
<Button
variant={statusFilter === 'open' ? 'default' : 'outline'}
@@ -346,7 +351,7 @@ export function IssueManagerPage() {
onClick={() => setStatusFilter('open')}
>
<Badge variant="info" className="mr-2">{statusCounts.open}</Badge>
Open
{formatMessage({ id: 'issues.status.open' })}
</Button>
<Button
variant={statusFilter === 'in_progress' ? 'default' : 'outline'}
@@ -354,7 +359,7 @@ export function IssueManagerPage() {
onClick={() => setStatusFilter('in_progress')}
>
<Badge variant="warning" className="mr-2">{statusCounts.in_progress}</Badge>
In Progress
{formatMessage({ id: 'issues.status.inProgress' })}
</Button>
<Button
variant={priorityFilter === 'critical' ? 'destructive' : 'outline'}
@@ -365,7 +370,7 @@ export function IssueManagerPage() {
}}
>
<Badge variant="destructive" className="mr-2">{criticalCount}</Badge>
Critical
{formatMessage({ id: 'issues.priority.critical' })}
</Button>
</div>

View File

@@ -0,0 +1,318 @@
// ========================================
// LiteTaskDetailPage Component
// ========================================
// Lite task detail page with flowchart visualization
import * as React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useIntl } from 'react-intl';
import {
ArrowLeft,
FileEdit,
Wrench,
Calendar,
Loader2,
XCircle,
CheckCircle,
Clock,
Code,
Zap,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import { useLiteTaskSession } from '@/hooks/useLiteTasks';
import { Flowchart } from '@/components/shared/Flowchart';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Card, CardContent } from '@/components/ui/Card';
import type { LiteTask } from '@/lib/api';
/**
* LiteTaskDetailPage component - Display single lite task session with flowchart
*/
export function LiteTaskDetailPage() {
const { sessionId } = useParams<{ sessionId: string }>();
const navigate = useNavigate();
const { formatMessage } = useIntl();
// Determine type from URL or state
const [sessionType, setSessionType] = React.useState<'lite-plan' | 'lite-fix' | 'multi-cli-plan'>('lite-plan');
const { session, isLoading, error, refetch } = useLiteTaskSession(sessionId, sessionType);
// Track expanded tasks
const [expandedTasks, setExpandedTasks] = React.useState<Set<string>>(new Set());
// Try to detect type from session data
React.useEffect(() => {
if (session?.type) {
setSessionType(session.type);
}
}, [session]);
const handleBack = () => {
navigate('/lite-tasks');
};
const toggleTaskExpanded = (taskId: string) => {
setExpandedTasks(prev => {
const next = new Set(prev);
if (next.has(taskId)) {
next.delete(taskId);
} else {
next.add(taskId);
}
return next;
});
};
// Get task status badge
const getTaskStatusBadge = (task: LiteTask) => {
switch (task.status) {
case 'completed':
return { variant: 'success' as const, label: formatMessage({ id: 'sessionDetail.status.completed' }), icon: CheckCircle };
case 'in_progress':
return { variant: 'warning' as const, label: formatMessage({ id: 'sessionDetail.status.inProgress' }), icon: Loader2 };
case 'blocked':
return { variant: 'destructive' as const, label: formatMessage({ id: 'sessionDetail.status.blocked' }), icon: XCircle };
case 'failed':
return { variant: 'destructive' as const, label: formatMessage({ id: 'fixSession.status.failed' }), icon: XCircle };
default:
return { variant: 'secondary' as const, label: formatMessage({ id: 'sessionDetail.status.pending' }), icon: Clock };
}
};
// Loading state
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" disabled>
<ArrowLeft className="h-4 w-4 mr-2" />
{formatMessage({ id: 'common.back' })}
</Button>
<div className="h-8 w-64 rounded bg-muted animate-pulse" />
</div>
<div className="h-64 rounded-lg bg-muted animate-pulse" />
</div>
);
}
// Error state
if (error) {
return (
<div className="flex items-center gap-2 p-4 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive">
<XCircle className="h-5 w-5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium">{formatMessage({ id: 'common.errors.loadFailed' })}</p>
<p className="text-xs mt-0.5">{error.message}</p>
</div>
<Button variant="outline" size="sm" onClick={() => refetch()}>
{formatMessage({ id: 'common.actions.retry' })}
</Button>
</div>
);
}
// Session not found
if (!session) {
return (
<div className="flex flex-col items-center justify-center py-16 px-4">
<Zap className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'liteTasksDetail.notFound.title' })}
</h3>
<p className="text-sm text-muted-foreground mb-4">
{formatMessage({ id: 'liteTasksDetail.notFound.message' })}
</p>
<Button onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
{formatMessage({ id: 'common.back' })}
</Button>
</div>
);
}
const tasks = session.tasks || [];
const completedTasks = tasks.filter(t => t.status === 'completed').length;
const isLitePlan = session.type === 'lite-plan';
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
{formatMessage({ id: 'common.back' })}
</Button>
<div>
<h1 className="text-2xl font-semibold text-foreground">
{session.title || session.id || session.session_id}
</h1>
{(session.title || (session.session_id && session.session_id !== session.id)) && (
<p className="text-sm text-muted-foreground mt-0.5">{session.id || session.session_id}</p>
)}
</div>
</div>
<Badge variant={isLitePlan ? 'info' : 'warning'} className="gap-1">
{isLitePlan ? <FileEdit className="h-3 w-3" /> : <Wrench className="h-3 w-3" />}
{formatMessage({ id: isLitePlan ? 'liteTasks.type.plan' : 'liteTasks.type.fix' })}
</Badge>
</div>
{/* Info Bar */}
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground p-4 bg-background rounded-lg border">
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
<span className="font-medium">{formatMessage({ id: 'sessionDetail.info.created' })}:</span>{' '}
{session.createdAt ? new Date(session.createdAt).toLocaleString() : 'N/A'}
</div>
<div className="flex items-center gap-1">
<CheckCircle className="h-4 w-4" />
<span className="font-medium">{formatMessage({ id: 'sessionDetail.info.tasks' })}:</span>{' '}
{completedTasks}/{tasks.length}
</div>
</div>
{/* Description (if exists) */}
{session.description && (
<div className="p-4 bg-background rounded-lg border">
<h3 className="text-sm font-semibold text-foreground mb-2">
{formatMessage({ id: 'sessionDetail.info.description' })}
</h3>
<p className="text-sm text-muted-foreground">{session.description}</p>
</div>
)}
{/* Tasks List */}
{tasks.length === 0 ? (
<Card>
<CardContent className="p-12 text-center">
<Zap className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'liteTasksDetail.empty.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'liteTasksDetail.empty.message' })}
</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{tasks.map((task, index) => {
const taskId = task.task_id || task.id || `T${index + 1}`;
const isExpanded = expandedTasks.has(taskId);
const statusBadge = getTaskStatusBadge(task);
const StatusIcon = statusBadge.icon;
const hasFlowchart = task.flow_control?.implementation_approach &&
task.flow_control.implementation_approach.length > 0;
return (
<Card key={taskId} className="overflow-hidden">
<CardContent className="p-4">
{/* Task Header */}
<div
className="flex items-start justify-between gap-3 cursor-pointer"
onClick={() => toggleTaskExpanded(taskId)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-muted-foreground">{taskId}</span>
<Badge variant={statusBadge.variant} className="gap-1">
<StatusIcon className="h-3 w-3" />
{statusBadge.label}
</Badge>
{task.priority && (
<Badge variant="outline" className="text-xs">
{task.priority}
</Badge>
)}
{hasFlowchart && (
<Badge variant="info" className="gap-1">
<Code className="h-3 w-3" />
{formatMessage({ id: 'liteTasksDetail.flowchart' })}
</Badge>
)}
</div>
<h4 className="font-medium text-foreground text-sm">
{task.title || formatMessage({ id: 'sessionDetail.tasks.untitled' })}
</h4>
{task.description && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{task.description}
</p>
)}
{task.context?.depends_on && task.context.depends_on.length > 0 && (
<div className="flex items-center gap-1 mt-2 text-xs text-muted-foreground">
<Code className="h-3 w-3" />
<span>Depends on: {task.context.depends_on.join(', ')}</span>
</div>
)}
</div>
<Button variant="ghost" size="sm" className="flex-shrink-0">
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
</div>
{/* Expanded Content */}
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border">
{/* Flowchart */}
{hasFlowchart && task.flow_control && (
<div className="mb-4">
<h5 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
<Code className="h-4 w-4" />
{formatMessage({ id: 'liteTasksDetail.implementationFlow' })}
</h5>
<Flowchart flowControl={task.flow_control} className="border border-border rounded-lg" />
</div>
)}
{/* Focus Paths */}
{task.context?.focus_paths && task.context.focus_paths.length > 0 && (
<div className="mb-4">
<h5 className="text-sm font-semibold text-foreground mb-2">
{formatMessage({ id: 'liteTasksDetail.focusPaths' })}
</h5>
<div className="space-y-1">
{task.context.focus_paths.map((path, idx) => (
<code
key={idx}
className="block text-xs bg-muted px-2 py-1 rounded font-mono"
>
{path}
</code>
))}
</div>
</div>
)}
{/* Acceptance Criteria */}
{task.context?.acceptance && task.context.acceptance.length > 0 && (
<div>
<h5 className="text-sm font-semibold text-foreground mb-2">
{formatMessage({ id: 'liteTasksDetail.acceptanceCriteria' })}
</h5>
<ul className="space-y-1">
{task.context.acceptance.map((criteria, idx) => (
<li key={idx} className="text-xs text-muted-foreground flex items-start gap-2">
<span className="text-primary font-bold">{idx + 1}.</span>
<span>{criteria}</span>
</li>
))}
</ul>
</div>
)}
</div>
)}
</CardContent>
</Card>
);
})}
</div>
)}
</div>
);
}
export default LiteTaskDetailPage;

View File

@@ -0,0 +1,302 @@
// ========================================
// LiteTasksPage Component
// ========================================
// Lite-plan and lite-fix task list page with flowchart rendering
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import { useIntl } from 'react-intl';
import {
ArrowLeft,
Zap,
Wrench,
FileEdit,
MessagesSquare,
Calendar,
ListChecks,
XCircle,
Activity,
Repeat,
MessageCircle,
} from 'lucide-react';
import { useLiteTasks } from '@/hooks/useLiteTasks';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Card, CardContent } from '@/components/ui/Card';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
type LiteTaskTab = 'lite-plan' | 'lite-fix' | 'multi-cli-plan';
/**
* Get i18n text from label object (supports {en, zh} format)
*/
function getI18nText(label: string | { en?: string; zh?: string } | undefined, fallback: string): string {
if (!label) return fallback;
if (typeof label === 'string') return label;
return label.en || label.zh || fallback;
}
/**
* LiteTasksPage component - Display lite-plan and lite-fix sessions
*/
export function LiteTasksPage() {
const navigate = useNavigate();
const { formatMessage } = useIntl();
const { litePlan, liteFix, multiCliPlan, isLoading, error, refetch } = useLiteTasks();
const [activeTab, setActiveTab] = React.useState<LiteTaskTab>('lite-plan');
const handleBack = () => {
navigate('/sessions');
};
// Get status badge color
const getStatusColor = (status?: string) => {
const statusColors: Record<string, string> = {
decided: 'success',
converged: 'success',
plan_generated: 'success',
completed: 'success',
exploring: 'info',
initialized: 'info',
analyzing: 'warning',
debating: 'warning',
blocked: 'destructive',
conflict: 'destructive',
};
return statusColors[status || ''] || 'secondary';
};
// Render lite task card
const renderLiteTaskCard = (session: { id: string; type: string; createdAt?: string; tasks?: unknown[] }) => {
const isLitePlan = session.type === 'lite-plan';
const taskCount = session.tasks?.length || 0;
return (
<Card
key={session.id}
className="cursor-pointer hover:shadow-md transition-shadow"
onClick={() => navigate(`/lite-tasks/${session.id}`)}
>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="font-medium text-foreground text-sm">{session.id}</h3>
</div>
<Badge variant={isLitePlan ? 'info' : 'warning'} className="gap-1">
{isLitePlan ? <FileEdit className="h-3 w-3" /> : <Wrench className="h-3 w-3" />}
{formatMessage({ id: isLitePlan ? 'liteTasks.type.plan' : 'liteTasks.type.fix' })}
</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{session.createdAt && (
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
{new Date(session.createdAt).toLocaleDateString()}
</span>
)}
<span className="flex items-center gap-1">
<ListChecks className="h-3.5 w-3.5" />
{taskCount} {formatMessage({ id: 'session.tasks' })}
</span>
</div>
</CardContent>
</Card>
);
};
// Render multi-cli plan card
const renderMultiCliCard = (session: {
id: string;
metadata?: Record<string, unknown>;
latestSynthesis?: { title?: string | { en?: string; zh?: string }; status?: string };
roundCount?: number;
status?: string;
createdAt?: string;
}) => {
const metadata = session.metadata || {};
const latestSynthesis = session.latestSynthesis || {};
const roundCount = (metadata.roundId as number) || session.roundCount || 1;
const topicTitle = getI18nText(
latestSynthesis.title as string | { en?: string; zh?: string } | undefined,
'Discussion Topic'
);
const status = latestSynthesis.status || session.status || 'analyzing';
const createdAt = (metadata.timestamp as string) || session.createdAt || '';
return (
<Card
key={session.id}
className="cursor-pointer hover:shadow-md transition-shadow"
onClick={() => navigate(`/lite-tasks/${session.id}`)}
>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex-1 min-w-0">
<h3 className="font-medium text-foreground text-sm">{session.id}</h3>
</div>
<Badge variant="info" className="gap-1">
<MessagesSquare className="h-3 w-3" />
{formatMessage({ id: 'liteTasks.type.multiCli' })}
</Badge>
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-3">
<MessageCircle className="h-4 w-4" />
<span className="line-clamp-1">{topicTitle}</span>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{createdAt && (
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
{new Date(createdAt).toLocaleDateString()}
</span>
)}
<span className="flex items-center gap-1">
<Repeat className="h-3.5 w-3.5" />
{roundCount} {formatMessage({ id: 'liteTasks.rounds' })}
</span>
<Badge variant={getStatusColor(status) as 'success' | 'info' | 'warning' | 'destructive' | 'secondary'} className="gap-1">
<Activity className="h-3 w-3" />
{status}
</Badge>
</div>
</CardContent>
</Card>
);
};
// Loading state
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" disabled>
<ArrowLeft className="h-4 w-4 mr-2" />
{formatMessage({ id: 'common.back' })}
</Button>
<div className="h-8 w-64 rounded bg-muted animate-pulse" />
</div>
<div className="h-64 rounded-lg bg-muted animate-pulse" />
</div>
);
}
// Error state
if (error) {
return (
<div className="flex items-center gap-2 p-4 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive">
<XCircle className="h-5 w-5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium">{formatMessage({ id: 'common.errors.loadFailed' })}</p>
<p className="text-xs mt-0.5">{error.message}</p>
</div>
<Button variant="outline" size="sm" onClick={() => refetch()}>
{formatMessage({ id: 'common.actions.retry' })}
</Button>
</div>
);
}
const totalSessions = litePlan.length + liteFix.length + multiCliPlan.length;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
{formatMessage({ id: 'common.back' })}
</Button>
<div>
<h1 className="text-2xl font-semibold text-foreground">
{formatMessage({ id: 'liteTasks.title' })}
</h1>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'liteTasks.subtitle' }, { count: totalSessions })}
</p>
</div>
</div>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as LiteTaskTab)}>
<TabsList>
<TabsTrigger value="lite-plan">
<FileEdit className="h-4 w-4 mr-2" />
{formatMessage({ id: 'liteTasks.type.plan' })}
<Badge variant="secondary" className="ml-2">
{litePlan.length}
</Badge>
</TabsTrigger>
<TabsTrigger value="lite-fix">
<Wrench className="h-4 w-4 mr-2" />
{formatMessage({ id: 'liteTasks.type.fix' })}
<Badge variant="secondary" className="ml-2">
{liteFix.length}
</Badge>
</TabsTrigger>
<TabsTrigger value="multi-cli-plan">
<MessagesSquare className="h-4 w-4 mr-2" />
{formatMessage({ id: 'liteTasks.type.multiCli' })}
<Badge variant="secondary" className="ml-2">
{multiCliPlan.length}
</Badge>
</TabsTrigger>
</TabsList>
{/* Lite Plan Tab */}
<TabsContent value="lite-plan" className="mt-4">
{litePlan.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Zap className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'liteTasks.empty.title' }, { type: 'lite-plan' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'liteTasks.empty.message' })}
</p>
</div>
) : (
<div className="grid gap-3">{litePlan.map(renderLiteTaskCard)}</div>
)}
</TabsContent>
{/* Lite Fix Tab */}
<TabsContent value="lite-fix" className="mt-4">
{liteFix.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Zap className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'liteTasks.empty.title' }, { type: 'lite-fix' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'liteTasks.empty.message' })}
</p>
</div>
) : (
<div className="grid gap-3">{liteFix.map(renderLiteTaskCard)}</div>
)}
</TabsContent>
{/* Multi-CLI Plan Tab */}
<TabsContent value="multi-cli-plan" className="mt-4">
{multiCliPlan.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Zap className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'liteTasks.empty.title' }, { type: 'multi-cli-plan' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'liteTasks.empty.message' })}
</p>
</div>
) : (
<div className="grid gap-3">{multiCliPlan.map(renderMultiCliCard)}</div>
)}
</TabsContent>
</Tabs>
</div>
);
}
export default LiteTasksPage;

View File

@@ -4,6 +4,7 @@
// Monitor running development loops with Kanban board
import { useState, useCallback } from 'react';
import { useIntl } from 'react-intl';
import {
RefreshCw,
Play,
@@ -157,6 +158,7 @@ interface NewLoopDialogProps {
}
function NewLoopDialog({ open, onOpenChange, onSubmit, isCreating }: NewLoopDialogProps) {
const { formatMessage } = useIntl();
const [prompt, setPrompt] = useState('');
const [tool, setTool] = useState('');
@@ -173,42 +175,42 @@ function NewLoopDialog({ open, onOpenChange, onSubmit, isCreating }: NewLoopDial
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Start New Loop</DialogTitle>
<DialogTitle>{formatMessage({ id: 'loops.createDialog.title' })}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
<div>
<label className="text-sm font-medium text-foreground">Prompt</label>
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'loops.createDialog.labels.prompt' })}</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Enter your development loop prompt..."
placeholder={formatMessage({ id: 'loops.createDialog.placeholders.prompt' })}
className="mt-1 w-full min-h-[100px] p-3 bg-background border border-input rounded-md text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary"
required
/>
</div>
<div>
<label className="text-sm font-medium text-foreground">CLI Tool (optional)</label>
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'loops.createDialog.labels.tool' })}</label>
<Input
value={tool}
onChange={(e) => setTool(e.target.value)}
placeholder="e.g., gemini, qwen, codex"
placeholder={formatMessage({ id: 'loops.createDialog.placeholders.tool' })}
className="mt-1"
/>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
{formatMessage({ id: 'loops.createDialog.buttons.cancel' })}
</Button>
<Button type="submit" disabled={isCreating || !prompt.trim()}>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
{formatMessage({ id: 'common.status.creating' })}
</>
) : (
<>
<Play className="w-4 h-4 mr-2" />
Start Loop
{formatMessage({ id: 'loops.createDialog.buttons.create' })}
</>
)}
</Button>
@@ -222,6 +224,7 @@ function NewLoopDialog({ open, onOpenChange, onSubmit, isCreating }: NewLoopDial
// ========== Main Page Component ==========
export function LoopMonitorPage() {
const { formatMessage } = useIntl();
const [searchQuery, setSearchQuery] = useState('');
const [isNewLoopOpen, setIsNewLoopOpen] = useState(false);
@@ -322,20 +325,20 @@ export function LoopMonitorPage() {
<div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
<RefreshCw className="w-6 h-6 text-primary" />
Loop Monitor
{formatMessage({ id: 'loops.title' })}
</h1>
<p className="text-muted-foreground mt-1">
Monitor and control running development loops
{formatMessage({ id: 'loops.description' })}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
Refresh
{formatMessage({ id: 'common.actions.refresh' })}
</Button>
<Button onClick={() => setIsNewLoopOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
New Loop
{formatMessage({ id: 'loops.actions.create' })}
</Button>
</div>
</div>
@@ -347,28 +350,28 @@ export function LoopMonitorPage() {
<Loader2 className="w-5 h-5 text-primary" />
<span className="text-2xl font-bold">{runningCount}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">Running</p>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'loops.columns.running' })}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2">
<Pause className="w-5 h-5 text-warning" />
<span className="text-2xl font-bold">{loopsByStatus.paused?.length || 0}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">Paused</p>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'loops.columns.paused' })}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-success" />
<span className="text-2xl font-bold">{completedCount}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">Completed</p>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'loops.columns.completed' })}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2">
<XCircle className="w-5 h-5 text-destructive" />
<span className="text-2xl font-bold">{failedCount}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">Failed</p>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'loops.columns.failed' })}</p>
</Card>
</div>
@@ -376,7 +379,7 @@ export function LoopMonitorPage() {
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search loops..."
placeholder={formatMessage({ id: 'common.actions.searchLoops' })}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
@@ -401,14 +404,14 @@ export function LoopMonitorPage() {
<Card className="p-8 text-center">
<RefreshCw className="w-12 h-12 mx-auto text-muted-foreground/50" />
<h3 className="mt-4 text-lg font-medium text-foreground">
No active loops
{formatMessage({ id: 'loops.emptyState.title' })}
</h3>
<p className="mt-2 text-muted-foreground">
Start a new development loop to begin monitoring progress.
{formatMessage({ id: 'loops.emptyState.message' })}
</p>
<Button className="mt-4" onClick={() => setIsNewLoopOpen(true)}>
<Play className="w-4 h-4 mr-2" />
Start New Loop
{formatMessage({ id: 'loops.emptyState.createFirst' })}
</Button>
</Card>
) : (
@@ -416,7 +419,7 @@ export function LoopMonitorPage() {
columns={columns}
onDragEnd={handleDragEnd}
renderItem={renderLoopItem}
emptyColumnMessage="No loops"
emptyColumnMessage={formatMessage({ id: 'loops.card.error' })}
className="min-h-[400px]"
/>
)}

View File

@@ -0,0 +1,364 @@
// ========================================
// MCP Manager Page
// ========================================
// Manage MCP servers (Model Context Protocol) with project/global scope switching
import { useState } from 'react';
import { useIntl } from 'react-intl';
import {
Server,
Plus,
Search,
RefreshCw,
Globe,
Folder,
Power,
PowerOff,
Edit,
Trash2,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
import { useMcpServers, useMcpServerMutations } from '@/hooks';
import type { McpServer } from '@/lib/api';
import { cn } from '@/lib/utils';
// ========== MCP Server Card Component ==========
interface McpServerCardProps {
server: McpServer;
isExpanded: boolean;
onToggleExpand: () => void;
onToggle: (serverName: string, enabled: boolean) => void;
onEdit: (server: McpServer) => void;
onDelete: (serverName: string) => void;
}
function McpServerCard({ server, isExpanded, onToggleExpand, onToggle, onEdit, onDelete }: McpServerCardProps) {
const { formatMessage } = useIntl();
return (
<Card className={cn('overflow-hidden', !server.enabled && 'opacity-60')}>
{/* Header */}
<div
className="p-4 cursor-pointer hover:bg-muted/50 transition-colors"
onClick={onToggleExpand}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-3">
<div className={cn(
'p-2 rounded-lg',
server.enabled ? 'bg-primary/10' : 'bg-muted'
)}>
<Server className={cn(
'w-5 h-5',
server.enabled ? 'text-primary' : 'text-muted-foreground'
)} />
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">
{server.name}
</span>
<Badge variant={server.scope === 'global' ? 'default' : 'secondary'} className="text-xs">
{server.scope === 'global' ? (
<><Globe className="w-3 h-3 mr-1" />{formatMessage({ id: 'mcp.scope.global' })}</>
) : (
<><Folder className="w-3 h-3 mr-1" />{formatMessage({ id: 'mcp.scope.project' })}</>
)}
</Badge>
{server.enabled && (
<Badge variant="outline" className="text-xs text-green-600">
{formatMessage({ id: 'mcp.status.enabled' })}
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground mt-1 font-mono">
{server.command} {server.args?.join(' ') || ''}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
onToggle(server.name, !server.enabled);
}}
>
{server.enabled ? <Power className="w-4 h-4 text-green-600" /> : <PowerOff className="w-4 h-4" />}
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
onEdit(server);
}}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
onDelete(server.name);
}}
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-muted-foreground" />
) : (
<ChevronDown className="w-5 h-5 text-muted-foreground" />
)}
</div>
</div>
</div>
{/* Expanded Content */}
{isExpanded && (
<div className="border-t border-border p-4 space-y-3 bg-muted/30">
{/* Command details */}
<div>
<p className="text-xs text-muted-foreground mb-1">{formatMessage({ id: 'mcp.command' })}</p>
<code className="text-sm bg-background px-2 py-1 rounded block overflow-x-auto">
{server.command}
</code>
</div>
{/* Args */}
{server.args && server.args.length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-1">{formatMessage({ id: 'mcp.args' })}</p>
<div className="flex flex-wrap gap-1">
{server.args.map((arg, idx) => (
<Badge key={idx} variant="outline" className="font-mono text-xs">
{arg}
</Badge>
))}
</div>
</div>
)}
{/* Environment variables */}
{server.env && Object.keys(server.env).length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-1">{formatMessage({ id: 'mcp.env' })}</p>
<div className="space-y-1">
{Object.entries(server.env).map(([key, value]) => (
<div key={key} className="flex items-center gap-2 text-sm">
<Badge variant="secondary" className="font-mono">{key}</Badge>
<span className="text-muted-foreground">=</span>
<code className="text-xs bg-background px-2 py-1 rounded flex-1 overflow-x-auto">
{value as string}
</code>
</div>
))}
</div>
</div>
)}
</div>
)}
</Card>
);
}
// ========== Main Page Component ==========
export function McpManagerPage() {
const { formatMessage } = useIntl();
const [searchQuery, setSearchQuery] = useState('');
const [scopeFilter, setScopeFilter] = useState<'all' | 'project' | 'global'>('all');
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
const {
servers,
projectServers,
globalServers,
totalCount,
enabledCount,
isLoading,
isFetching,
refetch,
} = useMcpServers({
scope: scopeFilter === 'all' ? undefined : scopeFilter,
});
const {
toggleServer,
deleteServer,
} = useMcpServerMutations();
const toggleExpand = (serverName: string) => {
setExpandedServers((prev) => {
const next = new Set(prev);
if (next.has(serverName)) {
next.delete(serverName);
} else {
next.add(serverName);
}
return next;
});
};
const handleToggle = (serverName: string, enabled: boolean) => {
toggleServer(serverName, enabled);
};
const handleDelete = (serverName: string) => {
if (confirm(formatMessage({ id: 'mcp.deleteConfirm' }, { name: serverName }))) {
deleteServer(serverName);
}
};
const handleEdit = (server: McpServer) => {
// TODO: Implement edit dialog
console.log('Edit server:', server);
};
// Filter servers by search query
const filteredServers = servers.filter((s) =>
s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
s.command.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
<Server className="w-6 h-6 text-primary" />
{formatMessage({ id: 'mcp.title' })}
</h1>
<p className="text-muted-foreground mt-1">
{formatMessage({ id: 'mcp.description' })}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
{formatMessage({ id: 'common.actions.refresh' })}
</Button>
<Button>
<Plus className="w-4 h-4 mr-2" />
{formatMessage({ id: 'mcp.actions.add' })}
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="p-4">
<div className="flex items-center gap-2">
<Server className="w-5 h-5 text-primary" />
<span className="text-2xl font-bold">{totalCount}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'mcp.stats.total' })}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2">
<Power className="w-5 h-5 text-green-600" />
<span className="text-2xl font-bold">{enabledCount}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'mcp.stats.enabled' })}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2">
<Globe className="w-5 h-5 text-info" />
<span className="text-2xl font-bold">{globalServers.length}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'mcp.stats.global' })}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2">
<Folder className="w-5 h-5 text-warning" />
<span className="text-2xl font-bold">{projectServers.length}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'mcp.stats.project' })}</p>
</Card>
</div>
{/* Filters and Search */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder={formatMessage({ id: 'mcp.filters.searchPlaceholder' })}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<div className="flex gap-2">
<Button
variant={scopeFilter === 'all' ? 'default' : 'outline'}
size="sm"
onClick={() => setScopeFilter('all')}
>
{formatMessage({ id: 'mcp.filters.all' })}
</Button>
<Button
variant={scopeFilter === 'global' ? 'default' : 'outline'}
size="sm"
onClick={() => setScopeFilter('global')}
>
<Globe className="w-4 h-4 mr-1" />
{formatMessage({ id: 'mcp.scope.global' })}
</Button>
<Button
variant={scopeFilter === 'project' ? 'default' : 'outline'}
size="sm"
onClick={() => setScopeFilter('project')}
>
<Folder className="w-4 h-4 mr-1" />
{formatMessage({ id: 'mcp.scope.project' })}
</Button>
</div>
</div>
{/* Servers List */}
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-24 bg-muted animate-pulse rounded-lg" />
))}
</div>
) : filteredServers.length === 0 ? (
<Card className="p-8 text-center">
<Server className="w-12 h-12 mx-auto text-muted-foreground/50" />
<h3 className="mt-4 text-lg font-medium text-foreground">{formatMessage({ id: 'mcp.emptyState.title' })}</h3>
<p className="mt-2 text-muted-foreground">
{formatMessage({ id: 'mcp.emptyState.message' })}
</p>
</Card>
) : (
<div className="space-y-3">
{filteredServers.map((server) => (
<McpServerCard
key={server.name}
server={server}
isExpanded={expandedServers.has(server.name)}
onToggleExpand={() => toggleExpand(server.name)}
onToggle={handleToggle}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
);
}
export default McpManagerPage;

View File

@@ -4,6 +4,7 @@
// View and manage core memory and context with CRUD operations
import { useState } from 'react';
import { useIntl } from 'react-intl';
import {
Brain,
Search,
@@ -171,6 +172,7 @@ function NewMemoryDialog({
isCreating,
editingMemory,
}: NewMemoryDialogProps) {
const { formatMessage } = useIntl();
const [content, setContent] = useState(editingMemory?.content || '');
const [tagsInput, setTagsInput] = useState(editingMemory?.tags?.join(', ') || '');
@@ -192,43 +194,43 @@ function NewMemoryDialog({
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{editingMemory ? 'Edit Memory' : 'Add New Memory'}
{editingMemory ? formatMessage({ id: 'memory.createDialog.editTitle' }) : formatMessage({ id: 'memory.createDialog.title' })}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
<div>
<label className="text-sm font-medium text-foreground">Content</label>
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'memory.createDialog.labels.content' })}</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Enter memory content..."
placeholder={formatMessage({ id: 'memory.createDialog.placeholders.content' })}
className="mt-1 w-full min-h-[200px] p-3 bg-background border border-input rounded-md text-sm font-mono resize-none focus:outline-none focus:ring-2 focus:ring-primary"
required
/>
</div>
<div>
<label className="text-sm font-medium text-foreground">Tags (comma-separated)</label>
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'memory.createDialog.labels.tags' })}</label>
<Input
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
placeholder="e.g., project, config, api"
placeholder={formatMessage({ id: 'memory.createDialog.placeholders.tags' })}
className="mt-1"
/>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
{formatMessage({ id: 'memory.createDialog.buttons.cancel' })}
</Button>
<Button type="submit" disabled={isCreating || !content.trim()}>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{editingMemory ? 'Updating...' : 'Creating...'}
{editingMemory ? formatMessage({ id: 'memory.createDialog.buttons.updating' }) : formatMessage({ id: 'memory.createDialog.buttons.creating' })}
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
{editingMemory ? 'Update Memory' : 'Add Memory'}
{editingMemory ? formatMessage({ id: 'memory.createDialog.buttons.update' }) : formatMessage({ id: 'memory.createDialog.buttons.create' })}
</>
)}
</Button>
@@ -242,6 +244,7 @@ function NewMemoryDialog({
// ========== Main Page Component ==========
export function MemoryPage() {
const { formatMessage } = useIntl();
const [searchQuery, setSearchQuery] = useState('');
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [isNewMemoryOpen, setIsNewMemoryOpen] = useState(false);
@@ -327,20 +330,20 @@ export function MemoryPage() {
<div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
<Brain className="w-6 h-6 text-primary" />
Memory
{formatMessage({ id: 'memory.title' })}
</h1>
<p className="text-muted-foreground mt-1">
Manage core memory, context, and knowledge base
{formatMessage({ id: 'memory.description' })}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
Refresh
{formatMessage({ id: 'common.actions.refresh' })}
</Button>
<Button onClick={() => { setEditingMemory(null); setIsNewMemoryOpen(true); }}>
<Plus className="w-4 h-4 mr-2" />
Add Memory
{formatMessage({ id: 'memory.actions.add' })}
</Button>
</div>
</div>
@@ -354,7 +357,7 @@ export function MemoryPage() {
</div>
<div>
<div className="text-2xl font-bold text-foreground">{memories.length}</div>
<p className="text-sm text-muted-foreground">Core Memories</p>
<p className="text-sm text-muted-foreground">{formatMessage({ id: 'memory.stats.count' })}</p>
</div>
</div>
</Card>
@@ -365,7 +368,7 @@ export function MemoryPage() {
</div>
<div>
<div className="text-2xl font-bold text-foreground">{claudeMdCount}</div>
<p className="text-sm text-muted-foreground">CLAUDE.md Files</p>
<p className="text-sm text-muted-foreground">{formatMessage({ id: 'memory.stats.claudeMdCount' })}</p>
</div>
</div>
</Card>
@@ -376,7 +379,7 @@ export function MemoryPage() {
</div>
<div>
<div className="text-2xl font-bold text-foreground">{formattedTotalSize}</div>
<p className="text-sm text-muted-foreground">Total Size</p>
<p className="text-sm text-muted-foreground">{formatMessage({ id: 'memory.stats.totalSize' })}</p>
</div>
</div>
</Card>
@@ -387,7 +390,7 @@ export function MemoryPage() {
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search memories..."
placeholder={formatMessage({ id: 'memory.filters.search' })}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
@@ -397,7 +400,7 @@ export function MemoryPage() {
{/* Tags Filter */}
{allTags.length > 0 && (
<div className="flex flex-wrap gap-2">
<span className="text-sm text-muted-foreground py-1">Tags:</span>
<span className="text-sm text-muted-foreground py-1">{formatMessage({ id: 'memory.card.tags' })}:</span>
{allTags.map((tag) => (
<Button
key={tag}
@@ -417,7 +420,7 @@ export function MemoryPage() {
className="h-7"
onClick={() => setSelectedTags([])}
>
Clear
{formatMessage({ id: 'memory.filters.clear' })}
</Button>
)}
</div>
@@ -435,14 +438,14 @@ export function MemoryPage() {
<Card className="p-8 text-center">
<Brain className="w-12 h-12 mx-auto text-muted-foreground/50" />
<h3 className="mt-4 text-lg font-medium text-foreground">
No memories stored
{formatMessage({ id: 'memory.emptyState.title' })}
</h3>
<p className="mt-2 text-muted-foreground">
Add context and knowledge to help Claude understand your project better.
{formatMessage({ id: 'memory.emptyState.message' })}
</p>
<Button className="mt-4" onClick={() => { setEditingMemory(null); setIsNewMemoryOpen(true); }}>
<Plus className="w-4 h-4 mr-2" />
Add First Memory
{formatMessage({ id: 'memory.emptyState.createFirst' })}
</Button>
</Card>
) : (

View File

@@ -0,0 +1,52 @@
// ========================================
// 404 Not Found Page
// ========================================
// Displayed when user navigates to a non-existent route
import { Link } from 'react-router-dom';
import { Home, ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
export function NotFoundPage() {
return (
<div className="min-h-[400px] flex items-center justify-center">
<Card className="max-w-md w-full p-8 text-center">
{/* 404 Number */}
<div className="text-6xl font-bold text-primary mb-4">404</div>
{/* Message */}
<h1 className="text-2xl font-bold text-foreground mb-2">
Page Not Found
</h1>
<p className="text-muted-foreground mb-6">
The page you're looking for doesn't exist or has been moved.
</p>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Button variant="default" asChild>
<Link to="/">
<Home className="w-4 h-4 mr-2" />
Go Home
</Link>
</Button>
<Button variant="outline" onClick={() => window.history.back()}>
<ArrowLeft className="w-4 h-4 mr-2" />
Go Back
</Button>
</div>
{/* Help Link */}
<p className="text-sm text-muted-foreground mt-6">
Need help? Visit the{' '}
<Link to="/help" className="text-primary hover:underline">
Help page
</Link>
</p>
</Card>
</div>
);
}
export default NotFoundPage;

View File

@@ -0,0 +1,752 @@
// ========================================
// ProjectOverviewPage Component
// ========================================
// Project overview page displaying architecture, tech stack, and components
import * as React from 'react';
import { useIntl } from 'react-intl';
import {
Code2,
Blocks,
Component,
GitBranch,
BarChart3,
ScrollText,
ClipboardList,
Sparkles,
Zap,
Bug,
Wrench,
BookOpen,
CheckSquare,
Lightbulb,
BookMarked,
ShieldAlert,
LayoutGrid,
GitCommitHorizontal,
} from 'lucide-react';
import { useProjectOverview } from '@/hooks/useProjectOverview';
import type {
KeyComponent,
DevelopmentIndexEntry,
GuidelineEntry,
LearningEntry,
} from '@/lib/api';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Card, CardContent } from '@/components/ui/Card';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
type DevIndexView = 'category' | 'timeline';
// Helper function to format date
function formatDate(dateString: string | undefined): string {
if (!dateString) return '-';
try {
const date = new Date(dateString);
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
} catch {
return '-';
}
}
/**
* ProjectOverviewPage component - Display project architecture and tech stack
*/
export function ProjectOverviewPage() {
const { formatMessage } = useIntl();
const { projectOverview, isLoading, error, refetch } = useProjectOverview();
const [devIndexView, setDevIndexView] = React.useState<DevIndexView>('category');
// Render loading state
if (isLoading) {
return (
<div className="space-y-6">
<div className="h-48 rounded-lg bg-muted animate-pulse" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-32 rounded-lg bg-muted animate-pulse" />
))}
</div>
</div>
);
}
// Render error state
if (error) {
return (
<div className="flex items-center gap-2 p-4 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive">
<Component className="h-5 w-5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium">{formatMessage({ id: 'common.errors.loadFailed' })}</p>
<p className="text-xs mt-0.5">{error.message}</p>
</div>
<Button variant="outline" size="sm" onClick={() => refetch()}>
{formatMessage({ id: 'common.actions.retry' })}
</Button>
</div>
);
}
// Render empty state
if (!projectOverview) {
return (
<div className="flex flex-col items-center justify-center py-16 px-4">
<ClipboardList className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'projectOverview.empty.title' })}
</h3>
<p className="text-sm text-muted-foreground text-center max-w-sm mb-4">
{formatMessage({ id: 'projectOverview.empty.message' })}
</p>
</div>
);
}
const { technologyStack, architecture, keyComponents, developmentIndex, guidelines, metadata } =
projectOverview;
// Calculate totals for development index
const devIndexCategories = [
{ key: 'feature', label: 'Features', icon: Sparkles, color: 'primary' },
{ key: 'enhancement', label: 'Enhancements', icon: Zap, color: 'success' },
{ key: 'bugfix', label: 'Bug Fixes', icon: Bug, color: 'destructive' },
{ key: 'refactor', label: 'Refactorings', icon: Wrench, color: 'warning' },
{ key: 'docs', label: 'Documentation', icon: BookOpen, color: 'muted' },
];
const devIndexTotals = devIndexCategories.reduce((acc, cat) => {
acc[cat.key] = (developmentIndex?.[cat.key] || []).length;
return acc;
}, {} as Record<string, number>);
const totalEntries = Object.values(devIndexTotals).reduce((sum, count) => sum + count, 0);
// Collect all entries for timeline
const allDevEntries = React.useMemo(() => {
const entries: Array<{
title: string;
description?: string;
type: string;
typeLabel: string;
typeIcon: React.ElementType;
typeColor: string;
date: string;
sessionId?: string;
sub_feature?: string;
tags?: string[];
}> = [];
devIndexCategories.forEach((cat) => {
(developmentIndex?.[cat.key] || []).forEach((entry: DevelopmentIndexEntry) => {
entries.push({
...entry,
type: cat.key,
typeLabel: cat.label,
typeIcon: cat.icon,
typeColor: cat.color,
date: entry.archivedAt || entry.date || entry.implemented_at || '',
});
});
});
return entries.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}, [developmentIndex, devIndexCategories]);
// Calculate statistics
const totalFeatures = devIndexCategories.reduce((sum, cat) => sum + devIndexTotals[cat.key], 0);
return (
<div className="space-y-6">
{/* Project Header */}
<Card>
<CardContent className="p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<h1 className="text-2xl font-bold text-foreground mb-2">
{projectOverview.projectName}
</h1>
<p className="text-muted-foreground">
{projectOverview.description || formatMessage({ id: 'projectOverview.noDescription' })}
</p>
</div>
<div className="text-sm text-muted-foreground text-right">
<div>
{formatMessage({ id: 'projectOverview.header.initialized' })}:{' '}
{formatDate(projectOverview.initializedAt)}
</div>
{metadata?.analysis_mode && (
<div className="mt-1">
<span className="font-mono text-xs px-2 py-0.5 bg-muted rounded">
{metadata.analysis_mode}
</span>
</div>
)}
</div>
</div>
</CardContent>
</Card>
{/* Technology Stack */}
<Card>
<CardContent className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<Code2 className="w-5 h-5" />
{formatMessage({ id: 'projectOverview.techStack.title' })}
</h3>
{/* Languages */}
<div className="mb-5">
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
{formatMessage({ id: 'projectOverview.techStack.languages' })}
</h4>
<div className="flex flex-wrap gap-3">
{technologyStack?.languages && technologyStack.languages.length > 0 ? (
technologyStack.languages.map((lang: { name: string; file_count: number; primary?: boolean }) => (
<div
key={lang.name}
className={`flex items-center gap-2 px-3 py-2 bg-background border border-border rounded-lg ${
lang.primary ? 'ring-2 ring-primary' : ''
}`}
>
<span className="font-semibold text-foreground">{lang.name}</span>
<span className="text-xs text-muted-foreground">{lang.file_count} files</span>
{lang.primary && (
<span className="text-xs px-1.5 py-0.5 bg-primary text-primary-foreground rounded">
{formatMessage({ id: 'projectOverview.techStack.primary' })}
</span>
)}
</div>
))
) : (
<span className="text-muted-foreground text-sm">
{formatMessage({ id: 'projectOverview.techStack.noLanguages' })}
</span>
)}
</div>
</div>
{/* Frameworks */}
<div className="mb-5">
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
{formatMessage({ id: 'projectOverview.techStack.frameworks' })}
</h4>
<div className="flex flex-wrap gap-2">
{technologyStack?.frameworks && technologyStack.frameworks.length > 0 ? (
technologyStack.frameworks.map((fw: string) => (
<Badge key={fw} variant="success" className="px-3 py-1.5">
{fw}
</Badge>
))
) : (
<span className="text-muted-foreground text-sm">
{formatMessage({ id: 'projectOverview.techStack.noFrameworks' })}
</span>
)}
</div>
</div>
{/* Build Tools */}
{technologyStack?.build_tools && technologyStack.build_tools.length > 0 && (
<div className="mb-5">
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
{formatMessage({ id: 'projectOverview.techStack.buildTools' })}
</h4>
<div className="flex flex-wrap gap-2">
{technologyStack.build_tools.map((tool: string) => (
<Badge key={tool} variant="warning" className="px-3 py-1.5">
{tool}
</Badge>
))}
</div>
</div>
)}
{/* Test Frameworks */}
{technologyStack?.test_frameworks && technologyStack.test_frameworks.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
{formatMessage({ id: 'projectOverview.techStack.testFrameworks' })}
</h4>
<div className="flex flex-wrap gap-2">
{technologyStack.test_frameworks.map((fw: string) => (
<Badge key={fw} variant="default" className="px-3 py-1.5">
{fw}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Architecture */}
{architecture && (
<Card>
<CardContent className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<Blocks className="w-5 h-5" />
{formatMessage({ id: 'projectOverview.architecture.title' })}
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
{/* Style */}
<div>
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'projectOverview.architecture.style' })}
</h4>
<div className="px-3 py-2 bg-background border border-border rounded-lg">
<span className="text-foreground font-medium">{architecture.style}</span>
</div>
</div>
{/* Layers */}
{architecture.layers && architecture.layers.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'projectOverview.architecture.layers' })}
</h4>
<div className="flex flex-wrap gap-2">
{architecture.layers.map((layer: string) => (
<span key={layer} className="px-2 py-1 bg-muted text-foreground rounded text-sm">
{layer}
</span>
))}
</div>
</div>
)}
{/* Patterns */}
{architecture.patterns && architecture.patterns.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'projectOverview.architecture.patterns' })}
</h4>
<div className="flex flex-wrap gap-2">
{architecture.patterns.map((pattern: string) => (
<span key={pattern} className="px-2 py-1 bg-muted text-foreground rounded text-sm">
{pattern}
</span>
))}
</div>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* Key Components */}
{keyComponents && keyComponents.length > 0 && (
<Card>
<CardContent className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<Component className="w-5 h-5" />
{formatMessage({ id: 'projectOverview.components.title' })}
</h3>
<div className="space-y-3">
{keyComponents.map((comp: KeyComponent) => {
const importance = comp.importance || 'low';
const importanceColors: Record<string, string> = {
high: 'border-l-4 border-l-destructive bg-destructive/5',
medium: 'border-l-4 border-l-warning bg-warning/5',
low: 'border-l-4 border-l-muted-foreground bg-muted',
};
const importanceBadges: Record<string, React.ReactElement> = {
high: (
<Badge variant="destructive" className="text-xs">
{formatMessage({ id: 'projectOverview.components.importance.high' })}
</Badge>
),
medium: (
<Badge variant="warning" className="text-xs">
{formatMessage({ id: 'projectOverview.components.importance.medium' })}
</Badge>
),
low: (
<Badge variant="secondary" className="text-xs">
{formatMessage({ id: 'projectOverview.components.importance.low' })}
</Badge>
),
};
return (
<div
key={comp.name}
className={`p-4 rounded-lg ${importanceColors[importance] || importanceColors.low}`}
>
<div className="flex items-start justify-between mb-2">
<h4 className="font-semibold text-foreground">{comp.name}</h4>
{importanceBadges[importance]}
</div>
{comp.description && (
<p className="text-sm text-muted-foreground mb-2">{comp.description}</p>
)}
{comp.responsibility && comp.responsibility.length > 0 && (
<ul className="text-xs text-muted-foreground list-disc list-inside">
{comp.responsibility.map((resp: string, i: number) => (
<li key={i}>{resp}</li>
))}
</ul>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
)}
{/* Development Index */}
{developmentIndex && totalEntries > 0 && (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
<GitBranch className="w-5 h-5" />
{formatMessage({ id: 'projectOverview.devIndex.title' })}
</h3>
<div className="flex items-center gap-2">
{devIndexCategories.map((cat) => {
const count = devIndexTotals[cat.key];
if (count === 0) return null;
const Icon = cat.icon;
return (
<Badge key={cat.key} variant={cat.color === 'primary' ? 'default' : 'secondary'}>
<Icon className="w-3 h-3 mr-1" />
{count}
</Badge>
);
})}
</div>
</div>
<Tabs value={devIndexView} onValueChange={(v) => setDevIndexView(v as DevIndexView)}>
<div className="flex items-center justify-between mb-4">
<TabsList>
<TabsTrigger value="category">
<LayoutGrid className="w-3.5 h-3.5 mr-1" />
{formatMessage({ id: 'projectOverview.devIndex.categories' })}
</TabsTrigger>
<TabsTrigger value="timeline">
<GitCommitHorizontal className="w-3.5 h-3.5 mr-1" />
{formatMessage({ id: 'projectOverview.devIndex.timeline' })}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="category">
<div className="space-y-4">
{devIndexCategories.map((cat) => {
const entries = developmentIndex?.[cat.key] || [];
if (entries.length === 0) return null;
const Icon = cat.icon;
return (
<div key={cat.key}>
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
<Icon className="w-4 h-4" />
<span>{cat.label}</span>
<Badge variant="secondary">{entries.length}</Badge>
</h4>
<div className="space-y-2">
{entries.slice(0, 5).map((entry: DevelopmentIndexEntry & { type?: string; typeLabel?: string; typeIcon?: React.ElementType; typeColor?: string; date?: string }, i: number) => (
<div
key={i}
className="p-3 bg-background border border-border rounded-lg hover:shadow-sm transition-shadow"
>
<div className="flex items-start justify-between mb-1">
<h5 className="font-medium text-foreground text-sm">{entry.title}</h5>
<span className="text-xs text-muted-foreground">
{formatDate(entry.archivedAt || entry.date || entry.implemented_at)}
</span>
</div>
{entry.description && (
<p className="text-sm text-muted-foreground mb-1">{entry.description}</p>
)}
<div className="flex items-center gap-2 text-xs flex-wrap">
{entry.sessionId && (
<span className="px-2 py-0.5 bg-primary-light text-primary rounded font-mono">
{entry.sessionId}
</span>
)}
{entry.sub_feature && (
<span className="px-2 py-0.5 bg-muted rounded">{entry.sub_feature}</span>
)}
{entry.status && (
<span
className={`px-2 py-0.5 rounded ${
entry.status === 'completed'
? 'bg-success-light text-success'
: 'bg-warning-light text-warning'
}`}
>
{entry.status}
</span>
)}
</div>
</div>
))}
{entries.length > 5 && (
<div className="text-sm text-muted-foreground text-center py-2">
... and {entries.length - 5} more
</div>
)}
</div>
</div>
);
})}
</div>
</TabsContent>
<TabsContent value="timeline">
<div className="space-y-4">
{allDevEntries.slice(0, 20).map((entry, i) => {
const Icon = entry.typeIcon;
return (
<div key={i} className="flex gap-4">
<div className="flex flex-col items-center">
<div
className={`w-8 h-8 rounded-full bg-${entry.typeColor}-light text-${entry.typeColor} flex items-center justify-center`}
>
<Icon className="w-4 h-4" />
</div>
{i < Math.min(allDevEntries.length, 20) - 1 && (
<div className="w-0.5 flex-1 bg-border mt-2" />
)}
</div>
<div className="flex-1 pb-4">
<div className="flex items-start justify-between mb-1">
<div className="flex items-center gap-2">
<Badge
variant={
entry.typeColor === 'primary'
? 'default'
: entry.typeColor === 'destructive'
? 'destructive'
: 'secondary'
}
className="text-xs"
>
{entry.typeLabel}
</Badge>
<h5 className="font-medium text-foreground text-sm">{entry.title}</h5>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatDate(entry.date)}
</span>
</div>
{entry.description && (
<p className="text-sm text-muted-foreground mb-2">{entry.description}</p>
)}
<div className="flex items-center gap-2 text-xs">
{entry.sessionId && (
<span className="px-2 py-0.5 bg-muted rounded font-mono">
{entry.sessionId}
</span>
)}
{entry.sub_feature && (
<span className="px-2 py-0.5 bg-muted rounded">{entry.sub_feature}</span>
)}
{entry.tags &&
entry.tags.slice(0, 3).map((tag) => (
<span key={tag} className="px-2 py-0.5 bg-accent rounded">
{tag}
</span>
))}
</div>
</div>
</div>
);
})}
{allDevEntries.length > 20 && (
<div className="text-sm text-muted-foreground text-center py-4">
... and {allDevEntries.length - 20} more entries
</div>
)}
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
)}
{/* Guidelines */}
{guidelines && (
<Card>
<CardContent className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<ScrollText className="w-5 h-5" />
{formatMessage({ id: 'projectOverview.guidelines.title' })}
</h3>
<div className="space-y-6">
{/* Conventions */}
{guidelines.conventions && (
<div>
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
<BookMarked className="w-4 h-4" />
<span>{formatMessage({ id: 'projectOverview.guidelines.conventions' })}</span>
</h4>
<div className="space-y-2">
{Object.entries(guidelines.conventions).slice(0, 4).map(([key, items]) => {
const itemList = Array.isArray(items) ? items : [];
if (itemList.length === 0) return null;
return (
<div key={key} className="space-y-1">
{itemList.slice(0, 3).map((item: string, i: number) => (
<div
key={i}
className="flex items-start gap-3 p-3 bg-background border border-border rounded-lg"
>
<span className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded">
{key}
</span>
<span className="text-sm text-foreground">{item as string}</span>
</div>
))}
</div>
);
})}
</div>
</div>
)}
{/* Constraints */}
{guidelines.constraints && (
<div>
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
<ShieldAlert className="w-4 h-4" />
<span>{formatMessage({ id: 'projectOverview.guidelines.constraints' })}</span>
</h4>
<div className="space-y-2">
{Object.entries(guidelines.constraints).slice(0, 4).map(([key, items]) => {
const itemList = Array.isArray(items) ? items : [];
if (itemList.length === 0) return null;
return (
<div key={key} className="space-y-1">
{itemList.slice(0, 3).map((item: string, i: number) => (
<div
key={i}
className="flex items-start gap-3 p-3 bg-background border border-border rounded-lg"
>
<span className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded">
{key}
</span>
<span className="text-sm text-foreground">{item as string}</span>
</div>
))}
</div>
);
})}
</div>
</div>
)}
{/* Quality Rules */}
{guidelines.quality_rules && guidelines.quality_rules.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
<CheckSquare className="w-4 h-4" />
<span>{formatMessage({ id: 'projectOverview.guidelines.qualityRules' })}</span>
</h4>
<div className="space-y-2">
{guidelines.quality_rules.slice(0, 5).map((rule: GuidelineEntry, i: number) => (
<div key={i} className="p-3 bg-background border border-border rounded-lg">
<div className="flex items-start justify-between mb-1">
<span className="text-sm text-foreground font-medium">{rule.rule}</span>
{rule.enforced_by && (
<span className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded">
{rule.enforced_by}
</span>
)}
</div>
<span className="text-xs text-muted-foreground">
{formatMessage({ id: 'projectOverview.guidelines.scope' })}: {rule.scope}
</span>
</div>
))}
</div>
</div>
)}
{/* Learnings */}
{guidelines.learnings && guidelines.learnings.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
<Lightbulb className="w-4 h-4" />
<span>{formatMessage({ id: 'projectOverview.guidelines.learnings' })}</span>
</h4>
<div className="space-y-2">
{guidelines.learnings.slice(0, 5).map((learning: LearningEntry, i: number) => (
<div
key={i}
className="p-3 bg-background border border-border rounded-lg border-l-4 border-l-primary"
>
<div className="flex items-start justify-between mb-2">
<span className="text-sm text-foreground">{learning.insight}</span>
<span className="text-xs text-muted-foreground whitespace-nowrap ml-2">
{formatDate(learning.date)}
</span>
</div>
<div className="flex items-center gap-2 text-xs">
{learning.category && (
<span className="px-2 py-0.5 bg-muted text-muted-foreground rounded">
{learning.category}
</span>
)}
{learning.session_id && (
<span className="px-2 py-0.5 bg-primary-light text-primary rounded font-mono">
{learning.session_id}
</span>
)}
</div>
{learning.context && (
<p className="text-xs text-muted-foreground mt-2">{learning.context}</p>
)}
</div>
))}
</div>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* Statistics */}
<Card>
<CardContent className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<BarChart3 className="w-5 h-5" />
{formatMessage({ id: 'projectOverview.stats.title' })}
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="text-center p-4 bg-background rounded-lg">
<div className="text-3xl font-bold text-primary mb-1">{totalFeatures}</div>
<div className="text-sm text-muted-foreground">
{formatMessage({ id: 'projectOverview.stats.totalFeatures' })}
</div>
</div>
<div className="text-center p-4 bg-background rounded-lg">
<div className="text-sm text-muted-foreground mb-1">
{formatMessage({ id: 'projectOverview.stats.lastUpdated' })}
</div>
<div className="text-sm font-medium text-foreground">
{allDevEntries.length > 0 ? formatDate(allDevEntries[0].date) : '-'}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
export default ProjectOverviewPage;

View File

@@ -0,0 +1,525 @@
// ========================================
// ReviewSessionPage Component
// ========================================
// Review session detail page with findings display and multi-select
import * as React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useIntl } from 'react-intl';
import {
ArrowLeft,
Search,
CheckCircle,
XCircle,
AlertTriangle,
Info,
FileText,
Download,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import { useReviewSession } from '@/hooks/useReviewSession';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Card, CardContent } from '@/components/ui/Card';
type SeverityFilter = 'all' | 'critical' | 'high' | 'medium' | 'low';
type SortField = 'severity' | 'dimension' | 'file';
type SortOrder = 'asc' | 'desc';
interface FindingWithSelection {
id: string;
title: string;
description?: string;
severity: 'critical' | 'high' | 'medium' | 'low';
dimension: string;
category?: string;
file?: string;
line?: string;
code_context?: string;
recommendations?: string[];
root_cause?: string;
impact?: string;
}
/**
* ReviewSessionPage component - Display review session findings
*/
export function ReviewSessionPage() {
const { sessionId } = useParams<{ sessionId: string }>();
const navigate = useNavigate();
const { formatMessage } = useIntl();
const {
reviewSession,
flattenedFindings,
severityCounts,
isLoading,
error,
refetch,
} = useReviewSession(sessionId);
const [severityFilter, setSeverityFilter] = React.useState<Set<SeverityFilter>>(
new Set(['critical', 'high', 'medium', 'low'])
);
const [searchQuery, setSearchQuery] = React.useState('');
const [sortField, setSortField] = React.useState<SortField>('severity');
const [sortOrder, setSortOrder] = React.useState<SortOrder>('desc');
const [selectedFindings, setSelectedFindings] = React.useState<Set<string>>(new Set());
const [expandedFindings, setExpandedFindings] = React.useState<Set<string>>(new Set());
const handleBack = () => {
navigate('/sessions');
};
const toggleSeverity = (severity: SeverityFilter) => {
setSeverityFilter(prev => {
const next = new Set(prev);
if (next.has(severity)) {
next.delete(severity);
} else {
next.add(severity);
}
return next;
});
};
const toggleSelectFinding = (findingId: string) => {
setSelectedFindings(prev => {
const next = new Set(prev);
if (next.has(findingId)) {
next.delete(findingId);
} else {
next.add(findingId);
}
return next;
});
};
const toggleSelectAll = () => {
const validIds = filteredFindings.map(f => f.id).filter((id): id is string => id !== undefined);
if (selectedFindings.size === validIds.length) {
setSelectedFindings(new Set());
} else {
setSelectedFindings(new Set(validIds));
}
};
const toggleExpandFinding = (findingId: string) => {
setExpandedFindings(prev => {
const next = new Set(prev);
if (next.has(findingId)) {
next.delete(findingId);
} else {
next.add(findingId);
}
return next;
});
};
const exportSelectedAsJson = () => {
const selected = flattenedFindings.filter(f => f.id !== undefined && selectedFindings.has(f.id));
if (selected.length === 0) return;
const exportData = {
session_id: sessionId,
findings: selected.map(f => ({
id: f.id,
title: f.title,
description: f.description,
severity: f.severity,
dimension: f.dimension,
file: f.file,
line: f.line,
recommendations: f.recommendations,
})),
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `review-${sessionId}-fix.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// Severity order for sorting
const severityOrder = { critical: 4, high: 3, medium: 2, low: 1 };
// Filter and sort findings
const filteredFindings = React.useMemo(() => {
let filtered = flattenedFindings;
// Apply severity filter
if (severityFilter.size > 0 && !severityFilter.has('all' as SeverityFilter)) {
filtered = filtered.filter(f => severityFilter.has(f.severity));
}
// Apply search filter
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(f =>
f.title.toLowerCase().includes(query) ||
f.description?.toLowerCase().includes(query) ||
f.file?.toLowerCase().includes(query) ||
f.dimension.toLowerCase().includes(query)
);
}
// Apply sorting
filtered = [...filtered].sort((a, b) => {
let comparison = 0;
switch (sortField) {
case 'severity':
comparison = severityOrder[a.severity] - severityOrder[b.severity];
break;
case 'dimension':
comparison = a.dimension.localeCompare(b.dimension);
break;
case 'file':
comparison = (a.file || '').localeCompare(b.file || '');
break;
}
return sortOrder === 'asc' ? comparison : -comparison;
});
return filtered;
}, [flattenedFindings, severityFilter, searchQuery, sortField, sortOrder]);
// Get severity badge props
const getSeverityBadge = (severity: FindingWithSelection['severity']) => {
switch (severity) {
case 'critical':
return { variant: 'destructive' as const, icon: XCircle, label: formatMessage({ id: 'reviewSession.severity.critical' }) };
case 'high':
return { variant: 'warning' as const, icon: AlertTriangle, label: formatMessage({ id: 'reviewSession.severity.high' }) };
case 'medium':
return { variant: 'info' as const, icon: Info, label: formatMessage({ id: 'reviewSession.severity.medium' }) };
case 'low':
return { variant: 'secondary' as const, icon: CheckCircle, label: formatMessage({ id: 'reviewSession.severity.low' }) };
}
};
// Loading state
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" disabled>
<ArrowLeft className="h-4 w-4 mr-2" />
{formatMessage({ id: 'common.back' })}
</Button>
<div className="h-8 w-64 rounded bg-muted animate-pulse" />
</div>
<div className="h-64 rounded-lg bg-muted animate-pulse" />
</div>
);
}
// Error state
if (error) {
return (
<div className="flex items-center gap-2 p-4 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive">
<XCircle className="h-5 w-5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium">{formatMessage({ id: 'common.errors.loadFailed' })}</p>
<p className="text-xs mt-0.5">{error.message}</p>
</div>
<Button variant="outline" size="sm" onClick={() => refetch()}>
{formatMessage({ id: 'common.actions.retry' })}
</Button>
</div>
);
}
// Session not found
if (!reviewSession) {
return (
<div className="flex flex-col items-center justify-center py-16 px-4">
<FileText className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'reviewSession.notFound.title' })}
</h3>
<p className="text-sm text-muted-foreground mb-4">
{formatMessage({ id: 'reviewSession.notFound.message' })}
</p>
<Button onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
{formatMessage({ id: 'common.back' })}
</Button>
</div>
);
}
const dimensions = reviewSession.reviewDimensions || [];
const totalFindings = flattenedFindings.length;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
{formatMessage({ id: 'common.back' })}
</Button>
<div>
<h1 className="text-2xl font-semibold text-foreground">
{formatMessage({ id: 'reviewSession.title' })}
</h1>
<p className="text-sm text-muted-foreground">{reviewSession.session_id}</p>
</div>
</div>
<Badge variant="info">
{formatMessage({ id: 'reviewSession.type' })}
</Badge>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card>
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-foreground">{totalFindings}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.stats.total' })}</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-destructive">{severityCounts.critical}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.severity.critical' })}</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-warning">{severityCounts.high}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.severity.high' })}</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-foreground">{dimensions.length}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.stats.dimensions' })}</div>
</CardContent>
</Card>
</div>
{/* Filters and Controls */}
<Card>
<CardContent className="p-4 space-y-4">
{/* Severity Filters */}
<div className="flex flex-wrap gap-2">
{(['critical', 'high', 'medium', 'low'] as const).map(severity => {
const isEnabled = severityFilter.has(severity);
const badge = getSeverityBadge(severity);
return (
<Badge
key={severity}
variant={isEnabled ? badge.variant : 'outline'}
className={`cursor-pointer ${isEnabled ? '' : 'opacity-50'}`}
onClick={() => toggleSeverity(severity)}
>
<badge.icon className="h-3 w-3 mr-1" />
{badge.label}: {severityCounts[severity]}
</Badge>
);
})}
</div>
{/* Search and Sort */}
<div className="flex flex-wrap gap-3">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder={formatMessage({ id: 'reviewSession.search.placeholder' })}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<select
value={sortField}
onChange={e => setSortField(e.target.value as SortField)}
className="px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="severity">{formatMessage({ id: 'reviewSession.sort.severity' })}</option>
<option value="dimension">{formatMessage({ id: 'reviewSession.sort.dimension' })}</option>
<option value="file">{formatMessage({ id: 'reviewSession.sort.file' })}</option>
</select>
<Button
variant="outline"
size="sm"
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
>
{sortOrder === 'asc' ? '↑' : '↓'}
</Button>
</div>
{/* Selection Controls */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{formatMessage({ id: 'reviewSession.selection.count' }, { count: selectedFindings.size })}
</span>
<Button variant="outline" size="sm" onClick={toggleSelectAll}>
{selectedFindings.size === filteredFindings.length
? formatMessage({ id: 'reviewSession.selection.clearAll' })
: formatMessage({ id: 'reviewSession.selection.selectAll' })}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setSelectedFindings(new Set())}
>
{formatMessage({ id: 'reviewSession.selection.clear' })}
</Button>
</div>
<Button
variant="default"
size="sm"
onClick={exportSelectedAsJson}
disabled={selectedFindings.size === 0}
className="gap-2"
>
<Download className="h-4 w-4" />
{formatMessage({ id: 'reviewSession.export' })}
</Button>
</div>
</CardContent>
</Card>
{/* Findings List */}
{filteredFindings.length === 0 ? (
<Card>
<CardContent className="p-12 text-center">
<FileText className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'reviewSession.empty.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'reviewSession.empty.message' })}
</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{filteredFindings.filter(f => f.id !== undefined).map(finding => {
const findingId = finding.id!;
const isExpanded = expandedFindings.has(findingId);
const isSelected = selectedFindings.has(findingId);
const badge = getSeverityBadge(finding.severity);
const BadgeIcon = badge.icon;
return (
<Card key={findingId} className={isSelected ? 'ring-2 ring-primary' : ''}>
<CardContent className="p-4">
<div className="flex items-start gap-3">
{/* Checkbox */}
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSelectFinding(findingId)}
className="mt-1"
/>
<div className="flex-1 min-w-0">
{/* Header */}
<div
className="flex items-start justify-between gap-3 cursor-pointer"
onClick={() => toggleExpandFinding(findingId)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<Badge variant={badge.variant} className="gap-1">
<BadgeIcon className="h-3 w-3" />
{badge.label}
</Badge>
<Badge variant="outline" className="text-xs">
{finding.dimension}
</Badge>
{finding.file && (
<span className="text-xs text-muted-foreground font-mono">
{finding.file}:{finding.line || '?'}
</span>
)}
</div>
<h4 className="font-medium text-foreground text-sm">{finding.title}</h4>
{finding.description && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{finding.description}
</p>
)}
</div>
<Button variant="ghost" size="sm" className="flex-shrink-0">
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
</div>
{/* Expanded Content */}
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border space-y-3">
{/* Code Context */}
{finding.code_context && (
<div>
<h5 className="text-xs font-semibold text-foreground mb-1">
{formatMessage({ id: 'reviewSession.codeContext' })}
</h5>
<pre className="text-xs bg-muted p-2 rounded overflow-x-auto">
<code>{finding.code_context}</code>
</pre>
</div>
)}
{/* Root Cause */}
{finding.root_cause && (
<div>
<h5 className="text-xs font-semibold text-foreground mb-1">
{formatMessage({ id: 'reviewSession.rootCause' })}
</h5>
<p className="text-xs text-muted-foreground">{finding.root_cause}</p>
</div>
)}
{/* Impact */}
{finding.impact && (
<div>
<h5 className="text-xs font-semibold text-foreground mb-1">
{formatMessage({ id: 'reviewSession.impact' })}
</h5>
<p className="text-xs text-muted-foreground">{finding.impact}</p>
</div>
)}
{/* Recommendations */}
{finding.recommendations && finding.recommendations.length > 0 && (
<div>
<h5 className="text-xs font-semibold text-foreground mb-1">
{formatMessage({ id: 'reviewSession.recommendations' })}
</h5>
<ul className="space-y-1">
{finding.recommendations.map((rec, idx) => (
<li key={idx} className="text-xs text-muted-foreground flex items-start gap-2">
<span className="text-primary"></span>
<span>{rec}</span>
</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</div>
);
}
export default ReviewSessionPage;

View File

@@ -0,0 +1,186 @@
// ========================================
// SessionDetailPage Component
// ========================================
// Session detail page with tabs for tasks, context, and summary
import * as React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useIntl } from 'react-intl';
import {
ArrowLeft,
Calendar,
ListChecks,
Package,
FileText,
XCircle,
} from 'lucide-react';
import { useSessionDetail } from '@/hooks/useSessionDetail';
import { TaskListTab } from './session-detail/TaskListTab';
import { ContextTab } from './session-detail/ContextTab';
import { SummaryTab } from './session-detail/SummaryTab';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
type TabValue = 'tasks' | 'context' | 'summary';
/**
* SessionDetailPage component - Main session detail page with tabs
*/
export function SessionDetailPage() {
const { sessionId } = useParams<{ sessionId: string }>();
const navigate = useNavigate();
const { formatMessage } = useIntl();
const { sessionDetail, isLoading, error, refetch } = useSessionDetail(sessionId!);
const [activeTab, setActiveTab] = React.useState<TabValue>('tasks');
const handleBack = () => {
navigate('/sessions');
};
// Loading state
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" disabled>
<ArrowLeft className="h-4 w-4 mr-2" />
{formatMessage({ id: 'common.back' })}
</Button>
<div className="h-8 w-64 rounded bg-muted animate-pulse" />
</div>
<div className="h-64 rounded-lg bg-muted animate-pulse" />
</div>
);
}
// Error state
if (error) {
return (
<div className="flex items-center gap-2 p-4 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive">
<XCircle className="h-5 w-5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium">{formatMessage({ id: 'common.errors.loadFailed' })}</p>
<p className="text-xs mt-0.5">{error.message}</p>
</div>
<Button variant="outline" size="sm" onClick={() => refetch()}>
{formatMessage({ id: 'common.actions.retry' })}
</Button>
</div>
);
}
// Session not found
if (!sessionDetail) {
return (
<div className="flex flex-col items-center justify-center py-16 px-4">
<Package className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'sessionDetail.notFound.title' })}
</h3>
<p className="text-sm text-muted-foreground mb-4">
{formatMessage({ id: 'sessionDetail.notFound.message' })}
</p>
<Button onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
{formatMessage({ id: 'common.back' })}
</Button>
</div>
);
}
const { session, context, summary } = sessionDetail;
const tasks = session.tasks || [];
const completedTasks = tasks.filter((t) => t.status === 'completed').length;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
{formatMessage({ id: 'common.back' })}
</Button>
<div>
<h1 className="text-2xl font-semibold text-foreground">
{session.title || session.session_id}
</h1>
{session.title && session.title !== session.session_id && (
<p className="text-sm text-muted-foreground mt-0.5">{session.session_id}</p>
)}
</div>
</div>
<Badge variant={session.status === 'completed' ? 'success' : 'secondary'}>
{formatMessage({ id: `sessions.status.${session.status}` })}
</Badge>
</div>
{/* Info Bar */}
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground p-4 bg-background rounded-lg border">
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
<span className="font-medium">{formatMessage({ id: 'sessionDetail.info.created' })}:</span>{' '}
{new Date(session.created_at).toLocaleString()}
</div>
{session.updated_at && (
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
<span className="font-medium">{formatMessage({ id: 'sessionDetail.info.updated' })}:</span>{' '}
{new Date(session.updated_at).toLocaleString()}
</div>
)}
<div className="flex items-center gap-1">
<ListChecks className="h-4 w-4" />
<span className="font-medium">{formatMessage({ id: 'sessionDetail.info.tasks' })}:</span>{' '}
{completedTasks}/{tasks.length}
</div>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabValue)}>
<TabsList>
<TabsTrigger value="tasks">
<ListChecks className="h-4 w-4 mr-2" />
{formatMessage({ id: 'sessionDetail.tabs.tasks' })}
<Badge variant="secondary" className="ml-2">
{tasks.length}
</Badge>
</TabsTrigger>
<TabsTrigger value="context">
<Package className="h-4 w-4 mr-2" />
{formatMessage({ id: 'sessionDetail.tabs.context' })}
</TabsTrigger>
<TabsTrigger value="summary">
<FileText className="h-4 w-4 mr-2" />
{formatMessage({ id: 'sessionDetail.tabs.summary' })}
</TabsTrigger>
</TabsList>
<TabsContent value="tasks" className="mt-4">
<TaskListTab session={session} />
</TabsContent>
<TabsContent value="context" className="mt-4">
<ContextTab context={context} />
</TabsContent>
<TabsContent value="summary" className="mt-4">
<SummaryTab summary={summary} />
</TabsContent>
</Tabs>
{/* Description (if exists) */}
{session.description && (
<div className="p-4 bg-background rounded-lg border">
<h3 className="text-sm font-semibold text-foreground mb-2">
{formatMessage({ id: 'sessionDetail.info.description' })}
</h3>
<p className="text-sm text-muted-foreground">{session.description}</p>
</div>
)}
</div>
);
}
export default SessionDetailPage;

View File

@@ -5,6 +5,7 @@
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import { useIntl } from 'react-intl';
import {
Plus,
RefreshCw,
@@ -51,6 +52,7 @@ type LocationFilter = 'all' | 'active' | 'archived';
* SessionsPage component - Sessions list with CRUD operations
*/
export function SessionsPage() {
const { formatMessage } = useIntl();
const navigate = useNavigate();
// Filter state
@@ -168,9 +170,9 @@ export function SessionsPage() {
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Sessions</h1>
<h1 className="text-2xl font-semibold text-foreground">{formatMessage({ id: 'sessions.title' })}</h1>
<p className="text-sm text-muted-foreground mt-1">
Manage your workflow sessions
{formatMessage({ id: 'sessions.description' })}
</p>
</div>
<div className="flex items-center gap-2">
@@ -181,11 +183,11 @@ export function SessionsPage() {
disabled={isFetching}
>
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />
Refresh
{formatMessage({ id: 'common.actions.refresh' })}
</Button>
<Button size="sm" onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
New Session
{formatMessage({ id: 'common.actions.new' })}
</Button>
</div>
</div>
@@ -195,11 +197,11 @@ export function SessionsPage() {
<div className="flex items-center gap-2 p-4 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive">
<AlertCircle className="h-5 w-5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium">Failed to load sessions</p>
<p className="text-sm font-medium">{formatMessage({ id: 'common.errors.loadFailed' })}</p>
<p className="text-xs mt-0.5">{error.message}</p>
</div>
<Button variant="outline" size="sm" onClick={() => refetch()}>
Retry
{formatMessage({ id: 'home.errors.retry' })}
</Button>
</div>
)}
@@ -209,9 +211,9 @@ export function SessionsPage() {
{/* Location tabs */}
<Tabs value={locationFilter} onValueChange={(v) => setLocationFilter(v as LocationFilter)}>
<TabsList>
<TabsTrigger value="active">Active</TabsTrigger>
<TabsTrigger value="archived">Archived</TabsTrigger>
<TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="active">{formatMessage({ id: 'sessions.filters.active' })}</TabsTrigger>
<TabsTrigger value="archived">{formatMessage({ id: 'sessions.filters.archived' })}</TabsTrigger>
<TabsTrigger value="all">{formatMessage({ id: 'sessions.filters.all' })}</TabsTrigger>
</TabsList>
</Tabs>
@@ -219,7 +221,7 @@ export function SessionsPage() {
<div className="flex-1 max-w-sm relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search sessions..."
placeholder={formatMessage({ id: 'sessions.searchPlaceholder' })}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-9"
@@ -239,7 +241,7 @@ export function SessionsPage() {
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Filter className="h-4 w-4" />
Filter
{formatMessage({ id: 'common.actions.filter' })}
{statusFilter.length > 0 && (
<Badge variant="secondary" className="ml-1 h-5 min-w-5 px-1">
{statusFilter.length}
@@ -248,7 +250,7 @@ export function SessionsPage() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>Status</DropdownMenuLabel>
<DropdownMenuLabel>{formatMessage({ id: 'common.status.label' })}</DropdownMenuLabel>
<DropdownMenuSeparator />
{(['planning', 'in_progress', 'completed', 'paused'] as const).map((status) => (
<DropdownMenuItem
@@ -256,7 +258,7 @@ export function SessionsPage() {
onClick={() => toggleStatusFilter(status)}
className="justify-between"
>
<span className="capitalize">{status.replace('_', ' ')}</span>
<span>{formatMessage({ id: `sessions.status.${status}` })}</span>
{statusFilter.includes(status) && (
<span className="text-primary">&#10003;</span>
)}
@@ -266,7 +268,7 @@ export function SessionsPage() {
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={clearFilters} className="text-destructive">
Clear filters
{formatMessage({ id: 'common.actions.clearFilters' })}
</DropdownMenuItem>
</>
)}
@@ -277,7 +279,7 @@ export function SessionsPage() {
{/* Active filters display */}
{hasActiveFilters && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">Filters:</span>
<span className="text-sm text-muted-foreground">{formatMessage({ id: 'common.actions.filters' })}:</span>
{statusFilter.map((status) => (
<Badge
key={status}
@@ -285,7 +287,7 @@ export function SessionsPage() {
className="cursor-pointer"
onClick={() => toggleStatusFilter(status)}
>
{status.replace('_', ' ')}
{formatMessage({ id: `sessions.status.${status}` })}
<X className="ml-1 h-3 w-3" />
</Badge>
))}
@@ -295,12 +297,12 @@ export function SessionsPage() {
className="cursor-pointer"
onClick={handleClearSearch}
>
Search: {searchQuery}
{formatMessage({ id: 'common.actions.search' })}: {searchQuery}
<X className="ml-1 h-3 w-3" />
</Badge>
)}
<Button variant="ghost" size="sm" onClick={clearFilters} className="h-6 text-xs">
Clear all
{formatMessage({ id: 'common.actions.clearAll' })}
</Button>
</div>
)}
@@ -316,21 +318,21 @@ export function SessionsPage() {
<div className="flex flex-col items-center justify-center py-16 px-4 border border-dashed border-border rounded-lg">
<FolderKanban className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-1">
{hasActiveFilters ? 'No sessions match your filters' : 'No sessions found'}
{hasActiveFilters ? formatMessage({ id: 'sessions.emptyState.title' }) : formatMessage({ id: 'sessions.emptyState.title' })}
</h3>
<p className="text-sm text-muted-foreground text-center max-w-sm mb-4">
{hasActiveFilters
? 'Try adjusting your filters or search query.'
: 'Create a new session to get started with your workflow.'}
? formatMessage({ id: 'sessions.emptyState.message' })
: formatMessage({ id: 'sessions.emptyState.createFirst' })}
</p>
{hasActiveFilters ? (
<Button variant="outline" onClick={clearFilters}>
Clear filters
{formatMessage({ id: 'common.actions.clearFilters' })}
</Button>
) : (
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
New Session
{formatMessage({ id: 'common.actions.new' })}
</Button>
)}
</div>
@@ -354,41 +356,41 @@ export function SessionsPage() {
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Session</DialogTitle>
<DialogTitle>{formatMessage({ id: 'common.dialog.createSession' })}</DialogTitle>
<DialogDescription>
Create a new workflow session to track your development tasks.
{formatMessage({ id: 'common.dialog.createSessionDesc' })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label htmlFor="sessionId" className="text-sm font-medium">
Session ID <span className="text-destructive">*</span>
{formatMessage({ id: 'common.form.sessionId' })} <span className="text-destructive">*</span>
</label>
<Input
id="sessionId"
placeholder="e.g., WFS-feature-auth"
placeholder={formatMessage({ id: 'common.form.sessionIdPlaceholder' })}
value={newSessionId}
onChange={(e) => setNewSessionId(e.target.value)}
/>
</div>
<div className="space-y-2">
<label htmlFor="sessionTitle" className="text-sm font-medium">
Title (optional)
{formatMessage({ id: 'common.form.title' })} ({formatMessage({ id: 'common.form.optional' })})
</label>
<Input
id="sessionTitle"
placeholder="e.g., Authentication System"
placeholder={formatMessage({ id: 'common.form.titlePlaceholder' })}
value={newSessionTitle}
onChange={(e) => setNewSessionTitle(e.target.value)}
/>
</div>
<div className="space-y-2">
<label htmlFor="sessionDescription" className="text-sm font-medium">
Description (optional)
{formatMessage({ id: 'common.form.description' })} ({formatMessage({ id: 'common.form.optional' })})
</label>
<Input
id="sessionDescription"
placeholder="Brief description of the session"
placeholder={formatMessage({ id: 'common.form.descriptionPlaceholder' })}
value={newSessionDescription}
onChange={(e) => setNewSessionDescription(e.target.value)}
/>
@@ -402,13 +404,13 @@ export function SessionsPage() {
resetCreateForm();
}}
>
Cancel
{formatMessage({ id: 'common.actions.cancel' })}
</Button>
<Button
onClick={handleCreateSession}
disabled={!newSessionId.trim() || isCreating}
>
{isCreating ? 'Creating...' : 'Create Session'}
{isCreating ? formatMessage({ id: 'common.status.creating' }) : formatMessage({ id: 'common.actions.create' })}
</Button>
</DialogFooter>
</DialogContent>
@@ -418,9 +420,9 @@ export function SessionsPage() {
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Session</DialogTitle>
<DialogTitle>{formatMessage({ id: 'common.dialog.deleteSession' })}</DialogTitle>
<DialogDescription>
Are you sure you want to delete this session? This action cannot be undone.
{formatMessage({ id: 'common.dialog.deleteConfirm' })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -431,14 +433,14 @@ export function SessionsPage() {
setSessionToDelete(null);
}}
>
Cancel
{formatMessage({ id: 'common.actions.cancel' })}
</Button>
<Button
variant="destructive"
onClick={handleConfirmDelete}
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete'}
{isDeleting ? formatMessage({ id: 'common.status.deleting' }) : formatMessage({ id: 'common.actions.delete' })}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -4,6 +4,7 @@
// Application settings and configuration with CLI tools management
import { useState } from 'react';
import { useIntl } from 'react-intl';
import {
Settings,
Moon,
@@ -16,15 +17,23 @@ import {
X,
ChevronDown,
ChevronUp,
Languages,
GitFork,
Scale,
Search,
Power,
PowerOff,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
import { useTheme } from '@/hooks';
import { useHooks, useRules, useToggleHook, useToggleRule } from '@/hooks';
import { useConfigStore, selectCliTools, selectDefaultCliTool, selectUserPreferences } from '@/stores/configStore';
import type { CliToolConfig, UserPreferences } from '@/types/store';
import { cn } from '@/lib/utils';
import { LanguageSwitcher } from '@/components/layout/LanguageSwitcher';
// ========== CLI Tool Card Component ==========
@@ -49,6 +58,8 @@ function CliToolCard({
onSetDefault,
onUpdateModel,
}: CliToolCardProps) {
const { formatMessage } = useIntl();
return (
<Card className={cn('overflow-hidden', !config.enabled && 'opacity-60')}>
{/* Header */}
@@ -73,7 +84,7 @@ function CliToolCard({
{toolId}
</span>
{isDefault && (
<Badge variant="default" className="text-xs">Default</Badge>
<Badge variant="default" className="text-xs">{formatMessage({ id: 'settings.cliTools.default' })}</Badge>
)}
<Badge variant="outline" className="text-xs">{config.type}</Badge>
</div>
@@ -95,12 +106,12 @@ function CliToolCard({
{config.enabled ? (
<>
<Check className="w-4 h-4 mr-1" />
Enabled
{formatMessage({ id: 'settings.cliTools.enabled' })}
</>
) : (
<>
<X className="w-4 h-4 mr-1" />
Disabled
{formatMessage({ id: 'settings.cliTools.disabled' })}
</>
)}
</Button>
@@ -129,7 +140,7 @@ function CliToolCard({
<div className="border-t border-border p-4 space-y-4 bg-muted/30">
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="text-sm font-medium text-foreground">Primary Model</label>
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'settings.cliTools.primaryModel' })}</label>
<Input
value={config.primaryModel}
onChange={(e) => onUpdateModel('primaryModel', e.target.value)}
@@ -137,7 +148,7 @@ function CliToolCard({
/>
</div>
<div>
<label className="text-sm font-medium text-foreground">Secondary Model</label>
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'settings.cliTools.secondaryModel' })}</label>
<Input
value={config.secondaryModel}
onChange={(e) => onUpdateModel('secondaryModel', e.target.value)}
@@ -147,7 +158,7 @@ function CliToolCard({
</div>
{!isDefault && config.enabled && (
<Button variant="outline" size="sm" onClick={onSetDefault}>
Set as Default
{formatMessage({ id: 'settings.cliTools.setDefault' })}
</Button>
)}
</div>
@@ -156,9 +167,214 @@ function CliToolCard({
);
}
// ========== Hooks Section Component ==========
function HooksSection() {
const { formatMessage } = useIntl();
const [searchQuery, setSearchQuery] = useState('');
const { hooks, enabledCount, totalCount, isLoading } = useHooks();
const { toggleHook, isToggling } = useToggleHook();
const filteredHooks = hooks.filter(h =>
h.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(h.description && h.description.toLowerCase().includes(searchQuery.toLowerCase()))
);
return (
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
<GitFork className="w-5 h-5" />
{formatMessage({ id: 'settings.sections.hooks' })}
</h2>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{enabledCount}/{totalCount} {formatMessage({ id: 'cliHooks.stats.enabled' })}
</span>
</div>
</div>
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder={formatMessage({ id: 'cliHooks.filters.searchPlaceholder' })}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<div className="space-y-2">
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-16 bg-muted animate-pulse rounded-lg" />
))}
</div>
) : filteredHooks.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<GitFork className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p>{formatMessage({ id: 'cliHooks.emptyState.title' })}</p>
</div>
) : (
filteredHooks.map((hook) => (
<div
key={hook.name}
className="flex items-center justify-between p-3 rounded-lg border border-border hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
<div className={cn(
'p-2 rounded-lg',
hook.enabled ? 'bg-primary/10' : 'bg-muted'
)}>
<GitFork className={cn(
'w-4 h-4',
hook.enabled ? 'text-primary' : 'text-muted-foreground'
)} />
</div>
<div>
<p className="text-sm font-medium text-foreground">{hook.name}</p>
{hook.description && (
<p className="text-xs text-muted-foreground">{hook.description}</p>
)}
<Badge variant="outline" className="text-xs mt-1">
{formatMessage({ id: `cliHooks.trigger.${hook.trigger}` })}
</Badge>
</div>
</div>
<Button
variant={hook.enabled ? 'default' : 'outline'}
size="sm"
className="h-8"
onClick={() => toggleHook(hook.name, !hook.enabled)}
disabled={isToggling}
>
{hook.enabled ? (
<><Power className="w-4 h-4 mr-1" />{formatMessage({ id: 'settings.cliTools.enabled' })}</>
) : (
<><PowerOff className="w-4 h-4 mr-1" />{formatMessage({ id: 'settings.cliTools.disabled' })}</>
)}
</Button>
</div>
))
)}
</div>
</Card>
);
}
// ========== Rules Section Component ==========
function RulesSection() {
const { formatMessage } = useIntl();
const [searchQuery, setSearchQuery] = useState('');
const { rules, enabledCount, totalCount, isLoading } = useRules();
const { toggleRule, isToggling } = useToggleRule();
const filteredRules = rules.filter(r =>
r.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(r.description && r.description.toLowerCase().includes(searchQuery.toLowerCase()))
);
return (
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
<Scale className="w-5 h-5" />
{formatMessage({ id: 'settings.sections.rules' })}
</h2>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{enabledCount}/{totalCount} {formatMessage({ id: 'cliRules.stats.enabled' })}
</span>
</div>
</div>
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder={formatMessage({ id: 'cliRules.filters.searchPlaceholder' })}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<div className="space-y-2">
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-16 bg-muted animate-pulse rounded-lg" />
))}
</div>
) : filteredRules.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Scale className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p>{formatMessage({ id: 'cliRules.emptyState.title' })}</p>
</div>
) : (
filteredRules.map((rule) => (
<div
key={rule.id}
className="flex items-center justify-between p-3 rounded-lg border border-border hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
<div className={cn(
'p-2 rounded-lg',
rule.enabled ? 'bg-primary/10' : 'bg-muted'
)}>
<Scale className={cn(
'w-4 h-4',
rule.enabled ? 'text-primary' : 'text-muted-foreground'
)} />
</div>
<div>
<p className="text-sm font-medium text-foreground">{rule.name}</p>
{rule.description && (
<p className="text-xs text-muted-foreground">{rule.description}</p>
)}
<div className="flex items-center gap-2 mt-1">
{rule.category && (
<Badge variant="secondary" className="text-xs">{rule.category}</Badge>
)}
{rule.severity && (
<Badge
variant={rule.severity === 'error' ? 'destructive' : 'outline'}
className="text-xs"
>
{formatMessage({ id: `cliRules.severity.${rule.severity}` })}
</Badge>
)}
</div>
</div>
</div>
<Button
variant={rule.enabled ? 'default' : 'outline'}
size="sm"
className="h-8"
onClick={() => toggleRule(rule.id, !rule.enabled)}
disabled={isToggling}
>
{rule.enabled ? (
<><Power className="w-4 h-4 mr-1" />{formatMessage({ id: 'settings.cliTools.enabled' })}</>
) : (
<><PowerOff className="w-4 h-4 mr-1" />{formatMessage({ id: 'settings.cliTools.disabled' })}</>
)}
</Button>
</div>
))
)}
</div>
</Card>
);
}
// ========== Main Page Component ==========
export function SettingsPage() {
const { formatMessage } = useIntl();
const { theme, setTheme } = useTheme();
const cliTools = useConfigStore(selectCliTools);
const defaultCliTool = useConfigStore(selectDefaultCliTool);
@@ -201,10 +417,10 @@ export function SettingsPage() {
<div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
<Settings className="w-6 h-6 text-primary" />
Settings
{formatMessage({ id: 'settings.title' })}
</h1>
<p className="text-muted-foreground mt-1">
Configure your dashboard preferences and CLI tools
{formatMessage({ id: 'settings.description' })}
</p>
</div>
@@ -212,14 +428,14 @@ export function SettingsPage() {
<Card className="p-6">
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
<Moon className="w-5 h-5" />
Appearance
{formatMessage({ id: 'settings.sections.appearance' })}
</h2>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-foreground">Theme</p>
<p className="font-medium text-foreground">{formatMessage({ id: 'settings.appearance.theme' })}</p>
<p className="text-sm text-muted-foreground">
Choose your preferred color theme
{formatMessage({ id: 'settings.appearance.description' })}
</p>
</div>
<div className="flex gap-2">
@@ -229,7 +445,7 @@ export function SettingsPage() {
onClick={() => setTheme('light')}
>
<Sun className="w-4 h-4 mr-2" />
Light
{formatMessage({ id: 'settings.appearance.themeOptions.light' })}
</Button>
<Button
variant={theme === 'dark' ? 'default' : 'outline'}
@@ -237,32 +453,35 @@ export function SettingsPage() {
onClick={() => setTheme('dark')}
>
<Moon className="w-4 h-4 mr-2" />
Dark
{formatMessage({ id: 'settings.appearance.themeOptions.dark' })}
</Button>
<Button
variant={theme === 'system' ? 'default' : 'outline'}
size="sm"
onClick={() => setTheme('system')}
>
System
{formatMessage({ id: 'settings.appearance.themeOptions.system' })}
</Button>
</div>
</div>
</div>
</Card>
{/* Language Settings */}
<Card className="p-6">
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
<Languages className="w-5 h-5" />
{formatMessage({ id: 'settings.sections.language' })}
</h2>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-foreground">Compact View</p>
<p className="font-medium text-foreground">{formatMessage({ id: 'settings.language.displayLanguage' })}</p>
<p className="text-sm text-muted-foreground">
Use a more compact layout for lists
{formatMessage({ id: 'settings.language.chooseLanguage' })}
</p>
</div>
<Button
variant={userPreferences.compactView ? 'default' : 'outline'}
size="sm"
onClick={() => handlePreferenceChange('compactView', !userPreferences.compactView)}
>
{userPreferences.compactView ? 'On' : 'Off'}
</Button>
<LanguageSwitcher />
</div>
</div>
</Card>
@@ -271,10 +490,10 @@ export function SettingsPage() {
<Card className="p-6">
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
<Cpu className="w-5 h-5" />
CLI Tools
{formatMessage({ id: 'settings.sections.cliTools' })}
</h2>
<p className="text-sm text-muted-foreground mb-4">
Configure available CLI tools and their models. Default tool: <strong className="text-foreground">{defaultCliTool}</strong>
{formatMessage({ id: 'settings.cliTools.description' })} <strong className="text-foreground">{defaultCliTool}</strong>
</p>
<div className="space-y-3">
{Object.entries(cliTools).map(([toolId, config]) => (
@@ -297,14 +516,14 @@ export function SettingsPage() {
<Card className="p-6">
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
<RefreshCw className="w-5 h-5" />
Data Refresh
{formatMessage({ id: 'settings.dataRefresh.title' })}
</h2>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-foreground">Auto Refresh</p>
<p className="font-medium text-foreground">{formatMessage({ id: 'settings.dataRefresh.autoRefresh' })}</p>
<p className="text-sm text-muted-foreground">
Automatically refresh data periodically
{formatMessage({ id: 'settings.dataRefresh.autoRefreshDesc' })}
</p>
</div>
<Button
@@ -312,16 +531,16 @@ export function SettingsPage() {
size="sm"
onClick={() => handlePreferenceChange('autoRefresh', !userPreferences.autoRefresh)}
>
{userPreferences.autoRefresh ? 'Enabled' : 'Disabled'}
{userPreferences.autoRefresh ? formatMessage({ id: 'settings.dataRefresh.enabled' }) : formatMessage({ id: 'settings.dataRefresh.disabled' })}
</Button>
</div>
{userPreferences.autoRefresh && (
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-foreground">Refresh Interval</p>
<p className="font-medium text-foreground">{formatMessage({ id: 'settings.dataRefresh.refreshInterval' })}</p>
<p className="text-sm text-muted-foreground">
How often to refresh data
{formatMessage({ id: 'settings.dataRefresh.refreshIntervalDesc' })}
</p>
</div>
<div className="flex gap-2">
@@ -345,14 +564,14 @@ export function SettingsPage() {
<Card className="p-6">
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
<Bell className="w-5 h-5" />
Notifications
{formatMessage({ id: 'settings.notifications.title' })}
</h2>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-foreground">Enable Notifications</p>
<p className="font-medium text-foreground">{formatMessage({ id: 'settings.notifications.enableNotifications' })}</p>
<p className="text-sm text-muted-foreground">
Show notifications for workflow events
{formatMessage({ id: 'settings.notifications.enableNotificationsDesc' })}
</p>
</div>
<Button
@@ -360,15 +579,15 @@ export function SettingsPage() {
size="sm"
onClick={() => handlePreferenceChange('notificationsEnabled', !userPreferences.notificationsEnabled)}
>
{userPreferences.notificationsEnabled ? 'Enabled' : 'Disabled'}
{userPreferences.notificationsEnabled ? formatMessage({ id: 'settings.dataRefresh.enabled' }) : formatMessage({ id: 'settings.dataRefresh.disabled' })}
</Button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-foreground">Sound Effects</p>
<p className="font-medium text-foreground">{formatMessage({ id: 'settings.notifications.soundEffects' })}</p>
<p className="text-sm text-muted-foreground">
Play sound for notifications
{formatMessage({ id: 'settings.notifications.soundEffectsDesc' })}
</p>
</div>
<Button
@@ -376,7 +595,7 @@ export function SettingsPage() {
size="sm"
onClick={() => handlePreferenceChange('soundEnabled', !userPreferences.soundEnabled)}
>
{userPreferences.soundEnabled ? 'On' : 'Off'}
{userPreferences.soundEnabled ? formatMessage({ id: 'settings.notifications.on' }) : formatMessage({ id: 'settings.notifications.off' })}
</Button>
</div>
</div>
@@ -386,14 +605,14 @@ export function SettingsPage() {
<Card className="p-6">
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
<Settings className="w-5 h-5" />
Display Settings
{formatMessage({ id: 'settings.sections.display' })}
</h2>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-foreground">Show Completed Tasks</p>
<p className="font-medium text-foreground">{formatMessage({ id: 'settings.display.showCompletedTasks' })}</p>
<p className="text-sm text-muted-foreground">
Display completed tasks in task lists
{formatMessage({ id: 'settings.display.showCompletedTasksDesc' })}
</p>
</div>
<Button
@@ -401,31 +620,37 @@ export function SettingsPage() {
size="sm"
onClick={() => handlePreferenceChange('showCompletedTasks', !userPreferences.showCompletedTasks)}
>
{userPreferences.showCompletedTasks ? 'Show' : 'Hide'}
{userPreferences.showCompletedTasks ? formatMessage({ id: 'settings.display.show' }) : formatMessage({ id: 'settings.display.hide' })}
</Button>
</div>
</div>
</Card>
{/* Git Hooks */}
<HooksSection />
{/* Rules */}
<RulesSection />
{/* Reset Settings */}
<Card className="p-6 border-destructive/50">
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
<RotateCcw className="w-5 h-5" />
Reset Settings
{formatMessage({ id: 'common.actions.reset' })}
</h2>
<p className="text-sm text-muted-foreground mb-4">
Reset all user preferences to their default values. This cannot be undone.
{formatMessage({ id: 'settings.reset.description' })}
</p>
<Button
variant="destructive"
onClick={() => {
if (confirm('Reset all settings to defaults?')) {
if (confirm(formatMessage({ id: 'settings.reset.confirm' }))) {
resetUserPreferences();
}
}}
>
<RotateCcw className="w-4 h-4 mr-2" />
Reset to Defaults
{formatMessage({ id: 'common.actions.resetToDefaults' })}
</Button>
</Card>
</div>

View File

@@ -4,6 +4,7 @@
// Browse and manage skills library with search/filter
import { useState, useMemo } from 'react';
import { useIntl } from 'react-intl';
import {
Sparkles,
Search,
@@ -34,6 +35,8 @@ interface SkillGridProps {
}
function SkillGrid({ skills, isLoading, onToggle, onClick, isToggling, compact }: SkillGridProps) {
const { formatMessage } = useIntl();
if (isLoading) {
return (
<div className={cn(
@@ -51,9 +54,9 @@ function SkillGrid({ skills, isLoading, onToggle, onClick, isToggling, compact }
return (
<Card className="p-8 text-center">
<Sparkles className="w-12 h-12 mx-auto text-muted-foreground/50" />
<h3 className="mt-4 text-lg font-medium text-foreground">No skills found</h3>
<h3 className="mt-4 text-lg font-medium text-foreground">{formatMessage({ id: 'skills.emptyState.title' })}</h3>
<p className="mt-2 text-muted-foreground">
Try adjusting your search or filters.
{formatMessage({ id: 'skills.emptyState.message' })}
</p>
</Card>
);
@@ -81,6 +84,7 @@ function SkillGrid({ skills, isLoading, onToggle, onClick, isToggling, compact }
// ========== Main Page Component ==========
export function SkillsManagerPage() {
const { formatMessage } = useIntl();
const [searchQuery, setSearchQuery] = useState('');
const [categoryFilter, setCategoryFilter] = useState<string>('all');
const [sourceFilter, setSourceFilter] = useState<string>('all');
@@ -125,20 +129,20 @@ export function SkillsManagerPage() {
<div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
<Sparkles className="w-6 h-6 text-primary" />
Skills Manager
{formatMessage({ id: 'skills.title' })}
</h1>
<p className="text-muted-foreground mt-1">
Browse, install, and manage Claude Code skills
{formatMessage({ id: 'skills.description' })}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
Refresh
{formatMessage({ id: 'common.actions.refresh' })}
</Button>
<Button>
<Plus className="w-4 h-4 mr-2" />
Install Skill
{formatMessage({ id: 'skills.actions.install' })}
</Button>
</div>
</div>
@@ -150,28 +154,28 @@ export function SkillsManagerPage() {
<Sparkles className="w-5 h-5 text-primary" />
<span className="text-2xl font-bold">{totalCount}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">Total Skills</p>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'common.stats.totalSkills' })}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2">
<Power className="w-5 h-5 text-success" />
<span className="text-2xl font-bold">{enabledCount}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">Enabled</p>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'skills.state.enabled' })}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2">
<PowerOff className="w-5 h-5 text-muted-foreground" />
<span className="text-2xl font-bold">{totalCount - enabledCount}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">Disabled</p>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'skills.state.disabled' })}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2">
<Tag className="w-5 h-5 text-info" />
<span className="text-2xl font-bold">{categories.length}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">Categories</p>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'skills.card.category' })}</p>
</Card>
</div>
@@ -180,7 +184,7 @@ export function SkillsManagerPage() {
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search skills by name, description, or trigger..."
placeholder={formatMessage({ id: 'skills.filters.searchPlaceholder' })}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
@@ -189,10 +193,10 @@ export function SkillsManagerPage() {
<div className="flex gap-2">
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Category" />
<SelectValue placeholder={formatMessage({ id: 'skills.card.category' })} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
<SelectItem value="all">{formatMessage({ id: 'skills.filters.all' })}</SelectItem>
{categories.map((cat) => (
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
))}
@@ -200,23 +204,23 @@ export function SkillsManagerPage() {
</Select>
<Select value={sourceFilter} onValueChange={setSourceFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Source" />
<SelectValue placeholder={formatMessage({ id: 'skills.card.source' })} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Sources</SelectItem>
<SelectItem value="builtin">Built-in</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
<SelectItem value="community">Community</SelectItem>
<SelectItem value="all">{formatMessage({ id: 'skills.filters.allSources' })}</SelectItem>
<SelectItem value="builtin">{formatMessage({ id: 'skills.source.builtin' })}</SelectItem>
<SelectItem value="custom">{formatMessage({ id: 'skills.source.custom' })}</SelectItem>
<SelectItem value="community">{formatMessage({ id: 'skills.source.community' })}</SelectItem>
</SelectContent>
</Select>
<Select value={enabledFilter} onValueChange={(v) => setEnabledFilter(v as 'all' | 'enabled' | 'disabled')}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Status" />
<SelectValue placeholder={formatMessage({ id: 'skills.state.enabled' })} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="enabled">Enabled Only</SelectItem>
<SelectItem value="disabled">Disabled Only</SelectItem>
<SelectItem value="all">{formatMessage({ id: 'skills.filters.all' })}</SelectItem>
<SelectItem value="enabled">{formatMessage({ id: 'skills.filters.enabled' })}</SelectItem>
<SelectItem value="disabled">{formatMessage({ id: 'skills.filters.disabled' })}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -229,7 +233,7 @@ export function SkillsManagerPage() {
size="sm"
onClick={() => setEnabledFilter('all')}
>
All ({totalCount})
{formatMessage({ id: 'skills.filters.all' })} ({totalCount})
</Button>
<Button
variant={enabledFilter === 'enabled' ? 'default' : 'outline'}
@@ -237,7 +241,7 @@ export function SkillsManagerPage() {
onClick={() => setEnabledFilter('enabled')}
>
<Power className="w-4 h-4 mr-1" />
Enabled ({enabledCount})
{formatMessage({ id: 'skills.state.enabled' })} ({enabledCount})
</Button>
<Button
variant={enabledFilter === 'disabled' ? 'default' : 'outline'}
@@ -245,7 +249,7 @@ export function SkillsManagerPage() {
onClick={() => setEnabledFilter('disabled')}
>
<PowerOff className="w-4 h-4 mr-1" />
Disabled ({totalCount - enabledCount})
{formatMessage({ id: 'skills.state.disabled' })} ({totalCount - enabledCount})
</Button>
<div className="flex-1" />
<Button
@@ -253,7 +257,7 @@ export function SkillsManagerPage() {
size="sm"
onClick={() => setViewMode(viewMode === 'grid' ? 'compact' : 'grid')}
>
{viewMode === 'grid' ? 'Compact View' : 'Grid View'}
{formatMessage({ id: viewMode === 'grid' ? 'skills.view.compact' : 'skills.view.grid' })}
</Button>
</div>

View File

@@ -5,6 +5,10 @@
export { HomePage } from './HomePage';
export { SessionsPage } from './SessionsPage';
export { FixSessionPage } from './FixSessionPage';
export { ProjectOverviewPage } from './ProjectOverviewPage';
export { SessionDetailPage } from './SessionDetailPage';
export { HistoryPage } from './HistoryPage';
export { OrchestratorPage } from './orchestrator';
export { LoopMonitorPage } from './LoopMonitorPage';
export { IssueManagerPage } from './IssueManagerPage';
@@ -13,3 +17,10 @@ export { CommandsManagerPage } from './CommandsManagerPage';
export { MemoryPage } from './MemoryPage';
export { SettingsPage } from './SettingsPage';
export { HelpPage } from './HelpPage';
export { NotFoundPage } from './NotFoundPage';
export { LiteTasksPage } from './LiteTasksPage';
export { LiteTaskDetailPage } from './LiteTaskDetailPage';
export { ReviewSessionPage } from './ReviewSessionPage';
export { McpManagerPage } from './McpManagerPage';
export { EndpointsPage } from './EndpointsPage';
export { InstallationsPage } from './InstallationsPage';

View File

@@ -0,0 +1,182 @@
// ========================================
// ContextTab Component
// ========================================
// Context tab for session detail page
import { useIntl } from 'react-intl';
import {
Package,
FileCode,
Tag,
Settings,
BookOpen,
CheckSquare,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import type { SessionDetailContext } from '@/lib/api';
export interface ContextTabProps {
context?: SessionDetailContext;
}
/**
* ContextTab component - Display session context information
*/
export function ContextTab({ context }: ContextTabProps) {
const { formatMessage } = useIntl();
if (!context) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Package className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'sessionDetail.context.empty.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'sessionDetail.context.empty.message' })}
</p>
</div>
);
}
const hasRequirements = context.requirements && context.requirements.length > 0;
const hasFocusPaths = context.focus_paths && context.focus_paths.length > 0;
const hasArtifacts = context.artifacts && context.artifacts.length > 0;
const hasSharedContext = context.shared_context;
if (
!hasRequirements &&
!hasFocusPaths &&
!hasArtifacts &&
!hasSharedContext
) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Package className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'sessionDetail.context.empty.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'sessionDetail.context.empty.message' })}
</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Requirements */}
{hasRequirements && (
<Card>
<CardContent className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<CheckSquare className="w-5 h-5" />
{formatMessage({ id: 'sessionDetail.context.requirements' })}
<Badge variant="secondary">{context.requirements!.length}</Badge>
</h3>
<ul className="space-y-2">
{context.requirements!.map((req, i) => (
<li
key={i}
className="flex items-start gap-2 p-3 bg-background rounded-lg border"
>
<span className="text-primary font-bold">{i + 1}.</span>
<span className="text-sm text-foreground flex-1">{req}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* Focus Paths */}
{hasFocusPaths && (
<Card>
<CardContent className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<FileCode className="w-5 h-5" />
{formatMessage({ id: 'sessionDetail.context.focusPaths' })}
<Badge variant="secondary">{context.focus_paths!.length}</Badge>
</h3>
<div className="space-y-2">
{context.focus_paths!.map((path, i) => (
<div
key={i}
className="p-3 bg-background rounded-lg border font-mono text-sm text-foreground"
>
{path}
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Artifacts */}
{hasArtifacts && (
<Card>
<CardContent className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<Tag className="w-5 h-5" />
{formatMessage({ id: 'sessionDetail.context.artifacts' })}
<Badge variant="secondary">{context.artifacts!.length}</Badge>
</h3>
<div className="flex flex-wrap gap-2">
{context.artifacts!.map((artifact, i) => (
<Badge key={i} variant="outline" className="px-3 py-1.5">
{artifact}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Shared Context */}
{hasSharedContext && (
<Card>
<CardContent className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<Settings className="w-5 h-5" />
{formatMessage({ id: 'sessionDetail.context.sharedContext' })}
</h3>
{/* Tech Stack */}
{context.shared_context!.tech_stack && context.shared_context!.tech_stack.length > 0 && (
<div className="mb-4">
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'sessionDetail.context.techStack' })}
</h4>
<div className="flex flex-wrap gap-2">
{context.shared_context!.tech_stack!.map((tech, i) => (
<Badge key={i} variant="success" className="px-3 py-1.5">
{tech}
</Badge>
))}
</div>
</div>
)}
{/* Conventions */}
{context.shared_context!.conventions && context.shared_context!.conventions.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'sessionDetail.context.conventions' })}
</h4>
<ul className="space-y-1">
{context.shared_context!.conventions!.map((conv, i) => (
<li key={i} className="text-sm text-foreground flex items-start gap-2">
<BookOpen className="h-4 w-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<span>{conv}</span>
</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,47 @@
// ========================================
// SummaryTab Component
// ========================================
// Summary tab for session detail page
import { useIntl } from 'react-intl';
import { FileText } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/Card';
export interface SummaryTabProps {
summary?: string;
}
/**
* SummaryTab component - Display session summary
*/
export function SummaryTab({ summary }: SummaryTabProps) {
const { formatMessage } = useIntl();
if (!summary) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<FileText className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'sessionDetail.summary.empty.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'sessionDetail.summary.empty.message' })}
</p>
</div>
);
}
return (
<Card>
<CardContent className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<FileText className="w-5 h-5" />
{formatMessage({ id: 'sessionDetail.summary.title' })}
</h3>
<div className="prose prose-sm max-w-none text-foreground">
<p className="whitespace-pre-wrap">{summary}</p>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,155 @@
// ========================================
// TaskListTab Component
// ========================================
// Tasks tab for session detail page
import { useIntl } from 'react-intl';
import {
ListChecks,
Loader2,
Circle,
CheckCircle,
Code,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import type { SessionMetadata } from '@/types/store';
export interface TaskListTabProps {
session: SessionMetadata;
}
// Status configuration
const taskStatusConfig: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'info' | null; icon: React.ComponentType<{ className?: string }> }> = {
pending: {
label: 'sessionDetail.tasks.status.pending',
variant: 'secondary',
icon: Circle,
},
in_progress: {
label: 'sessionDetail.tasks.status.inProgress',
variant: 'warning',
icon: Loader2,
},
completed: {
label: 'sessionDetail.tasks.status.completed',
variant: 'success',
icon: CheckCircle,
},
blocked: {
label: 'sessionDetail.tasks.status.blocked',
variant: 'destructive',
icon: Circle,
},
skipped: {
label: 'sessionDetail.tasks.status.skipped',
variant: 'default',
icon: Circle,
},
};
/**
* TaskListTab component - Display tasks in a list format
*/
export function TaskListTab({ session }: TaskListTabProps) {
const { formatMessage } = useIntl();
const tasks = session.tasks || [];
const completed = tasks.filter((t) => t.status === 'completed').length;
const inProgress = tasks.filter((t) => t.status === 'in_progress').length;
const pending = tasks.filter((t) => t.status === 'pending').length;
const blocked = tasks.filter((t) => t.status === 'blocked').length;
return (
<div className="space-y-4">
{/* Stats Bar */}
<div className="flex flex-wrap items-center gap-4 p-4 bg-background rounded-lg border">
<span className="flex items-center gap-1 text-sm">
<CheckCircle className="h-4 w-4 text-success" />
<strong>{completed}</strong> {formatMessage({ id: 'sessionDetail.tasks.completed' })}
</span>
<span className="flex items-center gap-1 text-sm">
<Loader2 className="h-4 w-4 text-warning" />
<strong>{inProgress}</strong> {formatMessage({ id: 'sessionDetail.tasks.inProgress' })}
</span>
<span className="flex items-center gap-1 text-sm">
<Circle className="h-4 w-4 text-muted-foreground" />
<strong>{pending}</strong> {formatMessage({ id: 'sessionDetail.tasks.pending' })}
</span>
{blocked > 0 && (
<span className="flex items-center gap-1 text-sm">
<Circle className="h-4 w-4 text-destructive" />
<strong>{blocked}</strong> {formatMessage({ id: 'sessionDetail.tasks.blocked' })}
</span>
)}
</div>
{/* Tasks List */}
{tasks.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<ListChecks className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'sessionDetail.tasks.empty.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'sessionDetail.tasks.empty.message' })}
</p>
</div>
) : (
<div className="space-y-2">
{tasks.map((task, index) => {
const currentStatusConfig = task.status ? taskStatusConfig[task.status] : taskStatusConfig.pending;
const StatusIcon = currentStatusConfig.icon;
return (
<Card
key={task.task_id || index}
className="hover:shadow-sm transition-shadow"
>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-muted-foreground">
{task.task_id}
</span>
<Badge variant={currentStatusConfig.variant} className="gap-1">
<StatusIcon className="h-3 w-3" />
{formatMessage({ id: currentStatusConfig.label })}
</Badge>
{task.priority && (
<Badge variant="outline" className="text-xs">
{task.priority}
</Badge>
)}
</div>
<h4 className="font-medium text-foreground text-sm">
{task.title || formatMessage({ id: 'sessionDetail.tasks.untitled' })}
</h4>
{task.description && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{task.description}
</p>
)}
{task.depends_on && task.depends_on.length > 0 && (
<div className="flex items-center gap-1 mt-2 text-xs text-muted-foreground">
<Code className="h-3 w-3" />
<span>Depends on: {task.depends_on.join(', ')}</span>
</div>
)}
</div>
{task.created_at && (
<div className="text-xs text-muted-foreground whitespace-nowrap">
{new Date(task.created_at).toLocaleDateString()}
</div>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</div>
);
}