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:
catlog22
2026-01-31 23:12:39 +08:00
parent 2f10305945
commit a2206df50f
43 changed files with 5843 additions and 466 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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"

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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';

View File

@@ -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;

View File

@@ -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'

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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';

View 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';

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
*/

View File

@@ -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';

View File

@@ -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 {

View 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;

View File

@@ -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.
);
});

View File

@@ -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) => {

View File

@@ -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';

View 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';
}

View 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';
}
}

View File

@@ -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';

View 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 };

View File

@@ -85,3 +85,10 @@ export {
ToastClose,
ToastAction,
} from "./Toast";
// Collapsible (Radix)
export {
Collapsible,
CollapsibleTrigger,
CollapsibleContent,
} from "./Collapsible";

View File

@@ -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,

View File

@@ -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"
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}

View File

@@ -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": "用户"
}

View File

@@ -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": "问题发现"
}
}
}

View File

@@ -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": "模型"
}
}

View 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;

View File

@@ -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>
);

View File

@@ -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';

View File

@@ -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',