mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-12 02:37:45 +08:00
feat: Add comprehensive tests for contentPattern and glob pattern matching
- Implemented final verification tests for contentPattern to validate behavior with empty strings, dangerous patterns, and normal patterns. - Created glob pattern matching tests to verify regex conversion and matching functionality. - Developed infinite loop risk tests using Worker threads to isolate potential blocking operations. - Introduced optimized contentPattern tests to validate improvements in the findMatches function. - Added verification tests to assess the effectiveness of contentPattern optimizations. - Conducted safety tests for contentPattern to identify edge cases and potential vulnerabilities. - Implemented unrestricted loop tests to analyze infinite loop risks without match limits. - Developed tests for zero-width pattern detection logic to ensure proper handling of dangerous regex patterns.
This commit is contained in:
@@ -26,6 +26,7 @@ import {
|
||||
Layers,
|
||||
Wrench,
|
||||
Cog,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -80,6 +81,7 @@ const navGroupDefinitions: NavGroupDef[] = [
|
||||
{ path: '/orchestrator', labelKey: 'navigation.main.orchestrator', icon: Workflow },
|
||||
{ path: '/history', labelKey: 'navigation.main.history', icon: Clock },
|
||||
{ path: '/issues', labelKey: 'navigation.main.issues', icon: AlertCircle },
|
||||
{ path: '/teams', labelKey: 'navigation.main.teams', icon: Users },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
Shield,
|
||||
Database,
|
||||
FileText,
|
||||
Files,
|
||||
HardDrive,
|
||||
MessageCircleQuestion,
|
||||
MessagesSquare,
|
||||
SearchCode,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
@@ -93,10 +95,12 @@ export interface CcwToolsMcpCardProps {
|
||||
export const CCW_MCP_TOOLS: CcwTool[] = [
|
||||
{ name: 'write_file', desc: 'Write/create files', core: true },
|
||||
{ name: 'edit_file', desc: 'Edit/replace content', core: true },
|
||||
{ name: 'read_file', desc: 'Read file contents', core: true },
|
||||
{ name: 'read_file', desc: 'Read single file', core: true },
|
||||
{ name: 'read_many_files', desc: 'Read multiple files/dirs', core: true },
|
||||
{ name: 'core_memory', desc: 'Core memory management', core: true },
|
||||
{ name: 'ask_question', desc: 'Interactive questions (A2UI)', core: false },
|
||||
{ name: 'smart_search', desc: 'Intelligent code search', core: true },
|
||||
{ name: 'team_msg', desc: 'Agent team message bus', core: false },
|
||||
];
|
||||
|
||||
// ========== Component ==========
|
||||
@@ -507,12 +511,16 @@ function getToolIcon(toolName: string): React.ReactElement {
|
||||
return <Check {...iconProps} />;
|
||||
case 'read_file':
|
||||
return <Database {...iconProps} />;
|
||||
case 'read_many_files':
|
||||
return <Files {...iconProps} />;
|
||||
case 'core_memory':
|
||||
return <Settings {...iconProps} />;
|
||||
case 'ask_question':
|
||||
return <MessageCircleQuestion {...iconProps} />;
|
||||
case 'smart_search':
|
||||
return <SearchCode {...iconProps} />;
|
||||
case 'team_msg':
|
||||
return <MessagesSquare {...iconProps} />;
|
||||
default:
|
||||
return <Settings {...iconProps} />;
|
||||
}
|
||||
|
||||
35
ccw/frontend/src/components/team/TeamEmptyState.tsx
Normal file
35
ccw/frontend/src/components/team/TeamEmptyState.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
// ========================================
|
||||
// TeamEmptyState Component
|
||||
// ========================================
|
||||
// Empty state displayed when no teams are available
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Users } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
|
||||
export function TeamEmptyState() {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center">
|
||||
<Users className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{formatMessage({ id: 'team.empty.title' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'team.empty.description' })}
|
||||
</p>
|
||||
</div>
|
||||
<code className="px-3 py-1.5 bg-muted rounded text-xs font-mono">
|
||||
/team:coordinate
|
||||
</code>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
ccw/frontend/src/components/team/TeamHeader.tsx
Normal file
94
ccw/frontend/src/components/team/TeamHeader.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
// ========================================
|
||||
// TeamHeader Component
|
||||
// ========================================
|
||||
// Team selector, stats chips, and controls
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Users, MessageSquare, Clock, RefreshCw } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Switch } from '@/components/ui/Switch';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/Select';
|
||||
import type { TeamSummary, TeamMember } from '@/types/team';
|
||||
|
||||
interface TeamHeaderProps {
|
||||
teams: TeamSummary[];
|
||||
selectedTeam: string | null;
|
||||
onSelectTeam: (name: string | null) => void;
|
||||
members: TeamMember[];
|
||||
totalMessages: number;
|
||||
autoRefresh: boolean;
|
||||
onToggleAutoRefresh: () => void;
|
||||
}
|
||||
|
||||
export function TeamHeader({
|
||||
teams,
|
||||
selectedTeam,
|
||||
onSelectTeam,
|
||||
members,
|
||||
totalMessages,
|
||||
autoRefresh,
|
||||
onToggleAutoRefresh,
|
||||
}: TeamHeaderProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Team Selector */}
|
||||
<Select
|
||||
value={selectedTeam ?? ''}
|
||||
onValueChange={(v) => onSelectTeam(v || null)}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder={formatMessage({ id: 'team.selectTeam' })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{teams.map((t) => (
|
||||
<SelectItem key={t.name} value={t.name}>
|
||||
{t.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Stats chips */}
|
||||
{selectedTeam && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
{formatMessage({ id: 'team.members' })}: {members.length}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{formatMessage({ id: 'team.messages' })}: {totalMessages}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="auto-refresh"
|
||||
checked={autoRefresh}
|
||||
onCheckedChange={onToggleAutoRefresh}
|
||||
/>
|
||||
<Label htmlFor="auto-refresh" className="text-sm text-muted-foreground cursor-pointer">
|
||||
{formatMessage({ id: 'team.autoRefresh' })}
|
||||
</Label>
|
||||
{autoRefresh && (
|
||||
<RefreshCw className="w-3.5 h-3.5 text-primary animate-spin" style={{ animationDuration: '3s' }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
ccw/frontend/src/components/team/TeamMembersPanel.tsx
Normal file
114
ccw/frontend/src/components/team/TeamMembersPanel.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
// ========================================
|
||||
// TeamMembersPanel Component
|
||||
// ========================================
|
||||
// Card-based member status display
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TeamMember } from '@/types/team';
|
||||
|
||||
interface TeamMembersPanelProps {
|
||||
members: TeamMember[];
|
||||
}
|
||||
|
||||
function formatRelativeTime(isoString: string): string {
|
||||
if (!isoString) return '';
|
||||
const now = Date.now();
|
||||
const then = new Date(isoString).getTime();
|
||||
const diffMs = now - then;
|
||||
|
||||
if (diffMs < 0) return 'now';
|
||||
const seconds = Math.floor(diffMs / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d`;
|
||||
}
|
||||
|
||||
function getMemberStatus(member: TeamMember): 'active' | 'idle' {
|
||||
if (!member.lastSeen) return 'idle';
|
||||
const diffMs = Date.now() - new Date(member.lastSeen).getTime();
|
||||
// Active if seen in last 2 minutes
|
||||
return diffMs < 2 * 60 * 1000 ? 'active' : 'idle';
|
||||
}
|
||||
|
||||
export function TeamMembersPanel({ members }: TeamMembersPanelProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
{formatMessage({ id: 'team.membersPanel.title' })}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{members.map((m) => {
|
||||
const status = getMemberStatus(m);
|
||||
const isActive = status === 'active';
|
||||
|
||||
return (
|
||||
<Card key={m.member} className="overflow-hidden">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Status indicator */}
|
||||
<div className="pt-0.5">
|
||||
<div
|
||||
className={cn(
|
||||
'w-2.5 h-2.5 rounded-full',
|
||||
isActive
|
||||
? 'bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.5)]'
|
||||
: 'bg-muted-foreground/40'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
{/* Name + status badge */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">{m.member}</span>
|
||||
<Badge
|
||||
variant={isActive ? 'success' : 'secondary'}
|
||||
className="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{formatMessage({ id: `team.membersPanel.${status}` })}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Last action */}
|
||||
{m.lastAction && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{m.lastAction}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="flex items-center gap-3 text-[10px] text-muted-foreground">
|
||||
<span>
|
||||
{m.messageCount} {formatMessage({ id: 'team.messages' }).toLowerCase()}
|
||||
</span>
|
||||
{m.lastSeen && (
|
||||
<span>
|
||||
{formatRelativeTime(m.lastSeen)} {formatMessage({ id: 'team.membersPanel.ago' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{members.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground text-center py-4">
|
||||
{formatMessage({ id: 'team.empty.noMessages' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
262
ccw/frontend/src/components/team/TeamMessageFeed.tsx
Normal file
262
ccw/frontend/src/components/team/TeamMessageFeed.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
// ========================================
|
||||
// TeamMessageFeed Component
|
||||
// ========================================
|
||||
// Message timeline with filtering and pagination
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { ChevronDown, ChevronUp, FileText, Filter, X } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/Select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TeamMessage, TeamMessageType, TeamMessageFilter } from '@/types/team';
|
||||
|
||||
interface TeamMessageFeedProps {
|
||||
messages: TeamMessage[];
|
||||
total: number;
|
||||
filter: TeamMessageFilter;
|
||||
onFilterChange: (filter: Partial<TeamMessageFilter>) => void;
|
||||
onClearFilter: () => void;
|
||||
expanded: boolean;
|
||||
onExpandedChange: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
// Message type → color mapping
|
||||
const typeColorMap: Record<string, string> = {
|
||||
plan_ready: 'bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/30',
|
||||
plan_approved: 'bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/30',
|
||||
plan_revision: 'bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30',
|
||||
task_unblocked: 'bg-cyan-500/15 text-cyan-600 dark:text-cyan-400 border-cyan-500/30',
|
||||
impl_complete: 'bg-primary/15 text-primary border-primary/30',
|
||||
impl_progress: 'bg-primary/15 text-primary border-primary/30',
|
||||
test_result: 'bg-green-500/15 text-green-600 dark:text-green-400 border-green-500/30',
|
||||
review_result: 'bg-purple-500/15 text-purple-600 dark:text-purple-400 border-purple-500/30',
|
||||
fix_required: 'bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/30',
|
||||
error: 'bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/30',
|
||||
shutdown: 'bg-muted text-muted-foreground border-border',
|
||||
message: 'bg-muted text-muted-foreground border-border',
|
||||
};
|
||||
|
||||
function MessageTypeBadge({ type }: { type: string }) {
|
||||
const { formatMessage } = useIntl();
|
||||
const color = typeColorMap[type] || typeColorMap.message;
|
||||
const labelKey = `team.messageType.${type}`;
|
||||
|
||||
let label: string;
|
||||
try {
|
||||
label = formatMessage({ id: labelKey });
|
||||
} catch {
|
||||
label = type;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cn('text-[10px] px-1.5 py-0.5 rounded border font-medium', color)}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageRow({ msg }: { msg: TeamMessage }) {
|
||||
const [dataExpanded, setDataExpanded] = useState(false);
|
||||
const time = msg.ts ? msg.ts.substring(11, 19) : '';
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 py-2.5 border-b border-border last:border-b-0 animate-in fade-in slide-in-from-top-1 duration-300">
|
||||
{/* Timestamp */}
|
||||
<span className="text-[10px] font-mono text-muted-foreground w-16 shrink-0 pt-0.5">
|
||||
{time}
|
||||
</span>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
{/* Header: from → to + type */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-medium">{msg.from}</span>
|
||||
<span className="text-[10px] text-muted-foreground">→</span>
|
||||
<span className="text-xs font-medium">{msg.to}</span>
|
||||
<MessageTypeBadge type={msg.type} />
|
||||
{msg.id && (
|
||||
<span className="text-[10px] text-muted-foreground">{msg.id}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<p className="text-xs text-foreground/80">{msg.summary}</p>
|
||||
|
||||
{/* Ref link */}
|
||||
{msg.ref && (
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<FileText className="w-3 h-3" />
|
||||
<span className="font-mono truncate">{msg.ref}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Data toggle */}
|
||||
{msg.data && Object.keys(msg.data).length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setDataExpanded(!dataExpanded)}
|
||||
className="text-[10px] text-primary hover:underline flex items-center gap-0.5"
|
||||
>
|
||||
{dataExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="w-3 h-3" /> collapse
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="w-3 h-3" /> data
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{dataExpanded && (
|
||||
<pre className="text-[10px] bg-muted p-2 rounded mt-1 overflow-x-auto max-h-40">
|
||||
{JSON.stringify(msg.data, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TeamMessageFeed({
|
||||
messages,
|
||||
total,
|
||||
filter,
|
||||
onFilterChange,
|
||||
onClearFilter,
|
||||
expanded,
|
||||
onExpandedChange,
|
||||
}: TeamMessageFeedProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const hasFilter = !!(filter.from || filter.to || filter.type);
|
||||
|
||||
// Extract unique senders/receivers for filter dropdowns
|
||||
const { senders, receivers, types } = useMemo(() => {
|
||||
const s = new Set<string>();
|
||||
const r = new Set<string>();
|
||||
const t = new Set<string>();
|
||||
for (const m of messages) {
|
||||
s.add(m.from);
|
||||
r.add(m.to);
|
||||
t.add(m.type);
|
||||
}
|
||||
return {
|
||||
senders: Array.from(s).sort(),
|
||||
receivers: Array.from(r).sort(),
|
||||
types: Array.from(t).sort(),
|
||||
};
|
||||
}, [messages]);
|
||||
|
||||
// Reverse for newest-first display
|
||||
const displayMessages = [...messages].reverse();
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => onExpandedChange(!expanded)}
|
||||
className="flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
{formatMessage({ id: 'team.timeline.title' })}
|
||||
<span className="text-xs font-normal">
|
||||
({formatMessage({ id: 'team.timeline.showing' }, { showing: messages.length, total })})
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{hasFilter && (
|
||||
<Button variant="ghost" size="sm" onClick={onClearFilter} className="h-6 text-xs gap-1">
|
||||
<X className="w-3 h-3" />
|
||||
{formatMessage({ id: 'team.timeline.clearFilters' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<>
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Select
|
||||
value={filter.from ?? '__all__'}
|
||||
onValueChange={(v) => onFilterChange({ from: v === '__all__' ? undefined : v })}
|
||||
>
|
||||
<SelectTrigger className="w-[130px] h-7 text-xs">
|
||||
<Filter className="w-3 h-3 mr-1" />
|
||||
<SelectValue placeholder={formatMessage({ id: 'team.timeline.filterFrom' })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">{formatMessage({ id: 'team.filterAll' })}</SelectItem>
|
||||
{senders.map((s) => (
|
||||
<SelectItem key={s} value={s}>{s}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filter.to ?? '__all__'}
|
||||
onValueChange={(v) => onFilterChange({ to: v === '__all__' ? undefined : v })}
|
||||
>
|
||||
<SelectTrigger className="w-[130px] h-7 text-xs">
|
||||
<SelectValue placeholder={formatMessage({ id: 'team.timeline.filterTo' })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">{formatMessage({ id: 'team.filterAll' })}</SelectItem>
|
||||
{receivers.map((r) => (
|
||||
<SelectItem key={r} value={r}>{r}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filter.type ?? '__all__'}
|
||||
onValueChange={(v) => onFilterChange({ type: v === '__all__' ? undefined : v })}
|
||||
>
|
||||
<SelectTrigger className="w-[150px] h-7 text-xs">
|
||||
<SelectValue placeholder={formatMessage({ id: 'team.timeline.filterType' })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">{formatMessage({ id: 'team.filterAll' })}</SelectItem>
|
||||
{types.map((t) => (
|
||||
<SelectItem key={t} value={t}>{t}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Messages list */}
|
||||
<Card>
|
||||
<CardContent className="p-3">
|
||||
{displayMessages.length > 0 ? (
|
||||
<div className="divide-y-0">
|
||||
{displayMessages.map((msg) => (
|
||||
<MessageRow key={msg.id} msg={msg} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'team.empty.noMessages' })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'team.empty.noMessagesHint' })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
ccw/frontend/src/components/team/TeamPipeline.tsx
Normal file
163
ccw/frontend/src/components/team/TeamPipeline.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
// ========================================
|
||||
// TeamPipeline Component
|
||||
// ========================================
|
||||
// CSS-based pipeline stage visualization: PLAN → IMPL → TEST + REVIEW
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { CheckCircle2, Circle, Loader2, Ban } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TeamMessage, PipelineStage, PipelineStageStatus } from '@/types/team';
|
||||
|
||||
interface TeamPipelineProps {
|
||||
messages: TeamMessage[];
|
||||
}
|
||||
|
||||
const STAGES: PipelineStage[] = ['plan', 'impl', 'test', 'review'];
|
||||
|
||||
/** Derive pipeline stage status from message history */
|
||||
function derivePipelineStatus(messages: TeamMessage[]): Record<PipelineStage, PipelineStageStatus> {
|
||||
const status: Record<PipelineStage, PipelineStageStatus> = {
|
||||
plan: 'pending',
|
||||
impl: 'pending',
|
||||
test: 'pending',
|
||||
review: 'pending',
|
||||
};
|
||||
|
||||
for (const msg of messages) {
|
||||
const t = msg.type;
|
||||
// Plan stage
|
||||
if (t === 'plan_ready') status.plan = 'in_progress';
|
||||
if (t === 'plan_approved') {
|
||||
status.plan = 'completed';
|
||||
if (status.impl === 'pending') status.impl = 'in_progress';
|
||||
}
|
||||
if (t === 'plan_revision') status.plan = 'in_progress';
|
||||
// Impl stage
|
||||
if (t === 'impl_progress') status.impl = 'in_progress';
|
||||
if (t === 'impl_complete') {
|
||||
status.impl = 'completed';
|
||||
if (status.test === 'pending') status.test = 'in_progress';
|
||||
if (status.review === 'pending') status.review = 'in_progress';
|
||||
}
|
||||
// Test stage
|
||||
if (t === 'test_result') {
|
||||
const passed = msg.data?.passed ?? msg.summary?.toLowerCase().includes('pass');
|
||||
status.test = passed ? 'completed' : 'in_progress';
|
||||
}
|
||||
// Review stage
|
||||
if (t === 'review_result') {
|
||||
const approved = msg.data?.approved ?? msg.summary?.toLowerCase().includes('approv');
|
||||
status.review = approved ? 'completed' : 'in_progress';
|
||||
}
|
||||
// Fix required resets impl
|
||||
if (t === 'fix_required') {
|
||||
status.impl = 'in_progress';
|
||||
}
|
||||
// Error blocks stages
|
||||
if (t === 'error') {
|
||||
// Keep current status, don't override to blocked
|
||||
}
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
const statusConfig: Record<PipelineStageStatus, { icon: typeof CheckCircle2; color: string; bg: string; animate?: boolean }> = {
|
||||
completed: { icon: CheckCircle2, color: 'text-green-500', bg: 'bg-green-500/10 border-green-500/30' },
|
||||
in_progress: { icon: Loader2, color: 'text-blue-500', bg: 'bg-blue-500/10 border-blue-500/30', animate: true },
|
||||
pending: { icon: Circle, color: 'text-muted-foreground', bg: 'bg-muted border-border' },
|
||||
blocked: { icon: Ban, color: 'text-red-500', bg: 'bg-red-500/10 border-red-500/30' },
|
||||
};
|
||||
|
||||
function StageNode({ stage, status }: { stage: PipelineStage; status: PipelineStageStatus }) {
|
||||
const { formatMessage } = useIntl();
|
||||
const config = statusConfig[status];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1.5 px-4 py-3 rounded-lg border-2 min-w-[90px] transition-all',
|
||||
config.bg
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn('w-5 h-5', config.color, config.animate && 'animate-spin')}
|
||||
style={config.animate ? { animationDuration: '2s' } : undefined}
|
||||
/>
|
||||
<span className="text-xs font-medium">
|
||||
{formatMessage({ id: `team.pipeline.${stage}` })}
|
||||
</span>
|
||||
<span className={cn('text-[10px]', config.color)}>
|
||||
{formatMessage({ id: `team.pipeline.${status === 'in_progress' ? 'inProgress' : status}` })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Arrow() {
|
||||
return (
|
||||
<div className="flex items-center px-1">
|
||||
<div className="w-6 h-0.5 bg-border" />
|
||||
<div className="w-0 h-0 border-t-[4px] border-t-transparent border-b-[4px] border-b-transparent border-l-[6px] border-l-border" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ForkArrow() {
|
||||
return (
|
||||
<div className="flex items-center px-1">
|
||||
<div className="w-4 h-0.5 bg-border" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="w-3 h-0.5 bg-border -rotate-20" />
|
||||
<div className="w-3 h-0.5 bg-border rotate-20" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TeamPipeline({ messages }: TeamPipelineProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const stageStatus = derivePipelineStatus(messages);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
{formatMessage({ id: 'team.pipeline.title' })}
|
||||
</h3>
|
||||
|
||||
{/* Desktop: horizontal layout */}
|
||||
<div className="hidden sm:flex items-center gap-0">
|
||||
<StageNode stage="plan" status={stageStatus.plan} />
|
||||
<Arrow />
|
||||
<StageNode stage="impl" status={stageStatus.impl} />
|
||||
<Arrow />
|
||||
<div className="flex flex-col gap-2">
|
||||
<StageNode stage="test" status={stageStatus.test} />
|
||||
<StageNode stage="review" status={stageStatus.review} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile: vertical layout */}
|
||||
<div className="flex sm:hidden flex-col items-center gap-2">
|
||||
{STAGES.map((stage) => (
|
||||
<StageNode key={stage} stage={stage} status={stageStatus[stage]} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-3 text-[10px] text-muted-foreground pt-1">
|
||||
{(['completed', 'in_progress', 'pending', 'blocked'] as PipelineStageStatus[]).map((s) => {
|
||||
const cfg = statusConfig[s];
|
||||
const Icon = cfg.icon;
|
||||
return (
|
||||
<span key={s} className="flex items-center gap-1">
|
||||
<Icon className={cn('w-3 h-3', cfg.color)} />
|
||||
{formatMessage({ id: `team.pipeline.${s === 'in_progress' ? 'inProgress' : s}` })}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
ccw/frontend/src/hooks/useTeamData.ts
Normal file
127
ccw/frontend/src/hooks/useTeamData.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
// ========================================
|
||||
// useTeamData Hook
|
||||
// ========================================
|
||||
// TanStack Query hooks for team execution visualization
|
||||
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { fetchTeams, fetchTeamMessages, fetchTeamStatus } from '@/lib/api';
|
||||
import { useTeamStore } from '@/stores/teamStore';
|
||||
import type {
|
||||
TeamSummary,
|
||||
TeamMessage,
|
||||
TeamMember,
|
||||
TeamMessageFilter,
|
||||
TeamMessagesResponse,
|
||||
TeamStatusResponse,
|
||||
TeamsListResponse,
|
||||
} from '@/types/team';
|
||||
|
||||
// Query key factory
|
||||
export const teamKeys = {
|
||||
all: ['teams'] as const,
|
||||
lists: () => [...teamKeys.all, 'list'] as const,
|
||||
messages: (team: string, filter?: TeamMessageFilter) =>
|
||||
[...teamKeys.all, 'messages', team, filter] as const,
|
||||
status: (team: string) => [...teamKeys.all, 'status', team] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook: list all teams
|
||||
*/
|
||||
export function useTeams() {
|
||||
const autoRefresh = useTeamStore((s) => s.autoRefresh);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: teamKeys.lists(),
|
||||
queryFn: async (): Promise<TeamsListResponse> => {
|
||||
const data = await fetchTeams();
|
||||
return { teams: data.teams ?? [] };
|
||||
},
|
||||
staleTime: 10_000,
|
||||
refetchInterval: autoRefresh ? 10_000 : false,
|
||||
});
|
||||
|
||||
return {
|
||||
teams: (query.data?.teams ?? []) as TeamSummary[],
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: get messages for selected team
|
||||
*/
|
||||
export function useTeamMessages(
|
||||
teamName: string | null,
|
||||
filter?: TeamMessageFilter,
|
||||
options?: { last?: number; offset?: number }
|
||||
) {
|
||||
const autoRefresh = useTeamStore((s) => s.autoRefresh);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: teamKeys.messages(teamName ?? '', filter),
|
||||
queryFn: async (): Promise<TeamMessagesResponse> => {
|
||||
if (!teamName) return { total: 0, showing: 0, messages: [] };
|
||||
const data = await fetchTeamMessages(teamName, {
|
||||
...filter,
|
||||
last: options?.last ?? 50,
|
||||
offset: options?.offset,
|
||||
});
|
||||
return {
|
||||
total: data.total,
|
||||
showing: data.showing,
|
||||
messages: data.messages as unknown as TeamMessage[],
|
||||
};
|
||||
},
|
||||
enabled: !!teamName,
|
||||
staleTime: 5_000,
|
||||
refetchInterval: autoRefresh ? 5_000 : false,
|
||||
});
|
||||
|
||||
return {
|
||||
messages: (query.data?.messages ?? []) as TeamMessage[],
|
||||
total: query.data?.total ?? 0,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: get member status for selected team
|
||||
*/
|
||||
export function useTeamStatus(teamName: string | null) {
|
||||
const autoRefresh = useTeamStore((s) => s.autoRefresh);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: teamKeys.status(teamName ?? ''),
|
||||
queryFn: async (): Promise<TeamStatusResponse> => {
|
||||
if (!teamName) return { members: [], total_messages: 0 };
|
||||
const data = await fetchTeamStatus(teamName);
|
||||
return {
|
||||
members: data.members as TeamMember[],
|
||||
total_messages: data.total_messages,
|
||||
};
|
||||
},
|
||||
enabled: !!teamName,
|
||||
staleTime: 5_000,
|
||||
refetchInterval: autoRefresh ? 5_000 : false,
|
||||
});
|
||||
|
||||
return {
|
||||
members: (query.data?.members ?? []) as TeamMember[],
|
||||
totalMessages: query.data?.total_messages ?? 0,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: invalidate all team queries
|
||||
*/
|
||||
export function useInvalidateTeamData() {
|
||||
const queryClient = useQueryClient();
|
||||
return () => queryClient.invalidateQueries({ queryKey: teamKeys.all });
|
||||
}
|
||||
@@ -5604,3 +5604,29 @@ export async function upgradeCcwInstallation(
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Team API ==========
|
||||
|
||||
export async function fetchTeams(): Promise<{ teams: Array<{ name: string; messageCount: number; lastActivity: string }> }> {
|
||||
return fetchApi('/api/teams');
|
||||
}
|
||||
|
||||
export async function fetchTeamMessages(
|
||||
teamName: string,
|
||||
params?: { from?: string; to?: string; type?: string; last?: number; offset?: number }
|
||||
): Promise<{ total: number; showing: number; messages: Array<Record<string, unknown>> }> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.from) searchParams.set('from', params.from);
|
||||
if (params?.to) searchParams.set('to', params.to);
|
||||
if (params?.type) searchParams.set('type', params.type);
|
||||
if (params?.last) searchParams.set('last', String(params.last));
|
||||
if (params?.offset) searchParams.set('offset', String(params.offset));
|
||||
const qs = searchParams.toString();
|
||||
return fetchApi(`/api/teams/${encodeURIComponent(teamName)}/messages${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export async function fetchTeamStatus(
|
||||
teamName: string
|
||||
): Promise<{ members: Array<{ member: string; lastSeen: string; lastAction: string; messageCount: number }>; total_messages: number }> {
|
||||
return fetchApi(`/api/teams/${encodeURIComponent(teamName)}/status`);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import notifications from './notifications.json';
|
||||
import workspace from './workspace.json';
|
||||
import help from './help.json';
|
||||
import cliViewer from './cli-viewer.json';
|
||||
import team from './team.json';
|
||||
|
||||
/**
|
||||
* Flattens nested JSON object to dot-separated keys
|
||||
@@ -99,4 +100,5 @@ export default {
|
||||
...flattenMessages(workspace, 'workspace'),
|
||||
...flattenMessages(help, 'help'),
|
||||
...flattenMessages(cliViewer, 'cliViewer'),
|
||||
...flattenMessages(team, 'team'),
|
||||
} as Record<string, string>;
|
||||
|
||||
@@ -123,7 +123,11 @@
|
||||
},
|
||||
"read_file": {
|
||||
"name": "read_file",
|
||||
"desc": "Read file contents"
|
||||
"desc": "Read a single file with optional line pagination"
|
||||
},
|
||||
"read_many_files": {
|
||||
"name": "read_many_files",
|
||||
"desc": "Read multiple files or directories with glob filtering and content search"
|
||||
},
|
||||
"core_memory": {
|
||||
"name": "core_memory",
|
||||
@@ -136,6 +140,10 @@
|
||||
"smart_search": {
|
||||
"name": "smart_search",
|
||||
"desc": "Intelligent code search with fuzzy and semantic modes"
|
||||
},
|
||||
"team_msg": {
|
||||
"name": "team_msg",
|
||||
"desc": "Persistent JSONL message bus for Agent Team communication"
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"hooks": "Hooks",
|
||||
"rules": "Rules",
|
||||
"explorer": "File Explorer",
|
||||
"graph": "Graph Explorer"
|
||||
"graph": "Graph Explorer",
|
||||
"teams": "Team Execution"
|
||||
},
|
||||
"sidebar": {
|
||||
"collapse": "Collapse",
|
||||
|
||||
65
ccw/frontend/src/locales/en/team.json
Normal file
65
ccw/frontend/src/locales/en/team.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"title": "Team Execution",
|
||||
"description": "Visualize agent team execution status and message flow",
|
||||
"selectTeam": "Select Team",
|
||||
"noTeamSelected": "Select a team to view",
|
||||
"members": "Members",
|
||||
"messages": "Messages",
|
||||
"elapsed": "Elapsed",
|
||||
"autoRefresh": "Auto-refresh",
|
||||
"filterByType": "Filter by type",
|
||||
"filterAll": "All Types",
|
||||
"stage": "Stage",
|
||||
"empty": {
|
||||
"title": "No Active Teams",
|
||||
"description": "Use /team:coordinate to create a team and start collaborating",
|
||||
"noMessages": "No Messages Yet",
|
||||
"noMessagesHint": "Team was just created, waiting for the first message"
|
||||
},
|
||||
"pipeline": {
|
||||
"title": "Pipeline Progress",
|
||||
"plan": "Plan",
|
||||
"impl": "Implement",
|
||||
"test": "Test",
|
||||
"review": "Review",
|
||||
"completed": "Completed",
|
||||
"inProgress": "In Progress",
|
||||
"pending": "Pending",
|
||||
"blocked": "Blocked"
|
||||
},
|
||||
"membersPanel": {
|
||||
"title": "Team Members",
|
||||
"active": "Active",
|
||||
"idle": "Idle",
|
||||
"lastAction": "Last Action",
|
||||
"messageCount": "Messages",
|
||||
"lastSeen": "Last Seen",
|
||||
"ago": "ago"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Message Timeline",
|
||||
"loadMore": "Load More",
|
||||
"showing": "Showing {showing} / {total} messages",
|
||||
"filterFrom": "From",
|
||||
"filterTo": "To",
|
||||
"filterType": "Type",
|
||||
"clearFilters": "Clear Filters",
|
||||
"expandData": "Expand Data",
|
||||
"collapseData": "Collapse Data",
|
||||
"noRef": "No reference"
|
||||
},
|
||||
"messageType": {
|
||||
"plan_ready": "Plan Ready",
|
||||
"plan_approved": "Plan Approved",
|
||||
"plan_revision": "Plan Revision",
|
||||
"task_unblocked": "Task Unblocked",
|
||||
"impl_complete": "Impl Complete",
|
||||
"impl_progress": "Impl Progress",
|
||||
"test_result": "Test Result",
|
||||
"review_result": "Review Result",
|
||||
"fix_required": "Fix Required",
|
||||
"error": "Error",
|
||||
"shutdown": "Shutdown",
|
||||
"message": "Message"
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import notifications from './notifications.json';
|
||||
import workspace from './workspace.json';
|
||||
import help from './help.json';
|
||||
import cliViewer from './cli-viewer.json';
|
||||
import team from './team.json';
|
||||
|
||||
/**
|
||||
* Flattens nested JSON object to dot-separated keys
|
||||
@@ -99,4 +100,5 @@ export default {
|
||||
...flattenMessages(workspace, 'workspace'),
|
||||
...flattenMessages(help, 'help'),
|
||||
...flattenMessages(cliViewer, 'cliViewer'),
|
||||
...flattenMessages(team, 'team'),
|
||||
} as Record<string, string>;
|
||||
|
||||
@@ -123,7 +123,11 @@
|
||||
},
|
||||
"read_file": {
|
||||
"name": "read_file",
|
||||
"desc": "读取文件内容"
|
||||
"desc": "读取单个文件内容"
|
||||
},
|
||||
"read_many_files": {
|
||||
"name": "read_many_files",
|
||||
"desc": "批量读取多个文件或目录,支持 glob 过滤和内容搜索"
|
||||
},
|
||||
"core_memory": {
|
||||
"name": "core_memory",
|
||||
@@ -136,6 +140,10 @@
|
||||
"smart_search": {
|
||||
"name": "smart_search",
|
||||
"desc": "智能代码搜索,支持模糊和语义搜索模式"
|
||||
},
|
||||
"team_msg": {
|
||||
"name": "team_msg",
|
||||
"desc": "Agent Team 持久化消息总线,用于团队协作通信"
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"hooks": "Hooks",
|
||||
"rules": "规则",
|
||||
"explorer": "文件浏览器",
|
||||
"graph": "图浏览器"
|
||||
"graph": "图浏览器",
|
||||
"teams": "团队执行"
|
||||
},
|
||||
"sidebar": {
|
||||
"collapse": "收起",
|
||||
|
||||
65
ccw/frontend/src/locales/zh/team.json
Normal file
65
ccw/frontend/src/locales/zh/team.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"title": "团队执行",
|
||||
"description": "可视化 Agent 团队的执行状态和消息流",
|
||||
"selectTeam": "选择团队",
|
||||
"noTeamSelected": "请选择一个团队",
|
||||
"members": "成员",
|
||||
"messages": "消息",
|
||||
"elapsed": "已用时间",
|
||||
"autoRefresh": "自动刷新",
|
||||
"filterByType": "按类型筛选",
|
||||
"filterAll": "所有类型",
|
||||
"stage": "阶段",
|
||||
"empty": {
|
||||
"title": "暂无活跃团队",
|
||||
"description": "使用 /team:coordinate 创建团队以开始协作",
|
||||
"noMessages": "暂无消息",
|
||||
"noMessagesHint": "团队刚刚创建,等待第一条消息"
|
||||
},
|
||||
"pipeline": {
|
||||
"title": "Pipeline 进度",
|
||||
"plan": "计划",
|
||||
"impl": "实现",
|
||||
"test": "测试",
|
||||
"review": "审查",
|
||||
"completed": "已完成",
|
||||
"inProgress": "进行中",
|
||||
"pending": "待处理",
|
||||
"blocked": "已阻塞"
|
||||
},
|
||||
"membersPanel": {
|
||||
"title": "团队成员",
|
||||
"active": "活跃",
|
||||
"idle": "空闲",
|
||||
"lastAction": "最后动作",
|
||||
"messageCount": "消息数",
|
||||
"lastSeen": "最后活跃",
|
||||
"ago": "前"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "消息时间线",
|
||||
"loadMore": "加载更多",
|
||||
"showing": "显示 {showing} / {total} 条消息",
|
||||
"filterFrom": "发送方",
|
||||
"filterTo": "接收方",
|
||||
"filterType": "消息类型",
|
||||
"clearFilters": "清除筛选",
|
||||
"expandData": "展开数据",
|
||||
"collapseData": "折叠数据",
|
||||
"noRef": "无引用"
|
||||
},
|
||||
"messageType": {
|
||||
"plan_ready": "计划就绪",
|
||||
"plan_approved": "计划批准",
|
||||
"plan_revision": "计划修订",
|
||||
"task_unblocked": "任务解锁",
|
||||
"impl_complete": "实现完成",
|
||||
"impl_progress": "实现进度",
|
||||
"test_result": "测试结果",
|
||||
"review_result": "审查结果",
|
||||
"fix_required": "需要修复",
|
||||
"error": "错误",
|
||||
"shutdown": "关闭",
|
||||
"message": "消息"
|
||||
}
|
||||
}
|
||||
118
ccw/frontend/src/pages/TeamPage.tsx
Normal file
118
ccw/frontend/src/pages/TeamPage.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
// ========================================
|
||||
// TeamPage
|
||||
// ========================================
|
||||
// Main page for team execution visualization
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Users } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { useTeamStore } from '@/stores/teamStore';
|
||||
import { useTeams, useTeamMessages, useTeamStatus } from '@/hooks/useTeamData';
|
||||
import { TeamEmptyState } from '@/components/team/TeamEmptyState';
|
||||
import { TeamHeader } from '@/components/team/TeamHeader';
|
||||
import { TeamPipeline } from '@/components/team/TeamPipeline';
|
||||
import { TeamMembersPanel } from '@/components/team/TeamMembersPanel';
|
||||
import { TeamMessageFeed } from '@/components/team/TeamMessageFeed';
|
||||
|
||||
export function TeamPage() {
|
||||
const { formatMessage } = useIntl();
|
||||
const {
|
||||
selectedTeam,
|
||||
setSelectedTeam,
|
||||
autoRefresh,
|
||||
toggleAutoRefresh,
|
||||
messageFilter,
|
||||
setMessageFilter,
|
||||
clearMessageFilter,
|
||||
timelineExpanded,
|
||||
setTimelineExpanded,
|
||||
} = useTeamStore();
|
||||
|
||||
// Data hooks
|
||||
const { teams, isLoading: teamsLoading } = useTeams();
|
||||
const { messages, total: messageTotal, isLoading: messagesLoading } = useTeamMessages(
|
||||
selectedTeam,
|
||||
messageFilter
|
||||
);
|
||||
const { members, totalMessages, isLoading: statusLoading } = useTeamStatus(selectedTeam);
|
||||
|
||||
// Auto-select first team if none selected
|
||||
useEffect(() => {
|
||||
if (!selectedTeam && teams.length > 0) {
|
||||
setSelectedTeam(teams[0].name);
|
||||
}
|
||||
}, [selectedTeam, teams, setSelectedTeam]);
|
||||
|
||||
// Show empty state when no teams exist
|
||||
if (!teamsLoading && teams.length === 0) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Users className="w-5 h-5" />
|
||||
<h1 className="text-xl font-semibold">{formatMessage({ id: 'team.title' })}</h1>
|
||||
</div>
|
||||
<TeamEmptyState />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Page title */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5" />
|
||||
<h1 className="text-xl font-semibold">{formatMessage({ id: 'team.title' })}</h1>
|
||||
</div>
|
||||
|
||||
{/* Team Header: selector + stats + controls */}
|
||||
<TeamHeader
|
||||
teams={teams}
|
||||
selectedTeam={selectedTeam}
|
||||
onSelectTeam={setSelectedTeam}
|
||||
members={members}
|
||||
totalMessages={totalMessages}
|
||||
autoRefresh={autoRefresh}
|
||||
onToggleAutoRefresh={toggleAutoRefresh}
|
||||
/>
|
||||
|
||||
{selectedTeam ? (
|
||||
<>
|
||||
{/* Main content grid: Pipeline (left) + Members (right) */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Pipeline visualization */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardContent className="p-4">
|
||||
<TeamPipeline messages={messages} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Members panel */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<TeamMembersPanel members={members} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Message timeline */}
|
||||
<TeamMessageFeed
|
||||
messages={messages}
|
||||
total={messageTotal}
|
||||
filter={messageFilter}
|
||||
onFilterChange={setMessageFilter}
|
||||
onClearFilter={clearMessageFilter}
|
||||
expanded={timelineExpanded}
|
||||
onExpandedChange={setTimelineExpanded}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
{formatMessage({ id: 'team.noTeamSelected' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamPage;
|
||||
@@ -34,3 +34,4 @@ export { CodexLensManagerPage } from './CodexLensManagerPage';
|
||||
export { ApiSettingsPage } from './ApiSettingsPage';
|
||||
export { CliViewerPage } from './CliViewerPage';
|
||||
export { IssueManagerPage } from './IssueManagerPage';
|
||||
export { TeamPage } from './TeamPage';
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
CodexLensManagerPage,
|
||||
ApiSettingsPage,
|
||||
CliViewerPage,
|
||||
TeamPage,
|
||||
} from '@/pages';
|
||||
|
||||
/**
|
||||
@@ -167,6 +168,10 @@ const routes: RouteObject[] = [
|
||||
path: 'graph',
|
||||
element: <GraphExplorerPage />,
|
||||
},
|
||||
{
|
||||
path: 'teams',
|
||||
element: <TeamPage />,
|
||||
},
|
||||
// Catch-all route for 404
|
||||
{
|
||||
path: '*',
|
||||
@@ -221,6 +226,7 @@ export const ROUTES = {
|
||||
HELP: '/help',
|
||||
EXPLORER: '/explorer',
|
||||
GRAPH: '/graph',
|
||||
TEAMS: '/teams',
|
||||
} as const;
|
||||
|
||||
export type RoutePath = (typeof ROUTES)[keyof typeof ROUTES];
|
||||
|
||||
41
ccw/frontend/src/stores/teamStore.ts
Normal file
41
ccw/frontend/src/stores/teamStore.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// ========================================
|
||||
// Team Store
|
||||
// ========================================
|
||||
// UI state for team execution visualization
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import type { TeamMessageFilter } from '@/types/team';
|
||||
|
||||
interface TeamStore {
|
||||
selectedTeam: string | null;
|
||||
autoRefresh: boolean;
|
||||
messageFilter: TeamMessageFilter;
|
||||
timelineExpanded: boolean;
|
||||
setSelectedTeam: (name: string | null) => void;
|
||||
toggleAutoRefresh: () => void;
|
||||
setMessageFilter: (filter: Partial<TeamMessageFilter>) => void;
|
||||
clearMessageFilter: () => void;
|
||||
setTimelineExpanded: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
export const useTeamStore = create<TeamStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set) => ({
|
||||
selectedTeam: null,
|
||||
autoRefresh: true,
|
||||
messageFilter: {},
|
||||
timelineExpanded: true,
|
||||
setSelectedTeam: (name) => set({ selectedTeam: name }),
|
||||
toggleAutoRefresh: () => set((s) => ({ autoRefresh: !s.autoRefresh })),
|
||||
setMessageFilter: (filter) =>
|
||||
set((s) => ({ messageFilter: { ...s.messageFilter, ...filter } })),
|
||||
clearMessageFilter: () => set({ messageFilter: {} }),
|
||||
setTimelineExpanded: (expanded) => set({ timelineExpanded: expanded }),
|
||||
}),
|
||||
{ name: 'ccw-team-store' }
|
||||
),
|
||||
{ name: 'TeamStore' }
|
||||
)
|
||||
);
|
||||
66
ccw/frontend/src/types/team.ts
Normal file
66
ccw/frontend/src/types/team.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
// ========================================
|
||||
// Team Types
|
||||
// ========================================
|
||||
// Types for team execution visualization
|
||||
|
||||
export interface TeamMessage {
|
||||
id: string;
|
||||
ts: string;
|
||||
from: string;
|
||||
to: string;
|
||||
type: TeamMessageType;
|
||||
summary: string;
|
||||
ref?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type TeamMessageType =
|
||||
| 'plan_ready'
|
||||
| 'plan_approved'
|
||||
| 'plan_revision'
|
||||
| 'task_unblocked'
|
||||
| 'impl_complete'
|
||||
| 'impl_progress'
|
||||
| 'test_result'
|
||||
| 'review_result'
|
||||
| 'fix_required'
|
||||
| 'error'
|
||||
| 'shutdown'
|
||||
| 'message';
|
||||
|
||||
export interface TeamMember {
|
||||
member: string;
|
||||
lastSeen: string;
|
||||
lastAction: string;
|
||||
messageCount: number;
|
||||
}
|
||||
|
||||
export interface TeamSummary {
|
||||
name: string;
|
||||
messageCount: number;
|
||||
lastActivity: string;
|
||||
}
|
||||
|
||||
export interface TeamMessagesResponse {
|
||||
total: number;
|
||||
showing: number;
|
||||
messages: TeamMessage[];
|
||||
}
|
||||
|
||||
export interface TeamStatusResponse {
|
||||
members: TeamMember[];
|
||||
total_messages: number;
|
||||
}
|
||||
|
||||
export interface TeamsListResponse {
|
||||
teams: TeamSummary[];
|
||||
}
|
||||
|
||||
export interface TeamMessageFilter {
|
||||
from?: string;
|
||||
to?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export type PipelineStage = 'plan' | 'impl' | 'test' | 'review';
|
||||
export type PipelineStageStatus = 'completed' | 'in_progress' | 'pending' | 'blocked';
|
||||
Reference in New Issue
Block a user