mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-05 16:13:08 +08:00
Refactor team collaboration skills and update documentation
- Renamed `team-lifecycle-v5` to `team-lifecycle` across various documentation files for consistency. - Updated references in code examples and usage sections to reflect the new skill name. - Added a new command file for the `monitor` functionality in the `team-iterdev` skill, detailing the coordinator's monitoring events and task management. - Introduced new components for dynamic pipeline visualization and session coordinates display in the frontend. - Implemented utility functions for pipeline stage detection and status derivation based on message history. - Enhanced the team role panel to map members to their respective pipeline roles with status indicators. - Updated Chinese documentation to reflect the changes in skill names and descriptions.
This commit is contained in:
139
ccw/frontend/src/components/team/DynamicPipeline.tsx
Normal file
139
ccw/frontend/src/components/team/DynamicPipeline.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
// ========================================
|
||||
// DynamicPipeline Component
|
||||
// ========================================
|
||||
// Dynamic pipeline stage visualization with fallback to static TeamPipeline
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { CheckCircle2, Circle, Loader2, Ban, SkipForward } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { TeamPipeline } from './TeamPipeline';
|
||||
import type { DynamicStage, DynamicStageStatus, PhaseInfo } from '@/types/team';
|
||||
|
||||
interface DynamicPipelineProps {
|
||||
stages: DynamicStage[];
|
||||
phaseInfo?: PhaseInfo | null;
|
||||
}
|
||||
|
||||
const statusConfig: Record<DynamicStageStatus, { 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' },
|
||||
skipped: { icon: SkipForward, color: 'text-yellow-500', bg: 'bg-yellow-500/10 border-yellow-500/30' },
|
||||
};
|
||||
|
||||
const LEGEND_STATUSES: DynamicStageStatus[] = ['completed', 'in_progress', 'pending', 'blocked', 'skipped'];
|
||||
|
||||
function StageNode({ stage }: { stage: DynamicStage }) {
|
||||
const { formatMessage } = useIntl();
|
||||
const config = statusConfig[stage.status];
|
||||
const Icon = config.icon;
|
||||
|
||||
const statusKey = stage.status === 'in_progress' ? 'inProgress' : stage.status;
|
||||
|
||||
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">
|
||||
{stage.label}
|
||||
</span>
|
||||
<span className={cn('text-[10px]', config.color)}>
|
||||
{formatMessage({ id: `team.pipeline.${statusKey}` })}
|
||||
</span>
|
||||
{stage.role && (
|
||||
<span className="text-[10px] text-muted-foreground truncate max-w-[80px]">
|
||||
{stage.role}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
export function DynamicPipeline({ stages, phaseInfo }: DynamicPipelineProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// Fallback to static pipeline when no dynamic stages
|
||||
if (stages.length === 0) {
|
||||
return <TeamPipeline messages={[]} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="space-y-3 flex-1">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
{formatMessage({ id: 'team.pipeline.title' })}
|
||||
</h3>
|
||||
|
||||
{/* Phase header */}
|
||||
{phaseInfo && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="secondary">
|
||||
{formatMessage({ id: 'team.coordinates.phase' })}: {phaseInfo.currentPhase}
|
||||
{phaseInfo.totalPhases != null && `/${phaseInfo.totalPhases}`}
|
||||
</Badge>
|
||||
{phaseInfo.currentStep && (
|
||||
<Badge variant="secondary">
|
||||
{phaseInfo.currentStep}
|
||||
</Badge>
|
||||
)}
|
||||
{phaseInfo.gapIteration > 0 && (
|
||||
<Badge variant="outline">
|
||||
{formatMessage({ id: 'team.coordinates.gap' })}: {phaseInfo.gapIteration}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Desktop: horizontal layout */}
|
||||
<div className="hidden sm:flex items-center gap-0">
|
||||
{stages.map((stage, idx) => (
|
||||
<div key={stage.id} className="flex items-center">
|
||||
{idx > 0 && <Arrow />}
|
||||
<StageNode stage={stage} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mobile: vertical layout */}
|
||||
<div className="flex sm:hidden flex-col items-center gap-2">
|
||||
{stages.map((stage) => (
|
||||
<StageNode key={stage.id} stage={stage} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-3 text-[10px] text-muted-foreground mt-auto pt-3 border-t border-border">
|
||||
{LEGEND_STATUSES.map((s) => {
|
||||
const cfg = statusConfig[s];
|
||||
const Icon = cfg.icon;
|
||||
const statusKey = s === 'in_progress' ? 'inProgress' : s;
|
||||
return (
|
||||
<span key={s} className="flex items-center gap-1">
|
||||
<Icon className={cn('w-3 h-3', cfg.color)} />
|
||||
{formatMessage({ id: `team.pipeline.${statusKey}` })}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
ccw/frontend/src/components/team/SessionCoordinates.tsx
Normal file
35
ccw/frontend/src/components/team/SessionCoordinates.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
// ========================================
|
||||
// SessionCoordinates Component
|
||||
// ========================================
|
||||
// Compact inline display of phase/step/gap using Badge components
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import type { PhaseInfo } from '@/types/team';
|
||||
|
||||
interface SessionCoordinatesProps {
|
||||
phaseInfo: PhaseInfo;
|
||||
}
|
||||
|
||||
export function SessionCoordinates({ phaseInfo }: SessionCoordinatesProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="secondary">
|
||||
{formatMessage({ id: 'team.coordinates.phase' })}: {phaseInfo.currentPhase}
|
||||
{phaseInfo.totalPhases != null && `/${phaseInfo.totalPhases}`}
|
||||
</Badge>
|
||||
{phaseInfo.currentStep && (
|
||||
<Badge variant="secondary">
|
||||
{formatMessage({ id: 'team.coordinates.step' })}: {phaseInfo.currentStep}
|
||||
</Badge>
|
||||
)}
|
||||
{phaseInfo.gapIteration > 0 && (
|
||||
<Badge variant="outline">
|
||||
{formatMessage({ id: 'team.coordinates.gap' })}: {phaseInfo.gapIteration}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
// Detail view header with back button, stats, and controls
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Users, MessageSquare, RefreshCw, ArrowLeft } from 'lucide-react';
|
||||
import { Users, MessageSquare, RefreshCw, ArrowLeft, GitBranch } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Switch } from '@/components/ui/Switch';
|
||||
@@ -18,6 +18,8 @@ interface TeamHeaderProps {
|
||||
totalMessages: number;
|
||||
autoRefresh: boolean;
|
||||
onToggleAutoRefresh: () => void;
|
||||
skillType?: string;
|
||||
pipelineMode?: string;
|
||||
}
|
||||
|
||||
export function TeamHeader({
|
||||
@@ -27,6 +29,8 @@ export function TeamHeader({
|
||||
totalMessages,
|
||||
autoRefresh,
|
||||
onToggleAutoRefresh,
|
||||
skillType,
|
||||
pipelineMode,
|
||||
}: TeamHeaderProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
@@ -55,6 +59,23 @@ export function TeamHeader({
|
||||
{formatMessage({ id: 'team.messages' })}: {totalMessages}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Skill type and pipeline mode badges */}
|
||||
{(skillType || pipelineMode) && (
|
||||
<div className="flex items-center gap-2">
|
||||
{skillType && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
{skillType}
|
||||
</Badge>
|
||||
)}
|
||||
{pipelineMode && (
|
||||
<Badge variant="default" className="gap-1">
|
||||
<GitBranch className="w-3 h-3" />
|
||||
{pipelineMode}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
148
ccw/frontend/src/components/team/TeamRolePanel.tsx
Normal file
148
ccw/frontend/src/components/team/TeamRolePanel.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
// ========================================
|
||||
// TeamRolePanel Component
|
||||
// ========================================
|
||||
// Member-to-pipeline-role mapping with fallback to TeamMembersPanel
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { TeamMembersPanel } from './TeamMembersPanel';
|
||||
import type { TeamMember, DynamicStage } from '@/types/team';
|
||||
|
||||
interface TeamRolePanelProps {
|
||||
members: TeamMember[];
|
||||
stages: DynamicStage[];
|
||||
roleState?: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
/** Find the matching dynamic stage for a member */
|
||||
function findMatchingStage(member: TeamMember, stages: DynamicStage[]): DynamicStage | undefined {
|
||||
const memberLower = member.member.toLowerCase();
|
||||
return stages.find(
|
||||
(stage) =>
|
||||
(stage.role && stage.role.toLowerCase() === memberLower) ||
|
||||
stage.id.toLowerCase() === memberLower
|
||||
);
|
||||
}
|
||||
|
||||
const stageBadgeVariant: Record<string, 'success' | 'info' | 'secondary' | 'destructive' | 'warning'> = {
|
||||
completed: 'success',
|
||||
in_progress: 'info',
|
||||
pending: 'secondary',
|
||||
blocked: 'destructive',
|
||||
skipped: 'warning',
|
||||
};
|
||||
|
||||
export function TeamRolePanel({ members, stages, roleState: _roleState }: TeamRolePanelProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// Fallback to static members panel when no dynamic stages
|
||||
if (stages.length === 0) {
|
||||
return <TeamMembersPanel members={members} />;
|
||||
}
|
||||
|
||||
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';
|
||||
const matchedStage = findMatchingStage(m, stages);
|
||||
|
||||
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 + stage badge */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<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>
|
||||
{matchedStage && (
|
||||
<Badge
|
||||
variant={stageBadgeVariant[matchedStage.status] ?? 'secondary'}
|
||||
className="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{matchedStage.label}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -7142,7 +7142,7 @@ export async function fetchCcwTools(): Promise<CcwToolInfo[]> {
|
||||
|
||||
// ========== Team API ==========
|
||||
|
||||
export async function fetchTeams(location?: string): Promise<{ teams: Array<{ name: string; messageCount: number; lastActivity: string; status: string; created_at: string; updated_at: string; archived_at?: string; pipeline_mode?: string; memberCount: number; members?: string[] }> }> {
|
||||
export async function fetchTeams(location?: string): Promise<{ teams: Array<{ name: string; messageCount: number; lastActivity: string; status: string; created_at: string; updated_at: string; archived_at?: string; pipeline_mode?: string; pipeline_stages?: string[]; role_state?: Record<string, Record<string, unknown>>; roles?: string[]; team_name?: string; memberCount: number; members?: string[] }> }> {
|
||||
const params = new URLSearchParams();
|
||||
if (location) params.set('location', location);
|
||||
const qs = params.toString();
|
||||
|
||||
119
ccw/frontend/src/lib/pipeline-utils.ts
Normal file
119
ccw/frontend/src/lib/pipeline-utils.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { TeamMessage, DynamicStage, DynamicStageStatus, PhaseInfo } from '@/types/team';
|
||||
|
||||
const LEGACY_STAGES = ['plan', 'impl', 'test', 'review'] as const;
|
||||
|
||||
/**
|
||||
* Capitalize first letter, lowercase rest.
|
||||
* "SCAN" -> "Scan", "review" -> "Review"
|
||||
*/
|
||||
export function formatStageLabel(stageId: string): string {
|
||||
if (!stageId) return '';
|
||||
return stageId.charAt(0).toUpperCase() + stageId.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the status of a pipeline stage from the message history.
|
||||
*
|
||||
* Matches messages whose `from` field equals or starts with `roleOrStage`
|
||||
* (case-insensitive). The status is determined by the LAST matching message's type.
|
||||
*/
|
||||
export function deriveStageStatus(
|
||||
roleOrStage: string,
|
||||
messages: TeamMessage[],
|
||||
): DynamicStageStatus {
|
||||
const needle = roleOrStage.toLowerCase();
|
||||
const matching = messages.filter((m) => {
|
||||
const from = m.from.toLowerCase();
|
||||
return from === needle || from.startsWith(needle);
|
||||
});
|
||||
|
||||
if (matching.length === 0) return 'pending';
|
||||
|
||||
const last = matching[matching.length - 1];
|
||||
const completionTypes: string[] = ['shutdown', 'impl_complete', 'review_result', 'test_result'];
|
||||
|
||||
if (completionTypes.includes(last.type)) return 'completed';
|
||||
if (last.type === 'error') return 'blocked';
|
||||
return 'in_progress';
|
||||
}
|
||||
|
||||
/**
|
||||
* Three-tier pipeline stage detection.
|
||||
*
|
||||
* Tier 1 - explicit `pipeline_stages` from meta
|
||||
* Tier 2 - inferred from message senders (excluding "coordinator")
|
||||
* Tier 3 - legacy 4-stage fallback
|
||||
*/
|
||||
export function derivePipelineStages(
|
||||
meta: {
|
||||
pipeline_stages?: string[];
|
||||
role_state?: Record<string, Record<string, unknown>>;
|
||||
roles?: string[];
|
||||
},
|
||||
messages: TeamMessage[],
|
||||
): DynamicStage[] {
|
||||
// Tier 1: explicit pipeline_stages
|
||||
if (meta.pipeline_stages && meta.pipeline_stages.length > 0) {
|
||||
return meta.pipeline_stages.map((stage) => {
|
||||
const id = stage.toUpperCase();
|
||||
const lowerStage = stage.toLowerCase();
|
||||
const role = meta.roles?.find((r) => r.toLowerCase().startsWith(lowerStage));
|
||||
return {
|
||||
id,
|
||||
label: formatStageLabel(stage),
|
||||
role,
|
||||
status: deriveStageStatus(stage, messages),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Tier 2: extract from message senders
|
||||
if (messages.length > 0) {
|
||||
const seen = new Map<string, string>(); // lowercase sender -> original sender
|
||||
for (const msg of messages) {
|
||||
const sender = msg.from;
|
||||
if (sender.toLowerCase() === 'coordinator') continue;
|
||||
const key = sender.toLowerCase();
|
||||
if (!seen.has(key)) {
|
||||
seen.set(key, sender);
|
||||
}
|
||||
}
|
||||
|
||||
if (seen.size > 0) {
|
||||
return Array.from(seen.entries()).map(([, sender]) => ({
|
||||
id: sender.toUpperCase(),
|
||||
label: formatStageLabel(sender),
|
||||
role: sender,
|
||||
status: deriveStageStatus(sender, messages),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3: legacy fallback
|
||||
return LEGACY_STAGES.map((stage) => ({
|
||||
id: stage.toUpperCase(),
|
||||
label: formatStageLabel(stage),
|
||||
role: undefined,
|
||||
status: deriveStageStatus(stage, messages),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect multi-phase execution from coordinator role state.
|
||||
* Returns PhaseInfo when the coordinator reports `current_phase`, otherwise null.
|
||||
*/
|
||||
export function detectMultiPhase(
|
||||
roleState?: Record<string, Record<string, unknown>>,
|
||||
): PhaseInfo | null {
|
||||
if (!roleState || Object.keys(roleState).length === 0) return null;
|
||||
|
||||
const coordinator = roleState['coordinator'];
|
||||
if (!coordinator || typeof coordinator.current_phase !== 'number') return null;
|
||||
|
||||
return {
|
||||
currentPhase: coordinator.current_phase as number,
|
||||
totalPhases: typeof coordinator.total_phases === 'number' ? coordinator.total_phases : null,
|
||||
currentStep: typeof coordinator.current_step === 'string' ? coordinator.current_step : null,
|
||||
gapIteration: typeof coordinator.gap_iteration === 'number' ? coordinator.gap_iteration : 0,
|
||||
};
|
||||
}
|
||||
@@ -43,6 +43,7 @@
|
||||
"backToList": "Back to Teams"
|
||||
},
|
||||
"tabs": {
|
||||
"pipeline": "Pipeline",
|
||||
"artifacts": "Artifacts",
|
||||
"messages": "Messages"
|
||||
},
|
||||
@@ -77,7 +78,13 @@
|
||||
"completed": "Completed",
|
||||
"inProgress": "In Progress",
|
||||
"pending": "Pending",
|
||||
"blocked": "Blocked"
|
||||
"blocked": "Blocked",
|
||||
"skipped": "Skipped"
|
||||
},
|
||||
"coordinates": {
|
||||
"phase": "Phase",
|
||||
"step": "Step",
|
||||
"gap": "Gap"
|
||||
},
|
||||
"membersPanel": {
|
||||
"title": "Team Members",
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"backToList": "返回团队列表"
|
||||
},
|
||||
"tabs": {
|
||||
"pipeline": "流水线",
|
||||
"artifacts": "产物",
|
||||
"messages": "消息"
|
||||
},
|
||||
@@ -77,7 +78,13 @@
|
||||
"completed": "已完成",
|
||||
"inProgress": "进行中",
|
||||
"pending": "待处理",
|
||||
"blocked": "已阻塞"
|
||||
"blocked": "已阻塞",
|
||||
"skipped": "已跳过"
|
||||
},
|
||||
"coordinates": {
|
||||
"phase": "阶段",
|
||||
"step": "步骤",
|
||||
"gap": "差距迭代"
|
||||
},
|
||||
"membersPanel": {
|
||||
"title": "团队成员",
|
||||
|
||||
@@ -3,21 +3,24 @@
|
||||
// ========================================
|
||||
// Main page for team execution - list/detail dual view with tabbed detail
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Package, MessageSquare, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { Package, MessageSquare, Maximize2, Minimize2, GitBranch } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore, selectIsImmersiveMode } from '@/stores/appStore';
|
||||
import { TabsNavigation, type TabItem } from '@/components/ui/TabsNavigation';
|
||||
import { useTeamStore } from '@/stores/teamStore';
|
||||
import type { TeamDetailTab } from '@/stores/teamStore';
|
||||
import { useTeamMessages, useTeamStatus } from '@/hooks/useTeamData';
|
||||
import { useTeams, useTeamMessages, useTeamStatus } from '@/hooks/useTeamData';
|
||||
import { TeamHeader } from '@/components/team/TeamHeader';
|
||||
import { TeamPipeline } from '@/components/team/TeamPipeline';
|
||||
import { TeamMembersPanel } from '@/components/team/TeamMembersPanel';
|
||||
import { DynamicPipeline } from '@/components/team/DynamicPipeline';
|
||||
import { TeamRolePanel } from '@/components/team/TeamRolePanel';
|
||||
import { SessionCoordinates } from '@/components/team/SessionCoordinates';
|
||||
import { TeamMessageFeed } from '@/components/team/TeamMessageFeed';
|
||||
import { TeamArtifacts } from '@/components/team/TeamArtifacts';
|
||||
import { TeamListView } from '@/components/team/TeamListView';
|
||||
import { derivePipelineStages, detectMultiPhase } from '@/lib/pipeline-utils';
|
||||
|
||||
export function TeamPage() {
|
||||
const { formatMessage } = useIntl();
|
||||
@@ -34,11 +37,13 @@ export function TeamPage() {
|
||||
detailTab,
|
||||
setDetailTab,
|
||||
backToList,
|
||||
locationFilter,
|
||||
} = useTeamStore();
|
||||
const isImmersiveMode = useAppStore(selectIsImmersiveMode);
|
||||
const toggleImmersiveMode = useAppStore((s) => s.toggleImmersiveMode);
|
||||
|
||||
// Data hooks (only active in detail mode)
|
||||
// Data hooks
|
||||
const { teams } = useTeams(locationFilter);
|
||||
const { messages, total: messageTotal } = useTeamMessages(
|
||||
viewMode === 'detail' ? selectedTeam : null,
|
||||
messageFilter
|
||||
@@ -47,6 +52,31 @@ export function TeamPage() {
|
||||
viewMode === 'detail' ? selectedTeam : null
|
||||
);
|
||||
|
||||
// Find enriched team data from list response
|
||||
const teamData = useMemo(
|
||||
() => teams.find((t) => t.name === selectedTeam),
|
||||
[teams, selectedTeam]
|
||||
);
|
||||
|
||||
// Derive dynamic pipeline stages and multi-phase info
|
||||
const stages = useMemo(
|
||||
() =>
|
||||
derivePipelineStages(
|
||||
{
|
||||
pipeline_stages: teamData?.pipeline_stages,
|
||||
role_state: teamData?.role_state,
|
||||
roles: teamData?.roles,
|
||||
},
|
||||
messages
|
||||
),
|
||||
[teamData?.pipeline_stages, teamData?.role_state, teamData?.roles, messages]
|
||||
);
|
||||
|
||||
const phaseInfo = useMemo(
|
||||
() => detectMultiPhase(teamData?.role_state),
|
||||
[teamData?.role_state]
|
||||
);
|
||||
|
||||
// List view
|
||||
if (viewMode === 'list' || !selectedTeam) {
|
||||
return (
|
||||
@@ -57,6 +87,11 @@ export function TeamPage() {
|
||||
}
|
||||
|
||||
const tabs: TabItem[] = [
|
||||
{
|
||||
value: 'pipeline',
|
||||
label: formatMessage({ id: 'team.tabs.pipeline' }),
|
||||
icon: <GitBranch className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: 'artifacts',
|
||||
label: formatMessage({ id: 'team.tabs.artifacts' }),
|
||||
@@ -72,7 +107,7 @@ export function TeamPage() {
|
||||
// Detail view
|
||||
return (
|
||||
<div className={cn("space-y-6", isImmersiveMode && "h-screen overflow-hidden")}>
|
||||
{/* Detail Header: back button + team name + stats + controls */}
|
||||
{/* Detail Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<TeamHeader
|
||||
selectedTeam={selectedTeam}
|
||||
@@ -81,6 +116,8 @@ export function TeamPage() {
|
||||
totalMessages={totalMessages}
|
||||
autoRefresh={autoRefresh}
|
||||
onToggleAutoRefresh={toggleAutoRefresh}
|
||||
skillType={teamData?.team_name ? `team-${teamData.team_name}` : undefined}
|
||||
pipelineMode={teamData?.pipeline_mode}
|
||||
/>
|
||||
<button
|
||||
onClick={toggleImmersiveMode}
|
||||
@@ -96,27 +133,45 @@ export function TeamPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Overview: Pipeline + Members (always visible) */}
|
||||
{/* Overview: DynamicPipeline + TeamRolePanel */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card className="lg:col-span-2 flex flex-col">
|
||||
<CardContent className="p-4 flex-1">
|
||||
<TeamPipeline messages={messages} />
|
||||
<DynamicPipeline stages={stages} phaseInfo={phaseInfo} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<TeamMembersPanel members={members} />
|
||||
<TeamRolePanel
|
||||
members={members}
|
||||
stages={stages}
|
||||
roleState={teamData?.role_state}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation: Artifacts / Messages */}
|
||||
{/* Session Coordinates (only for multi-phase sessions) */}
|
||||
{phaseInfo && (
|
||||
<SessionCoordinates phaseInfo={phaseInfo} />
|
||||
)}
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<TabsNavigation
|
||||
value={detailTab}
|
||||
onValueChange={(v) => setDetailTab(v as TeamDetailTab)}
|
||||
tabs={tabs}
|
||||
/>
|
||||
|
||||
{/* Pipeline Tab */}
|
||||
{detailTab === 'pipeline' && (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<DynamicPipeline stages={stages} phaseInfo={phaseInfo} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Artifacts Tab */}
|
||||
{detailTab === 'artifacts' && (
|
||||
<TeamArtifacts teamName={selectedTeam} />
|
||||
|
||||
@@ -7,7 +7,7 @@ import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import type { TeamMessageFilter } from '@/types/team';
|
||||
|
||||
export type TeamDetailTab = 'artifacts' | 'messages';
|
||||
export type TeamDetailTab = 'pipeline' | 'artifacts' | 'messages';
|
||||
|
||||
interface TeamStore {
|
||||
selectedTeam: string | null;
|
||||
@@ -42,7 +42,7 @@ export const useTeamStore = create<TeamStore>()(
|
||||
viewMode: 'list',
|
||||
locationFilter: 'active',
|
||||
searchQuery: '',
|
||||
detailTab: 'artifacts',
|
||||
detailTab: 'pipeline',
|
||||
setSelectedTeam: (name) => set({ selectedTeam: name }),
|
||||
toggleAutoRefresh: () => set((s) => ({ autoRefresh: !s.autoRefresh })),
|
||||
setMessageFilter: (filter) =>
|
||||
@@ -53,8 +53,8 @@ export const useTeamStore = create<TeamStore>()(
|
||||
setLocationFilter: (filter) => set({ locationFilter: filter }),
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
setDetailTab: (tab) => set({ detailTab: tab }),
|
||||
selectTeamAndShowDetail: (name) => set({ selectedTeam: name, viewMode: 'detail', detailTab: 'artifacts' }),
|
||||
backToList: () => set({ viewMode: 'list', detailTab: 'artifacts' }),
|
||||
selectTeamAndShowDetail: (name) => set({ selectedTeam: name, viewMode: 'detail', detailTab: 'pipeline' }),
|
||||
backToList: () => set({ viewMode: 'list', detailTab: 'pipeline' }),
|
||||
}),
|
||||
{ name: 'ccw-team-store' }
|
||||
),
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface TeamSummaryExtended extends TeamSummary {
|
||||
role_state?: Record<string, Record<string, unknown>>;
|
||||
memberCount: number;
|
||||
members: string[]; // Always provided by backend
|
||||
roles?: string[]; // List of role names from meta
|
||||
team_name?: string; // Skill name (e.g., "review")
|
||||
}
|
||||
|
||||
export interface TeamMessagesResponse {
|
||||
@@ -80,6 +82,22 @@ export interface TeamMessageFilter {
|
||||
export type PipelineStage = 'plan' | 'impl' | 'test' | 'review';
|
||||
export type PipelineStageStatus = 'completed' | 'in_progress' | 'pending' | 'blocked';
|
||||
|
||||
export type DynamicStageStatus = 'pending' | 'in_progress' | 'completed' | 'blocked' | 'skipped';
|
||||
|
||||
export interface DynamicStage {
|
||||
id: string; // Stage prefix (e.g., "SCAN", "REV", "FIX")
|
||||
label: string; // Display label (e.g., "Scanner", "Reviewer")
|
||||
role?: string; // Associated role name
|
||||
status: DynamicStageStatus;
|
||||
}
|
||||
|
||||
export interface PhaseInfo {
|
||||
currentPhase: number;
|
||||
totalPhases: number | null;
|
||||
currentStep: string | null;
|
||||
gapIteration: number;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Team Artifacts Types
|
||||
// ========================================
|
||||
|
||||
@@ -269,6 +269,10 @@ export async function handleTeamRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
updated_at: string;
|
||||
archived_at?: string;
|
||||
pipeline_mode?: string;
|
||||
pipeline_stages?: string[];
|
||||
role_state?: Record<string, Record<string, unknown>>;
|
||||
roles?: string[];
|
||||
team_name?: string;
|
||||
memberCount: number;
|
||||
members: string[];
|
||||
isLegacy: boolean;
|
||||
@@ -295,6 +299,10 @@ export async function handleTeamRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
updated_at: meta.updated_at,
|
||||
archived_at: meta.archived_at,
|
||||
pipeline_mode: meta.pipeline_mode,
|
||||
pipeline_stages: meta.pipeline_stages,
|
||||
role_state: meta.role_state,
|
||||
roles: meta.roles,
|
||||
team_name: meta.team_name,
|
||||
memberCount: memberSet.size,
|
||||
members: Array.from(memberSet),
|
||||
isLegacy: false,
|
||||
@@ -325,6 +333,10 @@ export async function handleTeamRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
updated_at: meta.updated_at,
|
||||
archived_at: meta.archived_at,
|
||||
pipeline_mode: meta.pipeline_mode,
|
||||
pipeline_stages: meta.pipeline_stages,
|
||||
role_state: meta.role_state,
|
||||
roles: meta.roles,
|
||||
team_name: meta.team_name,
|
||||
memberCount: memberSet.size,
|
||||
members: Array.from(memberSet),
|
||||
isLegacy: true,
|
||||
@@ -434,7 +446,7 @@ export async function handleTeamRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
if (!existsSync(sessionDir)) {
|
||||
// Check if it's a legacy team with session_id in meta
|
||||
const meta = getEffectiveTeamMeta(artifactsTeamName);
|
||||
const legacySessionId = (meta as Record<string, unknown>).session_id as string | undefined;
|
||||
const legacySessionId = (meta as unknown as Record<string, unknown>).session_id as string | undefined;
|
||||
if (legacySessionId) {
|
||||
// Legacy team with session_id - redirect to session directory
|
||||
const legacySessionDir = getSessionDir(legacySessionId, root);
|
||||
|
||||
@@ -117,9 +117,35 @@ async function serveStaticFile(
|
||||
const ext = extname(filePath);
|
||||
const mimeType = MIME_TYPES[ext] || 'application/octet-stream';
|
||||
|
||||
// Determine cache strategy based on file type
|
||||
const fileName = filePath.split('/').pop() || '';
|
||||
const isIndexHtml = filePath.endsWith('index.html');
|
||||
const isAssetFile = fileName.startsWith('index-') && (ext === '.js' || ext === '.css');
|
||||
|
||||
// For index.html: use no-cache to prevent stale content issues
|
||||
if (isIndexHtml) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': mimeType,
|
||||
'Cache-Control': 'no-cache',
|
||||
});
|
||||
res.end(content);
|
||||
return true;
|
||||
}
|
||||
|
||||
// For assets (JS/CSS with hash in filenames), use long-term cache
|
||||
if (isAssetFile) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': mimeType,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
});
|
||||
res.end(content);
|
||||
return true;
|
||||
}
|
||||
|
||||
// For other files (fallback to index.html for SPA), use no-cache
|
||||
res.writeHead(200, {
|
||||
'Content-Type': mimeType,
|
||||
'Cache-Control': 'public, max-age=31536000',
|
||||
'Cache-Control': 'no-cache',
|
||||
});
|
||||
res.end(content);
|
||||
return true;
|
||||
|
||||
Reference in New Issue
Block a user