mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
feat: Implement team artifacts feature with tree navigation and file preview
This commit is contained in:
@@ -1,111 +1,57 @@
|
||||
// ========================================
|
||||
// TeamArtifacts Component
|
||||
// ========================================
|
||||
// Displays team artifacts grouped by pipeline phase (plan/impl/test/review)
|
||||
// Displays team artifacts with hybrid layout: tree navigation + file preview
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Folder,
|
||||
FileText,
|
||||
ClipboardList,
|
||||
Code2,
|
||||
TestTube2,
|
||||
SearchCheck,
|
||||
Database,
|
||||
FileJson,
|
||||
Package,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import MarkdownModal from '@/components/shared/MarkdownModal';
|
||||
import { fetchFileContent } from '@/lib/api';
|
||||
import { fetchTeamArtifacts, fetchArtifactContent } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TeamMessage } from '@/types/team';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import type { ArtifactNode, ContentType } from '@/types/team';
|
||||
|
||||
// ========================================
|
||||
// Types
|
||||
// ========================================
|
||||
|
||||
type ArtifactPhase = 'plan' | 'impl' | 'test' | 'review';
|
||||
|
||||
interface Artifact {
|
||||
id: string;
|
||||
message: TeamMessage;
|
||||
phase: ArtifactPhase;
|
||||
ref?: string;
|
||||
}
|
||||
|
||||
interface TeamArtifactsProps {
|
||||
messages: TeamMessage[];
|
||||
teamName: string;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Constants
|
||||
// ========================================
|
||||
|
||||
const PHASE_MESSAGE_MAP: Record<string, ArtifactPhase> = {
|
||||
plan_ready: 'plan',
|
||||
plan_approved: 'plan',
|
||||
plan_revision: 'plan',
|
||||
impl_complete: 'impl',
|
||||
impl_progress: 'impl',
|
||||
test_result: 'test',
|
||||
review_result: 'review',
|
||||
};
|
||||
|
||||
const PHASE_CONFIG: Record<ArtifactPhase, { icon: typeof FileText; color: string }> = {
|
||||
plan: { icon: ClipboardList, color: 'text-blue-500' },
|
||||
impl: { icon: Code2, color: 'text-green-500' },
|
||||
test: { icon: TestTube2, color: 'text-amber-500' },
|
||||
review: { icon: SearchCheck, color: 'text-purple-500' },
|
||||
};
|
||||
|
||||
const PHASE_ORDER: ArtifactPhase[] = ['plan', 'impl', 'test', 'review'];
|
||||
|
||||
// ========================================
|
||||
// Helpers
|
||||
// ========================================
|
||||
|
||||
function extractArtifacts(messages: TeamMessage[]): Artifact[] {
|
||||
const artifacts: Artifact[] = [];
|
||||
for (const msg of messages) {
|
||||
const phase = PHASE_MESSAGE_MAP[msg.type];
|
||||
if (!phase) continue;
|
||||
// Include messages that have ref OR data (inline artifacts)
|
||||
if (!msg.ref && !msg.data) continue;
|
||||
artifacts.push({
|
||||
id: msg.id,
|
||||
message: msg,
|
||||
phase,
|
||||
ref: msg.ref,
|
||||
});
|
||||
}
|
||||
return artifacts;
|
||||
function getContentTypeFromPath(path: string): ContentType {
|
||||
if (path.endsWith('.json')) return 'json';
|
||||
if (path.endsWith('.md')) return 'markdown';
|
||||
if (path.endsWith('.txt') || path.endsWith('.log') || path.endsWith('.tsv') || path.endsWith('.csv')) return 'text';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function groupByPhase(artifacts: Artifact[]): Record<ArtifactPhase, Artifact[]> {
|
||||
const groups: Record<ArtifactPhase, Artifact[]> = {
|
||||
plan: [],
|
||||
impl: [],
|
||||
test: [],
|
||||
review: [],
|
||||
};
|
||||
for (const a of artifacts) {
|
||||
groups[a.phase].push(a);
|
||||
}
|
||||
return groups;
|
||||
function formatSize(bytes?: number): string {
|
||||
if (!bytes) return '';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function getContentType(ref: string): 'markdown' | 'json' | 'text' {
|
||||
if (ref.endsWith('.json')) return 'json';
|
||||
if (ref.endsWith('.md')) return 'markdown';
|
||||
return 'text';
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: string): string {
|
||||
function formatDate(ts?: string): string {
|
||||
if (!ts) return '';
|
||||
try {
|
||||
return new Date(ts).toLocaleString();
|
||||
const date = new Date(ts);
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return ts;
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,83 +59,92 @@ function formatTimestamp(ts: string): string {
|
||||
// Sub-components
|
||||
// ========================================
|
||||
|
||||
function ArtifactCard({
|
||||
artifact,
|
||||
onView,
|
||||
}: {
|
||||
artifact: Artifact;
|
||||
onView: (artifact: Artifact) => void;
|
||||
}) {
|
||||
const { formatMessage } = useIntl();
|
||||
const config = PHASE_CONFIG[artifact.phase];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
onClick={() => onView(artifact)}
|
||||
>
|
||||
<CardContent className="p-3 flex items-start gap-3">
|
||||
<div className={cn('mt-0.5', config.color)}>
|
||||
{artifact.ref ? (
|
||||
<Icon className="w-4 h-4" />
|
||||
) : (
|
||||
<Database className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{artifact.message.summary}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{artifact.ref ? (
|
||||
<span className="text-xs text-muted-foreground font-mono truncate">
|
||||
{artifact.ref.split('/').pop()}
|
||||
</span>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{formatMessage({ id: 'team.artifacts.noRef' })}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-[10px] text-muted-foreground ml-auto shrink-0">
|
||||
{formatTimestamp(artifact.message.ts)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
function FileIcon({ contentType }: { contentType?: ContentType }) {
|
||||
if (contentType === 'json') {
|
||||
return <FileJson className="w-4 h-4 text-blue-500" />;
|
||||
}
|
||||
return <FileText className="w-4 h-4 text-gray-500" />;
|
||||
}
|
||||
|
||||
function PhaseGroup({
|
||||
phase,
|
||||
artifacts,
|
||||
onView,
|
||||
}: {
|
||||
phase: ArtifactPhase;
|
||||
artifacts: Artifact[];
|
||||
onView: (artifact: Artifact) => void;
|
||||
}) {
|
||||
const { formatMessage } = useIntl();
|
||||
if (artifacts.length === 0) return null;
|
||||
interface TreeNodeProps {
|
||||
node: ArtifactNode;
|
||||
depth: number;
|
||||
expanded: Set<string>;
|
||||
selectedPath?: string;
|
||||
onToggle: (path: string) => void;
|
||||
onSelect: (node: ArtifactNode) => void;
|
||||
}
|
||||
|
||||
const config = PHASE_CONFIG[phase];
|
||||
const Icon = config.icon;
|
||||
function TreeNode({
|
||||
node,
|
||||
depth,
|
||||
expanded,
|
||||
selectedPath,
|
||||
onToggle,
|
||||
onSelect,
|
||||
}: TreeNodeProps) {
|
||||
if (node.type === 'directory') {
|
||||
const isExpanded = expanded.has(node.path);
|
||||
return (
|
||||
<div key={node.path}>
|
||||
<div
|
||||
className="flex items-center gap-1 py-1.5 px-2 cursor-pointer hover:bg-accent/50 rounded transition-colors"
|
||||
style={{ paddingLeft: depth * 16 + 8 }}
|
||||
onClick={() => onToggle(node.path)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
<Folder
|
||||
className={cn(
|
||||
'w-4 h-4',
|
||||
isExpanded ? 'text-amber-500' : 'text-amber-400'
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm truncate">{node.name}</span>
|
||||
</div>
|
||||
{isExpanded && node.children && (
|
||||
<div>
|
||||
{node.children.map((child) => (
|
||||
<TreeNode
|
||||
key={child.path}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
expanded={expanded}
|
||||
selectedPath={selectedPath}
|
||||
onToggle={onToggle}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// File node
|
||||
const isSelected = selectedPath === node.path;
|
||||
const contentType = node.contentType || getContentTypeFromPath(node.path);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={cn('w-4 h-4', config.color)} />
|
||||
<h4 className="text-sm font-medium">
|
||||
{formatMessage({ id: `team.artifacts.${phase}` })}
|
||||
</h4>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{artifacts.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-2 pl-6">
|
||||
{artifacts.map((artifact) => (
|
||||
<ArtifactCard key={artifact.id} artifact={artifact} onView={onView} />
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
key={node.path}
|
||||
className={cn(
|
||||
'flex items-center gap-1 py-1.5 px-2 cursor-pointer hover:bg-accent/50 rounded transition-colors',
|
||||
isSelected && 'bg-accent'
|
||||
)}
|
||||
style={{ paddingLeft: depth * 16 + 28 }}
|
||||
onClick={() => onSelect(node)}
|
||||
>
|
||||
<FileIcon contentType={contentType} />
|
||||
<span className="text-sm truncate flex-1">{node.name}</span>
|
||||
{node.size !== undefined && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{formatSize(node.size)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -198,88 +153,410 @@ function PhaseGroup({
|
||||
// Main Component
|
||||
// ========================================
|
||||
|
||||
export function TeamArtifacts({ messages }: TeamArtifactsProps) {
|
||||
export function TeamArtifacts({ teamName }: TeamArtifactsProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [selectedArtifact, setSelectedArtifact] = React.useState<Artifact | null>(null);
|
||||
const [modalContent, setModalContent] = React.useState('');
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [tree, setTree] = React.useState<ArtifactNode[]>([]);
|
||||
const [expanded, setExpanded] = React.useState<Set<string>>(new Set());
|
||||
const [selectedFile, setSelectedFile] = React.useState<ArtifactNode | null>(null);
|
||||
const [content, setContent] = React.useState<string>('');
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [treeLoading, setTreeLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const artifacts = React.useMemo(() => extractArtifacts(messages), [messages]);
|
||||
const grouped = React.useMemo(() => groupByPhase(artifacts), [artifacts]);
|
||||
// Load artifacts tree
|
||||
React.useEffect(() => {
|
||||
if (!teamName) return;
|
||||
|
||||
const handleView = React.useCallback(async (artifact: Artifact) => {
|
||||
setSelectedArtifact(artifact);
|
||||
setTreeLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (artifact.ref) {
|
||||
setIsLoading(true);
|
||||
setModalContent('');
|
||||
try {
|
||||
const result = await fetchFileContent(artifact.ref);
|
||||
setModalContent(result.content);
|
||||
} catch {
|
||||
setModalContent(`Failed to load: ${artifact.ref}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
fetchTeamArtifacts(teamName)
|
||||
.then((data) => {
|
||||
setTree(data.tree || []);
|
||||
// Auto-expand first level directories
|
||||
const firstLevelDirs = (data.tree || [])
|
||||
.filter((n) => n.type === 'directory')
|
||||
.map((n) => n.path);
|
||||
setExpanded(new Set(firstLevelDirs));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to load artifacts:', err);
|
||||
setError(formatMessage({ id: 'team.artifacts.loadError', defaultMessage: 'Failed to load artifacts' }));
|
||||
})
|
||||
.finally(() => {
|
||||
setTreeLoading(false);
|
||||
});
|
||||
}, [teamName, formatMessage]);
|
||||
|
||||
const handleToggle = React.useCallback((path: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
} else if (artifact.message.data) {
|
||||
setModalContent(JSON.stringify(artifact.message.data, null, 2));
|
||||
} else {
|
||||
setModalContent(artifact.message.summary);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setSelectedArtifact(null);
|
||||
setModalContent('');
|
||||
}, []);
|
||||
const handleSelect = React.useCallback(
|
||||
async (node: ArtifactNode) => {
|
||||
if (node.type === 'directory') return;
|
||||
|
||||
// Empty state
|
||||
if (artifacts.length === 0) {
|
||||
setSelectedFile(node);
|
||||
setLoading(true);
|
||||
setContent('');
|
||||
|
||||
try {
|
||||
const result = await fetchArtifactContent(teamName, node.path);
|
||||
setContent(result.content);
|
||||
} catch (err) {
|
||||
console.error('Failed to load file content:', err);
|
||||
setContent(formatMessage({ id: 'team.artifacts.contentError', defaultMessage: 'Failed to load file content' }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[teamName, formatMessage]
|
||||
);
|
||||
|
||||
// Get content type for preview
|
||||
const previewContentType = selectedFile
|
||||
? selectedFile.contentType || getContentTypeFromPath(selectedFile.path)
|
||||
: 'text';
|
||||
|
||||
// Map to MarkdownModal content type
|
||||
const modalContentType: 'markdown' | 'json' | 'text' =
|
||||
previewContentType === 'json' ? 'json' : previewContentType === 'markdown' ? 'markdown' : 'text';
|
||||
|
||||
// Loading state
|
||||
if (treeLoading) {
|
||||
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: 'team.artifacts.noArtifacts' })}
|
||||
</h3>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<span className="ml-3 text-muted-foreground">
|
||||
{formatMessage({ id: 'team.artifacts.loading', defaultMessage: 'Loading artifacts...' })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Determine content type for modal
|
||||
const modalContentType = selectedArtifact?.ref
|
||||
? getContentType(selectedArtifact.ref)
|
||||
: selectedArtifact?.message.data
|
||||
? 'json'
|
||||
: 'text';
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Package className="h-12 w-12 text-destructive mb-4" />
|
||||
<p className="text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const modalTitle = selectedArtifact?.ref
|
||||
? selectedArtifact.ref.split('/').pop() || 'File'
|
||||
: selectedArtifact?.message.summary || 'Data';
|
||||
// Empty state
|
||||
if (tree.length === 0) {
|
||||
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: 'team.artifacts.noArtifacts', defaultMessage: 'No artifacts yet' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'team.artifacts.emptyHint', defaultMessage: 'Artifacts will appear here when the team generates them' })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
{PHASE_ORDER.map((phase) => (
|
||||
<PhaseGroup
|
||||
key={phase}
|
||||
phase={phase}
|
||||
artifacts={grouped[phase]}
|
||||
onView={handleView}
|
||||
/>
|
||||
))}
|
||||
<div className="flex h-[600px] border rounded-lg overflow-hidden">
|
||||
{/* Left: Tree Navigation */}
|
||||
<div className="w-72 shrink-0 border-r bg-muted/30 flex flex-col">
|
||||
<div className="p-3 border-b bg-background shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{formatMessage({ id: 'team.artifacts.title', defaultMessage: 'Artifacts' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
{tree.map((node) => (
|
||||
<TreeNode
|
||||
key={node.path}
|
||||
node={node}
|
||||
depth={0}
|
||||
expanded={expanded}
|
||||
selectedPath={selectedFile?.path}
|
||||
onToggle={handleToggle}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedArtifact && (
|
||||
<MarkdownModal
|
||||
isOpen={!!selectedArtifact}
|
||||
onClose={handleClose}
|
||||
title={modalTitle}
|
||||
content={modalContent}
|
||||
contentType={modalContentType}
|
||||
maxWidth="3xl"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
{/* Right: File Preview */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{selectedFile ? (
|
||||
<>
|
||||
<div className="p-3 border-b bg-muted/30 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileIcon contentType={previewContentType} />
|
||||
<span className="text-sm font-medium truncate">
|
||||
{selectedFile.name}
|
||||
</span>
|
||||
{selectedFile.size !== undefined && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatSize(selectedFile.size)}
|
||||
</span>
|
||||
)}
|
||||
{selectedFile.modifiedAt && (
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{formatDate(selectedFile.modifiedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<PreviewContent
|
||||
content={content}
|
||||
contentType={modalContentType}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-center p-8">
|
||||
<div>
|
||||
<FileText className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
{formatMessage({ id: 'team.artifacts.selectFile', defaultMessage: 'Select a file to preview' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// JSON Card Viewer
|
||||
// ========================================
|
||||
|
||||
function JsonCardViewer({ data, depth = 0 }: { data: unknown; depth?: number }) {
|
||||
// Primitive values
|
||||
if (data === null) {
|
||||
return <span className="text-red-500 font-mono text-sm">null</span>;
|
||||
}
|
||||
if (data === undefined) {
|
||||
return <span className="text-muted-foreground font-mono text-sm">undefined</span>;
|
||||
}
|
||||
if (typeof data === 'boolean') {
|
||||
return (
|
||||
<span className={cn('font-mono text-sm', data ? 'text-orange-500' : 'text-red-500')}>
|
||||
{data ? 'true' : 'false'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (typeof data === 'number') {
|
||||
return <span className="text-blue-500 font-mono text-sm">{data}</span>;
|
||||
}
|
||||
if (typeof data === 'string') {
|
||||
// Check if it's a long string that should be truncated
|
||||
if (data.length > 200) {
|
||||
return (
|
||||
<span className="text-green-600 dark:text-green-400 font-mono text-sm break-all">
|
||||
"{data.slice(0, 200)}..."
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="text-green-600 dark:text-green-400 font-mono text-sm">"{data}"</span>;
|
||||
}
|
||||
|
||||
// Array
|
||||
if (Array.isArray(data)) {
|
||||
if (data.length === 0) {
|
||||
return <span className="text-muted-foreground text-sm">[]</span>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{data.map((item, index) => (
|
||||
<div key={index} className="flex items-start gap-2">
|
||||
<Badge variant="outline" className="text-xs shrink-0 mt-0.5">
|
||||
{index}
|
||||
</Badge>
|
||||
<div className="flex-1 min-w-0">
|
||||
{typeof item === 'object' && item !== null ? (
|
||||
<div className="bg-muted/30 rounded-lg p-2 border">
|
||||
<JsonCardViewer data={item} depth={depth + 1} />
|
||||
</div>
|
||||
) : (
|
||||
<JsonCardViewer data={item} depth={depth + 1} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Object
|
||||
if (typeof data === 'object') {
|
||||
const entries = Object.entries(data);
|
||||
if (entries.length === 0) {
|
||||
return <span className="text-muted-foreground text-sm">{'{}'}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', depth > 0 && 'pl-2 border-l-2 border-border')}>
|
||||
{entries.map(([key, value]) => {
|
||||
const isExpandable = typeof value === 'object' && value !== null;
|
||||
const isArray = Array.isArray(value);
|
||||
const itemCount = isArray ? value.length : isExpandable ? Object.keys(value).length : 0;
|
||||
|
||||
return (
|
||||
<div key={key} className="group">
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
<span className="text-purple-600 dark:text-purple-400 font-medium text-sm shrink-0">
|
||||
{key}
|
||||
</span>
|
||||
{isExpandable && (
|
||||
<Badge variant="secondary" className="text-[10px] h-4 px-1">
|
||||
{isArray ? `${itemCount} items` : `${itemCount} fields`}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
{isExpandable ? (
|
||||
<div className="bg-muted/20 rounded-md p-2 border">
|
||||
<JsonCardViewer data={value} depth={depth + 1} />
|
||||
</div>
|
||||
) : (
|
||||
<JsonCardViewer data={value} depth={depth + 1} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="text-sm">{String(data)}</span>;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// JSON Summary Card
|
||||
// ========================================
|
||||
|
||||
function JsonSummaryCard({ data }: { data: unknown }) {
|
||||
const stats = React.useMemo(() => {
|
||||
const result = {
|
||||
type: '',
|
||||
fields: 0,
|
||||
items: 0,
|
||||
depth: 0,
|
||||
};
|
||||
|
||||
const analyze = (obj: unknown, currentDepth: number): void => {
|
||||
result.depth = Math.max(result.depth, currentDepth);
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
result.type = 'Array';
|
||||
result.items = obj.length;
|
||||
obj.forEach(item => analyze(item, currentDepth + 1));
|
||||
} else if (typeof obj === 'object' && obj !== null) {
|
||||
result.type = 'Object';
|
||||
result.fields = Object.keys(obj).length;
|
||||
Object.values(obj).forEach(val => analyze(val, currentDepth + 1));
|
||||
}
|
||||
};
|
||||
|
||||
analyze(data, 0);
|
||||
return result;
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 p-3 bg-muted/50 rounded-lg mb-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{stats.type}</Badge>
|
||||
</div>
|
||||
{stats.type === 'Object' && (
|
||||
<div className="text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{stats.fields}</span> fields
|
||||
</div>
|
||||
)}
|
||||
{stats.type === 'Array' && (
|
||||
<div className="text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{stats.items}</span> items
|
||||
</div>
|
||||
)}
|
||||
<div className="text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{stats.depth}</span> levels deep
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Preview Content Component
|
||||
// ========================================
|
||||
|
||||
function PreviewContent({
|
||||
content,
|
||||
contentType,
|
||||
}: {
|
||||
content: string;
|
||||
contentType: 'markdown' | 'json' | 'text';
|
||||
}) {
|
||||
if (!content) {
|
||||
return (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
No content
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (contentType === 'json') {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<JsonSummaryCard data={parsed} />
|
||||
<div className="bg-card rounded-lg">
|
||||
<JsonCardViewer data={parsed} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} catch {
|
||||
return (
|
||||
<pre className="text-xs bg-muted/50 p-4 rounded-lg overflow-auto font-mono whitespace-pre-wrap break-words border">
|
||||
{content}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (contentType === 'markdown') {
|
||||
return (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-relaxed bg-transparent p-0">
|
||||
{content}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<pre className="text-xs bg-muted/50 p-4 rounded-lg overflow-auto font-mono whitespace-pre-wrap break-words border">
|
||||
{content}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamArtifacts;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// Typed fetch functions for API communication with CSRF token handling
|
||||
|
||||
import type { SessionMetadata, TaskData, IndexStatus, IndexRebuildRequest, Rule, RuleCreateInput, RulesResponse, Prompt, PromptInsight, Pattern, Suggestion, McpTemplate, McpTemplateInstallRequest, AllProjectsResponse, OtherProjectsServersResponse, CrossCliCopyRequest, CrossCliCopyResponse } from '../types/store';
|
||||
import type { TeamArtifactsResponse } from '../types/team';
|
||||
|
||||
// Re-export types for backward compatibility
|
||||
export type { IndexStatus, IndexRebuildRequest, Rule, RuleCreateInput, RulesResponse, Prompt, PromptInsight, Pattern, Suggestion, McpTemplate, McpTemplateInstallRequest, AllProjectsResponse, OtherProjectsServersResponse, CrossCliCopyRequest, CrossCliCopyResponse };
|
||||
@@ -6139,6 +6140,19 @@ export async function fetchTeamStatus(
|
||||
return fetchApi(`/api/teams/${encodeURIComponent(teamName)}/status`);
|
||||
}
|
||||
|
||||
export async function fetchTeamArtifacts(
|
||||
teamName: string
|
||||
): Promise<TeamArtifactsResponse> {
|
||||
return fetchApi(`/api/teams/${encodeURIComponent(teamName)}/artifacts`);
|
||||
}
|
||||
|
||||
export async function fetchArtifactContent(
|
||||
teamName: string,
|
||||
artifactPath: string
|
||||
): Promise<{ content: string; contentType: string; path: string }> {
|
||||
return fetchApi(`/api/teams/${encodeURIComponent(teamName)}/artifacts/${encodeURIComponent(artifactPath)}`);
|
||||
}
|
||||
|
||||
// ========== CLI Sessions (PTY) API ==========
|
||||
|
||||
export interface CliSession {
|
||||
|
||||
@@ -73,6 +73,7 @@ interface FixProgressData {
|
||||
* Fix Progress Carousel Component
|
||||
* Displays fix progress with polling and carousel navigation
|
||||
*/
|
||||
// @ts-expect-error Component is defined for future use when backend implements /api/fix-progress
|
||||
function FixProgressCarousel({ sessionId }: { sessionId: string }) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [fixProgressData, setFixProgressData] = React.useState<FixProgressData | null>(null);
|
||||
|
||||
@@ -101,7 +101,7 @@ export function TeamPage() {
|
||||
|
||||
{/* Artifacts Tab */}
|
||||
{detailTab === 'artifacts' && (
|
||||
<TeamArtifacts messages={messages} />
|
||||
<TeamArtifacts teamName={selectedTeam} />
|
||||
)}
|
||||
|
||||
{/* Messages Tab */}
|
||||
|
||||
@@ -50,7 +50,7 @@ export interface TeamSummaryExtended extends TeamSummary {
|
||||
archived_at?: string;
|
||||
pipeline_mode?: string;
|
||||
memberCount: number;
|
||||
members?: string[];
|
||||
members: string[]; // Always provided by backend
|
||||
}
|
||||
|
||||
export interface TeamMessagesResponse {
|
||||
@@ -76,3 +76,33 @@ export interface TeamMessageFilter {
|
||||
|
||||
export type PipelineStage = 'plan' | 'impl' | 'test' | 'review';
|
||||
export type PipelineStageStatus = 'completed' | 'in_progress' | 'pending' | 'blocked';
|
||||
|
||||
// ========================================
|
||||
// Team Artifacts Types
|
||||
// ========================================
|
||||
// Types for team artifacts tree visualization
|
||||
|
||||
export type ArtifactNodeType = 'file' | 'directory';
|
||||
|
||||
export type ContentType = 'markdown' | 'json' | 'text' | 'unknown';
|
||||
|
||||
export interface ArtifactNode {
|
||||
type: ArtifactNodeType;
|
||||
name: string;
|
||||
path: string;
|
||||
contentType: ContentType; // Always provided by backend
|
||||
size?: number;
|
||||
modifiedAt?: string;
|
||||
children?: ArtifactNode[];
|
||||
}
|
||||
|
||||
export interface TeamArtifactsResponse {
|
||||
teamName: string;
|
||||
sessionId: string;
|
||||
sessionPath: string;
|
||||
pipelineMode?: string;
|
||||
tree: ArtifactNode[];
|
||||
totalFiles: number;
|
||||
totalDirectories: number;
|
||||
totalSize: number;
|
||||
}
|
||||
|
||||
@@ -1,82 +1,346 @@
|
||||
/**
|
||||
* Team Routes - REST API for team message visualization & management
|
||||
*
|
||||
* Directory Structure (NEW - session-bound):
|
||||
* .workflow/.team/
|
||||
* ├── TLS-demo-2026-02-15/ # session-id (root)
|
||||
* │ ├── .msg/ # messages (session-level)
|
||||
* │ │ ├── meta.json
|
||||
* │ │ └── messages.jsonl
|
||||
* │ ├── spec/ # artifacts (siblings of .msg)
|
||||
* │ └── plan/
|
||||
*
|
||||
* Legacy Support: Also scans .workflow/.team-msg/{team-name}/
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /api/teams - List all teams (with ?location filter)
|
||||
* - GET /api/teams/:name/messages - Get messages (with filters)
|
||||
* - GET /api/teams/:name/status - Get member status summary
|
||||
* - POST /api/teams/:name/archive - Archive a team
|
||||
* - POST /api/teams/:name/unarchive - Unarchive a team
|
||||
* - DELETE /api/teams/:name - Delete a team
|
||||
* - GET /api/teams - List all teams (with ?location filter)
|
||||
* - GET /api/teams/:name/messages - Get messages (with filters)
|
||||
* - GET /api/teams/:name/status - Get member status summary
|
||||
* - GET /api/teams/:name/artifacts - Get artifacts tree structure
|
||||
* - GET /api/teams/:name/artifacts/*path - Get artifact file content
|
||||
* - POST /api/teams/:name/archive - Archive a team
|
||||
* - POST /api/teams/:name/unarchive - Unarchive a team
|
||||
* - DELETE /api/teams/:name - Delete a team
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { existsSync, readdirSync, rmSync, statSync, readFileSync } from 'fs';
|
||||
import { join, extname } from 'path';
|
||||
import type { RouteContext } from './types.js';
|
||||
import { readAllMessages, getLogDir, getEffectiveTeamMeta, readTeamMeta, writeTeamMeta } from '../../tools/team-msg.js';
|
||||
import type { TeamMeta } from '../../tools/team-msg.js';
|
||||
import { getProjectRoot } from '../../utils/path-validator.js';
|
||||
|
||||
/**
|
||||
* Artifact node structure for tree representation
|
||||
*/
|
||||
interface ArtifactNode {
|
||||
type: 'file' | 'directory';
|
||||
name: string;
|
||||
path: string; // Relative to session directory
|
||||
contentType: 'markdown' | 'json' | 'text' | 'unknown';
|
||||
size?: number; // File size (bytes)
|
||||
modifiedAt?: string; // Last modified time
|
||||
children?: ArtifactNode[]; // Directory children
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect content type from file extension
|
||||
*/
|
||||
function detectContentType(fileName: string): ArtifactNode['contentType'] {
|
||||
const ext = extname(fileName).toLowerCase();
|
||||
if (['.md', '.markdown'].includes(ext)) return 'markdown';
|
||||
if (['.json'].includes(ext)) return 'json';
|
||||
if (['.txt', '.log', '.tsv', '.csv'].includes(ext)) return 'text';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively scan artifacts directory
|
||||
* Skips .msg directory (message storage)
|
||||
*/
|
||||
function scanArtifactsDirectory(dirPath: string, basePath: string): ArtifactNode[] {
|
||||
const nodes: ArtifactNode[] = [];
|
||||
|
||||
if (!existsSync(dirPath)) {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = readdirSync(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
// Skip .msg directory - that's for messages, not artifacts
|
||||
if (entry.name === '.msg') continue;
|
||||
|
||||
const fullPath = join(dirPath, entry.name);
|
||||
const relativePath = join(basePath, entry.name).replace(/\\/g, '/');
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const children = scanArtifactsDirectory(fullPath, relativePath);
|
||||
const stat = statSync(fullPath);
|
||||
|
||||
nodes.push({
|
||||
type: 'directory',
|
||||
name: entry.name,
|
||||
path: relativePath,
|
||||
contentType: 'unknown',
|
||||
modifiedAt: stat.mtime.toISOString(),
|
||||
children,
|
||||
});
|
||||
} else if (entry.isFile()) {
|
||||
const stat = statSync(fullPath);
|
||||
|
||||
nodes.push({
|
||||
type: 'file',
|
||||
name: entry.name,
|
||||
path: relativePath,
|
||||
contentType: detectContentType(entry.name),
|
||||
size: stat.size,
|
||||
modifiedAt: stat.mtime.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: directories first, then files, both alphabetically
|
||||
nodes.sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === 'directory' ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
} catch (e) {
|
||||
// Ignore errors (permission, etc.)
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session directory (artifacts are siblings of .msg/)
|
||||
* NEW: .workflow/.team/{session-id}/
|
||||
*/
|
||||
function getSessionDir(sessionId: string, root: string): string {
|
||||
return join(root, '.workflow', '.team', sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get legacy team directory
|
||||
* OLD: .workflow/.team-msg/{team-name}/
|
||||
*/
|
||||
function getLegacyTeamDir(teamName: string, root: string): string {
|
||||
return join(root, '.workflow', '.team-msg', teamName);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all sessions from new .team/ directory
|
||||
* Each subdirectory with .msg/ folder is a valid session
|
||||
*/
|
||||
function listSessions(root: string): Array<{ sessionId: string; path: string }> {
|
||||
const teamDir = join(root, '.workflow', '.team');
|
||||
const sessions: Array<{ sessionId: string; path: string }> = [];
|
||||
|
||||
console.log('[team-routes] listSessions - root:', root);
|
||||
console.log('[team-routes] listSessions - teamDir:', teamDir);
|
||||
console.log('[team-routes] listSessions - existsSync(teamDir):', existsSync(teamDir));
|
||||
|
||||
if (!existsSync(teamDir)) {
|
||||
return sessions;
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = readdirSync(teamDir, { withFileTypes: true });
|
||||
console.log('[team-routes] listSessions - entries:', entries.map(e => e.name));
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const msgDir = join(teamDir, entry.name, '.msg');
|
||||
console.log('[team-routes] listSessions - checking msgDir:', msgDir, 'exists:', existsSync(msgDir));
|
||||
if (existsSync(msgDir)) {
|
||||
sessions.push({ sessionId: entry.name, path: join(teamDir, entry.name) });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[team-routes] listSessions error:', err);
|
||||
}
|
||||
|
||||
console.log('[team-routes] listSessions - found sessions:', sessions.length);
|
||||
return sessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* List teams from old .team-msg/ directory (backward compatibility)
|
||||
*/
|
||||
function listLegacyTeams(root: string): Array<{ teamName: string; path: string }> {
|
||||
const teamMsgDir = join(root, '.workflow', '.team-msg');
|
||||
const teams: Array<{ teamName: string; path: string }> = [];
|
||||
|
||||
if (!existsSync(teamMsgDir)) {
|
||||
return teams;
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = readdirSync(teamMsgDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
teams.push({ teamName: entry.name, path: join(teamMsgDir, entry.name) });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
return teams;
|
||||
}
|
||||
|
||||
function jsonResponse(res: import('http').ServerResponse, status: number, data: unknown): void {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve project root from context
|
||||
* Priority: initialPath (from server startup) > getProjectRoot()
|
||||
*/
|
||||
function resolveProjectRoot(ctx: RouteContext): string {
|
||||
return ctx.initialPath || getProjectRoot();
|
||||
}
|
||||
|
||||
export async function handleTeamRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const { pathname, req, res, url, handlePostRequest } = ctx;
|
||||
|
||||
if (!pathname.startsWith('/api/teams')) return false;
|
||||
|
||||
// ====== GET /api/teams/debug - Debug endpoint ======
|
||||
if (pathname === '/api/teams/debug' && req.method === 'GET') {
|
||||
const root = resolveProjectRoot(ctx);
|
||||
const teamDir = join(root, '.workflow', '.team');
|
||||
const legacyDir = join(root, '.workflow', '.team-msg');
|
||||
|
||||
const debug = {
|
||||
projectRoot: root,
|
||||
teamDir,
|
||||
legacyDir,
|
||||
teamDirExists: existsSync(teamDir),
|
||||
legacyDirExists: existsSync(legacyDir),
|
||||
teamDirContents: [] as string[],
|
||||
sessionMsgDirs: [] as { session: string; msgExists: boolean }[],
|
||||
};
|
||||
|
||||
if (existsSync(teamDir)) {
|
||||
try {
|
||||
const entries = readdirSync(teamDir, { withFileTypes: true });
|
||||
debug.teamDirContents = entries.map(e => `${e.name} (${e.isDirectory() ? 'dir' : 'file'})`);
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const msgDir = join(teamDir, entry.name, '.msg');
|
||||
debug.sessionMsgDirs.push({
|
||||
session: entry.name,
|
||||
msgExists: existsSync(msgDir),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
(debug as any).error = String(err);
|
||||
}
|
||||
}
|
||||
|
||||
jsonResponse(res, 200, debug);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ====== GET /api/teams - List all teams ======
|
||||
if (pathname === '/api/teams' && req.method === 'GET') {
|
||||
try {
|
||||
const root = getProjectRoot();
|
||||
const teamMsgDir = join(root, '.workflow', '.team-msg');
|
||||
const root = resolveProjectRoot(ctx);
|
||||
const locationFilter = url.searchParams.get('location') || 'active';
|
||||
|
||||
if (!existsSync(teamMsgDir)) {
|
||||
jsonResponse(res, 200, { teams: [] });
|
||||
return true;
|
||||
// Collect from new session-bound structure
|
||||
const sessions = listSessions(root);
|
||||
// Collect from legacy structure
|
||||
const legacyTeams = listLegacyTeams(root);
|
||||
|
||||
// Build unified team list
|
||||
const 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[];
|
||||
isLegacy: boolean;
|
||||
}> = [];
|
||||
|
||||
// Process new sessions
|
||||
for (const session of sessions) {
|
||||
const messages = readAllMessages(session.sessionId);
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
const meta = getEffectiveTeamMeta(session.sessionId);
|
||||
|
||||
const memberSet = new Set<string>();
|
||||
for (const msg of messages) {
|
||||
memberSet.add(msg.from);
|
||||
memberSet.add(msg.to);
|
||||
}
|
||||
|
||||
teams.push({
|
||||
name: session.sessionId,
|
||||
messageCount: messages.length,
|
||||
lastActivity: lastMsg?.ts || '',
|
||||
status: meta.status,
|
||||
created_at: meta.created_at,
|
||||
updated_at: meta.updated_at,
|
||||
archived_at: meta.archived_at,
|
||||
pipeline_mode: meta.pipeline_mode,
|
||||
memberCount: memberSet.size,
|
||||
members: Array.from(memberSet),
|
||||
isLegacy: false,
|
||||
});
|
||||
}
|
||||
|
||||
const locationFilter = url.searchParams.get('location') || 'active';
|
||||
const entries = readdirSync(teamMsgDir, { withFileTypes: true });
|
||||
// Process legacy teams
|
||||
for (const team of legacyTeams) {
|
||||
// Skip if already found in new structure (same name)
|
||||
if (teams.some(t => t.name === team.teamName)) continue;
|
||||
|
||||
const teams = entries
|
||||
.filter(e => e.isDirectory())
|
||||
.map(e => {
|
||||
const messages = readAllMessages(e.name);
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
const meta = getEffectiveTeamMeta(e.name);
|
||||
const messages = readAllMessages(team.teamName);
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
const meta = getEffectiveTeamMeta(team.teamName);
|
||||
|
||||
// Count unique members from messages
|
||||
const memberSet = new Set<string>();
|
||||
for (const msg of messages) {
|
||||
memberSet.add(msg.from);
|
||||
memberSet.add(msg.to);
|
||||
}
|
||||
const memberSet = new Set<string>();
|
||||
for (const msg of messages) {
|
||||
memberSet.add(msg.from);
|
||||
memberSet.add(msg.to);
|
||||
}
|
||||
|
||||
return {
|
||||
name: e.name,
|
||||
messageCount: messages.length,
|
||||
lastActivity: lastMsg?.ts || '',
|
||||
status: meta.status,
|
||||
created_at: meta.created_at,
|
||||
updated_at: meta.updated_at,
|
||||
archived_at: meta.archived_at,
|
||||
pipeline_mode: meta.pipeline_mode,
|
||||
memberCount: memberSet.size,
|
||||
members: Array.from(memberSet),
|
||||
};
|
||||
})
|
||||
teams.push({
|
||||
name: team.teamName,
|
||||
messageCount: messages.length,
|
||||
lastActivity: lastMsg?.ts || '',
|
||||
status: meta.status,
|
||||
created_at: meta.created_at,
|
||||
updated_at: meta.updated_at,
|
||||
archived_at: meta.archived_at,
|
||||
pipeline_mode: meta.pipeline_mode,
|
||||
memberCount: memberSet.size,
|
||||
members: Array.from(memberSet),
|
||||
isLegacy: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
const filteredTeams = teams
|
||||
.filter(t => {
|
||||
if (locationFilter === 'all') return true;
|
||||
if (locationFilter === 'archived') return t.status === 'archived';
|
||||
// 'active' = everything that's not archived
|
||||
return t.status !== 'archived';
|
||||
})
|
||||
.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
|
||||
|
||||
jsonResponse(res, 200, { teams });
|
||||
jsonResponse(res, 200, { teams: filteredTeams });
|
||||
return true;
|
||||
} catch (error) {
|
||||
jsonResponse(res, 500, { error: (error as Error).message });
|
||||
@@ -126,14 +390,70 @@ export async function handleTeamRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const deleteMatch = pathname.match(/^\/api\/teams\/([^/]+)$/);
|
||||
if (deleteMatch && req.method === 'DELETE') {
|
||||
const teamName = decodeURIComponent(deleteMatch[1]);
|
||||
const root = resolveProjectRoot(ctx);
|
||||
try {
|
||||
const dir = getLogDir(teamName);
|
||||
if (!existsSync(dir)) {
|
||||
jsonResponse(res, 404, { error: `Team "${teamName}" not found` });
|
||||
// Try new session-bound location first
|
||||
const sessionDir = getSessionDir(teamName, root);
|
||||
if (existsSync(sessionDir)) {
|
||||
rmSync(sessionDir, { recursive: true, force: true });
|
||||
jsonResponse(res, 200, { success: true, team: teamName, deleted: true });
|
||||
return true;
|
||||
}
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
jsonResponse(res, 200, { success: true, team: teamName, deleted: true });
|
||||
|
||||
// Fallback to legacy location
|
||||
const legacyDir = getLegacyTeamDir(teamName, root);
|
||||
if (existsSync(legacyDir)) {
|
||||
rmSync(legacyDir, { recursive: true, force: true });
|
||||
jsonResponse(res, 200, { success: true, team: teamName, deleted: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
jsonResponse(res, 404, { error: `Team "${teamName}" not found` });
|
||||
return true;
|
||||
} catch (error) {
|
||||
jsonResponse(res, 500, { error: (error as Error).message });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ====== GET requests only from here ======
|
||||
if (req.method !== 'GET') return false;
|
||||
|
||||
// ====== GET /api/teams/:name/artifacts or /api/teams/:name/artifacts/*path ======
|
||||
const artifactsMatch = pathname.match(/^\/api\/teams\/([^/]+)\/artifacts(?:\/(.*))?$/);
|
||||
if (artifactsMatch) {
|
||||
const artifactsTeamName = decodeURIComponent(artifactsMatch[1]);
|
||||
const artifactPath = artifactsMatch[2] ? decodeURIComponent(artifactsMatch[2]) : null;
|
||||
const root = resolveProjectRoot(ctx);
|
||||
|
||||
try {
|
||||
// NEW: Session directory contains both .msg/ and artifacts
|
||||
// The team name IS the session ID now
|
||||
const sessionDir = getSessionDir(artifactsTeamName, root);
|
||||
|
||||
if (!existsSync(sessionDir)) {
|
||||
// Check if it's a legacy team with session_id
|
||||
const meta = getEffectiveTeamMeta(artifactsTeamName);
|
||||
if (meta.session_id) {
|
||||
// Legacy team with session_id - redirect to session directory
|
||||
const legacySessionDir = getSessionDir(meta.session_id, root);
|
||||
if (existsSync(legacySessionDir)) {
|
||||
serveArtifacts(legacySessionDir, meta.session_id, meta, artifactPath, res);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
jsonResponse(res, 200, {
|
||||
tree: [],
|
||||
sessionId: null,
|
||||
message: 'Session directory not found'
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Direct session access - artifacts are siblings of .msg/
|
||||
const meta = getEffectiveTeamMeta(artifactsTeamName);
|
||||
serveArtifacts(sessionDir, artifactsTeamName, meta, artifactPath, res);
|
||||
return true;
|
||||
} catch (error) {
|
||||
jsonResponse(res, 500, { error: (error as Error).message });
|
||||
@@ -142,8 +462,6 @@ export async function handleTeamRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
}
|
||||
|
||||
// ====== GET /api/teams/:name/messages or /api/teams/:name/status ======
|
||||
if (req.method !== 'GET') return false;
|
||||
|
||||
const match = pathname.match(/^\/api\/teams\/([^/]+)\/(messages|status)$/);
|
||||
if (!match) return false;
|
||||
|
||||
@@ -208,3 +526,117 @@ export async function handleTeamRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve artifacts from session directory
|
||||
*/
|
||||
function serveArtifacts(
|
||||
sessionDir: string,
|
||||
sessionId: string,
|
||||
meta: TeamMeta,
|
||||
artifactPath: string | null,
|
||||
res: import('http').ServerResponse
|
||||
): void {
|
||||
// If specific file path requested
|
||||
if (artifactPath) {
|
||||
const filePath = join(sessionDir, artifactPath);
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
jsonResponse(res, 404, {
|
||||
error: 'Artifact not found',
|
||||
path: artifactPath
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const stat = statSync(filePath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Return directory listing
|
||||
const children = scanArtifactsDirectory(filePath, artifactPath);
|
||||
jsonResponse(res, 200, {
|
||||
type: 'directory',
|
||||
name: artifactPath.split('/').pop() || '',
|
||||
path: artifactPath,
|
||||
children,
|
||||
modifiedAt: stat.mtime.toISOString()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Return file content
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
const contentType = detectContentType(artifactPath.split('/').pop() || '');
|
||||
|
||||
jsonResponse(res, 200, {
|
||||
type: 'file',
|
||||
name: artifactPath.split('/').pop() || '',
|
||||
path: artifactPath,
|
||||
contentType,
|
||||
size: stat.size,
|
||||
modifiedAt: stat.mtime.toISOString(),
|
||||
content
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Return full artifacts tree
|
||||
const tree = scanArtifactsDirectory(sessionDir, '');
|
||||
|
||||
jsonResponse(res, 200, {
|
||||
teamName: sessionId,
|
||||
sessionId: sessionId,
|
||||
sessionPath: sessionDir,
|
||||
pipelineMode: meta.pipeline_mode,
|
||||
tree,
|
||||
totalFiles: countFiles(tree),
|
||||
totalDirectories: countDirectories(tree),
|
||||
totalSize: countTotalSize(tree)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total files in artifact tree
|
||||
*/
|
||||
function countFiles(nodes: ArtifactNode[]): number {
|
||||
let count = 0;
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'file') {
|
||||
count++;
|
||||
} else if (node.children) {
|
||||
count += countFiles(node.children);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total directories in artifact tree
|
||||
*/
|
||||
function countDirectories(nodes: ArtifactNode[]): number {
|
||||
let count = 0;
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'directory') {
|
||||
count++;
|
||||
if (node.children) {
|
||||
count += countDirectories(node.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total size of all files in artifact tree
|
||||
*/
|
||||
function countTotalSize(nodes: ArtifactNode[]): number {
|
||||
let totalSize = 0;
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'file' && node.size) {
|
||||
totalSize += node.size;
|
||||
} else if (node.children) {
|
||||
totalSize += countTotalSize(node.children);
|
||||
}
|
||||
}
|
||||
return totalSize;
|
||||
}
|
||||
|
||||
@@ -788,17 +788,22 @@ export class JsonLinesParser implements IOutputParser {
|
||||
|
||||
// Default: treat as stdout/stderr based on fallback
|
||||
if (json.content || json.message || json.text) {
|
||||
const rawContent = json.content || json.message || json.text;
|
||||
// Safely convert to string: content may be an array (e.g. Claude API content blocks) or object
|
||||
const contentStr = typeof rawContent === 'string'
|
||||
? rawContent
|
||||
: JSON.stringify(rawContent);
|
||||
this.debugLog('mapJsonToIR_fallback_stdout', {
|
||||
type: json.type,
|
||||
fallbackType: fallbackStreamType,
|
||||
hasContent: !!json.content,
|
||||
hasMessage: !!json.message,
|
||||
hasText: !!json.text,
|
||||
contentPreview: (json.content || json.message || json.text || '').substring(0, 100)
|
||||
contentPreview: contentStr.substring(0, 100)
|
||||
});
|
||||
return {
|
||||
type: fallbackStreamType,
|
||||
content: json.content || json.message || json.text,
|
||||
content: contentStr,
|
||||
timestamp
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface TeamMeta {
|
||||
updated_at: string;
|
||||
archived_at?: string;
|
||||
pipeline_mode?: string;
|
||||
session_id?: string; // Links to .workflow/.team/{session-id}/ artifacts directory
|
||||
}
|
||||
|
||||
export function getMetaPath(team: string): string {
|
||||
@@ -106,7 +107,7 @@ export interface StatusEntry {
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
operation: z.enum(['log', 'read', 'list', 'status', 'delete', 'clear']).describe('Operation to perform'),
|
||||
team: z.string().describe('Team name (maps to .workflow/.team-msg/{team}/messages.jsonl)'),
|
||||
team: z.string().describe('Session ID (new: .workflow/.team/{session-id}/.msg/) or team name (legacy: .workflow/.team-msg/{team}/)'),
|
||||
|
||||
// log params
|
||||
from: z.string().optional().describe('[log/list] Sender role name'),
|
||||
@@ -121,6 +122,9 @@ const ParamsSchema = z.object({
|
||||
|
||||
// list params
|
||||
last: z.number().min(1).max(100).optional().describe('[list] Return last N messages (default: 20)'),
|
||||
|
||||
// session_id for artifact discovery
|
||||
session_id: z.string().optional().describe('[log] Session ID for artifact discovery (links team to .workflow/.team/{session-id}/)'),
|
||||
});
|
||||
|
||||
type Params = z.infer<typeof ParamsSchema>;
|
||||
@@ -131,14 +135,21 @@ export const schema: ToolSchema = {
|
||||
name: 'team_msg',
|
||||
description: `Team message bus - persistent JSONL log for Agent Team communication.
|
||||
|
||||
Directory Structure (NEW):
|
||||
.workflow/.team/{session-id}/.msg/messages.jsonl
|
||||
|
||||
Directory Structure (LEGACY):
|
||||
.workflow/.team-msg/{team-name}/messages.jsonl
|
||||
|
||||
Operations:
|
||||
team_msg(operation="log", team="my-team", from="planner", to="coordinator", type="plan_ready", summary="Plan ready: 3 tasks", ref=".workflow/.team-plan/my-team/plan.json")
|
||||
team_msg(operation="read", team="my-team", id="MSG-003")
|
||||
team_msg(operation="list", team="my-team")
|
||||
team_msg(operation="list", team="my-team", from="tester", last=5)
|
||||
team_msg(operation="status", team="my-team")
|
||||
team_msg(operation="delete", team="my-team", id="MSG-003")
|
||||
team_msg(operation="clear", team="my-team")
|
||||
team_msg(operation="log", team="TLS-my-team-2026-02-15", from="planner", to="coordinator", type="plan_ready", summary="Plan ready: 3 tasks", ref=".workflow/.team-plan/my-team/plan.json")
|
||||
team_msg(operation="log", team="TLS-my-team-2026-02-15", from="coordinator", to="implementer", type="task_unblocked", summary="Task ready")
|
||||
team_msg(operation="read", team="TLS-my-team-2026-02-15", id="MSG-003")
|
||||
team_msg(operation="list", team="TLS-my-team-2026-02-15")
|
||||
team_msg(operation="list", team="TLS-my-team-2026-02-15", from="tester", last=5)
|
||||
team_msg(operation="status", team="TLS-my-team-2026-02-15")
|
||||
team_msg(operation="delete", team="TLS-my-team-2026-02-15", id="MSG-003")
|
||||
team_msg(operation="clear", team="TLS-my-team-2026-02-15")
|
||||
|
||||
Message types: plan_ready, plan_approved, plan_revision, task_unblocked, impl_complete, impl_progress, test_result, review_result, fix_required, error, shutdown`,
|
||||
inputSchema: {
|
||||
@@ -161,6 +172,7 @@ Message types: plan_ready, plan_approved, plan_revision, task_unblocked, impl_co
|
||||
data: { type: 'object', description: '[log] Optional structured data' },
|
||||
id: { type: 'string', description: '[read] Message ID (e.g. MSG-003)' },
|
||||
last: { type: 'number', description: '[list] Last N messages (default 20)', minimum: 1, maximum: 100 },
|
||||
session_id: { type: 'string', description: '[log] Session ID for artifact discovery' },
|
||||
},
|
||||
required: ['operation', 'team'],
|
||||
},
|
||||
@@ -168,9 +180,26 @@ Message types: plan_ready, plan_approved, plan_revision, task_unblocked, impl_co
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
export function getLogDir(team: string): string {
|
||||
/**
|
||||
* Get the log directory for a session.
|
||||
* New structure: .workflow/.team/{session-id}/.msg/
|
||||
*/
|
||||
export function getLogDir(sessionId: string): string {
|
||||
const root = getProjectRoot();
|
||||
return join(root, '.workflow', '.team-msg', team);
|
||||
return join(root, '.workflow', '.team', sessionId, '.msg');
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy support: Check both new (.team/{id}/.msg) and old (.team-msg/{id}) locations
|
||||
*/
|
||||
export function getLogDirWithFallback(sessionId: string): string {
|
||||
const newPath = getLogDir(sessionId);
|
||||
if (existsSync(newPath)) {
|
||||
return newPath;
|
||||
}
|
||||
// Fallback to old location for backward compatibility
|
||||
const root = getProjectRoot();
|
||||
return join(root, '.workflow', '.team-msg', sessionId);
|
||||
}
|
||||
|
||||
function getLogPath(team: string): string {
|
||||
@@ -241,6 +270,14 @@ function opLog(params: Params): ToolResult {
|
||||
|
||||
appendFileSync(logPath, JSON.stringify(msg) + '\n', 'utf-8');
|
||||
|
||||
// Update meta with session_id if provided
|
||||
if (params.session_id) {
|
||||
const meta = getEffectiveTeamMeta(params.team);
|
||||
meta.session_id = params.session_id;
|
||||
meta.updated_at = nowISO();
|
||||
writeTeamMeta(params.team, meta);
|
||||
}
|
||||
|
||||
return { success: true, result: { id, message: `Logged ${id}: [${msg.from} → ${msg.to}] ${msg.summary}` } };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user