mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
Add E2E tests for internationalization across multiple pages
- Implemented navigation.spec.ts to test language switching and translation of navigation elements. - Created sessions-page.spec.ts to verify translations on the sessions page, including headers, status badges, and date formatting. - Developed settings-page.spec.ts to ensure settings page content is translated and persists across sessions. - Added skills-page.spec.ts to validate translations for skill categories, action buttons, and empty states.
This commit is contained in:
@@ -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>
|
||||
) : (
|
||||
|
||||
334
ccw/frontend/src/pages/EndpointsPage.tsx
Normal file
334
ccw/frontend/src/pages/EndpointsPage.tsx
Normal 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;
|
||||
364
ccw/frontend/src/pages/FixSessionPage.tsx
Normal file
364
ccw/frontend/src/pages/FixSessionPage.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
314
ccw/frontend/src/pages/HistoryPage.tsx
Normal file
314
ccw/frontend/src/pages/HistoryPage.tsx
Normal 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;
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
310
ccw/frontend/src/pages/InstallationsPage.tsx
Normal file
310
ccw/frontend/src/pages/InstallationsPage.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
|
||||
318
ccw/frontend/src/pages/LiteTaskDetailPage.tsx
Normal file
318
ccw/frontend/src/pages/LiteTaskDetailPage.tsx
Normal 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;
|
||||
302
ccw/frontend/src/pages/LiteTasksPage.tsx
Normal file
302
ccw/frontend/src/pages/LiteTasksPage.tsx
Normal 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;
|
||||
@@ -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]"
|
||||
/>
|
||||
)}
|
||||
|
||||
364
ccw/frontend/src/pages/McpManagerPage.tsx
Normal file
364
ccw/frontend/src/pages/McpManagerPage.tsx
Normal 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;
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
52
ccw/frontend/src/pages/NotFoundPage.tsx
Normal file
52
ccw/frontend/src/pages/NotFoundPage.tsx
Normal 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;
|
||||
752
ccw/frontend/src/pages/ProjectOverviewPage.tsx
Normal file
752
ccw/frontend/src/pages/ProjectOverviewPage.tsx
Normal 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;
|
||||
525
ccw/frontend/src/pages/ReviewSessionPage.tsx
Normal file
525
ccw/frontend/src/pages/ReviewSessionPage.tsx
Normal 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;
|
||||
186
ccw/frontend/src/pages/SessionDetailPage.tsx
Normal file
186
ccw/frontend/src/pages/SessionDetailPage.tsx
Normal 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;
|
||||
@@ -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">✓</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
182
ccw/frontend/src/pages/session-detail/ContextTab.tsx
Normal file
182
ccw/frontend/src/pages/session-detail/ContextTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
ccw/frontend/src/pages/session-detail/SummaryTab.tsx
Normal file
47
ccw/frontend/src/pages/session-detail/SummaryTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
155
ccw/frontend/src/pages/session-detail/TaskListTab.tsx
Normal file
155
ccw/frontend/src/pages/session-detail/TaskListTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user