mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-04 01:40:45 +08:00
feat: add CliStreamMonitor and related components for CLI output streaming
- Implemented CliStreamMonitor component for real-time CLI output monitoring with multi-execution support. - Created JsonFormatter component for displaying JSON content in various formats (text, card, inline). - Added utility functions for JSON detection and formatting in jsonUtils.ts. - Introduced LogBlock utility functions for styling CLI output lines. - Developed a new Collapsible component for better UI interactions. - Created IssueHubPage for managing issues, queue, and discovery with tab navigation.
This commit is contained in:
1581
ccw/frontend/package-lock.json
generated
1581
ccw/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.0",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
@@ -34,11 +35,15 @@
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lucide-react": "^0.460.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-intl": "^6.8.9",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^2.5.0",
|
||||
"zod": "^3.23.8",
|
||||
"zustand": "^5.0.0"
|
||||
|
||||
153
ccw/frontend/src/components/issue/hub/DiscoveryPanel.tsx
Normal file
153
ccw/frontend/src/components/issue/hub/DiscoveryPanel.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
// ========================================
|
||||
// Discovery Panel
|
||||
// ========================================
|
||||
// Content panel for Discovery tab in IssueHub
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Radar, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useIssueDiscovery } from '@/hooks/useIssues';
|
||||
import { DiscoveryCard } from '@/components/issue/discovery/DiscoveryCard';
|
||||
import { DiscoveryDetail } from '@/components/issue/discovery/DiscoveryDetail';
|
||||
|
||||
// ========== Main Panel Component ==========
|
||||
|
||||
export function DiscoveryPanel() {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const {
|
||||
sessions,
|
||||
activeSession,
|
||||
findings,
|
||||
isLoadingSessions,
|
||||
isLoadingFindings,
|
||||
error,
|
||||
filters,
|
||||
setFilters,
|
||||
selectSession,
|
||||
exportFindings,
|
||||
} = useIssueDiscovery({ refetchInterval: 3000 });
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="p-8 text-center">
|
||||
<AlertCircle className="w-12 h-12 mx-auto text-destructive" />
|
||||
<h3 className="mt-4 text-lg font-medium text-foreground">
|
||||
{formatMessage({ id: 'common.error' })}
|
||||
</h3>
|
||||
<p className="mt-2 text-muted-foreground">{error.message}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Radar className="w-5 h-5 text-primary" />
|
||||
<span className="text-2xl font-bold">{sessions.length}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'issues.discovery.totalSessions' })}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="success" className="w-5 h-5 flex items-center justify-center p-0">
|
||||
{sessions.filter(s => s.status === 'completed').length}
|
||||
</Badge>
|
||||
<span className="text-2xl font-bold">{sessions.filter(s => s.status === 'completed').length}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'issues.discovery.completedSessions' })}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="warning" className="w-5 h-5 flex items-center justify-center p-0">
|
||||
{sessions.filter(s => s.status === 'running').length}
|
||||
</Badge>
|
||||
<span className="text-2xl font-bold">{sessions.filter(s => s.status === 'running').length}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'issues.discovery.runningSessions' })}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-bold">
|
||||
{sessions.reduce((sum, s) => sum + s.findings_count, 0)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'issues.discovery.totalFindings' })}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Main Content: Split Pane */}
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{/* Left: Session List */}
|
||||
<div className="md:col-span-1 space-y-4">
|
||||
<h2 className="text-lg font-medium text-foreground">
|
||||
{formatMessage({ id: 'issues.discovery.sessionList' })}
|
||||
</h2>
|
||||
|
||||
{isLoadingSessions ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-32 bg-muted animate-pulse rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<Radar className="w-12 h-12 mx-auto text-muted-foreground/50" />
|
||||
<h3 className="mt-4 text-lg font-medium text-foreground">
|
||||
{formatMessage({ id: 'issues.discovery.noSessions' })}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'issues.discovery.noSessionsDescription' })}
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sessions.map((session) => (
|
||||
<DiscoveryCard
|
||||
key={session.id}
|
||||
session={session}
|
||||
isActive={activeSession?.id === session.id}
|
||||
onClick={() => selectSession(session.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Findings Detail */}
|
||||
<div className="md:col-span-2 space-y-4">
|
||||
<h2 className="text-lg font-medium text-foreground">
|
||||
{formatMessage({ id: 'issues.discovery.findingsDetail' })}
|
||||
</h2>
|
||||
|
||||
{isLoadingFindings ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<DiscoveryDetail
|
||||
sessionId={activeSession?.id || ''}
|
||||
session={activeSession}
|
||||
findings={findings}
|
||||
filters={filters}
|
||||
onFilterChange={setFilters}
|
||||
onExport={exportFindings}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
ccw/frontend/src/components/issue/hub/IssueHubHeader.tsx
Normal file
52
ccw/frontend/src/components/issue/hub/IssueHubHeader.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
// ========================================
|
||||
// Issue Hub Header
|
||||
// ========================================
|
||||
// Dynamic header component for IssueHub
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { AlertCircle, Radar, ListTodo } from 'lucide-react';
|
||||
|
||||
type IssueTab = 'issues' | 'queue' | 'discovery';
|
||||
|
||||
interface IssueHubHeaderProps {
|
||||
currentTab: IssueTab;
|
||||
}
|
||||
|
||||
export function IssueHubHeader({ currentTab }: IssueHubHeaderProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// Tab configuration with icons and labels
|
||||
const tabConfig = {
|
||||
issues: {
|
||||
icon: <AlertCircle className="w-6 h-6 text-primary" />,
|
||||
title: formatMessage({ id: 'issues.title' }),
|
||||
description: formatMessage({ id: 'issues.description' }),
|
||||
},
|
||||
queue: {
|
||||
icon: <ListTodo className="w-6 h-6 text-primary" />,
|
||||
title: formatMessage({ id: 'issues.queue.pageTitle' }),
|
||||
description: formatMessage({ id: 'issues.queue.description' }),
|
||||
},
|
||||
discovery: {
|
||||
icon: <Radar className="w-6 h-6 text-primary" />,
|
||||
title: formatMessage({ id: 'issues.discovery.pageTitle' }),
|
||||
description: formatMessage({ id: 'issues.discovery.description' }),
|
||||
},
|
||||
};
|
||||
|
||||
const config = tabConfig[currentTab];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{config.icon}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
{config.title}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
{config.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
ccw/frontend/src/components/issue/hub/IssueHubTabs.tsx
Normal file
45
ccw/frontend/src/components/issue/hub/IssueHubTabs.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
// ========================================
|
||||
// Issue Hub Tabs
|
||||
// ========================================
|
||||
// Tab navigation for IssueHub
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type IssueTab = 'issues' | 'queue' | 'discovery';
|
||||
|
||||
interface IssueHubTabsProps {
|
||||
currentTab: IssueTab;
|
||||
onTabChange: (tab: IssueTab) => void;
|
||||
}
|
||||
|
||||
export function IssueHubTabs({ currentTab, onTabChange }: IssueHubTabsProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const tabs: Array<{ value: IssueTab; label: string }> = [
|
||||
{ value: 'issues', label: formatMessage({ id: 'issues.hub.tabs.issues' }) },
|
||||
{ value: 'queue', label: formatMessage({ id: 'issues.hub.tabs.queue' }) },
|
||||
{ value: 'discovery', label: formatMessage({ id: 'issues.hub.tabs.discovery' }) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 border-b border-border">
|
||||
{tabs.map((tab) => (
|
||||
<Button
|
||||
key={tab.value}
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"border-b-2 rounded-none h-11 px-4",
|
||||
currentTab === tab.value
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => onTabChange(tab.value)}
|
||||
>
|
||||
{tab.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
320
ccw/frontend/src/components/issue/hub/IssuesPanel.tsx
Normal file
320
ccw/frontend/src/components/issue/hub/IssuesPanel.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
// ========================================
|
||||
// Issues Panel
|
||||
// ========================================
|
||||
// Issue list panel for IssueHub
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
Github,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select';
|
||||
import { IssueCard } from '@/components/shared/IssueCard';
|
||||
import { useIssues, useIssueMutations } from '@/hooks';
|
||||
import type { Issue } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type StatusFilter = 'all' | Issue['status'];
|
||||
type PriorityFilter = 'all' | Issue['priority'];
|
||||
|
||||
interface NewIssueDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (data: { title: string; context?: string; priority?: Issue['priority'] }) => void;
|
||||
isCreating: boolean;
|
||||
}
|
||||
|
||||
function NewIssueDialog({ open, onOpenChange, onSubmit, isCreating }: NewIssueDialogProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [title, setTitle] = useState('');
|
||||
const [context, setContext] = useState('');
|
||||
const [priority, setPriority] = useState<Issue['priority']>('medium');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (title.trim()) {
|
||||
onSubmit({ title: title.trim(), context: context.trim() || undefined, priority });
|
||||
setTitle('');
|
||||
setContext('');
|
||||
setPriority('medium');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{formatMessage({ id: 'issues.createDialog.title' })}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'issues.createDialog.labels.title' })}</label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'issues.createDialog.placeholders.title' })}
|
||||
className="mt-1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'issues.createDialog.labels.context' })}</label>
|
||||
<textarea
|
||||
value={context}
|
||||
onChange={(e) => setContext(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'issues.createDialog.placeholders.context' })}
|
||||
className="mt-1 w-full min-h-[100px] p-3 bg-background border border-input rounded-md text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'issues.createDialog.labels.priority' })}</label>
|
||||
<Select value={priority} onValueChange={(v) => setPriority(v as Issue['priority'])}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">{formatMessage({ id: 'issues.priority.low' })}</SelectItem>
|
||||
<SelectItem value="medium">{formatMessage({ id: 'issues.priority.medium' })}</SelectItem>
|
||||
<SelectItem value="high">{formatMessage({ id: 'issues.priority.high' })}</SelectItem>
|
||||
<SelectItem value="critical">{formatMessage({ id: 'issues.priority.critical' })}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{formatMessage({ id: 'issues.createDialog.buttons.cancel' })}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isCreating || !title.trim()}>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{formatMessage({ id: 'issues.createDialog.buttons.creating' })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.createDialog.buttons.create' })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface IssueListProps {
|
||||
issues: Issue[];
|
||||
isLoading: boolean;
|
||||
onIssueClick: (issue: Issue) => void;
|
||||
onIssueEdit: (issue: Issue) => void;
|
||||
onIssueDelete: (issue: Issue) => void;
|
||||
onStatusChange: (issue: Issue, status: Issue['status']) => void;
|
||||
}
|
||||
|
||||
function IssueList({ issues, isLoading, onIssueClick, onIssueEdit, onIssueDelete, onStatusChange }: IssueListProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-32 bg-muted animate-pulse rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (issues.length === 0) {
|
||||
return (
|
||||
<Card className="p-8 text-center">
|
||||
<AlertCircle className="w-12 h-12 mx-auto text-muted-foreground/50" />
|
||||
<h3 className="mt-4 text-lg font-medium text-foreground">{formatMessage({ id: 'issues.emptyState.title' })}</h3>
|
||||
<p className="mt-2 text-muted-foreground">{formatMessage({ id: 'issues.emptyState.message' })}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{issues.map((issue) => (
|
||||
<IssueCard
|
||||
key={issue.id}
|
||||
issue={issue}
|
||||
onClick={onIssueClick}
|
||||
onEdit={onIssueEdit}
|
||||
onDelete={onIssueDelete}
|
||||
onStatusChange={onStatusChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssuesPanel() {
|
||||
const { formatMessage } = useIntl();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||
const [priorityFilter, setPriorityFilter] = useState<PriorityFilter>('all');
|
||||
const [isNewIssueOpen, setIsNewIssueOpen] = useState(false);
|
||||
|
||||
const { issues, issuesByStatus, openCount, criticalCount, isLoading, isFetching, refetch } = useIssues({
|
||||
filter: {
|
||||
search: searchQuery || undefined,
|
||||
status: statusFilter !== 'all' ? [statusFilter] : undefined,
|
||||
priority: priorityFilter !== 'all' ? [priorityFilter] : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { createIssue, updateIssue, deleteIssue, isCreating } = useIssueMutations();
|
||||
|
||||
const statusCounts = useMemo(() => ({
|
||||
all: issues.length,
|
||||
open: issuesByStatus.open?.length || 0,
|
||||
in_progress: issuesByStatus.in_progress?.length || 0,
|
||||
resolved: issuesByStatus.resolved?.length || 0,
|
||||
closed: issuesByStatus.closed?.length || 0,
|
||||
completed: issuesByStatus.completed?.length || 0,
|
||||
}), [issues, issuesByStatus]);
|
||||
|
||||
const handleCreateIssue = async (data: { title: string; context?: string; priority?: Issue['priority'] }) => {
|
||||
await createIssue(data);
|
||||
setIsNewIssueOpen(false);
|
||||
};
|
||||
|
||||
const handleEditIssue = (_issue: Issue) => {};
|
||||
|
||||
const handleDeleteIssue = async (issue: Issue) => {
|
||||
if (confirm(`Delete issue "${issue.title}"?`)) {
|
||||
await deleteIssue(issue.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (issue: Issue, status: Issue['status']) => {
|
||||
await updateIssue(issue.id, { status });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
|
||||
{formatMessage({ id: 'common.actions.refresh' })}
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Github className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.actions.github' })}
|
||||
</Button>
|
||||
<Button onClick={() => setIsNewIssueOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.actions.create' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-info" />
|
||||
<span className="text-2xl font-bold">{openCount}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'common.status.openIssues' })}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-warning" />
|
||||
<span className="text-2xl font-bold">{issuesByStatus.in_progress?.length || 0}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'issues.status.inProgress' })}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-destructive" />
|
||||
<span className="text-2xl font-bold">{criticalCount}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'issues.priority.critical' })}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-5 h-5 text-success" />
|
||||
<span className="text-2xl font-bold">{issuesByStatus.resolved?.length || 0}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'issues.status.resolved' })}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={formatMessage({ id: 'common.actions.searchIssues' })}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder={formatMessage({ id: 'common.status.label' })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{formatMessage({ id: 'issues.filters.all' })}</SelectItem>
|
||||
<SelectItem value="open">{formatMessage({ id: 'issues.status.open' })}</SelectItem>
|
||||
<SelectItem value="in_progress">{formatMessage({ id: 'issues.status.inProgress' })}</SelectItem>
|
||||
<SelectItem value="resolved">{formatMessage({ id: 'issues.status.resolved' })}</SelectItem>
|
||||
<SelectItem value="closed">{formatMessage({ id: 'issues.status.closed' })}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={priorityFilter} onValueChange={(v) => setPriorityFilter(v as PriorityFilter)}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder={formatMessage({ id: 'issues.priority.label' })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{formatMessage({ id: 'issues.filters.byPriority' })}</SelectItem>
|
||||
<SelectItem value="critical">{formatMessage({ id: 'issues.priority.critical' })}</SelectItem>
|
||||
<SelectItem value="high">{formatMessage({ id: 'issues.priority.high' })}</SelectItem>
|
||||
<SelectItem value="medium">{formatMessage({ id: 'issues.priority.medium' })}</SelectItem>
|
||||
<SelectItem value="low">{formatMessage({ id: 'issues.priority.low' })}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant={statusFilter === 'all' ? 'default' : 'outline'} size="sm" onClick={() => setStatusFilter('all')}>
|
||||
{formatMessage({ id: 'issues.filters.all' })} ({statusCounts.all})
|
||||
</Button>
|
||||
<Button variant={statusFilter === 'open' ? 'default' : 'outline'} size="sm" onClick={() => setStatusFilter('open')}>
|
||||
<Badge variant="info" className="mr-2">{statusCounts.open}</Badge>
|
||||
{formatMessage({ id: 'issues.status.open' })}
|
||||
</Button>
|
||||
<Button variant={statusFilter === 'in_progress' ? 'default' : 'outline'} size="sm" onClick={() => setStatusFilter('in_progress')}>
|
||||
<Badge variant="warning" className="mr-2">{statusCounts.in_progress}</Badge>
|
||||
{formatMessage({ id: 'issues.status.inProgress' })}
|
||||
</Button>
|
||||
<Button variant={priorityFilter === 'critical' ? 'destructive' : 'outline'} size="sm" onClick={() => { setPriorityFilter(priorityFilter === 'critical' ? 'all' : 'critical'); setStatusFilter('all'); }}>
|
||||
<Badge variant="destructive" className="mr-2">{criticalCount}</Badge>
|
||||
{formatMessage({ id: 'issues.priority.critical' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<IssueList issues={issues} isLoading={isLoading} onIssueClick={() => {}} onIssueEdit={handleEditIssue} onIssueDelete={handleDeleteIssue} onStatusChange={handleStatusChange} />
|
||||
|
||||
<NewIssueDialog open={isNewIssueOpen} onOpenChange={setIsNewIssueOpen} onSubmit={handleCreateIssue} isCreating={isCreating} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
268
ccw/frontend/src/components/issue/hub/QueuePanel.tsx
Normal file
268
ccw/frontend/src/components/issue/hub/QueuePanel.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
// ========================================
|
||||
// Queue Panel
|
||||
// ========================================
|
||||
// Content panel for Queue tab in IssueHub
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
ListTodo,
|
||||
GitMerge,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { QueueCard } from '@/components/issue/queue/QueueCard';
|
||||
import { useIssueQueue, useQueueMutations } from '@/hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Loading Skeleton ==========
|
||||
|
||||
function QueuePanelSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats Cards Skeleton */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Card key={i} className="p-4">
|
||||
<div className="h-6 bg-muted animate-pulse rounded w-16 mb-2" />
|
||||
<div className="h-4 bg-muted animate-pulse rounded w-24" />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Queue Cards Skeleton */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{[1, 2].map((i) => (
|
||||
<Card key={i} className="p-4">
|
||||
<div className="h-6 bg-muted animate-pulse rounded w-32 mb-4" />
|
||||
<div className="h-4 bg-muted animate-pulse rounded w-full mb-2" />
|
||||
<div className="h-4 bg-muted animate-pulse rounded w-3/4" />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Empty State ==========
|
||||
|
||||
function QueueEmptyState() {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<Card className="p-12 text-center">
|
||||
<AlertCircle className="w-16 h-16 mx-auto text-muted-foreground/50" />
|
||||
<h3 className="mt-4 text-lg font-medium text-foreground">
|
||||
{formatMessage({ id: 'issues.queue.emptyState.title' })}
|
||||
</h3>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{formatMessage({ id: 'issues.queue.emptyState.description' })}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Panel Component ==========
|
||||
|
||||
export function QueuePanel() {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const { data: queueData, isLoading, isFetching, refetch, error } = useIssueQueue();
|
||||
const {
|
||||
activateQueue,
|
||||
deactivateQueue,
|
||||
deleteQueue,
|
||||
mergeQueues,
|
||||
isActivating,
|
||||
isDeactivating,
|
||||
isDeleting,
|
||||
isMerging,
|
||||
} = useQueueMutations();
|
||||
|
||||
// Get queue data with proper type
|
||||
const queue = queueData;
|
||||
const taskCount = queue?.tasks?.length || 0;
|
||||
const solutionCount = queue?.solutions?.length || 0;
|
||||
const conflictCount = queue?.conflicts?.length || 0;
|
||||
const groupCount = Object.keys(queue?.grouped_items || {}).length;
|
||||
const totalItems = taskCount + solutionCount;
|
||||
|
||||
const handleActivate = async (queueId: string) => {
|
||||
try {
|
||||
await activateQueue(queueId);
|
||||
} catch (err) {
|
||||
console.error('Failed to activate queue:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeactivate = async () => {
|
||||
try {
|
||||
await deactivateQueue();
|
||||
} catch (err) {
|
||||
console.error('Failed to deactivate queue:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (queueId: string) => {
|
||||
try {
|
||||
await deleteQueue(queueId);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete queue:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMerge = async (sourceId: string, targetId: string) => {
|
||||
try {
|
||||
await mergeQueues(sourceId, targetId);
|
||||
} catch (err) {
|
||||
console.error('Failed to merge queues:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <QueuePanelSkeleton />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="p-12 text-center">
|
||||
<AlertCircle className="w-16 h-16 mx-auto text-destructive/50" />
|
||||
<h3 className="mt-4 text-lg font-medium text-foreground">
|
||||
{formatMessage({ id: 'issues.queue.error.title' })}
|
||||
</h3>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{(error as Error).message || formatMessage({ id: 'issues.queue.error.message' })}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!queue || totalItems === 0) {
|
||||
return <QueueEmptyState />;
|
||||
}
|
||||
|
||||
// Check if queue is active (has items and no conflicts)
|
||||
const isActive = totalItems > 0 && conflictCount === 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header Actions */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
|
||||
{formatMessage({ id: 'common.actions.refresh' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ListTodo className="w-5 h-5 text-info" />
|
||||
<span className="text-2xl font-bold">{totalItems}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'issues.queue.stats.totalItems' })}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitMerge className="w-5 h-5 text-warning" />
|
||||
<span className="text-2xl font-bold">{groupCount}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'issues.queue.stats.groups' })}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-warning" />
|
||||
<span className="text-2xl font-bold">{taskCount}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'issues.queue.stats.tasks' })}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-5 h-5 text-success" />
|
||||
<span className="text-2xl font-bold">{solutionCount}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'issues.queue.stats.solutions' })}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Conflicts Warning */}
|
||||
{conflictCount > 0 && (
|
||||
<Card className="p-4 border-destructive/50 bg-destructive/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-destructive shrink-0" />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-destructive">
|
||||
{formatMessage({ id: 'issues.queue.conflicts.title' })}
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{conflictCount} {formatMessage({ id: 'issues.queue.conflicts.description' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Queue Card */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<QueueCard
|
||||
key="current"
|
||||
queue={queue}
|
||||
isActive={isActive}
|
||||
onActivate={handleActivate}
|
||||
onDeactivate={handleDeactivate}
|
||||
onDelete={handleDelete}
|
||||
onMerge={handleMerge}
|
||||
isActivating={isActivating}
|
||||
isDeactivating={isDeactivating}
|
||||
isDeleting={isDeleting}
|
||||
isMerging={isMerging}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Footer */}
|
||||
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{isActive ? (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 text-success" />
|
||||
{formatMessage({ id: 'issues.queue.status.ready' })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Clock className="w-4 h-4 text-warning" />
|
||||
{formatMessage({ id: 'issues.queue.status.pending' })}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant={isActive ? 'success' : 'secondary'} className="gap-1">
|
||||
{isActive ? (
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
) : (
|
||||
<Clock className="w-3 h-3" />
|
||||
)}
|
||||
{isActive
|
||||
? formatMessage({ id: 'issues.queue.status.active' })
|
||||
: formatMessage({ id: 'issues.queue.status.inactive' })
|
||||
}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
ccw/frontend/src/components/issue/hub/index.ts
Normal file
10
ccw/frontend/src/components/issue/hub/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// ========================================
|
||||
// Issue Hub Components Export
|
||||
// ========================================
|
||||
|
||||
export { IssueHubHeader } from './IssueHubHeader';
|
||||
export { IssueHubTabs } from './IssueHubTabs';
|
||||
export { IssuesPanel } from './IssuesPanel';
|
||||
export { QueuePanel } from './QueuePanel';
|
||||
export { DiscoveryPanel } from './DiscoveryPanel';
|
||||
export { type IssueTab } from './IssueHubTabs';
|
||||
@@ -46,7 +46,7 @@ export function QueueCard({
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// Get queue ID - IssueQueue interface doesn't have an id field, using tasks array as key
|
||||
const queueId = queue.tasks.join(',') || queue.solutions.join(',');
|
||||
const queueId = (queue.tasks || []).join(',') || (queue.solutions || []).join(',') || 'unknown';
|
||||
|
||||
// Calculate item counts
|
||||
const taskCount = queue.tasks?.length || 0;
|
||||
|
||||
@@ -60,8 +60,8 @@ const navItemDefinitions: Omit<NavItem, 'label'>[] = [
|
||||
{ path: '/orchestrator', icon: Workflow },
|
||||
{ path: '/loops', icon: RefreshCw },
|
||||
{ path: '/issues', icon: AlertCircle },
|
||||
{ path: '/issues/queue', icon: ListTodo },
|
||||
{ path: '/issues/discovery', icon: Search },
|
||||
{ path: '/issues?tab=queue', icon: ListTodo },
|
||||
{ path: '/issues?tab=discovery', icon: Search },
|
||||
{ path: '/skills', icon: Sparkles },
|
||||
{ path: '/commands', icon: Terminal },
|
||||
{ path: '/memory', icon: Brain },
|
||||
@@ -110,8 +110,8 @@ export function Sidebar({
|
||||
'/orchestrator': 'main.orchestrator',
|
||||
'/loops': 'main.loops',
|
||||
'/issues': 'main.issues',
|
||||
'/issues/queue': 'main.issueQueue',
|
||||
'/issues/discovery': 'main.issueDiscovery',
|
||||
'/issues?tab=queue': 'main.issueQueue',
|
||||
'/issues?tab=discovery': 'main.issueDiscovery',
|
||||
'/skills': 'main.skills',
|
||||
'/commands': 'main.commands',
|
||||
'/memory': 'main.memory',
|
||||
@@ -155,8 +155,13 @@ export function Sidebar({
|
||||
<ul className="space-y-1 px-2">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location.pathname === item.path ||
|
||||
(item.path !== '/' && location.pathname.startsWith(item.path));
|
||||
// Parse item path to extract base path and query params
|
||||
const [basePath, searchParams] = item.path.split('?');
|
||||
const isActive = location.pathname === basePath ||
|
||||
(basePath !== '/' && location.pathname.startsWith(basePath));
|
||||
// For query param items, also check if search matches
|
||||
const isQueryParamActive = searchParams &&
|
||||
location.search.includes(searchParams);
|
||||
|
||||
return (
|
||||
<li key={item.path}>
|
||||
@@ -166,7 +171,7 @@ export function Sidebar({
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors',
|
||||
'hover:bg-hover hover:text-foreground',
|
||||
isActive
|
||||
(isActive && !searchParams) || isQueryParamActive
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-muted-foreground',
|
||||
isCollapsed && 'justify-center px-2'
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
// ========================================
|
||||
// CliStreamMonitor Component (New Layout)
|
||||
// ========================================
|
||||
// Redesigned CLI streaming monitor with smart parsing and message-based layout
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCliStreamStore, type CliOutputLine } from '@/stores/cliStreamStore';
|
||||
import { useNotificationStore, selectWsLastMessage } from '@/stores';
|
||||
import { useActiveCliExecutions, useInvalidateActiveCliExecutions } from '@/hooks/useActiveCliExecutions';
|
||||
|
||||
// New layout components
|
||||
import { MonitorHeader } from './MonitorHeader';
|
||||
import { MonitorToolbar, type FilterType, type ViewMode } from './MonitorToolbar';
|
||||
import { MonitorBody } from './MonitorBody';
|
||||
import {
|
||||
SystemMessage,
|
||||
UserMessage,
|
||||
AssistantMessage,
|
||||
ErrorMessage,
|
||||
} from './messages';
|
||||
|
||||
// ========== Types for CLI WebSocket Messages ==========
|
||||
|
||||
interface CliStreamStartedPayload {
|
||||
executionId: string;
|
||||
tool: string;
|
||||
mode: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface CliStreamOutputPayload {
|
||||
executionId: string;
|
||||
chunkType: string;
|
||||
data: unknown;
|
||||
unit?: {
|
||||
content: unknown;
|
||||
type?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CliStreamCompletedPayload {
|
||||
executionId: string;
|
||||
success: boolean;
|
||||
duration?: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface CliStreamErrorPayload {
|
||||
executionId: string;
|
||||
error?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// ========== Message Type Detection ==========
|
||||
|
||||
type MessageType = 'system' | 'user' | 'assistant' | 'error';
|
||||
|
||||
interface ParsedMessage {
|
||||
id: string;
|
||||
type: MessageType;
|
||||
timestamp: number;
|
||||
content: string;
|
||||
metadata?: {
|
||||
toolName?: string;
|
||||
mode?: string;
|
||||
status?: string;
|
||||
duration?: number;
|
||||
tokens?: number;
|
||||
model?: string;
|
||||
};
|
||||
raw?: CliOutputLine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect message type from output line
|
||||
*/
|
||||
function detectMessageType(line: CliOutputLine): MessageType {
|
||||
const content = line.content.trim().toLowerCase();
|
||||
|
||||
// Error detection
|
||||
if (line.type === 'stderr' ||
|
||||
content.includes('error') ||
|
||||
content.includes('failed') ||
|
||||
content.includes('exception')) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
// System message detection
|
||||
if (line.type === 'system' ||
|
||||
content.startsWith('[system]') ||
|
||||
content.startsWith('[info]') ||
|
||||
content.includes('cli execution started')) {
|
||||
return 'system';
|
||||
}
|
||||
|
||||
// User/assistant detection (based on context)
|
||||
// For now, default to assistant for stdout
|
||||
if (line.type === 'stdout') {
|
||||
return 'assistant';
|
||||
}
|
||||
|
||||
// Tool call metadata
|
||||
if (line.type === 'tool_call') {
|
||||
return 'system';
|
||||
}
|
||||
|
||||
// Default to assistant
|
||||
return 'assistant';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse output lines into structured messages
|
||||
*/
|
||||
function parseOutputToMessages(
|
||||
executionId: string,
|
||||
output: CliOutputLine[]
|
||||
): ParsedMessage[] {
|
||||
const messages: ParsedMessage[] = [];
|
||||
let currentMessage: Partial<ParsedMessage> | null = null;
|
||||
|
||||
for (let i = 0; i < output.length; i++) {
|
||||
const line = output[i];
|
||||
const messageType = detectMessageType(line);
|
||||
|
||||
// Start new message if type changes
|
||||
if (!currentMessage || currentMessage.type !== messageType) {
|
||||
if (currentMessage && currentMessage.content) {
|
||||
messages.push({
|
||||
id: `${executionId}-${messages.length}`,
|
||||
type: currentMessage.type!,
|
||||
timestamp: currentMessage.timestamp!,
|
||||
content: currentMessage.content,
|
||||
metadata: currentMessage.metadata,
|
||||
raw: currentMessage.raw,
|
||||
});
|
||||
}
|
||||
currentMessage = {
|
||||
type: messageType,
|
||||
timestamp: line.timestamp,
|
||||
content: '',
|
||||
raw: line,
|
||||
};
|
||||
}
|
||||
|
||||
// Append content
|
||||
const separator = currentMessage.content ? '\n' : '';
|
||||
currentMessage.content = currentMessage.content + separator + line.content;
|
||||
currentMessage.timestamp = Math.max(currentMessage.timestamp || 0, line.timestamp);
|
||||
|
||||
// Extract metadata from tool calls
|
||||
if (line.type === 'tool_call') {
|
||||
const toolMatch = line.content.match(/\[Tool\]\s+(\w+)/);
|
||||
if (toolMatch) {
|
||||
currentMessage.metadata = {
|
||||
...currentMessage.metadata,
|
||||
toolName: toolMatch[1],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last message
|
||||
if (currentMessage && currentMessage.content) {
|
||||
messages.push({
|
||||
id: `${executionId}-${messages.length}`,
|
||||
type: currentMessage.type!,
|
||||
timestamp: currentMessage.timestamp!,
|
||||
content: currentMessage.content,
|
||||
metadata: currentMessage.metadata,
|
||||
raw: currentMessage.raw,
|
||||
});
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return `${hours}h ${remainingMinutes}m`;
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export interface CliStreamMonitorNewProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CliStreamMonitorNew({ isOpen, onClose }: CliStreamMonitorNewProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// UI State
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filter, setFilter] = useState<FilterType>('all');
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('preview');
|
||||
|
||||
// Store state
|
||||
const executions = useCliStreamStore((state) => state.executions);
|
||||
const currentExecutionId = useCliStreamStore((state) => state.currentExecutionId);
|
||||
const removeExecution = useCliStreamStore((state) => state.removeExecution);
|
||||
|
||||
// Active execution sync
|
||||
const { isLoading: isSyncing, refetch } = useActiveCliExecutions(isOpen);
|
||||
const invalidateActive = useInvalidateActiveCliExecutions();
|
||||
|
||||
// WebSocket last message
|
||||
const lastMessage = useNotificationStore(selectWsLastMessage);
|
||||
|
||||
// Handle WebSocket messages (same as original)
|
||||
useEffect(() => {
|
||||
if (!lastMessage) return;
|
||||
|
||||
const { type, payload } = lastMessage;
|
||||
|
||||
if (type === 'CLI_STARTED') {
|
||||
const p = payload as CliStreamStartedPayload;
|
||||
const startTime = p.timestamp ? new Date(p.timestamp).getTime() : Date.now();
|
||||
useCliStreamStore.getState().upsertExecution(p.executionId, {
|
||||
tool: p.tool || 'cli',
|
||||
mode: p.mode || 'analysis',
|
||||
status: 'running',
|
||||
startTime,
|
||||
output: [
|
||||
{
|
||||
type: 'system',
|
||||
content: `[${new Date(startTime).toLocaleTimeString()}] CLI execution started: ${p.tool} (${p.mode} mode)`,
|
||||
timestamp: startTime
|
||||
}
|
||||
]
|
||||
});
|
||||
invalidateActive();
|
||||
} else if (type === 'CLI_OUTPUT') {
|
||||
const p = payload as CliStreamOutputPayload;
|
||||
const unitContent = p.unit?.content;
|
||||
const unitType = p.unit?.type || p.chunkType;
|
||||
|
||||
let content: string;
|
||||
if (unitType === 'tool_call' && typeof unitContent === 'object' && unitContent !== null) {
|
||||
const toolCall = unitContent as { action?: string; toolName?: string; parameters?: unknown; status?: string; output?: string };
|
||||
if (toolCall.action === 'invoke') {
|
||||
const params = toolCall.parameters ? JSON.stringify(toolCall.parameters) : '';
|
||||
content = `[Tool] ${toolCall.toolName}(${params})`;
|
||||
} else if (toolCall.action === 'result') {
|
||||
const status = toolCall.status || 'unknown';
|
||||
const output = toolCall.output ? `: ${toolCall.output.substring(0, 200)}${toolCall.output.length > 200 ? '...' : ''}` : '';
|
||||
content = `[Tool Result] ${status}${output}`;
|
||||
} else {
|
||||
content = JSON.stringify(unitContent);
|
||||
}
|
||||
} else {
|
||||
content = typeof p.data === 'string' ? p.data : JSON.stringify(p.data);
|
||||
}
|
||||
|
||||
const lines = content.split('\n');
|
||||
const addOutput = useCliStreamStore.getState().addOutput;
|
||||
lines.forEach(line => {
|
||||
if (line.trim() || lines.length === 1) {
|
||||
addOutput(p.executionId, {
|
||||
type: (unitType as CliOutputLine['type']) || 'stdout',
|
||||
content: line,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
});
|
||||
} else if (type === 'CLI_COMPLETED') {
|
||||
const p = payload as CliStreamCompletedPayload;
|
||||
const endTime = p.timestamp ? new Date(p.timestamp).getTime() : Date.now();
|
||||
useCliStreamStore.getState().upsertExecution(p.executionId, {
|
||||
status: p.success ? 'completed' : 'error',
|
||||
endTime,
|
||||
output: [
|
||||
{
|
||||
type: 'system',
|
||||
content: `[${new Date(endTime).toLocaleTimeString()}] CLI execution ${p.success ? 'completed successfully' : 'failed'}${p.duration ? ` (${formatDuration(p.duration)})` : ''}`,
|
||||
timestamp: endTime
|
||||
}
|
||||
]
|
||||
});
|
||||
invalidateActive();
|
||||
} else if (type === 'CLI_ERROR') {
|
||||
const p = payload as CliStreamErrorPayload;
|
||||
const endTime = p.timestamp ? new Date(p.timestamp).getTime() : Date.now();
|
||||
useCliStreamStore.getState().upsertExecution(p.executionId, {
|
||||
status: 'error',
|
||||
endTime,
|
||||
output: [
|
||||
{
|
||||
type: 'stderr',
|
||||
content: `[ERROR] ${p.error || 'Unknown error occurred'}`,
|
||||
timestamp: endTime
|
||||
}
|
||||
]
|
||||
});
|
||||
invalidateActive();
|
||||
}
|
||||
}, [lastMessage, invalidateActive]);
|
||||
|
||||
// Get execution stats
|
||||
const executionStats = useMemo(() => {
|
||||
const all = Object.values(executions);
|
||||
return {
|
||||
total: all.length,
|
||||
active: all.filter(e => e.status === 'running').length,
|
||||
error: all.filter(e => e.status === 'error').length,
|
||||
completed: all.filter(e => e.status === 'completed').length,
|
||||
};
|
||||
}, [executions]);
|
||||
|
||||
// Get current execution
|
||||
const currentExecution = currentExecutionId ? executions[currentExecutionId] : null;
|
||||
|
||||
// Parse messages from current execution
|
||||
const messages = useMemo(() => {
|
||||
if (!currentExecution?.output) return [];
|
||||
return parseOutputToMessages(currentExecutionId || '', currentExecution.output);
|
||||
}, [currentExecution?.output, currentExecutionId]);
|
||||
|
||||
// Filter messages
|
||||
const filteredMessages = useMemo(() => {
|
||||
let filtered = messages;
|
||||
|
||||
// Apply type filter
|
||||
if (filter !== 'all') {
|
||||
filtered = filtered.filter(m => {
|
||||
if (filter === 'errors') return m.type === 'error';
|
||||
if (filter === 'content') return m.type === 'user' || m.type === 'assistant';
|
||||
if (filter === 'system') return m.type === 'system';
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(m =>
|
||||
m.content.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [messages, filter, searchQuery]);
|
||||
|
||||
// Copy message content
|
||||
const handleCopy = useCallback(async (content: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle message actions
|
||||
const handleRetry = useCallback((executionId: string) => {
|
||||
// TODO: Implement retry logic
|
||||
console.log('Retry execution:', executionId);
|
||||
}, []);
|
||||
|
||||
const handleDismiss = useCallback((executionId: string) => {
|
||||
removeExecution(executionId);
|
||||
}, [removeExecution]);
|
||||
|
||||
// Don't render if not open
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 bg-black/40 transition-opacity z-40',
|
||||
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
)}
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Main Panel */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed top-0 right-0 h-full w-[640px] bg-background border-l border-border shadow-2xl z-50 flex flex-col transition-transform duration-300 ease-in-out',
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="cli-monitor-title"
|
||||
>
|
||||
{/* Header */}
|
||||
<MonitorHeader
|
||||
onClose={onClose}
|
||||
activeCount={executionStats.active}
|
||||
totalCount={executionStats.total}
|
||||
errorCount={executionStats.error}
|
||||
/>
|
||||
|
||||
{/* Toolbar */}
|
||||
<MonitorToolbar
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
filter={filter}
|
||||
onFilterChange={setFilter}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
|
||||
{/* Body */}
|
||||
<MonitorBody autoScroll={true}>
|
||||
{currentExecution ? (
|
||||
<div className="space-y-4 p-4">
|
||||
{filteredMessages.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
{searchQuery
|
||||
? formatMessage({ id: 'cliMonitor.noMatch' })
|
||||
: formatMessage({ id: 'cliMonitor.noMessages' })
|
||||
}
|
||||
</div>
|
||||
) : (
|
||||
filteredMessages.map((message) => {
|
||||
switch (message.type) {
|
||||
case 'system':
|
||||
return (
|
||||
<SystemMessage
|
||||
key={message.id}
|
||||
title={message.metadata?.toolName || 'System Message'}
|
||||
timestamp={message.timestamp}
|
||||
metadata={`Mode: ${message.metadata?.mode || 'N/A'}`}
|
||||
content={message.content}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'user':
|
||||
return (
|
||||
<UserMessage
|
||||
key={message.id}
|
||||
content={message.content}
|
||||
timestamp={message.timestamp}
|
||||
onCopy={() => handleCopy(message.content)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'assistant':
|
||||
return (
|
||||
<AssistantMessage
|
||||
key={message.id}
|
||||
modelName={message.metadata?.model || 'AI Assistant'}
|
||||
status={message.metadata?.status === 'thinking' ? 'thinking' : 'completed'}
|
||||
content={message.content}
|
||||
timestamp={message.timestamp}
|
||||
duration={message.metadata?.duration}
|
||||
tokenCount={message.metadata?.tokens}
|
||||
onCopy={() => handleCopy(message.content)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'error':
|
||||
return (
|
||||
<ErrorMessage
|
||||
key={message.id}
|
||||
title="Error"
|
||||
message={message.content}
|
||||
timestamp={message.timestamp}
|
||||
onRetry={() => handleRetry(currentExecutionId!)}
|
||||
onDismiss={() => handleDismiss(currentExecutionId!)}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<Terminal className="h-16 w-16 mx-auto mb-4 opacity-30" />
|
||||
<p className="text-sm mb-1">{formatMessage({ id: 'cliMonitor.noExecutions' })}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'cliMonitor.noExecutionsHint' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</MonitorBody>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-muted/30 border-t border-border text-xs text-muted-foreground">
|
||||
<span>
|
||||
{formatMessage(
|
||||
{ id: 'cliMonitor.statusBar' },
|
||||
{
|
||||
total: executionStats.total,
|
||||
active: executionStats.active,
|
||||
error: executionStats.error,
|
||||
lines: currentExecution?.output.length || 0
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={isSyncing}
|
||||
className="hover:text-foreground transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSyncing
|
||||
? formatMessage({ id: 'cliMonitor.refreshing' })
|
||||
: formatMessage({ id: 'cliMonitor.refresh' })
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default CliStreamMonitorNew;
|
||||
@@ -0,0 +1,383 @@
|
||||
// ========================================
|
||||
// MessageRenderer Component
|
||||
// ========================================
|
||||
// Renders message content with Markdown support, JSON formatting,
|
||||
// and escape sequence handling
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { JsonFormatter } from '../../LogBlock/JsonFormatter';
|
||||
import { detectJsonContent } from '../../LogBlock/jsonUtils';
|
||||
|
||||
// Import highlight.js styles for code syntax highlighting
|
||||
// Using a base style that works with both light and dark themes
|
||||
import 'highlight.js/styles/base16/atelier-forest.css';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface MessageRendererProps {
|
||||
/** Content to render */
|
||||
content: string;
|
||||
/** Additional CSS className */
|
||||
className?: string;
|
||||
/** Format hint (auto-detect if not specified) */
|
||||
format?: 'markdown' | 'text' | 'json';
|
||||
/** Maximum lines to display (for text mode) */
|
||||
maxLines?: number;
|
||||
}
|
||||
|
||||
// ========== Markdown Component Styles ==========
|
||||
|
||||
const markdownComponents: Record<string, string> = {
|
||||
h1: 'text-xl font-bold mt-4 mb-2 text-foreground',
|
||||
h2: 'text-lg font-semibold mt-3 mb-2 text-foreground',
|
||||
h3: 'text-base font-semibold mt-2 mb-1 text-foreground',
|
||||
p: 'text-sm leading-relaxed mb-2 text-foreground',
|
||||
ul: 'list-disc list-inside mb-2 text-sm space-y-1',
|
||||
ol: 'list-decimal list-inside mb-2 text-sm space-y-1',
|
||||
li: 'text-sm text-foreground',
|
||||
code: 'font-mono text-xs bg-muted px-1.5 py-0.5 rounded text-amber-600 dark:text-amber-400',
|
||||
pre: 'bg-muted/80 dark:bg-muted/30 p-3 rounded-lg overflow-x-auto my-2 border border-border/50',
|
||||
blockquote: 'border-l-4 border-muted-foreground pl-4 italic text-muted-foreground my-2',
|
||||
strong: 'font-semibold text-foreground',
|
||||
a: 'text-blue-600 dark:text-blue-400 hover:underline',
|
||||
};
|
||||
|
||||
// ========== Helper Components ==========
|
||||
|
||||
/**
|
||||
* Inline code renderer for Markdown
|
||||
*/
|
||||
function MarkdownCode({ className, children, ...props }: React.HTMLAttributes<HTMLElement>) {
|
||||
return (
|
||||
<code
|
||||
className={cn(markdownComponents.code, className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Code block renderer for Markdown with syntax highlighting
|
||||
*/
|
||||
function MarkdownPre({ className, children, ...props }: React.HTMLAttributes<HTMLElement>) {
|
||||
return (
|
||||
<pre
|
||||
className={cn(markdownComponents.pre, className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paragraph renderer for Markdown
|
||||
*/
|
||||
function MarkdownParagraph({ className, children, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p
|
||||
className={cn(markdownComponents.p, className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heading renderers for Markdown
|
||||
*/
|
||||
function MarkdownH1({ className, children, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h1
|
||||
className={cn(markdownComponents.h1, className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownH2({ className, children, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h2
|
||||
className={cn(markdownComponents.h2, className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownH3({ className, children, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h3
|
||||
className={cn(markdownComponents.h3, className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* List renderers for Markdown
|
||||
*/
|
||||
function MarkdownUl({ className, children, ...props }: React.HTMLAttributes<HTMLUListElement>) {
|
||||
return (
|
||||
<ul
|
||||
className={cn(markdownComponents.ul, className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownOl({ className, children, ...props }: React.HTMLAttributes<HTMLOListElement>) {
|
||||
return (
|
||||
<ol
|
||||
className={cn(markdownComponents.ol, className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownLi({ className, children, ...props }: React.HTMLAttributes<HTMLLIElement>) {
|
||||
return (
|
||||
<li
|
||||
className={cn(markdownComponents.li, className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Blockquote renderer for Markdown
|
||||
*/
|
||||
function MarkdownBlockquote({ className, children, ...props }: React.HTMLAttributes<HTMLQuoteElement>) {
|
||||
return (
|
||||
<blockquote
|
||||
className={cn(markdownComponents.blockquote, className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anchor renderer for Markdown
|
||||
*/
|
||||
function MarkdownA({ className, children, href, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) {
|
||||
return (
|
||||
<a
|
||||
className={cn(markdownComponents.a, className)}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strong/Bold renderer for Markdown
|
||||
*/
|
||||
function MarkdownStrong({ className, children, ...props }: React.HTMLAttributes<HTMLElement>) {
|
||||
return (
|
||||
<strong
|
||||
className={cn(markdownComponents.strong, className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</strong>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Markdown Renderer ==========
|
||||
|
||||
/**
|
||||
* Markdown content renderer with syntax highlighting
|
||||
*/
|
||||
function MarkdownRenderer({ content, className }: { content: string; className?: string }) {
|
||||
return (
|
||||
<div className={cn('prose prose-sm dark:prose-invert max-w-none', className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeHighlight]}
|
||||
components={{
|
||||
h1: MarkdownH1,
|
||||
h2: MarkdownH2,
|
||||
h3: MarkdownH3,
|
||||
p: MarkdownParagraph,
|
||||
ul: MarkdownUl,
|
||||
ol: MarkdownOl,
|
||||
li: MarkdownLi,
|
||||
code: MarkdownCode,
|
||||
pre: MarkdownPre,
|
||||
blockquote: MarkdownBlockquote,
|
||||
a: MarkdownA,
|
||||
strong: MarkdownStrong,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Text Renderer ==========
|
||||
|
||||
/**
|
||||
* Plain text renderer with escape sequence handling
|
||||
*/
|
||||
function TextRenderer({ content, maxLines }: { content: string; maxLines?: number }) {
|
||||
const lines = useMemo(() => {
|
||||
return content.split('\n');
|
||||
}, [content]);
|
||||
|
||||
const displayLines = useMemo(() => {
|
||||
if (maxLines && lines.length > maxLines) {
|
||||
return lines.slice(0, maxLines);
|
||||
}
|
||||
return lines;
|
||||
}, [lines, maxLines]);
|
||||
|
||||
const showTruncated = maxLines && lines.length > maxLines;
|
||||
|
||||
return (
|
||||
<div className="text-sm text-foreground whitespace-pre-wrap break-words font-mono">
|
||||
{displayLines.map((line, index) => (
|
||||
<div key={index}>{line || '\u00A0'}</div>
|
||||
))}
|
||||
{showTruncated && (
|
||||
<div className="text-muted-foreground italic text-xs mt-2">
|
||||
// ... {lines.length - maxLines} more lines
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Content Detection ==========
|
||||
|
||||
/**
|
||||
* Auto-detect content format
|
||||
*/
|
||||
function detectContentFormat(content: string): 'markdown' | 'json' | 'text' {
|
||||
const trimmed = content.trim();
|
||||
|
||||
// Check for JSON first
|
||||
const jsonDetection = detectJsonContent(trimmed);
|
||||
if (jsonDetection.isJson) {
|
||||
return 'json';
|
||||
}
|
||||
|
||||
// Check for Markdown patterns
|
||||
const markdownPatterns = [
|
||||
/^#{1,6}\s+/m, // Headings
|
||||
/^\*{3,}$/m, // Horizontal rule
|
||||
/^\s*[-*+]\s+/m, // Unordered lists
|
||||
/^\s*\d+\.\s+/m, // Ordered lists
|
||||
/\[.*?\]\(.*?\)/, // Links
|
||||
/`{3,}[\s\S]*?`{3,}/, // Code blocks
|
||||
/\*\*.*?\*\*/, // Bold
|
||||
/_.*?_/, // Italic
|
||||
/^\s*>\s+/m, // Blockquotes
|
||||
];
|
||||
|
||||
for (const pattern of markdownPatterns) {
|
||||
if (pattern.test(trimmed)) {
|
||||
return 'markdown';
|
||||
}
|
||||
}
|
||||
|
||||
// Default to text
|
||||
return 'text';
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
/**
|
||||
* MessageRenderer Component
|
||||
*
|
||||
* Renders message content with automatic format detection:
|
||||
* - JSON: Structured display with JsonFormatter
|
||||
* - Markdown: Rich text with syntax highlighting
|
||||
* - Text: Plain text with escape sequence handling
|
||||
*
|
||||
* Supports escape sequence handling (e.g., \n → newline)
|
||||
*/
|
||||
export function MessageRenderer({
|
||||
content,
|
||||
className,
|
||||
format,
|
||||
maxLines,
|
||||
}: MessageRendererProps) {
|
||||
// Process escape sequences
|
||||
const processedContent = useMemo(() => {
|
||||
// Replace common escape sequences
|
||||
return content
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\\t/g, ' ')
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\'/g, "'")
|
||||
.replace(/\\\\/g, '\\');
|
||||
}, [content]);
|
||||
|
||||
// Auto-detect format if not specified
|
||||
const detectedFormat = useMemo(() => {
|
||||
if (format) {
|
||||
return format;
|
||||
}
|
||||
return detectContentFormat(processedContent);
|
||||
}, [processedContent, format]);
|
||||
|
||||
// Render based on format
|
||||
switch (detectedFormat) {
|
||||
case 'json':
|
||||
return (
|
||||
<div className={className}>
|
||||
<JsonFormatter
|
||||
content={processedContent}
|
||||
displayMode="text"
|
||||
maxLines={maxLines}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'markdown':
|
||||
return (
|
||||
<MarkdownRenderer
|
||||
content={processedContent}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'text':
|
||||
default:
|
||||
return (
|
||||
<div className={className}>
|
||||
<TextRenderer
|
||||
content={processedContent}
|
||||
maxLines={maxLines}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default MessageRenderer;
|
||||
@@ -0,0 +1,136 @@
|
||||
// ========================================
|
||||
// MonitorBody Component
|
||||
// ========================================
|
||||
// Scrollable container for message list
|
||||
|
||||
import { useEffect, useRef, useCallback, forwardRef, ForwardedRef, useState, useImperativeHandle } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ArrowDownToLine } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface MonitorBodyProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
autoScroll?: boolean;
|
||||
onScroll?: () => void;
|
||||
showScrollButton?: boolean;
|
||||
scrollThreshold?: number;
|
||||
}
|
||||
|
||||
export interface MonitorBodyRef {
|
||||
scrollToBottom: () => void;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
// ========== Helper Components ==========
|
||||
|
||||
interface ScrollToBottomButtonProps {
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function ScrollToBottomButton({ onClick, className }: ScrollToBottomButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className={cn('absolute bottom-4 right-4 shadow-lg', className)}
|
||||
onClick={onClick}
|
||||
title="Scroll to bottom"
|
||||
>
|
||||
<ArrowDownToLine className="h-4 w-4 mr-1" />
|
||||
Scroll to bottom
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
function MonitorBodyComponent(
|
||||
props: MonitorBodyProps,
|
||||
ref: ForwardedRef<MonitorBodyRef>
|
||||
) {
|
||||
const {
|
||||
children,
|
||||
className,
|
||||
autoScroll = true,
|
||||
onScroll,
|
||||
showScrollButton = true,
|
||||
scrollThreshold = 50,
|
||||
} = props;
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||
const [isUserScrolling, setIsUserScrolling] = useState(false);
|
||||
|
||||
// Expose methods via ref
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
scrollToBottom: () => {
|
||||
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
setIsUserScrolling(false);
|
||||
},
|
||||
containerRef,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
// Auto-scroll to bottom when children change
|
||||
useEffect(() => {
|
||||
if (autoScroll && !isUserScrolling && logsEndRef.current) {
|
||||
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [children, autoScroll, isUserScrolling]);
|
||||
|
||||
// Handle scroll to detect user scrolling
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < scrollThreshold;
|
||||
|
||||
const wasScrolling = isUserScrolling;
|
||||
setIsUserScrolling(!isAtBottom);
|
||||
|
||||
// Call onScroll callback when user starts/stops scrolling
|
||||
if (onScroll && wasScrolling !== !isAtBottom) {
|
||||
onScroll();
|
||||
}
|
||||
}, [scrollThreshold, isUserScrolling, onScroll]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn('flex-1 overflow-y-auto bg-background relative', className)}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div className="h-full">
|
||||
{children}
|
||||
{/* Anchor for scroll to bottom */}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Show scroll button when user is not at bottom */}
|
||||
{showScrollButton && isUserScrolling && (
|
||||
<ScrollToBottomButton
|
||||
onClick={() => {
|
||||
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
setIsUserScrolling(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Export with forwardRef
|
||||
export const MonitorBody = forwardRef<MonitorBodyRef, MonitorBodyProps>(
|
||||
MonitorBodyComponent
|
||||
);
|
||||
|
||||
MonitorBody.displayName = 'MonitorBody';
|
||||
|
||||
export default MonitorBody;
|
||||
@@ -0,0 +1,114 @@
|
||||
// ========================================
|
||||
// MonitorHeader Component
|
||||
// ========================================
|
||||
// Header component for CLI Stream Monitor
|
||||
|
||||
import { memo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { X, Activity, ChevronDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
export interface MonitorHeaderProps {
|
||||
/** Callback when close button is clicked */
|
||||
onClose: () => void;
|
||||
/** Number of active (running) executions */
|
||||
activeCount?: number;
|
||||
/** Total number of executions */
|
||||
totalCount?: number;
|
||||
/** Number of executions with errors */
|
||||
errorCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* MonitorHeader - Header component for CLI Stream Monitor
|
||||
*
|
||||
* Displays:
|
||||
* - Left: Close button + title
|
||||
* - Right: Live status indicator + execution count badge
|
||||
*/
|
||||
export const MonitorHeader = memo(function MonitorHeader({
|
||||
onClose,
|
||||
activeCount = 0,
|
||||
totalCount = 0,
|
||||
errorCount = 0,
|
||||
}: MonitorHeaderProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const hasActive = activeCount > 0;
|
||||
const hasErrors = errorCount > 0;
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
// Layout
|
||||
'flex items-center justify-between',
|
||||
// Sizing
|
||||
'h-14 px-4',
|
||||
// Colors
|
||||
'bg-card dark:bg-surface-900',
|
||||
// Border
|
||||
'border-b border-border'
|
||||
)}
|
||||
>
|
||||
{/* Left side: Close button + Title */}
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="shrink-0"
|
||||
aria-label="Close monitor"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Activity className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<h1 className="text-base font-semibold text-foreground truncate">
|
||||
{formatMessage({ id: 'cliMonitor.title' })}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side: Status + Count badge */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{/* Live status indicator */}
|
||||
{hasActive && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full',
|
||||
hasErrors
|
||||
? 'bg-amber-500 animate-pulse'
|
||||
: 'bg-green-500 animate-pulse'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'cliMonitor.live' })}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execution count badge */}
|
||||
{totalCount > 0 && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 rounded-md bg-secondary/50">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'cliMonitor.executions' }, { count: totalCount })}
|
||||
</span>
|
||||
{activeCount > 0 && (
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{formatMessage({ id: 'cliMonitor.active' }, { count: activeCount })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
});
|
||||
|
||||
export default MonitorHeader;
|
||||
@@ -0,0 +1,197 @@
|
||||
// ========================================
|
||||
// MonitorToolbar Component
|
||||
// ========================================
|
||||
// Toolbar for CLI Stream Monitor with search, filter, and view mode controls
|
||||
|
||||
import { Search, Settings, ChevronDown, X } from 'lucide-react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/Dropdown';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export type FilterType = 'all' | 'errors' | 'content' | 'system';
|
||||
export type ViewMode = 'preview' | 'json' | 'raw';
|
||||
|
||||
export interface MonitorToolbarProps {
|
||||
/** Current search query */
|
||||
searchQuery: string;
|
||||
/** Callback when search query changes */
|
||||
onSearchChange: (value: string) => void;
|
||||
/** Current filter type */
|
||||
filter: FilterType;
|
||||
/** Callback when filter changes */
|
||||
onFilterChange: (filter: FilterType) => void;
|
||||
/** Current view mode */
|
||||
viewMode: ViewMode;
|
||||
/** Callback when view mode changes */
|
||||
onViewModeChange: (mode: ViewMode) => void;
|
||||
/** Optional settings click handler */
|
||||
onSettingsClick?: () => void;
|
||||
/** Optional class name for custom styling */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ========== Filter Button Component ==========
|
||||
|
||||
interface FilterButtonProps {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const FilterButton = ({ active, onClick, children }: FilterButtonProps) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-sm rounded-md transition-colors',
|
||||
active
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
// ========== Main Toolbar Component ==========
|
||||
|
||||
export const MonitorToolbar = ({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
filter,
|
||||
onFilterChange,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
onSettingsClick,
|
||||
className,
|
||||
}: MonitorToolbarProps) => {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const filterLabels: Record<FilterType, string> = {
|
||||
all: formatMessage({ id: 'cliMonitor.filter.all' }),
|
||||
errors: formatMessage({ id: 'cliMonitor.filter.errors' }),
|
||||
content: formatMessage({ id: 'cliMonitor.filter.content' }),
|
||||
system: formatMessage({ id: 'cliMonitor.filter.system' }),
|
||||
};
|
||||
|
||||
const viewModeLabels: Record<ViewMode, string> = {
|
||||
preview: formatMessage({ id: 'cliMonitor.view.preview' }),
|
||||
json: formatMessage({ id: 'cliMonitor.view.json' }),
|
||||
raw: formatMessage({ id: 'cliMonitor.view.raw' }),
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'h-12 flex items-center justify-between px-4',
|
||||
'bg-muted/30 dark:bg-muted-900/30',
|
||||
'border-b border-border',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Left: Search and Filter */}
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
{/* Search Box */}
|
||||
<div className="relative flex items-center">
|
||||
<Search className="absolute left-3 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={formatMessage({ id: 'cliMonitor.searchPlaceholder' })}
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="h-9 pl-9 pr-8 text-sm w-64 bg-background border border-border"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSearchChange('')}
|
||||
className="absolute right-2 p-1 rounded-sm hover:bg-muted transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter Buttons */}
|
||||
<div className="flex items-center gap-1">
|
||||
<FilterButton
|
||||
active={filter === 'all'}
|
||||
onClick={() => onFilterChange('all')}
|
||||
>
|
||||
{filterLabels.all}
|
||||
</FilterButton>
|
||||
<FilterButton
|
||||
active={filter === 'errors'}
|
||||
onClick={() => onFilterChange('errors')}
|
||||
>
|
||||
{filterLabels.errors}
|
||||
</FilterButton>
|
||||
<FilterButton
|
||||
active={filter === 'content'}
|
||||
onClick={() => onFilterChange('content')}
|
||||
>
|
||||
{filterLabels.content}
|
||||
</FilterButton>
|
||||
<FilterButton
|
||||
active={filter === 'system'}
|
||||
onClick={() => onFilterChange('system')}
|
||||
>
|
||||
{filterLabels.system}
|
||||
</FilterButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: View Mode and Settings */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* View Mode Dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8 gap-1 pr-2">
|
||||
{viewModeLabels[viewMode]}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={4}>
|
||||
<DropdownMenuLabel>{formatMessage({ id: 'cliMonitor.viewMode' })}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onViewModeChange('preview')}>
|
||||
{formatMessage({ id: 'cliMonitor.view.preview' })}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onViewModeChange('json')}>
|
||||
{formatMessage({ id: 'cliMonitor.view.json' })}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onViewModeChange('raw')}>
|
||||
{formatMessage({ id: 'cliMonitor.view.raw' })}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Settings Button */}
|
||||
{onSettingsClick && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onSettingsClick}
|
||||
className="h-8 w-8"
|
||||
title={formatMessage({ id: 'cliMonitor.settings' })}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
MonitorToolbar.displayName = 'MonitorToolbar';
|
||||
36
ccw/frontend/src/components/shared/CliStreamMonitor/index.ts
Normal file
36
ccw/frontend/src/components/shared/CliStreamMonitor/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// ========================================
|
||||
// CliStreamMonitor Component Exports
|
||||
// ========================================
|
||||
// New layout exports for the redesigned CLI Stream Monitor
|
||||
|
||||
// Main component (new layout)
|
||||
export { CliStreamMonitorNew as CliStreamMonitor } from './CliStreamMonitorNew';
|
||||
export type { CliStreamMonitorNewProps as CliStreamMonitorProps } from './CliStreamMonitorNew';
|
||||
|
||||
// Layout components
|
||||
export { MonitorHeader } from './MonitorHeader';
|
||||
export type { MonitorHeaderProps } from './MonitorHeader';
|
||||
|
||||
export { MonitorToolbar } from './MonitorToolbar';
|
||||
export type { MonitorToolbarProps, FilterType, ViewMode } from './MonitorToolbar';
|
||||
|
||||
export { MonitorBody } from './MonitorBody';
|
||||
export type { MonitorBodyProps, MonitorBodyRef } from './MonitorBody';
|
||||
|
||||
// Message type components
|
||||
export {
|
||||
SystemMessage,
|
||||
UserMessage,
|
||||
AssistantMessage,
|
||||
ErrorMessage,
|
||||
} from './messages';
|
||||
export type {
|
||||
SystemMessageProps,
|
||||
UserMessageProps,
|
||||
AssistantMessageProps,
|
||||
ErrorMessageProps,
|
||||
} from './messages';
|
||||
|
||||
// Message renderer
|
||||
export { MessageRenderer } from './MessageRenderer';
|
||||
export type { MessageRendererProps } from './MessageRenderer';
|
||||
@@ -0,0 +1,196 @@
|
||||
// ========================================
|
||||
// AssistantMessage Component
|
||||
// ========================================
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Bot, ChevronDown, Copy, Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
// Status indicator component
|
||||
interface StatusIndicatorProps {
|
||||
status: 'thinking' | 'streaming' | 'completed' | 'error';
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
function StatusIndicator({ status, duration }: StatusIndicatorProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
if (status === 'thinking') {
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
|
||||
{formatMessage({ id: 'cliMonitor.thinking' })}
|
||||
<span className="animate-pulse">🟡</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'streaming') {
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400">
|
||||
{formatMessage({ id: 'cliMonitor.streaming' })}
|
||||
<span className="animate-pulse">🔵</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs text-red-600 dark:text-red-400">
|
||||
Error
|
||||
<span>❌</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (duration !== undefined) {
|
||||
const seconds = (duration / 1000).toFixed(1);
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{seconds}s
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Format duration helper
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return `${hours}h ${remainingMinutes}m`;
|
||||
}
|
||||
|
||||
export interface AssistantMessageProps {
|
||||
content: string;
|
||||
modelName?: string;
|
||||
status?: 'thinking' | 'streaming' | 'completed' | 'error';
|
||||
duration?: number;
|
||||
tokenCount?: number;
|
||||
timestamp?: number;
|
||||
onCopy?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AssistantMessage({
|
||||
content,
|
||||
modelName = 'AI',
|
||||
status = 'completed',
|
||||
duration,
|
||||
tokenCount,
|
||||
// timestamp is kept for future use but not currently displayed
|
||||
// timestamp,
|
||||
onCopy,
|
||||
className
|
||||
}: AssistantMessageProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (copied) {
|
||||
const timer = setTimeout(() => setCopied(false), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [copied]);
|
||||
|
||||
const handleCopy = () => {
|
||||
onCopy?.();
|
||||
setCopied(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-purple-50/50 dark:bg-purple-950/30 border-l-4 border-purple-500 rounded-r-lg overflow-hidden transition-all',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-purple-100/50 dark:hover:bg-purple-900/30 transition-colors',
|
||||
'group'
|
||||
)}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<Bot className="h-4 w-4 text-purple-600 dark:text-purple-400 shrink-0" />
|
||||
<span className="text-sm font-semibold text-purple-900 dark:text-purple-100">
|
||||
{modelName}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<StatusIndicator status={status} duration={duration} />
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 text-muted-foreground transition-transform',
|
||||
!isExpanded && '-rotate-90'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isExpanded && (
|
||||
<>
|
||||
<div className="px-3 py-2 bg-purple-50/30 dark:bg-purple-950/20">
|
||||
<div className="bg-white/50 dark:bg-black/20 rounded border border-purple-200/50 dark:border-purple-800/50 p-3">
|
||||
<div className="text-sm text-foreground whitespace-pre-wrap break-words">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata Footer */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-1.5 bg-purple-50/30 dark:bg-purple-950/20',
|
||||
'text-xs text-muted-foreground',
|
||||
'justify-between'
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{tokenCount !== undefined && (
|
||||
<span>{formatMessage({ id: 'cliMonitor.tokens' }, { count: tokenCount.toLocaleString() })}</span>
|
||||
)}
|
||||
{duration !== undefined && (
|
||||
<span>{formatMessage({ id: 'cliMonitor.duration' }, { value: formatDuration(duration) })}</span>
|
||||
)}
|
||||
{modelName && <span>{formatMessage({ id: 'cliMonitor.model' }, { name: modelName })}</span>}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
{formatMessage({ id: 'cliMonitor.copied' })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3 w-3 mr-1" />
|
||||
{formatMessage({ id: 'cliMonitor.copy' })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AssistantMessage;
|
||||
@@ -0,0 +1,88 @@
|
||||
// ========================================
|
||||
// ErrorMessage Component
|
||||
// ========================================
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
export interface ErrorMessageProps {
|
||||
title: string;
|
||||
message: string;
|
||||
timestamp?: number;
|
||||
onRetry?: () => void;
|
||||
onDismiss?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ErrorMessage({
|
||||
title,
|
||||
message,
|
||||
timestamp,
|
||||
onRetry,
|
||||
onDismiss,
|
||||
className
|
||||
}: ErrorMessageProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const timeString = timestamp
|
||||
? new Date(timestamp).toLocaleTimeString('en-US', { hour12: false })
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-destructive/10 border-l-4 border-destructive rounded-r-lg overflow-hidden transition-all',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<AlertCircle className="h-4 w-4 text-destructive shrink-0" />
|
||||
{timeString && (
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
[{timeString}]
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm font-semibold text-destructive">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-3 py-2 bg-destructive/5">
|
||||
<p className="text-sm text-destructive-foreground whitespace-pre-wrap break-words">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{(onRetry || onDismiss) && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-destructive/5">
|
||||
{onRetry && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRetry}
|
||||
className="h-8 px-3 text-xs border-destructive/30 text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
{formatMessage({ id: 'cliMonitor.retry' })}
|
||||
</Button>
|
||||
)}
|
||||
{onDismiss && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDismiss}
|
||||
className="h-8 px-3 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{formatMessage({ id: 'cliMonitor.dismiss' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorMessage;
|
||||
@@ -0,0 +1,75 @@
|
||||
// ========================================
|
||||
// SystemMessage Component
|
||||
// ========================================
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Info, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SystemMessageProps {
|
||||
title: string;
|
||||
timestamp?: number;
|
||||
metadata?: string;
|
||||
content?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SystemMessage({
|
||||
title,
|
||||
timestamp,
|
||||
metadata,
|
||||
content,
|
||||
className
|
||||
}: SystemMessageProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const timeString = timestamp
|
||||
? new Date(timestamp).toLocaleTimeString('en-US', { hour12: false })
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-muted/30 dark:bg-muted/20 border-l-2 border-info rounded-r-lg overflow-hidden transition-all',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => content && setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<Info className="h-3.5 w-3.5 text-info shrink-0" />
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
[{timeString}]
|
||||
</span>
|
||||
<span className="text-sm font-medium text-foreground truncate flex-1">
|
||||
{title}
|
||||
</span>
|
||||
{metadata && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{metadata}
|
||||
</span>
|
||||
)}
|
||||
{content && (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 text-muted-foreground transition-transform',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable Content */}
|
||||
{isExpanded && content && (
|
||||
<div className="px-3 py-2 bg-muted/20 border-t border-border/50">
|
||||
<div className="text-xs text-muted-foreground whitespace-pre-wrap break-words">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SystemMessage;
|
||||
@@ -0,0 +1,133 @@
|
||||
// ========================================
|
||||
// UserMessage Component
|
||||
// ========================================
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { User, ChevronDown, Copy, Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
export interface UserMessageProps {
|
||||
content: string;
|
||||
timestamp?: number;
|
||||
onCopy?: () => void;
|
||||
onViewRaw?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function UserMessage({
|
||||
content,
|
||||
timestamp,
|
||||
onCopy,
|
||||
onViewRaw,
|
||||
className
|
||||
}: UserMessageProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timeString = timestamp
|
||||
? new Date(timestamp).toLocaleTimeString('en-US', { hour12: false })
|
||||
: '';
|
||||
|
||||
// Auto-reset copied state
|
||||
useEffect(() => {
|
||||
if (copied) {
|
||||
const timer = setTimeout(() => setCopied(false), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [copied]);
|
||||
|
||||
const handleCopy = () => {
|
||||
onCopy?.();
|
||||
setCopied(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-blue-50/50 dark:bg-blue-950/30 border-l-4 border-blue-500 rounded-r-lg overflow-hidden transition-all',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-blue-100/50 dark:hover:bg-blue-900/30 transition-colors',
|
||||
'group'
|
||||
)}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<User className="h-4 w-4 text-blue-600 dark:text-blue-400 shrink-0" />
|
||||
<span className="text-sm font-semibold text-blue-900 dark:text-blue-100">
|
||||
{formatMessage({ id: 'cliMonitor.user' })}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 text-muted-foreground transition-transform ml-auto',
|
||||
!isExpanded && '-rotate-90'
|
||||
)}
|
||||
/>
|
||||
{timeString && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
[{timeString}]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isExpanded && (
|
||||
<>
|
||||
<div className="px-3 py-2 bg-blue-50/30 dark:bg-blue-950/20">
|
||||
<div className="bg-white/50 dark:bg-black/20 rounded border border-blue-200/50 dark:border-blue-800/50 p-3">
|
||||
<pre className="text-sm text-foreground whitespace-pre-wrap break-words font-sans">
|
||||
{content}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 bg-blue-50/30 dark:bg-blue-950/20',
|
||||
'justify-end'
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
{formatMessage({ id: 'cliMonitor.copied' })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3 w-3 mr-1" />
|
||||
{formatMessage({ id: 'cliMonitor.copy' })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{onViewRaw && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onViewRaw}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{formatMessage({ id: 'cliMonitor.rawJson' })}
|
||||
<ChevronDown className="h-3 w-3 ml-1" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserMessage;
|
||||
@@ -0,0 +1,110 @@
|
||||
// ========================================
|
||||
// Message Components Usage Example
|
||||
// ========================================
|
||||
// This file demonstrates how to use the message components
|
||||
|
||||
import {
|
||||
SystemMessage,
|
||||
UserMessage,
|
||||
AssistantMessage,
|
||||
ErrorMessage
|
||||
} from './index';
|
||||
|
||||
export function MessageExample() {
|
||||
return (
|
||||
<div className="space-y-3 p-4">
|
||||
{/* System Message Example */}
|
||||
<SystemMessage
|
||||
title="Session Started"
|
||||
timestamp={Date.now()}
|
||||
metadata="gemini-2.5-pro | Context: 28 files"
|
||||
content="CLI execution started: gemini (analysis mode)"
|
||||
/>
|
||||
|
||||
{/* User Message Example */}
|
||||
<UserMessage
|
||||
timestamp={Date.now()}
|
||||
content={`PURPOSE: Review LogBlock component architecture
|
||||
TASK: • Analyze component structure • Identify patterns • Check performance
|
||||
MODE: analysis
|
||||
CONTEXT: @src/components/shared/LogBlock/**/*`}
|
||||
onCopy={() => console.log('Copied user message')}
|
||||
onViewRaw={() => console.log('View raw JSON')}
|
||||
/>
|
||||
|
||||
{/* Assistant Message Example - Thinking */}
|
||||
<AssistantMessage
|
||||
modelName="Gemini"
|
||||
status="thinking"
|
||||
content="Analyzing the LogBlock component structure..."
|
||||
timestamp={Date.now()}
|
||||
/>
|
||||
|
||||
{/* Assistant Message Example - Completed */}
|
||||
<AssistantMessage
|
||||
modelName="Gemini"
|
||||
status="completed"
|
||||
duration={4800}
|
||||
tokenCount={10510}
|
||||
timestamp={Date.now()}
|
||||
content={`I've analyzed the LogBlock component.
|
||||
|
||||
**Key Findings:**
|
||||
- Component uses React.memo for performance optimization
|
||||
- Status-based border colors provide visual feedback
|
||||
- Collapsible content area with chevron indicator
|
||||
- Action buttons appear on group hover
|
||||
|
||||
**Architecture:**
|
||||
- Header: Expandable with status icon, title, metadata
|
||||
- Content: Monospace font output with line icons
|
||||
- Actions: Copy command/output, re-run buttons`}
|
||||
onCopy={() => console.log('Copied assistant message')}
|
||||
/>
|
||||
|
||||
{/* Error Message Example */}
|
||||
<ErrorMessage
|
||||
title="Error"
|
||||
message="Failed to fetch active CLI executions\n\nStatus: 500 Internal Server Error\nDetails: Connection timeout after 30s"
|
||||
timestamp={Date.now()}
|
||||
onRetry={() => console.log('Retrying...')}
|
||||
onDismiss={() => console.log('Dismissed')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Props Interface Reference
|
||||
/*
|
||||
SystemMessageProps:
|
||||
- title: string
|
||||
- timestamp?: number
|
||||
- metadata?: string
|
||||
- content?: string
|
||||
- className?: string
|
||||
|
||||
UserMessageProps:
|
||||
- content: string
|
||||
- timestamp?: number
|
||||
- onCopy?: () => void
|
||||
- onViewRaw?: () => void
|
||||
- className?: string
|
||||
|
||||
AssistantMessageProps:
|
||||
- content: string
|
||||
- modelName?: string
|
||||
- status?: 'thinking' | 'streaming' | 'completed' | 'error'
|
||||
- duration?: number
|
||||
- tokenCount?: number
|
||||
- timestamp?: number
|
||||
- onCopy?: () => void
|
||||
- className?: string
|
||||
|
||||
ErrorMessageProps:
|
||||
- title: string
|
||||
- message: string
|
||||
- timestamp?: number
|
||||
- onRetry?: () => void
|
||||
- onDismiss?: () => void
|
||||
- className?: string
|
||||
*/
|
||||
@@ -0,0 +1,13 @@
|
||||
// ========================================
|
||||
// Message Components Exports
|
||||
// ========================================
|
||||
|
||||
export { SystemMessage } from './SystemMessage';
|
||||
export { UserMessage } from './UserMessage';
|
||||
export { AssistantMessage } from './AssistantMessage';
|
||||
export { ErrorMessage } from './ErrorMessage';
|
||||
|
||||
export type { SystemMessageProps } from './SystemMessage';
|
||||
export type { UserMessageProps } from './UserMessage';
|
||||
export type { AssistantMessageProps } from './AssistantMessage';
|
||||
export type { ErrorMessageProps } from './ErrorMessage';
|
||||
@@ -26,7 +26,7 @@ import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/Tabs';
|
||||
import { LogBlockList } from '@/components/shared/LogBlock';
|
||||
import { LogBlockList, getOutputLineClass } from '@/components/shared/LogBlock';
|
||||
import { useCliStreamStore, type CliOutputLine } from '@/stores/cliStreamStore';
|
||||
import { useNotificationStore, selectWsLastMessage } from '@/stores';
|
||||
import { useActiveCliExecutions, useInvalidateActiveCliExecutions } from '@/hooks/useActiveCliExecutions';
|
||||
@@ -77,6 +77,7 @@ function formatDuration(ms: number): string {
|
||||
return `${hours}h ${remainingMinutes}m`;
|
||||
}
|
||||
|
||||
// Local function for icon rendering (uses JSX, must stay in .tsx file)
|
||||
function getOutputLineIcon(type: CliOutputLine['type']) {
|
||||
switch (type) {
|
||||
case 'thought':
|
||||
@@ -95,24 +96,6 @@ function getOutputLineIcon(type: CliOutputLine['type']) {
|
||||
}
|
||||
}
|
||||
|
||||
function getOutputLineClass(type: CliOutputLine['type']): string {
|
||||
switch (type) {
|
||||
case 'thought':
|
||||
return 'text-purple-400';
|
||||
case 'system':
|
||||
return 'text-blue-400';
|
||||
case 'stderr':
|
||||
return 'text-red-400';
|
||||
case 'metadata':
|
||||
return 'text-yellow-400';
|
||||
case 'tool_call':
|
||||
return 'text-green-400';
|
||||
case 'stdout':
|
||||
default:
|
||||
return 'text-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export interface CliStreamMonitorProps {
|
||||
353
ccw/frontend/src/components/shared/LogBlock/JsonFormatter.tsx
Normal file
353
ccw/frontend/src/components/shared/LogBlock/JsonFormatter.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
// ========================================
|
||||
// JsonFormatter Component
|
||||
// ========================================
|
||||
// Displays JSON content in formatted text or card view
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { ChevronDown, ChevronRight, Copy, Check, Braces } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
detectJsonContent,
|
||||
formatJson,
|
||||
getJsonSummary,
|
||||
getJsonValueTypeColor,
|
||||
type JsonDisplayMode,
|
||||
} from './jsonUtils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface JsonFormatterProps {
|
||||
/** Content to format */
|
||||
content: string;
|
||||
/** Display mode */
|
||||
displayMode?: JsonDisplayMode;
|
||||
/** CSS className */
|
||||
className?: string;
|
||||
/** Maximum lines for text mode (default: 20) */
|
||||
maxLines?: number;
|
||||
/** Whether to show type labels in card mode (default: true) */
|
||||
showTypeLabels?: boolean;
|
||||
}
|
||||
|
||||
// ========== Helper Components ==========
|
||||
|
||||
/**
|
||||
* Copy button with feedback
|
||||
*/
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="h-6 px-2 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3 w-3 mr-1" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON value renderer with syntax highlighting
|
||||
*/
|
||||
function JsonValue({ value, depth = 0 }: { value: unknown; depth?: number }) {
|
||||
const indent = ' '.repeat(depth);
|
||||
const colorClass = getJsonValueTypeColor(value);
|
||||
|
||||
if (value === null) {
|
||||
return <span className={colorClass}>null</span>;
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return <span className={colorClass}>{String(value)}</span>;
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return <span className={colorClass}>{String(value)}</span>;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return <span className={colorClass}>"{value}"</span>;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return <span className="text-blue-400">[]</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-blue-400">
|
||||
<span>[</span>
|
||||
<div className="pl-4">
|
||||
{value.map((item, index) => (
|
||||
<div key={index} className="hover:bg-muted/50 rounded px-1">
|
||||
<JsonValue value={item} depth={depth + 1} />
|
||||
{index < value.length - 1 && <span className="text-foreground">,</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span>{indent}]</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
if (entries.length === 0) {
|
||||
return <span className="text-yellow-400">{`{}`}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-yellow-400">
|
||||
<span>{`{`}</span>
|
||||
<div className="pl-4">
|
||||
{entries.map(([key, val], index) => (
|
||||
<div key={key} className="hover:bg-muted/50 rounded px-1">
|
||||
<span className="text-cyan-400">"{key}"</span>
|
||||
<span className="text-foreground">: </span>
|
||||
<JsonValue value={val} depth={depth + 1} />
|
||||
{index < entries.length - 1 && <span className="text-foreground">,</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span>{indent}{`}`}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <span>{String(value)}</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact JSON view for inline display
|
||||
*/
|
||||
function JsonCompact({ data }: { data: unknown }) {
|
||||
return (
|
||||
<code className="text-xs font-mono">
|
||||
<JsonValue value={data} />
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Card view for structured JSON display
|
||||
*/
|
||||
function JsonCard({ data, showTypeLabels = true }: { data: unknown; showTypeLabels?: boolean }) {
|
||||
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleKey = useCallback((key: string) => {
|
||||
setExpandedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
|
||||
// Primitive or array - use inline view
|
||||
return (
|
||||
<div className="p-3 bg-muted/30 rounded border border-border">
|
||||
<JsonCompact data={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const entries = Object.entries(data as Record<string, unknown>);
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg overflow-hidden bg-card">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-muted/50 border-b border-border">
|
||||
<Braces className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">JSON Data</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({entries.length} {entries.length === 1 ? 'property' : 'properties'})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Properties */}
|
||||
<div className="divide-y divide-border">
|
||||
{entries.map(([key, value]) => {
|
||||
const isObject = value !== null && typeof value === 'object';
|
||||
const isExpanded = expandedKeys.has(key);
|
||||
const isArray = Array.isArray(value);
|
||||
const summary = getJsonSummary(value);
|
||||
|
||||
return (
|
||||
<div key={key} className="group">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-2 px-3 py-2 hover:bg-muted/30 transition-colors',
|
||||
isObject && 'cursor-pointer'
|
||||
)}
|
||||
onClick={() => isObject && toggleKey(key)}
|
||||
>
|
||||
{/* Expand/collapse icon */}
|
||||
{isObject && (
|
||||
<div className="shrink-0 mt-0.5">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key */}
|
||||
<span className="font-mono text-sm text-cyan-400 shrink-0">"{key}"</span>
|
||||
<span className="text-muted-foreground shrink-0">:</span>
|
||||
|
||||
{/* Value summary or full value */}
|
||||
<div className={cn('flex-1 min-w-0', getJsonValueTypeColor(value))}>
|
||||
{showTypeLabels && (
|
||||
<span className="text-xs text-muted-foreground mr-1">
|
||||
{isArray ? 'array' : isObject ? 'object' : typeof value}
|
||||
</span>
|
||||
)}
|
||||
{!isObject ? (
|
||||
<span className="text-sm font-mono break-all">{summary}</span>
|
||||
) : (
|
||||
<span className="text-sm">{summary}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded nested object */}
|
||||
{isObject && isExpanded && (
|
||||
<div className="pl-8 pr-3 pb-2">
|
||||
<div className="p-3 bg-muted/30 rounded border border-border">
|
||||
<JsonCard data={value} showTypeLabels={showTypeLabels} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Text view for formatted JSON
|
||||
*/
|
||||
function JsonText({ data, maxLines = 20 }: { data: unknown; maxLines?: number }) {
|
||||
const formatted = useMemo(() => formatJson(data), [data]);
|
||||
const lines = formatted.split('\n');
|
||||
|
||||
const showTruncated = maxLines && lines.length > maxLines;
|
||||
const displayLines = showTruncated ? lines.slice(0, maxLines) : lines;
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<pre className="text-xs font-mono bg-muted/30 p-3 rounded border border-border overflow-x-auto">
|
||||
<code className="text-foreground">
|
||||
{displayLines.map((line, i) => (
|
||||
<div key={i} className="hover:bg-muted/50 px-1 -mx-1">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
{showTruncated && (
|
||||
<div className="text-muted-foreground italic">
|
||||
// ... {lines.length - maxLines} more lines
|
||||
</div>
|
||||
)}
|
||||
</code>
|
||||
</pre>
|
||||
|
||||
{/* Copy button */}
|
||||
<div className="absolute top-2 right-2">
|
||||
<CopyButton text={formatted} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
/**
|
||||
* JsonFormatter Component
|
||||
*
|
||||
* Displays JSON content in various formats:
|
||||
* - `text`: Formatted JSON text with syntax highlighting
|
||||
* - `card`: Structured card view with collapsible properties
|
||||
* - `inline`: Compact inline display
|
||||
*
|
||||
* Auto-detects JSON from mixed content and validates it.
|
||||
*/
|
||||
export function JsonFormatter({
|
||||
content,
|
||||
displayMode = 'text',
|
||||
className,
|
||||
maxLines = 20,
|
||||
showTypeLabels = true,
|
||||
}: JsonFormatterProps) {
|
||||
// Detect JSON content
|
||||
const detection = useMemo(() => detectJsonContent(content), [content]);
|
||||
|
||||
// Not JSON or invalid - show as plain text
|
||||
if (!detection.isJson) {
|
||||
return (
|
||||
<div className={cn('text-xs font-mono text-foreground', className)}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Valid JSON - render based on display mode
|
||||
switch (displayMode) {
|
||||
case 'card':
|
||||
return (
|
||||
<div className={className}>
|
||||
<JsonCard data={detection.parsed} showTypeLabels={showTypeLabels} />
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'inline':
|
||||
return (
|
||||
<div className={cn('inline-flex items-center gap-1 px-2 py-1 bg-muted/50 rounded border border-border', className)}>
|
||||
<Braces className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-mono">
|
||||
<JsonCompact data={detection.parsed} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'text':
|
||||
default:
|
||||
return (
|
||||
<div className={className}>
|
||||
<JsonText data={detection.parsed} maxLines={maxLines} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default JsonFormatter;
|
||||
@@ -22,8 +22,9 @@ import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import type { LogBlockProps, LogLine } from './types';
|
||||
import { getOutputLineClass } from './utils';
|
||||
|
||||
// Re-use output line styling helpers from CliStreamMonitor
|
||||
// Local function for icon rendering (uses JSX, must stay in .tsx file)
|
||||
function getOutputLineIcon(type: LogLine['type']) {
|
||||
switch (type) {
|
||||
case 'thought':
|
||||
@@ -42,24 +43,6 @@ function getOutputLineIcon(type: LogLine['type']) {
|
||||
}
|
||||
}
|
||||
|
||||
function getOutputLineClass(type: LogLine['type']): string {
|
||||
switch (type) {
|
||||
case 'thought':
|
||||
return 'text-purple-400';
|
||||
case 'system':
|
||||
return 'text-blue-400';
|
||||
case 'stderr':
|
||||
return 'text-red-400';
|
||||
case 'metadata':
|
||||
return 'text-yellow-400';
|
||||
case 'tool_call':
|
||||
return 'text-green-400';
|
||||
case 'stdout':
|
||||
default:
|
||||
return 'text-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
function getBlockBorderClass(status: LogBlockProps['block']['status']): string {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
@@ -247,13 +230,22 @@ export const LogBlock = memo(function LogBlock({
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
// Custom comparison for performance
|
||||
// Compare all relevant block fields to detect changes
|
||||
const prevBlock = prevProps.block;
|
||||
const nextBlock = nextProps.block;
|
||||
|
||||
return (
|
||||
prevProps.block.id === nextProps.block.id &&
|
||||
prevProps.block.status === nextProps.block.status &&
|
||||
prevProps.block.lineCount === nextProps.block.lineCount &&
|
||||
prevProps.block.duration === nextProps.block.duration &&
|
||||
prevProps.isExpanded === nextProps.isExpanded &&
|
||||
prevProps.className === nextProps.className
|
||||
prevProps.className === nextProps.className &&
|
||||
prevBlock.id === nextBlock.id &&
|
||||
prevBlock.status === nextBlock.status &&
|
||||
prevBlock.title === nextBlock.title &&
|
||||
prevBlock.toolName === nextBlock.toolName &&
|
||||
prevBlock.lineCount === nextBlock.lineCount &&
|
||||
prevBlock.duration === nextBlock.duration
|
||||
// Note: We don't compare block.lines deeply for performance reasons.
|
||||
// The store's getBlocks method returns cached arrays, so if lines change
|
||||
// significantly, a new block object will be created and the id will change.
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,208 +3,9 @@
|
||||
// ========================================
|
||||
// Container component for displaying grouped CLI output blocks
|
||||
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { useCliStreamStore } from '@/stores/cliStreamStore';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useCliStreamStore, type LogBlockData } from '@/stores/cliStreamStore';
|
||||
import { LogBlock } from './LogBlock';
|
||||
import type { LogBlockData, LogLine } from './types';
|
||||
import type { CliOutputLine } from '@/stores/cliStreamStore';
|
||||
|
||||
/**
|
||||
* Parse tool call metadata from content
|
||||
* Expected format: "[Tool] toolName(args)"
|
||||
*/
|
||||
function parseToolCallMetadata(content: string): { toolName: string; args: string } | undefined {
|
||||
const toolCallMatch = content.match(/^\[Tool\]\s+(\w+)\((.*)\)$/);
|
||||
if (toolCallMatch) {
|
||||
return {
|
||||
toolName: toolCallMatch[1],
|
||||
args: toolCallMatch[2] || '',
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate block title based on type and content
|
||||
*/
|
||||
function generateBlockTitle(lineType: string, content: string): string {
|
||||
switch (lineType) {
|
||||
case 'tool_call':
|
||||
const metadata = parseToolCallMetadata(content);
|
||||
if (metadata) {
|
||||
return metadata.args ? `${metadata.toolName}(${metadata.args})` : metadata.toolName;
|
||||
}
|
||||
return 'Tool Call';
|
||||
case 'thought':
|
||||
return 'Thought';
|
||||
case 'system':
|
||||
return 'System';
|
||||
case 'stderr':
|
||||
return 'Error Output';
|
||||
case 'stdout':
|
||||
return 'Output';
|
||||
case 'metadata':
|
||||
return 'Metadata';
|
||||
default:
|
||||
return 'Log';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get block type for a line
|
||||
*/
|
||||
function getBlockType(lineType: string): LogBlockData['type'] {
|
||||
switch (lineType) {
|
||||
case 'tool_call':
|
||||
return 'tool';
|
||||
case 'thought':
|
||||
return 'info';
|
||||
case 'system':
|
||||
return 'info';
|
||||
case 'stderr':
|
||||
return 'error';
|
||||
case 'stdout':
|
||||
case 'metadata':
|
||||
default:
|
||||
return 'output';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a line type should start a new block
|
||||
*/
|
||||
function shouldStartNewBlock(lineType: string, currentBlockType: string | null): boolean {
|
||||
// No current block exists
|
||||
if (!currentBlockType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// These types always start new blocks
|
||||
if (lineType === 'tool_call' || lineType === 'thought' || lineType === 'system') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// stderr starts a new block if not already in stderr
|
||||
if (lineType === 'stderr' && currentBlockType !== 'stderr') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// tool_call block captures all following stdout/stderr until next tool_call
|
||||
if (currentBlockType === 'tool_call' && (lineType === 'stdout' || lineType === 'stderr')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// stderr block captures all stderr until next different type
|
||||
if (currentBlockType === 'stderr' && lineType === 'stderr') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// stdout merges into current stdout block
|
||||
if (currentBlockType === 'stdout' && lineType === 'stdout') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Different type - start new block
|
||||
if (currentBlockType !== lineType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group CLI output lines into log blocks
|
||||
*
|
||||
* Block grouping rules:
|
||||
* 1. tool_call starts new block, includes following stdout/stderr until next tool_call
|
||||
* 2. thought becomes independent block
|
||||
* 3. system becomes independent block
|
||||
* 4. stderr becomes highlighted block
|
||||
* 5. Other stdout merges into normal blocks
|
||||
*/
|
||||
function groupLinesIntoBlocks(
|
||||
lines: CliOutputLine[],
|
||||
executionId: string,
|
||||
executionStatus: 'running' | 'completed' | 'error'
|
||||
): LogBlockData[] {
|
||||
const blocks: LogBlockData[] = [];
|
||||
let currentLines: LogLine[] = [];
|
||||
let currentType: string | null = null;
|
||||
let currentTitle = '';
|
||||
let currentToolName: string | undefined;
|
||||
let blockStartTime = 0;
|
||||
let blockIndex = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const blockType = getBlockType(line.type);
|
||||
|
||||
// Check if we need to start a new block
|
||||
if (shouldStartNewBlock(line.type, currentType)) {
|
||||
// Save current block if exists
|
||||
if (currentLines.length > 0) {
|
||||
const duration = blockStartTime > 0 ? line.timestamp - blockStartTime : undefined;
|
||||
blocks.push({
|
||||
id: `${executionId}-block-${blockIndex}`,
|
||||
title: currentTitle || generateBlockTitle(currentType || '', currentLines[0]?.content || ''),
|
||||
type: getBlockType(currentType || ''),
|
||||
status: executionStatus === 'running' ? 'running' : 'completed',
|
||||
toolName: currentToolName,
|
||||
lineCount: currentLines.length,
|
||||
duration,
|
||||
lines: currentLines,
|
||||
timestamp: blockStartTime,
|
||||
});
|
||||
blockIndex++;
|
||||
}
|
||||
|
||||
// Start new block
|
||||
currentType = line.type;
|
||||
currentTitle = generateBlockTitle(line.type, line.content);
|
||||
currentLines = [
|
||||
{
|
||||
type: line.type,
|
||||
content: line.content,
|
||||
timestamp: line.timestamp,
|
||||
},
|
||||
];
|
||||
blockStartTime = line.timestamp;
|
||||
|
||||
// Extract tool name for tool_call blocks
|
||||
if (line.type === 'tool_call') {
|
||||
const metadata = parseToolCallMetadata(line.content);
|
||||
currentToolName = metadata?.toolName;
|
||||
} else {
|
||||
currentToolName = undefined;
|
||||
}
|
||||
} else {
|
||||
// Add line to current block
|
||||
currentLines.push({
|
||||
type: line.type,
|
||||
content: line.content,
|
||||
timestamp: line.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize the last block
|
||||
if (currentLines.length > 0) {
|
||||
const lastLine = currentLines[currentLines.length - 1];
|
||||
const duration = blockStartTime > 0 ? lastLine.timestamp - blockStartTime : undefined;
|
||||
blocks.push({
|
||||
id: `${executionId}-block-${blockIndex}`,
|
||||
title: currentTitle || generateBlockTitle(currentType || '', currentLines[0]?.content || ''),
|
||||
type: getBlockType(currentType || ''),
|
||||
status: executionStatus === 'running' ? 'running' : 'completed',
|
||||
toolName: currentToolName,
|
||||
lineCount: currentLines.length,
|
||||
duration,
|
||||
lines: currentLines,
|
||||
timestamp: blockStartTime,
|
||||
});
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for LogBlockList component
|
||||
@@ -219,29 +20,26 @@ export interface LogBlockListProps {
|
||||
/**
|
||||
* LogBlockList component
|
||||
* Displays CLI output grouped into collapsible blocks
|
||||
*
|
||||
* Uses the store's getBlocks method to retrieve pre-computed blocks,
|
||||
* avoiding duplicate logic and ensuring consistent block grouping.
|
||||
*/
|
||||
export function LogBlockList({ executionId, className }: LogBlockListProps) {
|
||||
// Get execution data from store
|
||||
const executions = useCliStreamStore((state) => state.executions);
|
||||
// Get blocks directly from store using the getBlocks selector
|
||||
// This avoids duplicate logic and leverages store-side caching
|
||||
const blocks = useCliStreamStore(
|
||||
(state) => executionId ? state.getBlocks(executionId) : [],
|
||||
(a, b) => a === b // Shallow comparison - arrays are cached in store
|
||||
);
|
||||
|
||||
// Get current execution or execution by ID
|
||||
const currentExecution = useMemo(() => {
|
||||
if (!executionId) return null;
|
||||
return executions[executionId] || null;
|
||||
}, [executions, executionId]);
|
||||
// Get execution status for empty state display
|
||||
const currentExecution = useCliStreamStore((state) =>
|
||||
executionId ? state.executions[executionId] : null
|
||||
);
|
||||
|
||||
// Manage expanded blocks state
|
||||
const [expandedBlocks, setExpandedBlocks] = useState<Set<string>>(new Set());
|
||||
|
||||
// Group output lines into blocks
|
||||
const blocks = useMemo(() => {
|
||||
if (!currentExecution?.output || currentExecution.output.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return groupLinesIntoBlocks(currentExecution.output, executionId!, currentExecution.status);
|
||||
}, [currentExecution, executionId]);
|
||||
|
||||
// Toggle block expand/collapse
|
||||
const toggleBlockExpand = useCallback((blockId: string) => {
|
||||
setExpandedBlocks((prev) => {
|
||||
|
||||
@@ -4,4 +4,5 @@
|
||||
|
||||
export { LogBlock, default } from './LogBlock';
|
||||
export { LogBlockList, type LogBlockListProps } from './LogBlockList';
|
||||
export { getOutputLineClass } from './utils';
|
||||
export type { LogBlockProps, LogBlockData, LogLine } from './types';
|
||||
|
||||
187
ccw/frontend/src/components/shared/LogBlock/jsonUtils.ts
Normal file
187
ccw/frontend/src/components/shared/LogBlock/jsonUtils.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
// ========================================
|
||||
// LogBlock JSON Utilities
|
||||
// ========================================
|
||||
// JSON content detection and formatting utilities
|
||||
|
||||
/**
|
||||
* JSON content type detection result
|
||||
*/
|
||||
export interface JsonDetectionResult {
|
||||
isJson: boolean;
|
||||
parsed?: unknown;
|
||||
error?: string;
|
||||
format: 'object' | 'array' | 'primitive' | 'invalid';
|
||||
}
|
||||
|
||||
/**
|
||||
* Display mode for JSON content
|
||||
*/
|
||||
export type JsonDisplayMode = 'text' | 'card' | 'inline';
|
||||
|
||||
/**
|
||||
* Detect if content is valid JSON
|
||||
*
|
||||
* @param content - Content string to check
|
||||
* @returns Detection result with parsed data if valid
|
||||
*/
|
||||
export function detectJson(content: string): JsonDetectionResult {
|
||||
const trimmed = content.trim();
|
||||
|
||||
// Quick check for JSON patterns
|
||||
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
||||
return { isJson: false, format: 'invalid' };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
|
||||
// Determine format type
|
||||
let format: JsonDetectionResult['format'] = 'primitive';
|
||||
if (Array.isArray(parsed)) {
|
||||
format = 'array';
|
||||
} else if (parsed !== null && typeof parsed === 'object') {
|
||||
format = 'object';
|
||||
}
|
||||
|
||||
return { isJson: true, parsed, format };
|
||||
} catch (error) {
|
||||
return {
|
||||
isJson: false,
|
||||
format: 'invalid',
|
||||
error: error instanceof Error ? error.message : 'Unknown parse error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract JSON from mixed content
|
||||
* Handles cases where JSON is embedded in text output
|
||||
*
|
||||
* @param content - Content that may contain JSON
|
||||
* @returns Extracted JSON string or null if not found
|
||||
*/
|
||||
export function extractJson(content: string): string | null {
|
||||
const trimmed = content.trim();
|
||||
|
||||
// Direct JSON
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
// Find the matching bracket
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escape = false;
|
||||
let end = -1;
|
||||
|
||||
for (let i = 0; i < trimmed.length; i++) {
|
||||
const char = trimmed[i];
|
||||
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\') {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inString) {
|
||||
if (char === '{' || char === '[') {
|
||||
depth++;
|
||||
} else if (char === '}' || char === ']') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
end = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (end > 0) {
|
||||
return trimmed.substring(0, end);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find JSON in code blocks
|
||||
const codeBlockMatch = content.match(/```(?:json)?\s*\n([\s\S]*?)\n```/);
|
||||
if (codeBlockMatch) {
|
||||
return codeBlockMatch[1].trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if content should be displayed as JSON
|
||||
* Combines extraction and validation
|
||||
*
|
||||
* @param content - Content to check
|
||||
* @returns Detection result
|
||||
*/
|
||||
export function detectJsonContent(content: string): JsonDetectionResult & { extracted: string | null } {
|
||||
const extracted = extractJson(content);
|
||||
|
||||
if (!extracted) {
|
||||
return { isJson: false, format: 'invalid', extracted: null };
|
||||
}
|
||||
|
||||
const result = detectJson(extracted);
|
||||
return { ...result, extracted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format JSON for display
|
||||
*
|
||||
* @param data - Parsed JSON data
|
||||
* @param indent - Indentation spaces (default: 2)
|
||||
* @returns Formatted JSON string
|
||||
*/
|
||||
export function formatJson(data: unknown, indent: number = 2): string {
|
||||
return JSON.stringify(data, null, indent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a summary string for JSON data
|
||||
*
|
||||
* @param data - Parsed JSON data
|
||||
* @returns Summary description
|
||||
*/
|
||||
export function getJsonSummary(data: unknown): string {
|
||||
if (data === null) return 'null';
|
||||
if (typeof data === 'boolean') return data ? 'true' : 'false';
|
||||
if (typeof data === 'number') return String(data);
|
||||
if (typeof data === 'string') return `"${data.length > 30 ? data.substring(0, 30) + '...' : data}"`;
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
const length = data.length;
|
||||
return `Array[${length}]${length > 0 ? ` (${getJsonSummary(data[0])}, ...)` : ''}`;
|
||||
}
|
||||
|
||||
if (typeof data === 'object') {
|
||||
const keys = Object.keys(data);
|
||||
return `Object{${keys.length}}${keys.length > 0 ? ` (${keys.slice(0, 3).join(', ')}${keys.length > 3 ? ', ...' : ''})` : ''}`;
|
||||
}
|
||||
|
||||
return String(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color class for JSON value type
|
||||
*
|
||||
* @param value - JSON value
|
||||
* @returns Tailwind color class
|
||||
*/
|
||||
export function getJsonValueTypeColor(value: unknown): string {
|
||||
if (value === null) return 'text-muted-foreground';
|
||||
if (typeof value === 'boolean') return 'text-purple-400';
|
||||
if (typeof value === 'number') return 'text-orange-400';
|
||||
if (typeof value === 'string') return 'text-green-400';
|
||||
if (Array.isArray(value)) return 'text-blue-400';
|
||||
if (typeof value === 'object') return 'text-yellow-400';
|
||||
return 'text-foreground';
|
||||
}
|
||||
30
ccw/frontend/src/components/shared/LogBlock/utils.ts
Normal file
30
ccw/frontend/src/components/shared/LogBlock/utils.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// ========================================
|
||||
// LogBlock Utility Functions
|
||||
// ========================================
|
||||
// Shared helper functions for LogBlock components
|
||||
|
||||
import type { CliOutputLine } from '@/stores/cliStreamStore';
|
||||
|
||||
/**
|
||||
* Get the CSS class name for a given output line type
|
||||
*
|
||||
* @param type - The output line type
|
||||
* @returns The CSS class name for styling the line
|
||||
*/
|
||||
export function getOutputLineClass(type: CliOutputLine['type']): string {
|
||||
switch (type) {
|
||||
case 'thought':
|
||||
return 'text-purple-400';
|
||||
case 'system':
|
||||
return 'text-blue-400';
|
||||
case 'stderr':
|
||||
return 'text-red-400';
|
||||
case 'metadata':
|
||||
return 'text-yellow-400';
|
||||
case 'tool_call':
|
||||
return 'text-green-400';
|
||||
case 'stdout':
|
||||
default:
|
||||
return 'text-foreground';
|
||||
}
|
||||
}
|
||||
@@ -60,12 +60,72 @@ export type { FlowchartProps } from './Flowchart';
|
||||
export { CliStreamPanel } from './CliStreamPanel';
|
||||
export type { CliStreamPanelProps } from './CliStreamPanel';
|
||||
|
||||
export { CliStreamMonitor } from './CliStreamMonitor';
|
||||
export type { CliStreamMonitorProps } from './CliStreamMonitor';
|
||||
// New CliStreamMonitor with message-based layout
|
||||
export { CliStreamMonitor } from './CliStreamMonitor/index';
|
||||
export type { CliStreamMonitorProps } from './CliStreamMonitor/index';
|
||||
|
||||
// Legacy CliStreamMonitor (old layout)
|
||||
export { default as CliStreamMonitorLegacy } from './CliStreamMonitorLegacy';
|
||||
export type { CliStreamMonitorProps as CliStreamMonitorLegacyProps } from './CliStreamMonitorLegacy';
|
||||
|
||||
export { StreamingOutput } from './StreamingOutput';
|
||||
export type { StreamingOutputProps } from './StreamingOutput';
|
||||
|
||||
// CliStreamMonitor sub-components
|
||||
export { MonitorHeader } from './CliStreamMonitor/index';
|
||||
export type { MonitorHeaderProps } from './CliStreamMonitor/index';
|
||||
|
||||
export { MonitorToolbar } from './CliStreamMonitor/index';
|
||||
export type { MonitorToolbarProps, FilterType, ViewMode } from './CliStreamMonitor/index';
|
||||
|
||||
export { MonitorBody } from './CliStreamMonitor/index';
|
||||
export type { MonitorBodyProps, MonitorBodyRef } from './CliStreamMonitor/index';
|
||||
|
||||
export { MessageRenderer } from './CliStreamMonitor/index';
|
||||
export type { MessageRendererProps } from './CliStreamMonitor/index';
|
||||
|
||||
// Message components for CLI streaming
|
||||
export {
|
||||
SystemMessage,
|
||||
UserMessage,
|
||||
AssistantMessage,
|
||||
ErrorMessage
|
||||
} from './CliStreamMonitor/messages';
|
||||
export type {
|
||||
SystemMessageProps,
|
||||
UserMessageProps,
|
||||
AssistantMessageProps,
|
||||
ErrorMessageProps
|
||||
} from './CliStreamMonitor/messages';
|
||||
|
||||
// LogBlock components
|
||||
export {
|
||||
LogBlock,
|
||||
LogBlockList,
|
||||
getOutputLineClass,
|
||||
} from './LogBlock';
|
||||
export type {
|
||||
LogBlockProps,
|
||||
LogBlockData,
|
||||
LogLine,
|
||||
LogBlockListProps,
|
||||
} from './LogBlock';
|
||||
|
||||
// JsonFormatter
|
||||
export { JsonFormatter } from './LogBlock/JsonFormatter';
|
||||
export type { JsonFormatterProps, JsonDisplayMode } from './LogBlock/JsonFormatter';
|
||||
|
||||
// JSON utilities
|
||||
export {
|
||||
detectJson,
|
||||
detectJsonContent,
|
||||
extractJson,
|
||||
formatJson,
|
||||
getJsonSummary,
|
||||
getJsonValueTypeColor,
|
||||
} from './LogBlock/jsonUtils';
|
||||
export type { JsonDetectionResult, JsonDisplayMode as JsonMode } from './LogBlock/jsonUtils';
|
||||
|
||||
// Dialog components
|
||||
export { RuleDialog } from './RuleDialog';
|
||||
export type { RuleDialogProps } from './RuleDialog';
|
||||
|
||||
24
ccw/frontend/src/components/ui/Collapsible.tsx
Normal file
24
ccw/frontend/src/components/ui/Collapsible.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from "react";
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root;
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.Trigger;
|
||||
|
||||
const CollapsibleContent = React.forwardRef<
|
||||
React.ElementRef<typeof CollapsiblePrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CollapsiblePrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CollapsibleContent.displayName = CollapsiblePrimitive.Content.displayName;
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
@@ -85,3 +85,10 @@ export {
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
} from "./Toast";
|
||||
|
||||
// Collapsible (Radix)
|
||||
export {
|
||||
Collapsible,
|
||||
CollapsibleTrigger,
|
||||
CollapsibleContent,
|
||||
} from "./Collapsible";
|
||||
|
||||
@@ -1033,6 +1033,7 @@ export interface SessionDetailResponse {
|
||||
session: SessionMetadata;
|
||||
context?: SessionDetailContext;
|
||||
summary?: string;
|
||||
summaries?: Array<{ name: string; content: string }>;
|
||||
implPlan?: unknown;
|
||||
conflicts?: unknown[];
|
||||
review?: unknown;
|
||||
@@ -1061,10 +1062,17 @@ export async function fetchSessionDetail(sessionId: string, projectPath?: string
|
||||
const detailData = await fetchApi<any>(`/api/session-detail?path=${encodeURIComponent(pathParam)}&type=all`);
|
||||
|
||||
// Step 3: Transform the response to match SessionDetailResponse interface
|
||||
// Also check for summaries array and extract first one if summary is empty
|
||||
let finalSummary = detailData.summary;
|
||||
if (!finalSummary && detailData.summaries && detailData.summaries.length > 0) {
|
||||
finalSummary = detailData.summaries[0].content || detailData.summaries[0].name || '';
|
||||
}
|
||||
|
||||
return {
|
||||
session,
|
||||
context: detailData.context,
|
||||
summary: detailData.summary,
|
||||
summary: finalSummary,
|
||||
summaries: detailData.summaries,
|
||||
implPlan: detailData.implPlan,
|
||||
conflicts: detailData.conflicts,
|
||||
review: detailData.review,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"title": "CLI Stream Monitor",
|
||||
"searchPlaceholder": "Search output...",
|
||||
"searchPlaceholder": "Search logs...",
|
||||
"noExecutions": "No active CLI executions",
|
||||
"noExecutionsHint": "Start a CLI command to see streaming output",
|
||||
"selectExecution": "Select an execution to view output",
|
||||
@@ -14,5 +14,36 @@
|
||||
"autoScroll": "Auto-scroll",
|
||||
"scrollToBottom": "Scroll to bottom",
|
||||
"close": "Close",
|
||||
"refresh": "Refresh"
|
||||
"refresh": "Refresh",
|
||||
"refreshing": "Refreshing...",
|
||||
"live": "Live",
|
||||
"executions": "{count} execution{count, plural, =1 {} other {s}}",
|
||||
"active": "{count} active",
|
||||
"filter": {
|
||||
"all": "All",
|
||||
"errors": "Errors",
|
||||
"content": "Content",
|
||||
"system": "System"
|
||||
},
|
||||
"view": {
|
||||
"preview": "Preview",
|
||||
"json": "JSON",
|
||||
"raw": "Raw"
|
||||
},
|
||||
"viewMode": "View Mode",
|
||||
"settings": "Settings",
|
||||
"noMessages": "Waiting for messages...",
|
||||
"noMatch": "No matching messages found",
|
||||
"statusBar": "{total} executions | {active} active | {error} error | {lines} lines",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"rawJson": "Raw JSON",
|
||||
"retry": "Retry",
|
||||
"dismiss": "Dismiss",
|
||||
"thinking": "Thinking...",
|
||||
"streaming": "Streaming...",
|
||||
"tokens": "Tokens: {count}",
|
||||
"duration": "Duration: {value}",
|
||||
"model": "Model: {name}",
|
||||
"user": "User"
|
||||
}
|
||||
|
||||
@@ -89,6 +89,14 @@
|
||||
"title": "Discovery",
|
||||
"pageTitle": "Issue Discovery",
|
||||
"description": "View and manage issue discovery sessions",
|
||||
"totalSessions": "Total Sessions",
|
||||
"completedSessions": "Completed",
|
||||
"runningSessions": "Running",
|
||||
"totalFindings": "Findings",
|
||||
"sessionList": "Session List",
|
||||
"noSessions": "No sessions found",
|
||||
"noSessionsDescription": "Start a new discovery session to begin",
|
||||
"findingsDetail": "Findings Detail",
|
||||
"stats": {
|
||||
"totalSessions": "Total Sessions",
|
||||
"completed": "Completed",
|
||||
@@ -135,5 +143,14 @@
|
||||
"export": "Export Findings",
|
||||
"refresh": "Refresh"
|
||||
}
|
||||
},
|
||||
"hub": {
|
||||
"title": "Issue Hub",
|
||||
"description": "Unified management for issues, queues, and discoveries",
|
||||
"tabs": {
|
||||
"issues": "Issues",
|
||||
"queue": "Queue",
|
||||
"discovery": "Discovery"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,5 +41,42 @@
|
||||
"sessions": "Sessions",
|
||||
"detail": "Details",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"cliMonitor": {
|
||||
"title": "CLI Stream Monitor",
|
||||
"live": "Live",
|
||||
"executions": "executions",
|
||||
"active": "active",
|
||||
"errors": "errors",
|
||||
"lines": "lines",
|
||||
"refresh": "Refresh",
|
||||
"refreshing": "Refreshing...",
|
||||
"searchPlaceholder": "Search logs...",
|
||||
"clear": "Clear",
|
||||
"filterAll": "All",
|
||||
"filterErrors": "Errors",
|
||||
"filterContent": "Content",
|
||||
"filterSystem": "System",
|
||||
"viewPreview": "Preview",
|
||||
"viewJson": "JSON",
|
||||
"viewRaw": "Raw",
|
||||
"settings": "Settings",
|
||||
"noExecutions": "No active CLI executions",
|
||||
"noExecutionsHint": "Start a CLI command to see streaming output",
|
||||
"noMessages": "Waiting for messages...",
|
||||
"noMatch": "No matching messages found",
|
||||
"statusBar": "{total} executions | {active} active | {errors} error | {lines} lines",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied!",
|
||||
"rawJson": "Raw JSON",
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse",
|
||||
"retry": "Retry",
|
||||
"dismiss": "Dismiss",
|
||||
"thinking": "Thinking...",
|
||||
"completed": "Completed",
|
||||
"tokens": "Tokens",
|
||||
"duration": "Duration",
|
||||
"model": "Model"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"title": "CLI 流式监控",
|
||||
"searchPlaceholder": "搜索输出...",
|
||||
"searchPlaceholder": "搜索日志...",
|
||||
"noExecutions": "没有正在执行的 CLI 任务",
|
||||
"noExecutionsHint": "启动 CLI 命令以查看实时输出",
|
||||
"selectExecution": "选择一个任务以查看输出",
|
||||
@@ -14,5 +14,36 @@
|
||||
"autoScroll": "自动滚动",
|
||||
"scrollToBottom": "滚动到底部",
|
||||
"close": "关闭",
|
||||
"refresh": "刷新"
|
||||
"refresh": "刷新",
|
||||
"refreshing": "刷新中...",
|
||||
"live": "实时",
|
||||
"executions": "{count} 个执行",
|
||||
"active": "{count} 个活跃",
|
||||
"filter": {
|
||||
"all": "全部",
|
||||
"errors": "错误",
|
||||
"content": "内容",
|
||||
"system": "系统"
|
||||
},
|
||||
"view": {
|
||||
"preview": "预览",
|
||||
"json": "JSON",
|
||||
"raw": "原始"
|
||||
},
|
||||
"viewMode": "视图模式",
|
||||
"settings": "设置",
|
||||
"noMessages": "等待消息...",
|
||||
"noMatch": "没有匹配的消息",
|
||||
"statusBar": "{total} 个执行 | {active} 个活跃 | {error} 个错误 | {lines} 行",
|
||||
"copy": "复制",
|
||||
"copied": "已复制",
|
||||
"rawJson": "原始 JSON",
|
||||
"retry": "重试",
|
||||
"dismiss": "关闭",
|
||||
"thinking": "思考中...",
|
||||
"streaming": "流式输出中...",
|
||||
"tokens": "令牌: {count}",
|
||||
"duration": "时长: {value}",
|
||||
"model": "模型: {name}",
|
||||
"user": "用户"
|
||||
}
|
||||
|
||||
@@ -89,6 +89,14 @@
|
||||
"title": "发现",
|
||||
"pageTitle": "问题发现",
|
||||
"description": "查看和管理问题发现会话",
|
||||
"totalSessions": "总会话数",
|
||||
"completedSessions": "已完成",
|
||||
"runningSessions": "运行中",
|
||||
"totalFindings": "发现",
|
||||
"sessionList": "会话列表",
|
||||
"noSessions": "未发现会话",
|
||||
"noSessionsDescription": "启动新的问题发现会话以开始",
|
||||
"findingsDetail": "发现详情",
|
||||
"stats": {
|
||||
"totalSessions": "总会话数",
|
||||
"completed": "已完成",
|
||||
@@ -135,5 +143,14 @@
|
||||
"export": "导出发现",
|
||||
"refresh": "刷新"
|
||||
}
|
||||
},
|
||||
"hub": {
|
||||
"title": "问题中心",
|
||||
"description": "统一管理问题、队列和发现",
|
||||
"tabs": {
|
||||
"issues": "问题列表",
|
||||
"queue": "执行队列",
|
||||
"discovery": "问题发现"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,5 +41,42 @@
|
||||
"sessions": "会话",
|
||||
"detail": "详情",
|
||||
"settings": "设置"
|
||||
},
|
||||
"cliMonitor": {
|
||||
"title": "CLI 流式监控",
|
||||
"live": "实时",
|
||||
"executions": "个执行",
|
||||
"active": "活跃",
|
||||
"errors": "错误",
|
||||
"lines": "行",
|
||||
"refresh": "刷新",
|
||||
"refreshing": "刷新中...",
|
||||
"searchPlaceholder": "搜索日志...",
|
||||
"clear": "清除",
|
||||
"filterAll": "全部",
|
||||
"filterErrors": "错误",
|
||||
"filterContent": "内容",
|
||||
"filterSystem": "系统",
|
||||
"viewPreview": "预览",
|
||||
"viewJson": "JSON",
|
||||
"viewRaw": "原始",
|
||||
"settings": "设置",
|
||||
"noExecutions": "无活跃的 CLI 执行",
|
||||
"noExecutionsHint": "启动 CLI 命令以查看流式输出",
|
||||
"noMessages": "等待消息...",
|
||||
"noMatch": "未找到匹配的消息",
|
||||
"statusBar": "{total} 个执行 | {active} 个活跃 | {errors} 个错误 | {lines} 行",
|
||||
"copy": "复制",
|
||||
"copied": "已复制!",
|
||||
"rawJson": "原始 JSON",
|
||||
"expand": "展开",
|
||||
"collapse": "折叠",
|
||||
"retry": "重试",
|
||||
"dismiss": "忽略",
|
||||
"thinking": "思考中...",
|
||||
"completed": "已完成",
|
||||
"tokens": "令牌数",
|
||||
"duration": "耗时",
|
||||
"model": "模型"
|
||||
}
|
||||
}
|
||||
|
||||
32
ccw/frontend/src/pages/IssueHubPage.tsx
Normal file
32
ccw/frontend/src/pages/IssueHubPage.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
// ========================================
|
||||
// Issue Hub Page
|
||||
// ========================================
|
||||
// Unified page for issues, queue, and discovery with tab navigation
|
||||
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { IssueHubHeader } from '@/components/issue/hub/IssueHubHeader';
|
||||
import { IssueHubTabs, type IssueTab } from '@/components/issue/hub/IssueHubTabs';
|
||||
import { IssuesPanel } from '@/components/issue/hub/IssuesPanel';
|
||||
import { QueuePanel } from '@/components/issue/hub/QueuePanel';
|
||||
import { DiscoveryPanel } from '@/components/issue/hub/DiscoveryPanel';
|
||||
|
||||
export function IssueHubPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const currentTab = (searchParams.get('tab') as IssueTab) || 'issues';
|
||||
|
||||
const setCurrentTab = (tab: IssueTab) => {
|
||||
setSearchParams({ tab });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<IssueHubHeader currentTab={currentTab} />
|
||||
<IssueHubTabs currentTab={currentTab} onTabChange={setCurrentTab} />
|
||||
{currentTab === 'issues' && <IssuesPanel />}
|
||||
{currentTab === 'queue' && <QueuePanel />}
|
||||
{currentTab === 'discovery' && <DiscoveryPanel />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default IssueHubPage;
|
||||
@@ -1,7 +1,12 @@
|
||||
// ========================================
|
||||
// LiteTaskDetailPage Component
|
||||
// ========================================
|
||||
// Lite task detail page with flowchart visualization
|
||||
// Lite task detail page with multi-tab task view supporting:
|
||||
// - Lite-Plan/Lite-Fix: Tasks, Plan, Diagnoses, Context, Summary tabs
|
||||
// - Multi-CLI: Tasks, Discussion, Context, Summary tabs
|
||||
// - Context Package parsing with collapsible sections
|
||||
// - Exploration packages with multiple analysis angles
|
||||
// - Flowchart visualization for implementation steps
|
||||
|
||||
import * as React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
@@ -17,32 +22,219 @@ import {
|
||||
Clock,
|
||||
Code,
|
||||
Zap,
|
||||
ListTodo,
|
||||
Package,
|
||||
FileCode,
|
||||
Settings,
|
||||
BookOpen,
|
||||
Search,
|
||||
Folder,
|
||||
MessageSquare,
|
||||
FileText,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Ruler,
|
||||
Stethoscope,
|
||||
} from 'lucide-react';
|
||||
import { useLiteTaskSession } from '@/hooks/useLiteTasks';
|
||||
import { Flowchart } from '@/components/shared/Flowchart';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import type { LiteTask } from '@/lib/api';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/Collapsible';
|
||||
import type { LiteTask, LiteTaskSession } from '@/lib/api';
|
||||
|
||||
// ========================================
|
||||
// Type Definitions
|
||||
// ========================================
|
||||
|
||||
type SessionType = 'lite-plan' | 'lite-fix' | 'multi-cli-plan';
|
||||
|
||||
type LitePlanTab = 'tasks' | 'plan' | 'diagnoses' | 'context' | 'summary';
|
||||
type MultiCliTab = 'tasks' | 'discussion' | 'context' | 'summary';
|
||||
|
||||
type TaskTabValue = 'task' | 'context';
|
||||
|
||||
// Context Package Structure
|
||||
interface ContextPackage {
|
||||
task_description?: string;
|
||||
constraints?: string[];
|
||||
focus_paths?: string[];
|
||||
relevant_files?: Array<string | { path: string; reason?: string }>;
|
||||
dependencies?: string[] | Array<{ name: string; type: string; version: string }>;
|
||||
conflict_risks?: string[] | Array<{ description: string; severity: string }>;
|
||||
session_id?: string;
|
||||
metadata?: {
|
||||
created_at: string;
|
||||
version: string;
|
||||
source: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Exploration Structure
|
||||
interface Exploration {
|
||||
name: string;
|
||||
path: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
interface ExplorationData {
|
||||
manifest?: {
|
||||
task_description: string;
|
||||
complexity: 'low' | 'medium' | 'high';
|
||||
exploration_count: number;
|
||||
created_at: string;
|
||||
};
|
||||
data?: {
|
||||
architecture?: ExplorationAngle;
|
||||
dependencies?: ExplorationAngle;
|
||||
patterns?: ExplorationAngle;
|
||||
'integration-points'?: ExplorationAngle;
|
||||
testing?: ExplorationAngle;
|
||||
};
|
||||
}
|
||||
|
||||
interface ExplorationAngle {
|
||||
findings: string[];
|
||||
recommendations: string[];
|
||||
patterns: string[];
|
||||
risks: string[];
|
||||
}
|
||||
|
||||
// Diagnosis Structure
|
||||
interface Diagnosis {
|
||||
symptom: string;
|
||||
root_cause: string;
|
||||
issues: Array<{
|
||||
file: string;
|
||||
line: number;
|
||||
severity: 'high' | 'medium' | 'low';
|
||||
message: string;
|
||||
}>;
|
||||
affected_files: string[];
|
||||
fix_hints: string[];
|
||||
recommendations: string[];
|
||||
}
|
||||
|
||||
// Discussion/Round Structure
|
||||
interface DiscussionRound {
|
||||
metadata: {
|
||||
roundId: number;
|
||||
timestamp: string;
|
||||
durationSeconds: number;
|
||||
contributingAgents: Array<{ name: string; id: string }>;
|
||||
};
|
||||
solutions: DiscussionSolution[];
|
||||
_internal: {
|
||||
convergence: {
|
||||
score: number;
|
||||
recommendation: 'proceed' | 'continue' | 'pause';
|
||||
reasoning: string;
|
||||
};
|
||||
cross_verification: {
|
||||
agreements: string[];
|
||||
disagreements: string[];
|
||||
resolution: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface DiscussionSolution {
|
||||
id: string;
|
||||
name: string;
|
||||
summary: string | { en: string; zh: string };
|
||||
feasibility: number;
|
||||
effort: 'low' | 'medium' | 'high';
|
||||
risk: 'low' | 'medium' | 'high';
|
||||
source_cli: string[];
|
||||
implementation_plan: {
|
||||
approach: string;
|
||||
tasks: ImplementationTask[];
|
||||
milestones: Milestone[];
|
||||
};
|
||||
}
|
||||
|
||||
// Synthesis Structure
|
||||
interface Synthesis {
|
||||
convergence: {
|
||||
summary: string | { en: string; zh: string };
|
||||
score: number;
|
||||
recommendation: 'proceed' | 'continue' | 'pause' | 'complete' | 'halt';
|
||||
};
|
||||
cross_verification: {
|
||||
agreements: string[];
|
||||
disagreements: string[];
|
||||
resolution: string;
|
||||
};
|
||||
final_solution: DiscussionSolution;
|
||||
alternative_solutions: DiscussionSolution[];
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Helper Functions
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* LiteTaskDetailPage component - Display single lite task session with flowchart
|
||||
* Get i18n text (handles both string and {en, zh} object)
|
||||
*/
|
||||
function getI18nText(text: string | { en?: string; zh?: string } | undefined, locale: string = 'zh'): string {
|
||||
if (!text) return '';
|
||||
if (typeof text === 'string') return text;
|
||||
return text[locale as keyof typeof text] || text.en || text.zh || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task status badge configuration
|
||||
*/
|
||||
function getTaskStatusBadge(
|
||||
status: LiteTask['status'],
|
||||
formatMessage: (key: { id: string }) => string
|
||||
) {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return { variant: 'success' as const, label: formatMessage({ id: 'sessionDetail.status.completed' }), icon: CheckCircle };
|
||||
case 'in_progress':
|
||||
return { variant: 'warning' as const, label: formatMessage({ id: 'sessionDetail.status.inProgress' }), icon: Loader2 };
|
||||
case 'blocked':
|
||||
return { variant: 'destructive' as const, label: formatMessage({ id: 'sessionDetail.status.blocked' }), icon: XCircle };
|
||||
case 'failed':
|
||||
return { variant: 'destructive' as const, label: formatMessage({ id: 'fixSession.status.failed' }), icon: XCircle };
|
||||
default:
|
||||
return { variant: 'secondary' as const, label: formatMessage({ id: 'sessionDetail.status.pending' }), icon: Clock };
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Main Component
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* LiteTaskDetailPage component - Display single lite task session with multi-tab view
|
||||
* Supports:
|
||||
* - Lite-Plan/Lite-Fix: Tasks, Plan, Diagnoses, Context, Summary tabs
|
||||
* - Multi-CLI: Tasks, Discussion, Context, Summary tabs
|
||||
* - Context Package parsing with collapsible sections
|
||||
* - Exploration packages with multiple analysis angles
|
||||
* - Flowchart visualization for implementation steps
|
||||
*/
|
||||
export function LiteTaskDetailPage() {
|
||||
const { sessionId } = useParams<{ sessionId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { formatMessage } = useIntl();
|
||||
const { formatMessage, locale } = useIntl();
|
||||
|
||||
// Determine type from URL or state
|
||||
const [sessionType, setSessionType] = React.useState<'lite-plan' | 'lite-fix' | 'multi-cli-plan'>('lite-plan');
|
||||
// Session type state
|
||||
const [sessionType, setSessionType] = React.useState<SessionType>('lite-plan');
|
||||
|
||||
// Fetch session data
|
||||
const { session, isLoading, error, refetch } = useLiteTaskSession(sessionId, sessionType);
|
||||
|
||||
// Track expanded tasks
|
||||
const [expandedTasks, setExpandedTasks] = React.useState<Set<string>>(new Set());
|
||||
// Tab states
|
||||
const [litePlanActiveTab, setLitePlanActiveTab] = React.useState<LitePlanTab>('tasks');
|
||||
const [multiCliActiveTab, setMultiCliActiveTab] = React.useState<MultiCliTab>('tasks');
|
||||
const [activeTaskTabs, setActiveTaskTabs] = React.useState<Record<string, TaskTabValue>>({});
|
||||
|
||||
// Try to detect type from session data
|
||||
// Detect session type from data
|
||||
React.useEffect(() => {
|
||||
if (session?.type) {
|
||||
setSessionType(session.type);
|
||||
@@ -53,32 +245,8 @@ export function LiteTaskDetailPage() {
|
||||
navigate('/lite-tasks');
|
||||
};
|
||||
|
||||
const toggleTaskExpanded = (taskId: string) => {
|
||||
setExpandedTasks(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(taskId)) {
|
||||
next.delete(taskId);
|
||||
} else {
|
||||
next.add(taskId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Get task status badge
|
||||
const getTaskStatusBadge = (task: LiteTask) => {
|
||||
switch (task.status) {
|
||||
case 'completed':
|
||||
return { variant: 'success' as const, label: formatMessage({ id: 'sessionDetail.status.completed' }), icon: CheckCircle };
|
||||
case 'in_progress':
|
||||
return { variant: 'warning' as const, label: formatMessage({ id: 'sessionDetail.status.inProgress' }), icon: Loader2 };
|
||||
case 'blocked':
|
||||
return { variant: 'destructive' as const, label: formatMessage({ id: 'sessionDetail.status.blocked' }), icon: XCircle };
|
||||
case 'failed':
|
||||
return { variant: 'destructive' as const, label: formatMessage({ id: 'fixSession.status.failed' }), icon: XCircle };
|
||||
default:
|
||||
return { variant: 'secondary' as const, label: formatMessage({ id: 'sessionDetail.status.pending' }), icon: Clock };
|
||||
}
|
||||
const handleTaskTabChange = (taskId: string, tab: TaskTabValue) => {
|
||||
setActiveTaskTabs(prev => ({ ...prev, [taskId]: tab }));
|
||||
};
|
||||
|
||||
// Loading state
|
||||
@@ -113,7 +281,7 @@ export function LiteTaskDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Session not found
|
||||
// Not found state
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-4">
|
||||
@@ -132,9 +300,9 @@ export function LiteTaskDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const tasks = session.tasks || [];
|
||||
const completedTasks = tasks.filter(t => t.status === 'completed').length;
|
||||
const isLitePlan = session.type === 'lite-plan';
|
||||
const isLiteFix = session.type === 'lite-fix';
|
||||
const isMultiCli = session.type === 'multi-cli-plan';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -154,162 +322,280 @@ export function LiteTaskDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={isLitePlan ? 'info' : 'warning'} className="gap-1">
|
||||
{isLitePlan ? <FileEdit className="h-3 w-3" /> : <Wrench className="h-3 w-3" />}
|
||||
{formatMessage({ id: isLitePlan ? 'liteTasks.type.plan' : 'liteTasks.type.fix' })}
|
||||
<Badge variant={isLitePlan ? 'info' : isLiteFix ? 'warning' : 'default'} className="gap-1">
|
||||
{isLitePlan ? <FileEdit className="h-3 w-3" /> : isLiteFix ? <Wrench className="h-3 w-3" /> : <MessageSquare className="h-3 w-3" />}
|
||||
{formatMessage({ id: isLitePlan ? 'liteTasks.type.plan' : isLiteFix ? 'liteTasks.type.fix' : 'liteTasks.type.multiCli' })}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Info Bar */}
|
||||
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground p-4 bg-background rounded-lg border">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span className="font-medium">{formatMessage({ id: 'sessionDetail.info.created' })}:</span>{' '}
|
||||
{session.createdAt ? new Date(session.createdAt).toLocaleString() : 'N/A'}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<span className="font-medium">{formatMessage({ id: 'sessionDetail.info.tasks' })}:</span>{' '}
|
||||
{completedTasks}/{tasks.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description (if exists) */}
|
||||
{session.description && (
|
||||
<div className="p-4 bg-background rounded-lg border">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-2">
|
||||
{formatMessage({ id: 'sessionDetail.info.description' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">{session.description}</p>
|
||||
</div>
|
||||
{/* Session Type-Specific Tabs */}
|
||||
{isMultiCli ? (
|
||||
<Tabs value={multiCliActiveTab} onValueChange={(v) => setMultiCliActiveTab(v as MultiCliTab)}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="tasks" className="flex-1 gap-1">
|
||||
<ListTodo className="h-4 w-4" />
|
||||
{formatMessage({ id: 'liteTasksDetail.tabs.tasks' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="discussion" className="flex-1 gap-1">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
{formatMessage({ id: 'liteTasksDetail.tabs.discussion' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="context" className="flex-1 gap-1">
|
||||
<Package className="h-4 w-4" />
|
||||
{formatMessage({ id: 'liteTasksDetail.tabs.context' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="summary" className="flex-1 gap-1">
|
||||
<FileText className="h-4 w-4" />
|
||||
{formatMessage({ id: 'liteTasksDetail.tabs.summary' })}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
) : (
|
||||
<Tabs value={litePlanActiveTab} onValueChange={(v) => setLitePlanActiveTab(v as LitePlanTab)}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="tasks" className="flex-1 gap-1">
|
||||
<ListTodo className="h-4 w-4" />
|
||||
{formatMessage({ id: 'liteTasksDetail.tabs.tasks' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="plan" className="flex-1 gap-1">
|
||||
<Ruler className="h-4 w-4" />
|
||||
{formatMessage({ id: 'liteTasksDetail.tabs.plan' })}
|
||||
</TabsTrigger>
|
||||
{isLiteFix && (
|
||||
<TabsTrigger value="diagnoses" className="flex-1 gap-1">
|
||||
<Stethoscope className="h-4 w-4" />
|
||||
{formatMessage({ id: 'liteTasksDetail.tabs.diagnoses' })}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="context" className="flex-1 gap-1">
|
||||
<Package className="h-4 w-4" />
|
||||
{formatMessage({ id: 'liteTasksDetail.tabs.context' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="summary" className="flex-1 gap-1">
|
||||
<FileText className="h-4 w-4" />
|
||||
{formatMessage({ id: 'liteTasksDetail.tabs.summary' })}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
{/* Tasks List */}
|
||||
{tasks.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<Zap className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{formatMessage({ id: 'liteTasksDetail.empty.title' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'liteTasksDetail.empty.message' })}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{tasks.map((task, index) => {
|
||||
const taskId = task.task_id || task.id || `T${index + 1}`;
|
||||
const isExpanded = expandedTasks.has(taskId);
|
||||
const statusBadge = getTaskStatusBadge(task);
|
||||
const StatusIcon = statusBadge.icon;
|
||||
const hasFlowchart = task.flow_control?.implementation_approach &&
|
||||
task.flow_control.implementation_approach.length > 0;
|
||||
{/* Task List with Multi-Tab Content */}
|
||||
<div className="space-y-4">
|
||||
{session.tasks?.map((task, index) => {
|
||||
const taskId = task.task_id || task.id || `T${index + 1}`;
|
||||
const activeTaskTab = activeTaskTabs[taskId] || 'task';
|
||||
const hasFlowchart = task.flow_control?.implementation_approach && task.flow_control.implementation_approach.length > 0;
|
||||
|
||||
return (
|
||||
<Card key={taskId} className="overflow-hidden">
|
||||
<CardContent className="p-4">
|
||||
{/* Task Header */}
|
||||
<div
|
||||
className="flex items-start justify-between gap-3 cursor-pointer"
|
||||
onClick={() => toggleTaskExpanded(taskId)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-muted-foreground">{taskId}</span>
|
||||
<Badge variant={statusBadge.variant} className="gap-1">
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{statusBadge.label}
|
||||
</Badge>
|
||||
{task.priority && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{task.priority}
|
||||
</Badge>
|
||||
)}
|
||||
{hasFlowchart && (
|
||||
<Badge variant="info" className="gap-1">
|
||||
<Code className="h-3 w-3" />
|
||||
{formatMessage({ id: 'liteTasksDetail.flowchart' })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="font-medium text-foreground text-sm">
|
||||
{task.title || formatMessage({ id: 'sessionDetail.tasks.untitled' })}
|
||||
</h4>
|
||||
{task.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||
{task.description}
|
||||
</p>
|
||||
return (
|
||||
<Card key={taskId} className="overflow-hidden">
|
||||
{/* Task Header */}
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-base font-medium flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-mono text-muted-foreground">{taskId}</span>
|
||||
<Badge
|
||||
variant={task.status === 'completed' ? 'success' : task.status === 'in_progress' ? 'warning' : 'secondary'}
|
||||
>
|
||||
{task.status}
|
||||
</Badge>
|
||||
{task.priority && (
|
||||
<Badge variant="outline" className="text-xs">{task.priority}</Badge>
|
||||
)}
|
||||
{task.context?.depends_on && task.context.depends_on.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-2 text-xs text-muted-foreground">
|
||||
{hasFlowchart && (
|
||||
<Badge variant="info" className="gap-1 text-xs">
|
||||
<Code className="h-3 w-3" />
|
||||
<span>Depends on: {task.context.depends_on.join(', ')}</span>
|
||||
</div>
|
||||
Flowchart
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="flex-shrink-0">
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</Button>
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">{task.title || 'Untitled Task'}</p>
|
||||
{task.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">{task.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{isExpanded && (
|
||||
<div className="mt-4 pt-4 border-t border-border">
|
||||
{/* Flowchart */}
|
||||
{hasFlowchart && task.flow_control && (
|
||||
<div className="mb-4">
|
||||
<h5 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
|
||||
<Code className="h-4 w-4" />
|
||||
{formatMessage({ id: 'liteTasksDetail.implementationFlow' })}
|
||||
</h5>
|
||||
<Flowchart flowControl={task.flow_control} className="border border-border rounded-lg" />
|
||||
</div>
|
||||
)}
|
||||
{/* Multi-Tab Content */}
|
||||
<Tabs
|
||||
value={activeTaskTab}
|
||||
onValueChange={(v) => handleTaskTabChange(taskId, v as TaskTabValue)}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="w-full rounded-none border-y border-border bg-muted/50 px-4">
|
||||
<TabsTrigger value="task" className="flex-1 gap-1.5">
|
||||
<ListTodo className="h-4 w-4" />
|
||||
Task
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="context" className="flex-1 gap-1.5">
|
||||
<Package className="h-4 w-4" />
|
||||
Context
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Focus Paths */}
|
||||
{task.context?.focus_paths && task.context.focus_paths.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h5 className="text-sm font-semibold text-foreground mb-2">
|
||||
{formatMessage({ id: 'liteTasksDetail.focusPaths' })}
|
||||
</h5>
|
||||
<div className="space-y-1">
|
||||
{task.context.focus_paths.map((path, idx) => (
|
||||
<code
|
||||
key={idx}
|
||||
className="block text-xs bg-muted px-2 py-1 rounded font-mono"
|
||||
>
|
||||
{path}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Acceptance Criteria */}
|
||||
{task.context?.acceptance && task.context.acceptance.length > 0 && (
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-foreground mb-2">
|
||||
{formatMessage({ id: 'liteTasksDetail.acceptanceCriteria' })}
|
||||
</h5>
|
||||
<ul className="space-y-1">
|
||||
{task.context.acceptance.map((criteria, idx) => (
|
||||
<li key={idx} className="text-xs text-muted-foreground flex items-start gap-2">
|
||||
<span className="text-primary font-bold">{idx + 1}.</span>
|
||||
<span>{criteria}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{/* Task Tab - Implementation Details */}
|
||||
<TabsContent value="task" className="p-4 space-y-4">
|
||||
{/* Flowchart */}
|
||||
{hasFlowchart && task.flow_control && (
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
|
||||
<Code className="h-4 w-4" />
|
||||
Implementation Flow
|
||||
</h5>
|
||||
<Flowchart flowControl={task.flow_control} className="border border-border rounded-lg" />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Target Files */}
|
||||
{task.flow_control?.target_files && task.flow_control.target_files.length > 0 && (
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-foreground mb-2 flex items-center gap-2">
|
||||
<FileCode className="h-4 w-4" />
|
||||
Target Files
|
||||
</h5>
|
||||
<div className="space-y-1">
|
||||
{task.flow_control.target_files.map((file, idx) => {
|
||||
const displayPath = typeof file === 'string' ? file : (file.path || file.name || 'Unknown');
|
||||
return (
|
||||
<code key={idx} className="block text-xs bg-muted px-2 py-1 rounded font-mono">
|
||||
{displayPath}
|
||||
</code>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dependencies */}
|
||||
{task.context?.depends_on && task.context.depends_on.length > 0 && (
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-foreground mb-2">Dependencies</h5>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{task.context.depends_on.map((dep, idx) => (
|
||||
<Badge key={idx} variant="outline" className="text-xs">{dep}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Context Tab - Planning Context */}
|
||||
<TabsContent value="context" className="p-4 space-y-4">
|
||||
{/* Focus Paths */}
|
||||
{task.context?.focus_paths && task.context.focus_paths.length > 0 && (
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-foreground mb-2 flex items-center gap-2">
|
||||
<Search className="h-4 w-4" />
|
||||
Focus Paths
|
||||
</h5>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{task.context.focus_paths.map((path, idx) => (
|
||||
<Badge key={idx} variant="secondary" className="text-xs font-mono">{path}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Acceptance Criteria */}
|
||||
{task.context?.acceptance && task.context.acceptance.length > 0 && (
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-foreground mb-2 flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
Acceptance Criteria
|
||||
</h5>
|
||||
<ul className="space-y-1">
|
||||
{task.context.acceptance.map((criteria, idx) => (
|
||||
<li key={idx} className="text-xs text-muted-foreground flex items-start gap-2">
|
||||
<span className="text-primary font-bold">{idx + 1}.</span>
|
||||
<span>{criteria}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tech Stack from Session Metadata */}
|
||||
{session.metadata?.tech_stack && (
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-foreground mb-2 flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
Tech Stack
|
||||
</h5>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(session.metadata.tech_stack as string[]).map((tech, idx) => (
|
||||
<Badge key={idx} variant="success" className="text-xs">{tech}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conventions from Session Metadata */}
|
||||
{session.metadata?.conventions && (
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold text-foreground mb-2 flex items-center gap-2">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
Conventions
|
||||
</h5>
|
||||
<ul className="space-y-1">
|
||||
{(session.metadata.conventions as string[]).map((conv, idx) => (
|
||||
<li key={idx} className="text-xs text-muted-foreground flex items-start gap-2">
|
||||
<span className="text-primary">•</span>
|
||||
<span>{conv}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Session-Level Explorations (if available) */}
|
||||
{session.metadata?.explorations && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Search className="w-5 h-5" />
|
||||
Explorations
|
||||
<Badge variant="secondary">{(session.metadata.explorations as Exploration[]).length}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{(session.metadata.explorations as Exploration[]).map((exp, idx) => (
|
||||
<Collapsible key={idx}>
|
||||
<CollapsibleTrigger className="w-full flex items-center gap-2 p-3 bg-background rounded-lg border hover:bg-muted/50 transition-colors">
|
||||
<Folder className="h-4 w-4 text-primary flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-foreground flex-1 text-left truncate">
|
||||
{exp.name}
|
||||
</span>
|
||||
{exp.content && (
|
||||
<Badge variant="outline" className="text-xs flex-shrink-0">
|
||||
Has Content
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground transition-transform" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 ml-4">
|
||||
{exp.content ? (
|
||||
<div className="p-3 bg-muted rounded-lg text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{exp.content}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 bg-muted rounded-lg text-sm text-muted-foreground">
|
||||
No content available for this exploration.
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ export { SessionDetailPage } from './SessionDetailPage';
|
||||
export { HistoryPage } from './HistoryPage';
|
||||
export { OrchestratorPage } from './orchestrator';
|
||||
export { LoopMonitorPage } from './LoopMonitorPage';
|
||||
export { IssueHubPage } from './IssueHubPage';
|
||||
export { IssueManagerPage } from './IssueManagerPage';
|
||||
export { QueuePage } from './QueuePage';
|
||||
export { DiscoveryPage } from './DiscoveryPage';
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// ========================================
|
||||
// React Router v6 configuration with all dashboard routes
|
||||
|
||||
import { createBrowserRouter, RouteObject } from 'react-router-dom';
|
||||
import { createBrowserRouter, RouteObject, Navigate } from 'react-router-dom';
|
||||
import { AppShell } from '@/components/layout';
|
||||
import {
|
||||
HomePage,
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
HistoryPage,
|
||||
OrchestratorPage,
|
||||
LoopMonitorPage,
|
||||
IssueHubPage,
|
||||
IssueManagerPage,
|
||||
QueuePage,
|
||||
DiscoveryPage,
|
||||
@@ -93,15 +94,16 @@ const routes: RouteObject[] = [
|
||||
},
|
||||
{
|
||||
path: 'issues',
|
||||
element: <IssueManagerPage />,
|
||||
element: <IssueHubPage />,
|
||||
},
|
||||
// Legacy routes - redirect to hub with tab parameter
|
||||
{
|
||||
path: 'issues/queue',
|
||||
element: <QueuePage />,
|
||||
element: <Navigate to="/issues?tab=queue" replace />,
|
||||
},
|
||||
{
|
||||
path: 'issues/discovery',
|
||||
element: <DiscoveryPage />,
|
||||
element: <Navigate to="/issues?tab=discovery" replace />,
|
||||
},
|
||||
{
|
||||
path: 'skills',
|
||||
@@ -191,8 +193,9 @@ export const ROUTES = {
|
||||
EXECUTIONS: '/executions',
|
||||
LOOPS: '/loops',
|
||||
ISSUES: '/issues',
|
||||
ISSUE_QUEUE: '/issues/queue',
|
||||
ISSUE_DISCOVERY: '/issues/discovery',
|
||||
// Legacy issue routes - use ISSUES with ?tab parameter instead
|
||||
ISSUE_QUEUE: '/issues?tab=queue',
|
||||
ISSUE_DISCOVERY: '/issues?tab=discovery',
|
||||
SKILLS: '/skills',
|
||||
COMMANDS: '/commands',
|
||||
MEMORY: '/memory',
|
||||
|
||||
Reference in New Issue
Block a user