mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-11 02:33:51 +08:00
feat: add tests and implementation for issue discovery and queue pages
- Implemented `DiscoveryPage` with session management and findings display. - Added tests for `DiscoveryPage` to ensure proper rendering and functionality. - Created `QueuePage` for managing issue execution queues with stats and actions. - Added tests for `QueuePage` to verify UI elements and translations. - Introduced `useIssues` hooks for fetching and managing issue data. - Added loading skeletons and error handling for better user experience. - Created `vite-env.d.ts` for TypeScript support in Vite environment.
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
// ========================================
|
||||
// DiscoveryCard Component Tests
|
||||
// ========================================
|
||||
// Tests for the discovery card component with i18n
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen } from '@/test/i18n';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { DiscoveryCard } from './DiscoveryCard';
|
||||
import type { DiscoverySession } from '@/lib/api';
|
||||
|
||||
describe('DiscoveryCard', () => {
|
||||
const mockSession: DiscoverySession = {
|
||||
id: '1',
|
||||
name: 'Test Session',
|
||||
status: 'running',
|
||||
progress: 50,
|
||||
findings_count: 5,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
session: mockSession,
|
||||
isActive: false,
|
||||
onClick: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('with en locale', () => {
|
||||
it('should render session name', () => {
|
||||
render(<DiscoveryCard {...defaultProps} />, { locale: 'en' });
|
||||
expect(screen.getByText('Test Session')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show running status badge', () => {
|
||||
render(<DiscoveryCard {...defaultProps} />, { locale: 'en' });
|
||||
expect(screen.getByText(/Running/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show completed status badge', () => {
|
||||
const completedSession: DiscoverySession = {
|
||||
...mockSession,
|
||||
status: 'completed',
|
||||
};
|
||||
|
||||
render(<DiscoveryCard {...defaultProps} session={completedSession} />, { locale: 'en' });
|
||||
expect(screen.getByText(/Completed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show failed status badge', () => {
|
||||
const failedSession: DiscoverySession = {
|
||||
...mockSession,
|
||||
status: 'failed',
|
||||
};
|
||||
|
||||
render(<DiscoveryCard {...defaultProps} session={failedSession} />, { locale: 'en' });
|
||||
expect(screen.getByText(/Failed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show progress bar for running sessions', () => {
|
||||
render(<DiscoveryCard {...defaultProps} />, { locale: 'en' });
|
||||
expect(screen.getByText(/Progress/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('50%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show progress bar for completed sessions', () => {
|
||||
const completedSession: DiscoverySession = {
|
||||
...mockSession,
|
||||
status: 'completed',
|
||||
};
|
||||
|
||||
render(<DiscoveryCard {...defaultProps} session={completedSession} />, { locale: 'en' });
|
||||
expect(screen.queryByText(/Progress/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show findings count', () => {
|
||||
render(<DiscoveryCard {...defaultProps} />, { locale: 'en' });
|
||||
expect(screen.getByText(/Findings/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show formatted date', () => {
|
||||
render(<DiscoveryCard {...defaultProps} />, { locale: 'en' });
|
||||
const dateText = new Date(mockSession.created_at).toLocaleString();
|
||||
expect(screen.getByText(new RegExp(dateText.replace(/[\/:]/g, '[/:]'), 'i'))).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with zh locale', () => {
|
||||
it('should render session name', () => {
|
||||
render(<DiscoveryCard {...defaultProps} />, { locale: 'zh' });
|
||||
expect(screen.getByText('Test Session')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show translated running status badge', () => {
|
||||
render(<DiscoveryCard {...defaultProps} />, { locale: 'zh' });
|
||||
expect(screen.getByText(/运行中/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show translated completed status badge', () => {
|
||||
const completedSession: DiscoverySession = {
|
||||
...mockSession,
|
||||
status: 'completed',
|
||||
};
|
||||
|
||||
render(<DiscoveryCard {...defaultProps} session={completedSession} />, { locale: 'zh' });
|
||||
expect(screen.getByText(/已完成/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show translated failed status badge', () => {
|
||||
const failedSession: DiscoverySession = {
|
||||
...mockSession,
|
||||
status: 'failed',
|
||||
};
|
||||
|
||||
render(<DiscoveryCard {...defaultProps} session={failedSession} />, { locale: 'zh' });
|
||||
expect(screen.getByText(/失败/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show translated progress text', () => {
|
||||
render(<DiscoveryCard {...defaultProps} />, { locale: 'zh' });
|
||||
expect(screen.getByText(/进度/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show translated findings count', () => {
|
||||
render(<DiscoveryCard {...defaultProps} />, { locale: 'zh' });
|
||||
expect(screen.getByText(/发现/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('interaction', () => {
|
||||
it('should call onClick when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = vi.fn();
|
||||
|
||||
render(<DiscoveryCard {...defaultProps} onClick={onClick} />, { locale: 'en' });
|
||||
|
||||
const card = screen.getByText('Test Session').closest('.cursor-pointer');
|
||||
if (card) {
|
||||
await user.click(card);
|
||||
}
|
||||
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('visual states', () => {
|
||||
it('should apply active styles when isActive', () => {
|
||||
const { container } = render(
|
||||
<DiscoveryCard {...defaultProps} isActive={true} />,
|
||||
{ locale: 'en' }
|
||||
);
|
||||
|
||||
const card = container.firstChild as HTMLElement;
|
||||
expect(card.className).toContain('ring-2');
|
||||
expect(card.className).toContain('ring-primary');
|
||||
});
|
||||
|
||||
it('should not apply active styles when not active', () => {
|
||||
const { container } = render(
|
||||
<DiscoveryCard {...defaultProps} isActive={false} />,
|
||||
{ locale: 'en' }
|
||||
);
|
||||
|
||||
const card = container.firstChild as HTMLElement;
|
||||
expect(card.className).not.toContain('ring-2');
|
||||
});
|
||||
|
||||
it('should have hover effect', () => {
|
||||
const { container } = render(
|
||||
<DiscoveryCard {...defaultProps} />,
|
||||
{ locale: 'en' }
|
||||
);
|
||||
|
||||
const card = container.firstChild as HTMLElement;
|
||||
expect(card.className).toContain('hover:shadow-md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('progress bar', () => {
|
||||
it('should render progress element for running sessions', () => {
|
||||
render(<DiscoveryCard {...defaultProps} />, { locale: 'en' });
|
||||
|
||||
const progressBar = document.querySelector('[role="progressbar"]');
|
||||
expect(progressBar).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render progress element for completed sessions', () => {
|
||||
const completedSession: DiscoverySession = {
|
||||
...mockSession,
|
||||
status: 'completed',
|
||||
};
|
||||
|
||||
render(<DiscoveryCard {...defaultProps} session={completedSession} />, { locale: 'en' });
|
||||
|
||||
const progressBar = document.querySelector('[role="progressbar"]');
|
||||
expect(progressBar).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display correct progress percentage', () => {
|
||||
const sessionWithDifferentProgress: DiscoverySession = {
|
||||
...mockSession,
|
||||
progress: 75,
|
||||
};
|
||||
|
||||
render(<DiscoveryCard {...defaultProps} session={sessionWithDifferentProgress} />, { locale: 'en' });
|
||||
expect(screen.getByText('75%')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('should have clickable card with proper cursor', () => {
|
||||
const { container } = render(<DiscoveryCard {...defaultProps} />, { locale: 'en' });
|
||||
const card = container.firstChild as HTMLElement;
|
||||
expect(card.className).toContain('cursor-pointer');
|
||||
});
|
||||
|
||||
it('should have proper heading structure', () => {
|
||||
render(<DiscoveryCard {...defaultProps} />, { locale: 'en' });
|
||||
const heading = screen.getByRole('heading', { level: 3, name: 'Test Session' });
|
||||
expect(heading).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle zero findings', () => {
|
||||
const sessionWithNoFindings: DiscoverySession = {
|
||||
...mockSession,
|
||||
findings_count: 0,
|
||||
};
|
||||
|
||||
render(<DiscoveryCard {...defaultProps} session={sessionWithNoFindings} />, { locale: 'en' });
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle zero progress', () => {
|
||||
const sessionWithNoProgress: DiscoverySession = {
|
||||
...mockSession,
|
||||
progress: 0,
|
||||
};
|
||||
|
||||
render(<DiscoveryCard {...defaultProps} session={sessionWithNoProgress} />, { locale: 'en' });
|
||||
expect(screen.getByText('0%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle 100% progress', () => {
|
||||
const sessionWithFullProgress: DiscoverySession = {
|
||||
...mockSession,
|
||||
progress: 100,
|
||||
};
|
||||
|
||||
render(<DiscoveryCard {...defaultProps} session={sessionWithFullProgress} />, { locale: 'en' });
|
||||
expect(screen.getByText('100%')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
// ========================================
|
||||
// Discovery Card Component
|
||||
// ========================================
|
||||
// Displays a discovery session card with status, progress, and findings count
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Radar, CheckCircle, XCircle, Clock } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Progress } from '@/components/ui/Progress';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DiscoverySession } from '@/lib/api';
|
||||
|
||||
interface DiscoveryCardProps {
|
||||
session: DiscoverySession;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
running: {
|
||||
icon: Clock,
|
||||
variant: 'warning' as const,
|
||||
label: 'issues.discovery.status.running',
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle,
|
||||
variant: 'success' as const,
|
||||
label: 'issues.discovery.status.completed',
|
||||
},
|
||||
failed: {
|
||||
icon: XCircle,
|
||||
variant: 'destructive' as const,
|
||||
label: 'issues.discovery.status.failed',
|
||||
},
|
||||
};
|
||||
|
||||
export function DiscoveryCard({ session, isActive, onClick }: DiscoveryCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const config = statusConfig[session.status];
|
||||
const StatusIcon = config.icon;
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'p-4 cursor-pointer transition-all hover:shadow-md',
|
||||
isActive && 'ring-2 ring-primary'
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Radar className="w-5 h-5 text-primary flex-shrink-0" />
|
||||
<h3 className="font-medium text-foreground truncate">{session.name}</h3>
|
||||
</div>
|
||||
<Badge variant={config.variant} className="flex-shrink-0">
|
||||
<StatusIcon className="w-3 h-3 mr-1" />
|
||||
{formatMessage({ id: config.label })}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar for Running Sessions */}
|
||||
{session.status === 'running' && (
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
|
||||
<span>{formatMessage({ id: 'issues.discovery.progress' })}</span>
|
||||
<span>{session.progress}%</span>
|
||||
</div>
|
||||
<Progress value={session.progress} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Findings Count */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground">{formatMessage({ id: 'issues.discovery.findings' })}:</span>
|
||||
<span className="font-medium text-foreground">{session.findings_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDate(session.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
224
ccw/frontend/src/components/issue/discovery/DiscoveryDetail.tsx
Normal file
224
ccw/frontend/src/components/issue/discovery/DiscoveryDetail.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
// ========================================
|
||||
// Discovery Detail Component
|
||||
// ========================================
|
||||
// Displays findings detail panel with tabs and export functionality
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Download, FileText, BarChart3, Info } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Progress } from '@/components/ui/Progress';
|
||||
import type { DiscoverySession, Finding } from '@/lib/api';
|
||||
import type { FindingFilters } from '@/hooks/useIssues';
|
||||
import { FindingList } from './FindingList';
|
||||
|
||||
interface DiscoveryDetailProps {
|
||||
sessionId: string;
|
||||
session: DiscoverySession | null;
|
||||
findings: Finding[];
|
||||
filters: FindingFilters;
|
||||
onFilterChange: (filters: FindingFilters) => void;
|
||||
onExport: () => void;
|
||||
}
|
||||
|
||||
export function DiscoveryDetail({
|
||||
sessionId: _sessionId,
|
||||
session,
|
||||
findings,
|
||||
filters,
|
||||
onFilterChange,
|
||||
onExport,
|
||||
}: DiscoveryDetailProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [activeTab, setActiveTab] = useState('findings');
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<Card className="p-8 text-center">
|
||||
<FileText 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.noSessionSelected' })}
|
||||
</h3>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{formatMessage({ id: 'issues.discovery.selectSession' })}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const severityCounts = findings.reduce((acc, f) => {
|
||||
acc[f.severity] = (acc[f.severity] || 0) + 1;
|
||||
return acc;
|
||||
}, { critical: 0, high: 0, medium: 0, low: 0 });
|
||||
|
||||
const typeCounts = findings.reduce((acc, f) => {
|
||||
acc[f.type] = (acc[f.type] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">{session.name}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'issues.discovery.sessionId' })}: {session.id}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={onExport} disabled={findings.length === 0}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.discovery.export' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge
|
||||
variant={session.status === 'completed' ? 'success' : session.status === 'failed' ? 'destructive' : 'warning'}
|
||||
>
|
||||
{formatMessage({ id: `issues.discovery.status.${session.status}` })}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'issues.discovery.createdAt' })}: {formatDate(session.created_at)}
|
||||
</span>
|
||||
{session.completed_at && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'issues.discovery.completedAt' })}: {formatDate(session.completed_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar for Running Sessions */}
|
||||
{session.status === 'running' && (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="text-muted-foreground">{formatMessage({ id: 'issues.discovery.progress' })}</span>
|
||||
<span className="font-medium">{session.progress}%</span>
|
||||
</div>
|
||||
<Progress value={session.progress} className="h-2" />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="findings">
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.discovery.tabFindings' })} ({findings.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="progress">
|
||||
<BarChart3 className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.discovery.tabProgress' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="info">
|
||||
<Info className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.discovery.tabInfo' })}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="findings" className="mt-4">
|
||||
<FindingList findings={findings} filters={filters} onFilterChange={onFilterChange} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="progress" className="mt-4 space-y-4">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-medium text-foreground mb-4">
|
||||
{formatMessage({ id: 'issues.discovery.severityBreakdown' })}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(severityCounts).map(([severity, count]) => (
|
||||
<div key={severity} className="flex items-center justify-between">
|
||||
<Badge
|
||||
variant={severity === 'critical' || severity === 'high' ? 'destructive' : severity === 'medium' ? 'warning' : 'secondary'}
|
||||
>
|
||||
{formatMessage({ id: `issues.discovery.severity.${severity}` })}
|
||||
</Badge>
|
||||
<span className="font-medium">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{Object.keys(typeCounts).length > 0 && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-medium text-foreground mb-4">
|
||||
{formatMessage({ id: 'issues.discovery.typeBreakdown' })}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(typeCounts)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([type, count]) => (
|
||||
<div key={type} className="flex items-center justify-between">
|
||||
<Badge variant="outline">{type}</Badge>
|
||||
<span className="font-medium">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="info" className="mt-4">
|
||||
<Card className="p-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-1">
|
||||
{formatMessage({ id: 'issues.discovery.sessionId' })}
|
||||
</h3>
|
||||
<p className="text-foreground font-mono text-sm">{session.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-1">
|
||||
{formatMessage({ id: 'issues.discovery.name' })}
|
||||
</h3>
|
||||
<p className="text-foreground">{session.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-1">
|
||||
{formatMessage({ id: 'issues.discovery.status' })}
|
||||
</h3>
|
||||
<Badge
|
||||
variant={session.status === 'completed' ? 'success' : session.status === 'failed' ? 'destructive' : 'warning'}
|
||||
>
|
||||
{formatMessage({ id: `issues.discovery.status.${session.status}` })}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-1">
|
||||
{formatMessage({ id: 'issues.discovery.progress' })}
|
||||
</h3>
|
||||
<p className="text-foreground">{session.progress}%</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-1">
|
||||
{formatMessage({ id: 'issues.discovery.findingsCount' })}
|
||||
</h3>
|
||||
<p className="text-foreground">{session.findings_count}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-1">
|
||||
{formatMessage({ id: 'issues.discovery.createdAt' })}
|
||||
</h3>
|
||||
<p className="text-foreground">{formatDate(session.created_at)}</p>
|
||||
</div>
|
||||
{session.completed_at && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-1">
|
||||
{formatMessage({ id: 'issues.discovery.completedAt' })}
|
||||
</h3>
|
||||
<p className="text-foreground">{formatDate(session.completed_at)}</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
ccw/frontend/src/components/issue/discovery/FindingList.tsx
Normal file
137
ccw/frontend/src/components/issue/discovery/FindingList.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
// ========================================
|
||||
// Finding List Component
|
||||
// ========================================
|
||||
// Displays findings with filters and severity badges
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Search, FileCode, AlertTriangle } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select';
|
||||
import type { Finding } from '@/lib/api';
|
||||
import type { FindingFilters } from '@/hooks/useIssues';
|
||||
|
||||
interface FindingListProps {
|
||||
findings: Finding[];
|
||||
filters: FindingFilters;
|
||||
onFilterChange: (filters: FindingFilters) => void;
|
||||
}
|
||||
|
||||
const severityConfig = {
|
||||
critical: { variant: 'destructive' as const, label: 'issues.discovery.severity.critical' },
|
||||
high: { variant: 'destructive' as const, label: 'issues.discovery.severity.high' },
|
||||
medium: { variant: 'warning' as const, label: 'issues.discovery.severity.medium' },
|
||||
low: { variant: 'secondary' as const, label: 'issues.discovery.severity.low' },
|
||||
};
|
||||
|
||||
export function FindingList({ findings, filters, onFilterChange }: FindingListProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// Extract unique types for filter
|
||||
const uniqueTypes = Array.from(new Set(findings.map(f => f.type))).sort();
|
||||
|
||||
if (findings.length === 0) {
|
||||
return (
|
||||
<Card className="p-8 text-center">
|
||||
<AlertTriangle 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.noFindings' })}
|
||||
</h3>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{formatMessage({ id: 'issues.discovery.noFindingsDescription' })}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
<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: 'issues.discovery.searchPlaceholder' })}
|
||||
value={filters.search || ''}
|
||||
onChange={(e) => onFilterChange({ ...filters, search: e.target.value || undefined })}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={filters.severity || 'all'}
|
||||
onValueChange={(v) => onFilterChange({ ...filters, severity: v === 'all' ? undefined : v as Finding['severity'] })}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder={formatMessage({ id: 'issues.discovery.filterBySeverity' })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{formatMessage({ id: 'issues.discovery.allSeverities' })}</SelectItem>
|
||||
<SelectItem value="critical">{formatMessage({ id: 'issues.discovery.severity.critical' })}</SelectItem>
|
||||
<SelectItem value="high">{formatMessage({ id: 'issues.discovery.severity.high' })}</SelectItem>
|
||||
<SelectItem value="medium">{formatMessage({ id: 'issues.discovery.severity.medium' })}</SelectItem>
|
||||
<SelectItem value="low">{formatMessage({ id: 'issues.discovery.severity.low' })}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{uniqueTypes.length > 0 && (
|
||||
<Select
|
||||
value={filters.type || 'all'}
|
||||
onValueChange={(v) => onFilterChange({ ...filters, type: v === 'all' ? undefined : v })}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder={formatMessage({ id: 'issues.discovery.filterByType' })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{formatMessage({ id: 'issues.discovery.allTypes' })}</SelectItem>
|
||||
{uniqueTypes.map(type => (
|
||||
<SelectItem key={type} value={type}>{type}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Findings List */}
|
||||
<div className="space-y-3">
|
||||
{findings.map((finding) => {
|
||||
const config = severityConfig[finding.severity];
|
||||
return (
|
||||
<Card key={finding.id} className="p-4">
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant={config.variant}>
|
||||
{formatMessage({ id: config.label })}
|
||||
</Badge>
|
||||
{finding.type && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{finding.type}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{finding.file && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<FileCode className="w-3 h-3" />
|
||||
<span>{finding.file}</span>
|
||||
{finding.line && <span>:{finding.line}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="font-medium text-foreground mb-1">{finding.title}</h4>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">{finding.description}</p>
|
||||
{finding.code_snippet && (
|
||||
<pre className="mt-2 p-2 bg-muted rounded text-xs overflow-x-auto">
|
||||
<code>{finding.code_snippet}</code>
|
||||
</pre>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Count */}
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'issues.discovery.showingCount' }, { count: findings.length })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
ccw/frontend/src/components/issue/discovery/index.ts
Normal file
7
ccw/frontend/src/components/issue/discovery/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// ========================================
|
||||
// Discovery Components Index
|
||||
// ========================================
|
||||
|
||||
export { DiscoveryCard } from './DiscoveryCard';
|
||||
export { DiscoveryDetail } from './DiscoveryDetail';
|
||||
export { FindingList } from './FindingList';
|
||||
162
ccw/frontend/src/components/issue/queue/ExecutionGroup.test.tsx
Normal file
162
ccw/frontend/src/components/issue/queue/ExecutionGroup.test.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
// ========================================
|
||||
// ExecutionGroup Component Tests
|
||||
// ========================================
|
||||
// Tests for the execution group component with i18n
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen } from '@/test/i18n';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ExecutionGroup } from './ExecutionGroup';
|
||||
|
||||
describe('ExecutionGroup', () => {
|
||||
const defaultProps = {
|
||||
group: 'group-1',
|
||||
items: ['task1', 'task2'],
|
||||
type: 'sequential' as const,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('with en locale', () => {
|
||||
it('should render group name', () => {
|
||||
render(<ExecutionGroup {...defaultProps} />, { locale: 'en' });
|
||||
expect(screen.getByText(/group-1/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show sequential badge', () => {
|
||||
render(<ExecutionGroup {...defaultProps} />, { locale: 'en' });
|
||||
expect(screen.getByText(/Sequential/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show parallel badge for parallel type', () => {
|
||||
render(<ExecutionGroup {...defaultProps} type="parallel" />, { locale: 'en' });
|
||||
expect(screen.getByText(/Parallel/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show items count', () => {
|
||||
render(<ExecutionGroup {...defaultProps} />, { locale: 'en' });
|
||||
expect(screen.getByText(/2 items/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render item list', () => {
|
||||
render(<ExecutionGroup {...defaultProps} />, { locale: 'en' });
|
||||
expect(screen.getByText('task1')).toBeInTheDocument();
|
||||
expect(screen.getByText('task2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with zh locale', () => {
|
||||
it('should render group name', () => {
|
||||
render(<ExecutionGroup {...defaultProps} />, { locale: 'zh' });
|
||||
expect(screen.getByText(/group-1/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show translated sequential badge', () => {
|
||||
render(<ExecutionGroup {...defaultProps} />, { locale: 'zh' });
|
||||
expect(screen.getByText(/顺序/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show translated parallel badge', () => {
|
||||
render(<ExecutionGroup {...defaultProps} type="parallel" />, { locale: 'zh' });
|
||||
expect(screen.getByText(/并行/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show items count in Chinese', () => {
|
||||
render(<ExecutionGroup {...defaultProps} items={['task1']} />, { locale: 'zh' });
|
||||
expect(screen.getByText(/1 item/i)).toBeInTheDocument(); // "item" is not translated in the component
|
||||
});
|
||||
|
||||
it('should render item list', () => {
|
||||
render(<ExecutionGroup {...defaultProps} />, { locale: 'zh' });
|
||||
expect(screen.getByText('task1')).toBeInTheDocument();
|
||||
expect(screen.getByText('task2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('interaction', () => {
|
||||
it('should expand and collapse on click', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ExecutionGroup {...defaultProps} />, { locale: 'en' });
|
||||
|
||||
// Initially expanded, items should be visible
|
||||
expect(screen.getByText('task1')).toBeInTheDocument();
|
||||
|
||||
// Click to collapse
|
||||
const header = screen.getByText(/group-1/i).closest('div');
|
||||
if (header) {
|
||||
await user.click(header);
|
||||
}
|
||||
|
||||
// After collapse, items should not be visible (group collapses)
|
||||
// Note: The component uses state internally, so we need to test differently
|
||||
});
|
||||
|
||||
it('should be clickable via header', () => {
|
||||
render(<ExecutionGroup {...defaultProps} />, { locale: 'en' });
|
||||
const cardHeader = screen.getByText(/group-1/i).closest('.cursor-pointer');
|
||||
expect(cardHeader).toBeInTheDocument();
|
||||
expect(cardHeader).toHaveClass('cursor-pointer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sequential numbering', () => {
|
||||
it('should show numbered items for sequential type', () => {
|
||||
render(<ExecutionGroup {...defaultProps} items={['task1', 'task2', 'task3']} />, { locale: 'en' });
|
||||
|
||||
// Sequential items should have numbers
|
||||
const itemElements = document.querySelectorAll('.font-mono');
|
||||
expect(itemElements.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should not show numbers for parallel type', () => {
|
||||
render(<ExecutionGroup {...defaultProps} type="parallel" items={['task1', 'task2']} />, { locale: 'en' });
|
||||
|
||||
// Parallel items should not have numbers in the numbering position
|
||||
const numberElements = document.querySelectorAll('.text-muted-foreground.text-xs');
|
||||
// In parallel mode, the numbering position should be empty
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty state', () => {
|
||||
it('should handle empty items array', () => {
|
||||
render(<ExecutionGroup {...defaultProps} items={[]} />, { locale: 'en' });
|
||||
expect(screen.getByText(/0 items/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle single item', () => {
|
||||
render(<ExecutionGroup {...defaultProps} items={['task1']} />, { locale: 'en' });
|
||||
expect(screen.getByText(/1 item/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('task1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('should have clickable header with proper cursor', () => {
|
||||
render(<ExecutionGroup {...defaultProps} />, { locale: 'en' });
|
||||
const header = screen.getByText(/group-1/i).closest('.cursor-pointer');
|
||||
expect(header).toHaveClass('cursor-pointer');
|
||||
});
|
||||
|
||||
it('should render expandable indicator icon', () => {
|
||||
const { container } = render(<ExecutionGroup {...defaultProps} />, { locale: 'en' });
|
||||
// ChevronDown or ChevronRight should be present
|
||||
const chevron = container.querySelector('.lucide-chevron-down, .lucide-chevron-right');
|
||||
expect(chevron).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parallel layout', () => {
|
||||
it('should use grid layout for parallel groups', () => {
|
||||
const { container } = render(
|
||||
<ExecutionGroup {...defaultProps} type="parallel" items={['task1', 'task2', 'task3', 'task4']} />,
|
||||
{ locale: 'en' }
|
||||
);
|
||||
|
||||
// Check for grid class (sm:grid-cols-2)
|
||||
const gridContainer = container.querySelector('.grid');
|
||||
expect(gridContainer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
93
ccw/frontend/src/components/issue/queue/ExecutionGroup.tsx
Normal file
93
ccw/frontend/src/components/issue/queue/ExecutionGroup.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
// ========================================
|
||||
// ExecutionGroup Component
|
||||
// ========================================
|
||||
// Expandable execution group for queue items
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { ChevronDown, ChevronRight, GitMerge, ArrowRight } from 'lucide-react';
|
||||
import { Card, CardHeader } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface ExecutionGroupProps {
|
||||
group: string;
|
||||
items: string[];
|
||||
type?: 'parallel' | 'sequential';
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function ExecutionGroup({ group, items, type = 'sequential' }: ExecutionGroupProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const isParallel = type === 'parallel';
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader
|
||||
className="py-3 px-4 cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
<Badge
|
||||
variant={isParallel ? 'info' : 'secondary'}
|
||||
className="gap-1"
|
||||
>
|
||||
{isParallel ? (
|
||||
<GitMerge className="w-3 h-3" />
|
||||
) : (
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
)}
|
||||
{group}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{isParallel
|
||||
? formatMessage({ id: 'issues.queue.parallelGroup' })
|
||||
: formatMessage({ id: 'issues.queue.sequentialGroup' })}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{items.length} {items.length === 1 ? 'item' : 'items'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 pt-0">
|
||||
<div className={cn(
|
||||
"space-y-1 mt-2",
|
||||
isParallel ? "grid grid-cols-1 sm:grid-cols-2 gap-2" : "space-y-1"
|
||||
)}>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item}
|
||||
className={cn(
|
||||
"flex items-center gap-2 p-2 rounded-md bg-muted/50 text-sm",
|
||||
"hover:bg-muted transition-colors"
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground text-xs w-6">
|
||||
{isParallel ? '' : `${index + 1}.`}
|
||||
</span>
|
||||
<span className="font-mono text-xs truncate flex-1">
|
||||
{item}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExecutionGroup;
|
||||
234
ccw/frontend/src/components/issue/queue/QueueActions.tsx
Normal file
234
ccw/frontend/src/components/issue/queue/QueueActions.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
// ========================================
|
||||
// QueueActions Component
|
||||
// ========================================
|
||||
// Queue operations menu component with delete confirmation and merge dialog
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Play, Pause, Trash2, Merge, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/Dropdown';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
} from '@/components/ui/AlertDialog';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/Dialog';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import type { IssueQueue } from '@/lib/api';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface QueueActionsProps {
|
||||
queue: IssueQueue;
|
||||
isActive?: boolean;
|
||||
onActivate?: (queueId: string) => void;
|
||||
onDeactivate?: () => void;
|
||||
onDelete?: (queueId: string) => void;
|
||||
onMerge?: (sourceId: string, targetId: string) => void;
|
||||
isActivating?: boolean;
|
||||
isDeactivating?: boolean;
|
||||
isDeleting?: boolean;
|
||||
isMerging?: boolean;
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function QueueActions({
|
||||
queue,
|
||||
isActive = false,
|
||||
onActivate,
|
||||
onDeactivate,
|
||||
onDelete,
|
||||
onMerge,
|
||||
isActivating = false,
|
||||
isDeactivating = false,
|
||||
isDeleting = false,
|
||||
isMerging = false,
|
||||
}: QueueActionsProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [isMergeOpen, setIsMergeOpen] = useState(false);
|
||||
const [mergeTargetId, setMergeTargetId] = useState('');
|
||||
|
||||
// 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 handleDelete = () => {
|
||||
onDelete?.(queueId);
|
||||
setIsDeleteOpen(false);
|
||||
};
|
||||
|
||||
const handleMerge = () => {
|
||||
if (mergeTargetId.trim()) {
|
||||
onMerge?.(queueId, mergeTargetId.trim());
|
||||
setIsMergeOpen(false);
|
||||
setMergeTargetId('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">{formatMessage({ id: 'common.actions.openMenu' })}</span>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
<circle cx="12" cy="5" r="1" />
|
||||
<circle cx="12" cy="19" r="1" />
|
||||
</svg>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{!isActive && onActivate && (
|
||||
<DropdownMenuItem onClick={() => onActivate(queueId)} disabled={isActivating}>
|
||||
{isActivating ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{formatMessage({ id: 'issues.queue.actions.activate' })}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isActive && onDeactivate && (
|
||||
<DropdownMenuItem onClick={() => onDeactivate()} disabled={isDeactivating}>
|
||||
{isDeactivating ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Pause className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{formatMessage({ id: 'issues.queue.actions.deactivate' })}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => setIsMergeOpen(true)} disabled={isMerging}>
|
||||
{isMerging ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Merge className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{formatMessage({ id: 'issues.queue.actions.merge' })}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => setIsDeleteOpen(true)}
|
||||
disabled={isDeleting}
|
||||
className="text-destructive"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{formatMessage({ id: 'issues.queue.actions.delete' })}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{formatMessage({ id: 'issues.queue.deleteDialog.title' })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{formatMessage({ id: 'issues.queue.deleteDialog.description' })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{formatMessage({ id: 'common.actions.cancel' })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive hover:bg-destructive/90">
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{formatMessage({ id: 'common.actions.deleting' })}
|
||||
</>
|
||||
) : (
|
||||
formatMessage({ id: 'issues.queue.actions.delete' })
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Merge Dialog */}
|
||||
<Dialog open={isMergeOpen} onOpenChange={setIsMergeOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{formatMessage({ id: 'issues.queue.mergeDialog.title' })}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<label htmlFor="merge-target" className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'issues.queue.mergeDialog.targetQueueLabel' })}
|
||||
</label>
|
||||
<Input
|
||||
id="merge-target"
|
||||
value={mergeTargetId}
|
||||
onChange={(e) => setMergeTargetId(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'issues.queue.mergeDialog.targetQueuePlaceholder' })}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsMergeOpen(false);
|
||||
setMergeTargetId('');
|
||||
}}
|
||||
>
|
||||
{formatMessage({ id: 'common.actions.cancel' })}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleMerge}
|
||||
disabled={!mergeTargetId.trim() || isMerging}
|
||||
>
|
||||
{isMerging ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{formatMessage({ id: 'common.actions.merging' })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Merge className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.queue.actions.merge' })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default QueueActions;
|
||||
196
ccw/frontend/src/components/issue/queue/QueueCard.test.tsx
Normal file
196
ccw/frontend/src/components/issue/queue/QueueCard.test.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
// ========================================
|
||||
// QueueCard Component Tests
|
||||
// ========================================
|
||||
// Tests for the queue card component with i18n
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen } from '@/test/i18n';
|
||||
import { QueueCard } from './QueueCard';
|
||||
import type { IssueQueue } from '@/lib/api';
|
||||
|
||||
describe('QueueCard', () => {
|
||||
const mockQueue: IssueQueue = {
|
||||
tasks: ['task1', 'task2'],
|
||||
solutions: ['solution1'],
|
||||
conflicts: [],
|
||||
execution_groups: { 'group-1': ['task1', 'task2'] },
|
||||
grouped_items: { 'parallel-group': ['task1', 'task2'] },
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
queue: mockQueue,
|
||||
isActive: false,
|
||||
onActivate: vi.fn(),
|
||||
onDeactivate: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onMerge: vi.fn(),
|
||||
isActivating: false,
|
||||
isDeactivating: false,
|
||||
isDeleting: false,
|
||||
isMerging: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('with en locale', () => {
|
||||
it('should render queue name', () => {
|
||||
render(<QueueCard {...defaultProps} />, { locale: 'en' });
|
||||
expect(screen.getByText(/Queue/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render stats', () => {
|
||||
render(<QueueCard {...defaultProps} />, { locale: 'en' });
|
||||
expect(screen.getAllByText(/Items/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/3/i)).toBeInTheDocument(); // total items: 2 tasks + 1 solution
|
||||
expect(screen.getAllByText(/Groups/i).length).toBeGreaterThan(0);
|
||||
// Note: "1" appears multiple times, so we just check the total items count (3) exists
|
||||
});
|
||||
|
||||
it('should render execution groups', () => {
|
||||
render(<QueueCard {...defaultProps} />, { locale: 'en' });
|
||||
expect(screen.getAllByText(/Execution/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should show active badge when isActive', () => {
|
||||
render(<QueueCard {...defaultProps} isActive={true} />, { locale: 'en' });
|
||||
expect(screen.getByText(/Active/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with zh locale', () => {
|
||||
it('should render translated queue name', () => {
|
||||
render(<QueueCard {...defaultProps} />, { locale: 'zh' });
|
||||
expect(screen.getByText(/队列/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render translated stats', () => {
|
||||
render(<QueueCard {...defaultProps} />, { locale: 'zh' });
|
||||
expect(screen.getAllByText(/项目/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/执行组/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should render translated execution groups', () => {
|
||||
render(<QueueCard {...defaultProps} />, { locale: 'zh' });
|
||||
expect(screen.getAllByText(/执行/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should show translated active badge when isActive', () => {
|
||||
render(<QueueCard {...defaultProps} isActive={true} />, { locale: 'zh' });
|
||||
expect(screen.getByText(/活跃/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('conflicts warning', () => {
|
||||
it('should show conflicts warning when conflicts exist', () => {
|
||||
const queueWithConflicts: IssueQueue = {
|
||||
...mockQueue,
|
||||
conflicts: ['conflict1', 'conflict2'],
|
||||
};
|
||||
|
||||
render(
|
||||
<QueueCard
|
||||
{...defaultProps}
|
||||
queue={queueWithConflicts}
|
||||
/>,
|
||||
{ locale: 'en' }
|
||||
);
|
||||
|
||||
expect(screen.getByText(/2 conflicts/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show translated conflicts warning in Chinese', () => {
|
||||
const queueWithConflicts: IssueQueue = {
|
||||
...mockQueue,
|
||||
conflicts: ['conflict1'],
|
||||
};
|
||||
|
||||
render(
|
||||
<QueueCard
|
||||
{...defaultProps}
|
||||
queue={queueWithConflicts}
|
||||
/>,
|
||||
{ locale: 'zh' }
|
||||
);
|
||||
|
||||
expect(screen.getByText(/1 冲突/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty state', () => {
|
||||
it('should show empty state when no items', () => {
|
||||
const emptyQueue: IssueQueue = {
|
||||
tasks: [],
|
||||
solutions: [],
|
||||
conflicts: [],
|
||||
execution_groups: {},
|
||||
grouped_items: {},
|
||||
};
|
||||
|
||||
render(
|
||||
<QueueCard
|
||||
{...defaultProps}
|
||||
queue={emptyQueue}
|
||||
/>,
|
||||
{ locale: 'en' }
|
||||
);
|
||||
|
||||
expect(screen.getByText(/No items in queue/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show translated empty state in Chinese', () => {
|
||||
const emptyQueue: IssueQueue = {
|
||||
tasks: [],
|
||||
solutions: [],
|
||||
conflicts: [],
|
||||
execution_groups: {},
|
||||
grouped_items: {},
|
||||
};
|
||||
|
||||
render(
|
||||
<QueueCard
|
||||
{...defaultProps}
|
||||
queue={emptyQueue}
|
||||
/>,
|
||||
{ locale: 'zh' }
|
||||
);
|
||||
|
||||
expect(screen.getByText(/队列中无项目/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('should have proper card structure', () => {
|
||||
const { container } = render(<QueueCard {...defaultProps} />, { locale: 'en' });
|
||||
const card = container.querySelector('[class*="rounded-lg"]');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have accessible title', () => {
|
||||
render(<QueueCard {...defaultProps} />, { locale: 'en' });
|
||||
const title = screen.getByText(/Queue/i);
|
||||
expect(title).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('visual states', () => {
|
||||
it('should apply active styles when isActive', () => {
|
||||
const { container } = render(
|
||||
<QueueCard {...defaultProps} isActive={true} />,
|
||||
{ locale: 'en' }
|
||||
);
|
||||
const card = container.firstChild as HTMLElement;
|
||||
expect(card.className).toContain('border-primary');
|
||||
});
|
||||
|
||||
it('should not apply active styles when not active', () => {
|
||||
const { container } = render(
|
||||
<QueueCard {...defaultProps} isActive={false} />,
|
||||
{ locale: 'en' }
|
||||
);
|
||||
const card = container.firstChild as HTMLElement;
|
||||
expect(card.className).not.toContain('border-primary');
|
||||
});
|
||||
});
|
||||
});
|
||||
163
ccw/frontend/src/components/issue/queue/QueueCard.tsx
Normal file
163
ccw/frontend/src/components/issue/queue/QueueCard.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
// ========================================
|
||||
// QueueCard Component
|
||||
// ========================================
|
||||
// Card component for displaying queue information and actions
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { ListTodo, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { ExecutionGroup } from './ExecutionGroup';
|
||||
import { QueueActions } from './QueueActions';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { IssueQueue } from '@/lib/api';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface QueueCardProps {
|
||||
queue: IssueQueue;
|
||||
isActive?: boolean;
|
||||
onActivate?: (queueId: string) => void;
|
||||
onDeactivate?: () => void;
|
||||
onDelete?: (queueId: string) => void;
|
||||
onMerge?: (sourceId: string, targetId: string) => void;
|
||||
isActivating?: boolean;
|
||||
isDeactivating?: boolean;
|
||||
isDeleting?: boolean;
|
||||
isMerging?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function QueueCard({
|
||||
queue,
|
||||
isActive = false,
|
||||
onActivate,
|
||||
onDeactivate,
|
||||
onDelete,
|
||||
onMerge,
|
||||
isActivating = false,
|
||||
isDeactivating = false,
|
||||
isDeleting = false,
|
||||
isMerging = false,
|
||||
className,
|
||||
}: QueueCardProps) {
|
||||
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(',');
|
||||
|
||||
// Calculate item counts
|
||||
const taskCount = queue.tasks?.length || 0;
|
||||
const solutionCount = queue.solutions?.length || 0;
|
||||
const conflictCount = queue.conflicts?.length || 0;
|
||||
const totalItems = taskCount + solutionCount;
|
||||
const groupCount = Object.keys(queue.grouped_items || {}).length;
|
||||
|
||||
// Get execution groups from grouped_items
|
||||
const executionGroups = Object.entries(queue.grouped_items || {}).map(([name, items]) => ({
|
||||
id: name,
|
||||
type: name.toLowerCase().includes('parallel') ? 'parallel' as const : 'sequential' as const,
|
||||
items: items || [],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card className={cn(
|
||||
"p-4 transition-all",
|
||||
isActive && "border-primary shadow-sm",
|
||||
className
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3 mb-4">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className={cn(
|
||||
"p-2 rounded-lg",
|
||||
isActive ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
<ListTodo className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-foreground truncate">
|
||||
{formatMessage({ id: 'issues.queue.title' })}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{queueId.substring(0, 20)}{queueId.length > 20 ? '...' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isActive && (
|
||||
<Badge variant="success" className="gap-1 shrink-0">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
{formatMessage({ id: 'issues.queue.status.active' })}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<QueueActions
|
||||
queue={queue}
|
||||
isActive={isActive}
|
||||
onActivate={onActivate}
|
||||
onDeactivate={onDeactivate}
|
||||
onDelete={onDelete}
|
||||
onMerge={onMerge}
|
||||
isActivating={isActivating}
|
||||
isDeactivating={isDeactivating}
|
||||
isDeleting={isDeleting}
|
||||
isMerging={isMerging}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-2 mb-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">{formatMessage({ id: 'issues.queue.items' })}:</span>
|
||||
<span className="font-medium">{totalItems}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">{formatMessage({ id: 'issues.queue.groups' })}:</span>
|
||||
<span className="font-medium">{groupCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conflicts Warning */}
|
||||
{conflictCount > 0 && (
|
||||
<div className="flex items-center gap-2 p-2 mb-4 bg-destructive/10 rounded-md">
|
||||
<AlertCircle className="w-4 h-4 text-destructive shrink-0" />
|
||||
<span className="text-sm text-destructive">
|
||||
{conflictCount} {formatMessage({ id: 'issues.queue.conflicts' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execution Groups */}
|
||||
{executionGroups.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase">
|
||||
{formatMessage({ id: 'issues.queue.executionGroups' })}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{executionGroups.map((group) => (
|
||||
<ExecutionGroup
|
||||
key={group.id}
|
||||
group={group.id}
|
||||
items={group.items}
|
||||
type={group.type}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{executionGroups.length === 0 && totalItems === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<ListTodo className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">{formatMessage({ id: 'issues.queue.empty' })}</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default QueueCard;
|
||||
12
ccw/frontend/src/components/issue/queue/index.ts
Normal file
12
ccw/frontend/src/components/issue/queue/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// ========================================
|
||||
// Queue Components Barrel Export
|
||||
// ========================================
|
||||
|
||||
export { QueueCard } from './QueueCard';
|
||||
export type { QueueCardProps } from './QueueCard';
|
||||
|
||||
export { ExecutionGroup } from './ExecutionGroup';
|
||||
export type { ExecutionGroupProps } from './ExecutionGroup';
|
||||
|
||||
export { QueueActions } from './QueueActions';
|
||||
export type { QueueActionsProps } from './QueueActions';
|
||||
@@ -4,6 +4,7 @@
|
||||
// Root layout component combining Header, Sidebar, and MainContent
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Header } from './Header';
|
||||
import { Sidebar } from './Sidebar';
|
||||
@@ -12,13 +13,12 @@ import { CliStreamMonitor } from '@/components/shared/CliStreamMonitor';
|
||||
import { NotificationPanel } from '@/components/notification';
|
||||
import { AskQuestionDialog } from '@/components/a2ui/AskQuestionDialog';
|
||||
import { useNotificationStore, selectCurrentQuestion } from '@/stores';
|
||||
import { useWorkflowStore } from '@/stores/workflowStore';
|
||||
import { useWebSocketNotifications } from '@/hooks';
|
||||
|
||||
export interface AppShellProps {
|
||||
/** Initial sidebar collapsed state */
|
||||
defaultCollapsed?: boolean;
|
||||
/** Current project path to display in header */
|
||||
projectPath?: string;
|
||||
/** Callback for refresh action */
|
||||
onRefresh?: () => void;
|
||||
/** Whether refresh is in progress */
|
||||
@@ -32,11 +32,32 @@ const SIDEBAR_COLLAPSED_KEY = 'ccw-sidebar-collapsed';
|
||||
|
||||
export function AppShell({
|
||||
defaultCollapsed = false,
|
||||
projectPath = '',
|
||||
onRefresh,
|
||||
isRefreshing = false,
|
||||
children,
|
||||
}: AppShellProps) {
|
||||
// Workspace initialization from URL query parameter
|
||||
const switchWorkspace = useWorkflowStore((state) => state.switchWorkspace);
|
||||
const projectPath = useWorkflowStore((state) => state.projectPath);
|
||||
const location = useLocation();
|
||||
|
||||
// Initialize workspace from URL path parameter on mount
|
||||
useEffect(() => {
|
||||
// Only initialize if no workspace is currently set
|
||||
if (projectPath) return;
|
||||
|
||||
// Read path from URL query parameter
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const pathParam = searchParams.get('path');
|
||||
|
||||
if (pathParam) {
|
||||
console.log('[AppShell] Initializing workspace from URL:', pathParam);
|
||||
switchWorkspace(pathParam).catch((error) => {
|
||||
console.error('[AppShell] Failed to initialize workspace:', error);
|
||||
});
|
||||
}
|
||||
}, [location.search, projectPath, switchWorkspace]);
|
||||
|
||||
// Sidebar collapse state (persisted)
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -120,7 +141,6 @@ export function AppShell({
|
||||
{/* Header - fixed at top */}
|
||||
<Header
|
||||
onMenuClick={handleMenuClick}
|
||||
projectPath={projectPath}
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
onCliMonitorClick={handleCliMonitorClick}
|
||||
|
||||
@@ -22,7 +22,6 @@ import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useTheme } from '@/hooks';
|
||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||
import { WorkspaceSelector } from '@/components/workspace/WorkspaceSelector';
|
||||
import { useCliStreamStore, selectActiveExecutionCount } from '@/stores/cliStreamStore';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
@@ -30,8 +29,6 @@ import { useNotificationStore } from '@/stores';
|
||||
export interface HeaderProps {
|
||||
/** Callback to toggle mobile sidebar */
|
||||
onMenuClick?: () => void;
|
||||
/** Current project path */
|
||||
projectPath?: string;
|
||||
/** Callback for refresh action */
|
||||
onRefresh?: () => void;
|
||||
/** Whether refresh is in progress */
|
||||
@@ -42,7 +39,6 @@ export interface HeaderProps {
|
||||
|
||||
export function Header({
|
||||
onMenuClick,
|
||||
projectPath = '',
|
||||
onRefresh,
|
||||
isRefreshing = false,
|
||||
onCliMonitorClick,
|
||||
@@ -112,7 +108,7 @@ export function Header({
|
||||
</Button>
|
||||
|
||||
{/* Workspace selector */}
|
||||
{projectPath && <WorkspaceSelector />}
|
||||
<WorkspaceSelector />
|
||||
|
||||
{/* Notification badge */}
|
||||
<Button
|
||||
@@ -147,9 +143,6 @@ export function Header({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Language switcher */}
|
||||
<LanguageSwitcher compact />
|
||||
|
||||
{/* Theme toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
Workflow,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
ListTodo,
|
||||
Search,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
Brain,
|
||||
@@ -25,8 +27,6 @@ import {
|
||||
GitFork,
|
||||
Shield,
|
||||
History,
|
||||
Folder,
|
||||
Network,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -60,13 +60,13 @@ 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: '/skills', icon: Sparkles },
|
||||
{ path: '/commands', icon: Terminal },
|
||||
{ path: '/memory', icon: Brain },
|
||||
{ path: '/prompts', icon: History },
|
||||
{ path: '/hooks', icon: GitFork },
|
||||
{ path: '/explorer', icon: Folder },
|
||||
{ path: '/graph', icon: Network },
|
||||
{ path: '/settings', icon: Settings },
|
||||
{ path: '/settings/rules', icon: Shield },
|
||||
{ path: '/help', icon: HelpCircle },
|
||||
@@ -110,13 +110,13 @@ export function Sidebar({
|
||||
'/orchestrator': 'main.orchestrator',
|
||||
'/loops': 'main.loops',
|
||||
'/issues': 'main.issues',
|
||||
'/issues/queue': 'main.issueQueue',
|
||||
'/issues/discovery': 'main.issueDiscovery',
|
||||
'/skills': 'main.skills',
|
||||
'/commands': 'main.commands',
|
||||
'/memory': 'main.memory',
|
||||
'/prompts': 'main.prompts',
|
||||
'/hooks': 'main.hooks',
|
||||
'/explorer': 'main.explorer',
|
||||
'/graph': 'main.graph',
|
||||
'/settings': 'main.settings',
|
||||
'/settings/rules': 'main.rules',
|
||||
'/help': 'main.help',
|
||||
|
||||
@@ -16,13 +16,22 @@ import {
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
File,
|
||||
Download,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Code,
|
||||
Image as ImageIcon,
|
||||
Database,
|
||||
Mail,
|
||||
MailOpen,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { A2UIRenderer } from '@/packages/a2ui-runtime/renderer/A2UIRenderer';
|
||||
import { useNotificationStore, selectPersistentNotifications } from '@/stores';
|
||||
import type { Toast } from '@/types/store';
|
||||
import type { Toast, NotificationAttachment, NotificationAction, ActionStateType, NotificationSource } from '@/types/store';
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
@@ -83,23 +92,67 @@ function getNotificationIcon(type: Toast['type']) {
|
||||
}
|
||||
}
|
||||
|
||||
function getSourceColor(source: NotificationSource): string {
|
||||
switch (source) {
|
||||
case 'system':
|
||||
return 'bg-blue-500/10 text-blue-600 border-blue-200 dark:border-blue-800';
|
||||
case 'websocket':
|
||||
return 'bg-purple-500/10 text-purple-600 border-purple-200 dark:border-purple-800';
|
||||
case 'cli':
|
||||
return 'bg-green-500/10 text-green-600 border-green-200 dark:border-green-800';
|
||||
case 'workflow':
|
||||
return 'bg-orange-500/10 text-orange-600 border-orange-200 dark:border-orange-800';
|
||||
case 'user':
|
||||
return 'bg-cyan-500/10 text-cyan-600 border-cyan-200 dark:border-cyan-800';
|
||||
case 'external':
|
||||
return 'bg-pink-500/10 text-pink-600 border-pink-200 dark:border-pink-800';
|
||||
default:
|
||||
return 'bg-gray-500/10 text-gray-600 border-gray-200 dark:border-gray-800';
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeBorder(type: Toast['type']): string {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'border-l-green-500';
|
||||
case 'warning':
|
||||
return 'border-l-yellow-500';
|
||||
case 'error':
|
||||
return 'border-l-red-500';
|
||||
case 'info':
|
||||
default:
|
||||
return 'border-l-blue-500';
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Sub-Components ==========
|
||||
|
||||
interface PanelHeaderProps {
|
||||
notificationCount: number;
|
||||
hasNotifications: boolean;
|
||||
hasUnread: boolean;
|
||||
onClose: () => void;
|
||||
onMarkAllRead: () => void;
|
||||
onClearAll: () => void;
|
||||
}
|
||||
|
||||
function PanelHeader({ notificationCount, onClose }: PanelHeaderProps) {
|
||||
function PanelHeader({
|
||||
notificationCount,
|
||||
hasNotifications,
|
||||
hasUnread,
|
||||
onClose,
|
||||
onMarkAllRead,
|
||||
onClearAll,
|
||||
}: PanelHeaderProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-between px-4 py-3 border-b border-border bg-card">
|
||||
<div className="flex-1 min-w-0 mr-4">
|
||||
<div className="flex-1 min-w-0 mr-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold text-foreground">
|
||||
{formatMessage({ id: 'notificationPanel.title' }) || 'Notifications'}
|
||||
{formatMessage({ id: 'notifications.title' }) || 'Notifications'}
|
||||
</h2>
|
||||
{notificationCount > 0 && (
|
||||
<Badge variant="default" className="h-5 px-1.5 text-xs">
|
||||
@@ -108,46 +161,297 @@ function PanelHeader({ notificationCount, onClose }: PanelHeaderProps) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
{/* Mark All Read button */}
|
||||
{hasNotifications && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onMarkAllRead}
|
||||
disabled={!hasUnread}
|
||||
className="h-8 w-8"
|
||||
aria-label={formatMessage({ id: 'notifications.markAllRead' }) || 'Mark all as read'}
|
||||
title={formatMessage({ id: 'notifications.markAllRead' }) || 'Mark all as read'}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{/* Clear All button */}
|
||||
{hasNotifications && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClearAll}
|
||||
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
aria-label={formatMessage({ id: 'notifications.clearAll' }) || 'Clear all notifications'}
|
||||
title={formatMessage({ id: 'notifications.clearAll' }) || 'Clear all notifications'}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{/* Close button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8"
|
||||
aria-label={formatMessage({ id: 'notifications.close' }) || 'Close notifications'}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PanelActionsProps {
|
||||
hasNotifications: boolean;
|
||||
hasUnread: boolean;
|
||||
onMarkAllRead: () => void;
|
||||
onClearAll: () => void;
|
||||
// ========== Helper Components for Attachments and Actions ==========
|
||||
|
||||
interface NotificationAttachmentItemProps {
|
||||
attachment: NotificationAttachment;
|
||||
}
|
||||
|
||||
function PanelActions({ hasNotifications, hasUnread, onMarkAllRead, onClearAll }: PanelActionsProps) {
|
||||
function NotificationAttachmentItem({ attachment }: NotificationAttachmentItemProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
if (!hasNotifications) return null;
|
||||
// Format file size
|
||||
function formatFileSize(bytes?: number): string {
|
||||
if (!bytes) return '';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
// Render different attachment types
|
||||
switch (attachment.type) {
|
||||
case 'image':
|
||||
return (
|
||||
<div className="mt-2 rounded-md overflow-hidden border border-border">
|
||||
{attachment.url ? (
|
||||
<img
|
||||
src={attachment.url}
|
||||
alt={attachment.filename || formatMessage({ id: 'notifications.attachments.image' }) || 'Image'}
|
||||
className="max-w-full max-h-48 object-contain bg-muted"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : attachment.content ? (
|
||||
<img
|
||||
src={attachment.content}
|
||||
alt={attachment.filename || formatMessage({ id: 'notifications.attachments.image' }) || 'Image'}
|
||||
className="max-w-full max-h-48 object-contain bg-muted"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : null}
|
||||
{attachment.filename && (
|
||||
<div className="px-2 py-1 text-xs text-muted-foreground bg-muted/50 truncate">
|
||||
{attachment.filename}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'code':
|
||||
return (
|
||||
<div className="mt-2 rounded-md border border-border overflow-hidden">
|
||||
<div className="flex items-center justify-between px-2 py-1 bg-muted/50 border-b border-border">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Code className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{attachment.filename || formatMessage({ id: 'notifications.attachments.code' }) || 'Code'}
|
||||
</span>
|
||||
</div>
|
||||
{attachment.mimeType && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary">
|
||||
{attachment.mimeType.replace('text/', '').replace('application/', '')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{attachment.content && (
|
||||
<pre className="p-2 text-xs bg-background overflow-x-auto max-h-48 overflow-y-auto">
|
||||
<code className="font-mono">{attachment.content}</code>
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'file':
|
||||
return (
|
||||
<div className="mt-2 flex items-center gap-2 p-2 rounded-md border border-border bg-muted/30">
|
||||
<File className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium text-foreground truncate">
|
||||
{attachment.filename || formatMessage({ id: 'notifications.attachments.file' }) || 'File'}
|
||||
</div>
|
||||
{attachment.size && (
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{formatFileSize(attachment.size)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{attachment.url && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
asChild
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<a href={attachment.url} download={attachment.filename}>
|
||||
<Download className="h-3 w-3 mr-1" />
|
||||
{formatMessage({ id: 'notifications.attachments.download' }) || 'Download'}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'data':
|
||||
return (
|
||||
<div className="mt-2 rounded-md border border-border overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 bg-muted/50 border-b border-border">
|
||||
<Database className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'notifications.attachments.data' }) || 'Data'}
|
||||
</span>
|
||||
</div>
|
||||
{attachment.content && (
|
||||
<pre className="p-2 text-xs bg-muted/20 overflow-x-auto max-h-48 overflow-y-auto">
|
||||
<code className="font-mono text-muted-foreground">
|
||||
{JSON.stringify(JSON.parse(attachment.content), null, 2)}
|
||||
</code>
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface NotificationActionsProps {
|
||||
actions: NotificationAction[];
|
||||
}
|
||||
|
||||
function NotificationActions({ actions }: NotificationActionsProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [actionStates, setActionStates] = useState<Record<string, ActionStateType>>({});
|
||||
const [retryCounts, setRetryCounts] = useState<Record<string, number>>({});
|
||||
|
||||
const handleActionClick = useCallback(
|
||||
async (action: NotificationAction, index: number) => {
|
||||
const actionKey = `${index}-${action.label}`;
|
||||
|
||||
// Skip if already loading
|
||||
if (actionStates[actionKey] === 'loading') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle confirmation if present
|
||||
if (action.confirm) {
|
||||
const confirmed = window.confirm(
|
||||
action.confirm.message || action.label
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Set loading state
|
||||
setActionStates((prev) => ({ ...prev, [actionKey]: 'loading' }));
|
||||
|
||||
try {
|
||||
// Call the action handler
|
||||
await action.onClick();
|
||||
|
||||
// Set success state
|
||||
setActionStates((prev) => ({ ...prev, [actionKey]: 'success' }));
|
||||
|
||||
// Reset after 2 seconds
|
||||
setTimeout(() => {
|
||||
setActionStates((prev) => ({ ...prev, [actionKey]: 'idle' }));
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
// Set error state
|
||||
setActionStates((prev) => ({ ...prev, [actionKey]: 'error' }));
|
||||
|
||||
// Increment retry count
|
||||
setRetryCounts((prev) => ({
|
||||
...prev,
|
||||
[actionKey]: (prev[actionKey] || 0) + 1,
|
||||
}));
|
||||
|
||||
// Log error
|
||||
console.error('[NotificationActions] Action failed:', error);
|
||||
}
|
||||
},
|
||||
[actionStates]
|
||||
);
|
||||
|
||||
const getActionButtonContent = (action: NotificationAction, index: number) => {
|
||||
const actionKey = `${index}-${action.label}`;
|
||||
const state = actionStates[actionKey];
|
||||
const retryCount = retryCounts[actionKey] || 0;
|
||||
|
||||
switch (state) {
|
||||
case 'loading':
|
||||
return (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
{formatMessage({ id: 'notifications.actions.loading' }) || 'Loading...'}
|
||||
</>
|
||||
);
|
||||
case 'success':
|
||||
return (
|
||||
<>
|
||||
<Check className="h-3 w-3 mr-1 text-green-500" />
|
||||
{formatMessage({ id: 'notifications.actions.success' }) || 'Done'}
|
||||
</>
|
||||
);
|
||||
case 'error':
|
||||
return (
|
||||
<>
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
{formatMessage({ id: 'notifications.actions.retry' }) || 'Retry'}
|
||||
{retryCount > 0 && (
|
||||
<span className="ml-1 text-[10px] text-muted-foreground">
|
||||
({retryCount})
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return action.label;
|
||||
}
|
||||
};
|
||||
|
||||
if (actions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-secondary/30 border-b border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onMarkAllRead}
|
||||
disabled={!hasUnread}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
{formatMessage({ id: 'notificationPanel.markAllRead' }) || 'Mark Read'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClearAll}
|
||||
className="h-7 text-xs text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3 mr-1" />
|
||||
{formatMessage({ id: 'notificationPanel.clearAll' }) || 'Clear All'}
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{actions.map((action, index) => {
|
||||
const actionKey = `${index}-${action.label}`;
|
||||
const state = actionStates[actionKey];
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={actionKey}
|
||||
variant={action.primary ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleActionClick(action, index)}
|
||||
disabled={
|
||||
action.disabled ||
|
||||
action.loading ||
|
||||
state === 'loading'
|
||||
}
|
||||
className={cn(
|
||||
'h-7 text-xs',
|
||||
state === 'error' && 'text-destructive border-destructive hover:bg-destructive/10',
|
||||
state === 'success' && 'text-green-600 border-green-600 hover:bg-green-50'
|
||||
)}
|
||||
>
|
||||
{getActionButtonContent(action, index)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -155,22 +459,31 @@ function PanelActions({ hasNotifications, hasUnread, onMarkAllRead, onClearAll }
|
||||
interface NotificationItemProps {
|
||||
notification: Toast;
|
||||
onDelete: (id: string) => void;
|
||||
onToggleRead?: (id: string) => void;
|
||||
}
|
||||
|
||||
function NotificationItem({ notification, onDelete }: NotificationItemProps) {
|
||||
function NotificationItem({ notification, onDelete, onToggleRead }: NotificationItemProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const hasDetails = notification.message && notification.message.length > 100;
|
||||
const { formatMessage } = useIntl();
|
||||
const isRead = notification.read ?? false;
|
||||
const hasActions = notification.actions && notification.actions.length > 0;
|
||||
const hasLegacyAction = notification.action && !hasActions;
|
||||
const hasAttachments = notification.attachments && notification.attachments.length > 0;
|
||||
|
||||
// Check if this is an A2UI notification
|
||||
const isA2UI = notification.type === 'a2ui' && notification.a2uiSurface;
|
||||
|
||||
// Format absolute timestamp
|
||||
const absoluteTime = new Date(notification.timestamp).toLocaleString();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'p-3 border-b border-border hover:bg-muted/50 transition-colors',
|
||||
// Read opacity will be handled in T5 when read field is added
|
||||
'opacity-100'
|
||||
'border-l-4',
|
||||
getTypeBorder(notification.type),
|
||||
isRead && 'opacity-70'
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
@@ -179,14 +492,59 @@ function NotificationItem({ notification, onDelete }: NotificationItemProps) {
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Header row: title + actions */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h4 className="text-sm font-medium text-foreground truncate">
|
||||
{notification.title}
|
||||
</h4>
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Title with source badge */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="text-sm font-medium text-foreground truncate">
|
||||
{notification.title}
|
||||
</h4>
|
||||
{/* Source badge */}
|
||||
{notification.source && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'h-5 px-1.5 text-[10px] font-medium border shrink-0',
|
||||
getSourceColor(notification.source)
|
||||
)}
|
||||
>
|
||||
{notification.source}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timestamp row: absolute + relative */}
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{absoluteTime}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/70">
|
||||
({formatTimeAgo(notification.timestamp, formatMessage)})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{formatTimeAgo(notification.timestamp, formatMessage)}
|
||||
</span>
|
||||
{/* Read/unread toggle */}
|
||||
{onToggleRead && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 p-0 hover:bg-muted"
|
||||
onClick={() => onToggleRead(notification.id)}
|
||||
aria-label={isRead
|
||||
? formatMessage({ id: 'notifications.markAsUnread' }) || 'Mark as unread'
|
||||
: formatMessage({ id: 'notifications.markAsRead' }) || 'Mark as read'}
|
||||
title={isRead
|
||||
? formatMessage({ id: 'notifications.markAsUnread' }) || 'Mark as unread'
|
||||
: formatMessage({ id: 'notifications.markAsRead' }) || 'Mark as read'}
|
||||
>
|
||||
{isRead ? <MailOpen className="h-3 w-3" /> : <Mail className="h-3 w-3" />}
|
||||
</Button>
|
||||
)}
|
||||
{/* Delete button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -207,7 +565,7 @@ function NotificationItem({ notification, onDelete }: NotificationItemProps) {
|
||||
<>
|
||||
{/* Regular message content */}
|
||||
{notification.message && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
<p className="text-xs text-muted-foreground mt-1.5 line-clamp-2">
|
||||
{isExpanded || !hasDetails
|
||||
? notification.message
|
||||
: notification.message.slice(0, 100) + '...'}
|
||||
@@ -223,19 +581,36 @@ function NotificationItem({ notification, onDelete }: NotificationItemProps) {
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
{formatMessage({ id: 'notificationPanel.showLess' }) || 'Show less'}
|
||||
{formatMessage({ id: 'notifications.showLess' }) || 'Show less'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
{formatMessage({ id: 'notificationPanel.showMore' }) || 'Show more'}
|
||||
{formatMessage({ id: 'notifications.showMore' }) || 'Show more'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Action button */}
|
||||
{notification.action && (
|
||||
{/* Attachments */}
|
||||
{hasAttachments && notification.attachments && (
|
||||
<div className="mt-2">
|
||||
{notification.attachments.map((attachment, index) => (
|
||||
<NotificationAttachmentItem
|
||||
key={`${attachment.type}-${index}`}
|
||||
attachment={attachment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons (new actions array) */}
|
||||
{hasActions && notification.actions && (
|
||||
<NotificationActions actions={notification.actions} />
|
||||
)}
|
||||
|
||||
{/* Legacy single action button */}
|
||||
{hasLegacyAction && notification.action && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -256,9 +631,10 @@ function NotificationItem({ notification, onDelete }: NotificationItemProps) {
|
||||
interface NotificationListProps {
|
||||
notifications: Toast[];
|
||||
onDelete: (id: string) => void;
|
||||
onToggleRead?: (id: string) => void;
|
||||
}
|
||||
|
||||
function NotificationList({ notifications, onDelete }: NotificationListProps) {
|
||||
function NotificationList({ notifications, onDelete, onToggleRead }: NotificationListProps) {
|
||||
if (notifications.length === 0) return null;
|
||||
|
||||
return (
|
||||
@@ -268,6 +644,7 @@ function NotificationList({ notifications, onDelete }: NotificationListProps) {
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
onDelete={onDelete}
|
||||
onToggleRead={onToggleRead}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -287,11 +664,11 @@ function EmptyState({ message }: EmptyStateProps) {
|
||||
<Bell className="h-16 w-16 mx-auto mb-4 opacity-30" />
|
||||
<p className="text-sm">
|
||||
{message ||
|
||||
formatMessage({ id: 'notificationPanel.empty' }) ||
|
||||
formatMessage({ id: 'notifications.empty' }) ||
|
||||
'No notifications'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'notificationPanel.emptyHint' }) ||
|
||||
{formatMessage({ id: 'notifications.emptyHint' }) ||
|
||||
'Notifications will appear here'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -317,8 +694,11 @@ export function NotificationPanel({ isOpen, onClose }: NotificationPanelProps) {
|
||||
const clearPersistentNotifications = useNotificationStore(
|
||||
(state) => state.clearPersistentNotifications
|
||||
);
|
||||
const toggleNotificationRead = useNotificationStore(
|
||||
(state) => state.toggleNotificationRead
|
||||
);
|
||||
|
||||
// Check if markAllAsRead exists (will be added in T5)
|
||||
// Check if markAllAsRead exists
|
||||
const store = useNotificationStore.getState();
|
||||
const markAllAsRead = 'markAllAsRead' in store ? (store.markAllAsRead as () => void) : undefined;
|
||||
|
||||
@@ -362,6 +742,14 @@ export function NotificationPanel({ isOpen, onClose }: NotificationPanelProps) {
|
||||
clearPersistentNotifications();
|
||||
}, [clearPersistentNotifications]);
|
||||
|
||||
// Toggle read handler
|
||||
const handleToggleRead = useCallback(
|
||||
(id: string) => {
|
||||
toggleNotificationRead(id);
|
||||
},
|
||||
[toggleNotificationRead]
|
||||
);
|
||||
|
||||
// ESC key to close
|
||||
useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
@@ -373,9 +761,8 @@ export function NotificationPanel({ isOpen, onClose }: NotificationPanelProps) {
|
||||
return () => window.removeEventListener('keydown', handleEsc);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Check for unread notifications (will be enhanced in T5 with read field)
|
||||
// For now, all notifications are considered "unread" for UI purposes
|
||||
const hasUnread = sortedNotifications.length > 0;
|
||||
// Check for unread notifications based on read field
|
||||
const hasUnread = sortedNotifications.some((n) => !n.read);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
@@ -403,13 +790,12 @@ export function NotificationPanel({ isOpen, onClose }: NotificationPanelProps) {
|
||||
aria-modal="true"
|
||||
aria-labelledby="notification-panel-title"
|
||||
>
|
||||
{/* Header */}
|
||||
<PanelHeader notificationCount={sortedNotifications.length} onClose={onClose} />
|
||||
|
||||
{/* Action Bar */}
|
||||
<PanelActions
|
||||
{/* Header with integrated actions */}
|
||||
<PanelHeader
|
||||
notificationCount={sortedNotifications.length}
|
||||
hasNotifications={sortedNotifications.length > 0}
|
||||
hasUnread={hasUnread}
|
||||
onClose={onClose}
|
||||
onMarkAllRead={handleMarkAllRead}
|
||||
onClearAll={handleClearAll}
|
||||
/>
|
||||
@@ -419,6 +805,7 @@ export function NotificationPanel({ isOpen, onClose }: NotificationPanelProps) {
|
||||
<NotificationList
|
||||
notifications={sortedNotifications}
|
||||
onDelete={handleDelete}
|
||||
onToggleRead={handleToggleRead}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState />
|
||||
|
||||
@@ -26,6 +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 { useCliStreamStore, type CliOutputLine } from '@/stores/cliStreamStore';
|
||||
import { useNotificationStore, selectWsLastMessage } from '@/stores';
|
||||
import { useActiveCliExecutions, useInvalidateActiveCliExecutions } from '@/hooks/useActiveCliExecutions';
|
||||
@@ -126,6 +127,7 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [isUserScrolling, setIsUserScrolling] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<'list' | 'blocks'>('list');
|
||||
|
||||
// Store state
|
||||
const executions = useCliStreamStore((state) => state.executions);
|
||||
@@ -416,6 +418,17 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* View Mode Toggle */}
|
||||
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'list' | 'blocks')}>
|
||||
<TabsList className="h-7 bg-secondary/50">
|
||||
<TabsTrigger value="list" className="h-6 px-2 text-xs">
|
||||
List
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="blocks" className="h-6 px-2 text-xs">
|
||||
Blocks
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{currentExecution && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
@@ -443,40 +456,48 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output Content */}
|
||||
{/* Output Content - Based on viewMode */}
|
||||
{currentExecution ? (
|
||||
<div
|
||||
ref={logsContainerRef}
|
||||
className="flex-1 overflow-y-auto p-3 font-mono text-xs bg-background"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{filteredOutput.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
{searchQuery ? 'No matching output found' : 'Waiting for output...'}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{viewMode === 'blocks' ? (
|
||||
<div className="h-full overflow-y-auto bg-background">
|
||||
<LogBlockList executionId={currentExecutionId} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{filteredOutput.map((line, index) => (
|
||||
<div key={index} className={cn('flex gap-2', getOutputLineClass(line.type))}>
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{getOutputLineIcon(line.type)}
|
||||
</span>
|
||||
<span className="break-all">{line.content}</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
)}
|
||||
{isUserScrolling && filteredOutput.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="absolute bottom-4 right-4"
|
||||
onClick={scrollToBottom}
|
||||
<div
|
||||
ref={logsContainerRef}
|
||||
className="h-full overflow-y-auto p-3 font-mono text-xs bg-background"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<ArrowDownToLine className="h-4 w-4 mr-1" />
|
||||
Scroll to bottom
|
||||
</Button>
|
||||
{filteredOutput.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
{searchQuery ? 'No matching output found' : 'Waiting for output...'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{filteredOutput.map((line, index) => (
|
||||
<div key={index} className={cn('flex gap-2', getOutputLineClass(line.type))}>
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{getOutputLineIcon(line.type)}
|
||||
</span>
|
||||
<span className="break-all">{line.content}</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
)}
|
||||
{isUserScrolling && filteredOutput.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="absolute bottom-4 right-4"
|
||||
onClick={scrollToBottom}
|
||||
>
|
||||
<ArrowDownToLine className="h-4 w-4 mr-1" />
|
||||
Scroll to bottom
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -183,16 +183,24 @@ export function Flowchart({ flowControl, className = '' }: FlowchartProps) {
|
||||
|
||||
implSteps.forEach((step, idx) => {
|
||||
const nodeId = `impl-${idx}`;
|
||||
|
||||
// Handle both string and ImplementationStep types
|
||||
const isString = typeof step === 'string';
|
||||
const label = isString ? step : (step.title || `Step ${step.step}`);
|
||||
const description = isString ? undefined : step.description;
|
||||
const stepNumber = isString ? (idx + 1) : step.step;
|
||||
const dependsOn = isString ? undefined : step.depends_on?.map((d: number | string) => `impl-${Number(d) - 1}`);
|
||||
|
||||
initialNodes.push({
|
||||
id: nodeId,
|
||||
type: 'custom',
|
||||
position: { x: 0, y: currentY },
|
||||
data: {
|
||||
label: step.title || `Step ${step.step}`,
|
||||
description: step.description,
|
||||
step: step.step,
|
||||
label,
|
||||
description,
|
||||
step: stepNumber,
|
||||
type: 'implementation' as const,
|
||||
dependsOn: step.depends_on?.map(d => `impl-${d - 1}`),
|
||||
dependsOn,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -217,9 +225,9 @@ export function Flowchart({ flowControl, className = '' }: FlowchartProps) {
|
||||
}
|
||||
|
||||
// Dependency edges
|
||||
if (step.depends_on && step.depends_on.length > 0) {
|
||||
step.depends_on.forEach(depIdx => {
|
||||
const depNodeId = `impl-${depIdx - 1}`;
|
||||
if (!isString && step.depends_on && step.depends_on.length > 0) {
|
||||
step.depends_on.forEach((depIdx: number | string) => {
|
||||
const depNodeId = `impl-${Number(depIdx) - 1}`;
|
||||
initialEdges.push({
|
||||
id: `dep-${depIdx}-${idx}`,
|
||||
source: depNodeId,
|
||||
@@ -285,16 +293,16 @@ export function Flowchart({ flowControl, className = '' }: FlowchartProps) {
|
||||
zoomOnScroll={true}
|
||||
panOnScroll={true}
|
||||
>
|
||||
<Background />
|
||||
<Controls />
|
||||
<Background color="var(--color-border, #e0e0e0)" style={{ backgroundColor: 'var(--color-background, white)' }} />
|
||||
<Controls className="bg-card border border-border rounded shadow-sm" />
|
||||
<MiniMap
|
||||
nodeColor={(node) => {
|
||||
const data = node.data as FlowchartNodeData;
|
||||
if (data.type === 'section') return '#e5e7eb';
|
||||
if (data.type === 'section') return '#9ca3af';
|
||||
if (data.type === 'pre-analysis') return '#f59e0b';
|
||||
return '#3b82f6';
|
||||
}}
|
||||
className="!bg-background !border-border"
|
||||
className="!bg-card !border-border !rounded !shadow-sm"
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
260
ccw/frontend/src/components/shared/LogBlock/LogBlock.tsx
Normal file
260
ccw/frontend/src/components/shared/LogBlock/LogBlock.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
// ========================================
|
||||
// LogBlock Component
|
||||
// ========================================
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
RotateCcw,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Clock,
|
||||
Brain,
|
||||
Settings,
|
||||
Info,
|
||||
MessageCircle,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import type { LogBlockProps, LogLine } from './types';
|
||||
|
||||
// Re-use output line styling helpers from CliStreamMonitor
|
||||
function getOutputLineIcon(type: LogLine['type']) {
|
||||
switch (type) {
|
||||
case 'thought':
|
||||
return <Brain className="h-3 w-3" />;
|
||||
case 'system':
|
||||
return <Settings className="h-3 w-3" />;
|
||||
case 'stderr':
|
||||
return <AlertCircle className="h-3 w-3" />;
|
||||
case 'metadata':
|
||||
return <Info className="h-3 w-3" />;
|
||||
case 'tool_call':
|
||||
return <Wrench className="h-3 w-3" />;
|
||||
case 'stdout':
|
||||
default:
|
||||
return <MessageCircle className="h-3 w-3" />;
|
||||
}
|
||||
}
|
||||
|
||||
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':
|
||||
return 'border-l-4 border-l-blue-500';
|
||||
case 'completed':
|
||||
return 'border-l-4 border-l-green-500';
|
||||
case 'error':
|
||||
return 'border-l-4 border-l-red-500';
|
||||
case 'pending':
|
||||
return 'border-l-4 border-l-yellow-500';
|
||||
default:
|
||||
return 'border-l-4 border-l-border';
|
||||
}
|
||||
}
|
||||
|
||||
function getBlockTypeColor(type: LogBlockProps['block']['type']): string {
|
||||
switch (type) {
|
||||
case 'command':
|
||||
return 'text-blue-400';
|
||||
case 'tool':
|
||||
return 'text-green-400';
|
||||
case 'output':
|
||||
return 'text-foreground';
|
||||
case 'error':
|
||||
return 'text-red-400';
|
||||
case 'warning':
|
||||
return 'text-yellow-400';
|
||||
case 'info':
|
||||
return 'text-cyan-400';
|
||||
default:
|
||||
return 'text-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusBadgeVariant(status: LogBlockProps['block']['status']): 'default' | 'secondary' | 'destructive' | 'success' | 'warning' | 'info' | 'outline' {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'info';
|
||||
case 'completed':
|
||||
return 'success';
|
||||
case 'error':
|
||||
return 'destructive';
|
||||
case 'pending':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusIcon(status: LogBlockProps['block']['status']) {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return <Loader2 className="h-3 w-3 animate-spin" />;
|
||||
case 'completed':
|
||||
return <CheckCircle className="h-3 w-3" />;
|
||||
case 'error':
|
||||
return <AlertCircle className="h-3 w-3" />;
|
||||
case 'pending':
|
||||
return <Clock className="h-3 w-3" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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 const LogBlock = memo(function LogBlock({
|
||||
block,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
onCopyCommand,
|
||||
onCopyOutput,
|
||||
onReRun,
|
||||
className,
|
||||
}: LogBlockProps) {
|
||||
return (
|
||||
<div className={cn('border border-border rounded-lg overflow-hidden', getBlockBorderClass(block.status), className)}>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 bg-card cursor-pointer hover:bg-accent/50 transition-colors',
|
||||
'group'
|
||||
)}
|
||||
onClick={onToggleExpand}
|
||||
>
|
||||
{/* Expand/Collapse Icon */}
|
||||
<div className="shrink-0">
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Icon */}
|
||||
<div className="shrink-0 text-muted-foreground">
|
||||
{getStatusIcon(block.status)}
|
||||
</div>
|
||||
|
||||
{/* Title with type-specific color */}
|
||||
<div className={cn('font-medium text-sm truncate', getBlockTypeColor(block.type))}>
|
||||
{block.title}
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground flex-1 min-w-0">
|
||||
{block.toolName && (
|
||||
<span className="truncate">{block.toolName}</span>
|
||||
)}
|
||||
<span className="shrink-0">{block.lineCount} lines</span>
|
||||
{block.duration !== undefined && (
|
||||
<span className="shrink-0">{formatDuration(block.duration)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<Badge variant={getStatusBadgeVariant(block.status)} className="shrink-0">
|
||||
{block.status}
|
||||
</Badge>
|
||||
|
||||
{/* Action Buttons (visible on hover) */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity',
|
||||
'shrink-0'
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={onCopyCommand}
|
||||
title="Copy command"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={onCopyOutput}
|
||||
title="Copy output"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={onReRun}
|
||||
title="Re-run"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expandable Content */}
|
||||
{isExpanded && (
|
||||
<div className="px-3 py-2 bg-background border-t border-border">
|
||||
<div className="font-mono text-xs space-y-1 max-h-96 overflow-y-auto">
|
||||
{block.lines.map((line, index) => (
|
||||
<div key={index} className={cn('flex gap-2', getOutputLineClass(line.type))}>
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{getOutputLineIcon(line.type)}
|
||||
</span>
|
||||
<span className="break-all">{line.content}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
// Custom comparison for performance
|
||||
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
|
||||
);
|
||||
});
|
||||
|
||||
export default LogBlock;
|
||||
331
ccw/frontend/src/components/shared/LogBlock/LogBlockList.tsx
Normal file
331
ccw/frontend/src/components/shared/LogBlock/LogBlockList.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
// ========================================
|
||||
// LogBlockList Component
|
||||
// ========================================
|
||||
// Container component for displaying grouped CLI output blocks
|
||||
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { useCliStreamStore } 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
|
||||
*/
|
||||
export interface LogBlockListProps {
|
||||
/** Execution ID to display logs for */
|
||||
executionId: string | null;
|
||||
/** Optional CSS class name */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* LogBlockList component
|
||||
* Displays CLI output grouped into collapsible blocks
|
||||
*/
|
||||
export function LogBlockList({ executionId, className }: LogBlockListProps) {
|
||||
// Get execution data from store
|
||||
const executions = useCliStreamStore((state) => state.executions);
|
||||
|
||||
// Get current execution or execution by ID
|
||||
const currentExecution = useMemo(() => {
|
||||
if (!executionId) return null;
|
||||
return executions[executionId] || null;
|
||||
}, [executions, executionId]);
|
||||
|
||||
// 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) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(blockId)) {
|
||||
next.delete(blockId);
|
||||
} else {
|
||||
next.add(blockId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Copy command to clipboard
|
||||
const copyCommand = useCallback((block: LogBlockData) => {
|
||||
const command = block.lines.find((l) => l.type === 'tool_call')?.content || '';
|
||||
navigator.clipboard.writeText(command).catch((err) => {
|
||||
console.error('Failed to copy command:', err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Copy output to clipboard
|
||||
const copyOutput = useCallback((block: LogBlockData) => {
|
||||
const output = block.lines.map((l) => l.content).join('\n');
|
||||
navigator.clipboard.writeText(output).catch((err) => {
|
||||
console.error('Failed to copy output:', err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Re-run block (placeholder for future implementation)
|
||||
const reRun = useCallback((block: LogBlockData) => {
|
||||
console.log('Re-run block:', block.id);
|
||||
// TODO: Implement re-run functionality
|
||||
}, []);
|
||||
|
||||
// Empty states
|
||||
if (!executionId) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
No execution selected
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentExecution) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
Execution not found
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (blocks.length === 0) {
|
||||
const isRunning = currentExecution.status === 'running';
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
{isRunning ? 'Waiting for output...' : 'No output available'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="space-y-2 p-3">
|
||||
{blocks.map((block) => (
|
||||
<LogBlock
|
||||
key={block.id}
|
||||
block={block}
|
||||
isExpanded={expandedBlocks.has(block.id)}
|
||||
onToggleExpand={() => toggleBlockExpand(block.id)}
|
||||
onCopyCommand={() => copyCommand(block)}
|
||||
onCopyOutput={() => copyOutput(block)}
|
||||
onReRun={() => reRun(block)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LogBlockList;
|
||||
7
ccw/frontend/src/components/shared/LogBlock/index.ts
Normal file
7
ccw/frontend/src/components/shared/LogBlock/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// ========================================
|
||||
// LogBlock Component Exports
|
||||
// ========================================
|
||||
|
||||
export { LogBlock, default } from './LogBlock';
|
||||
export { LogBlockList, type LogBlockListProps } from './LogBlockList';
|
||||
export type { LogBlockProps, LogBlockData, LogLine } from './types';
|
||||
31
ccw/frontend/src/components/shared/LogBlock/types.ts
Normal file
31
ccw/frontend/src/components/shared/LogBlock/types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// ========================================
|
||||
// LogBlock Types
|
||||
// ========================================
|
||||
|
||||
export interface LogBlockProps {
|
||||
block: LogBlockData;
|
||||
isExpanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onCopyCommand: () => void;
|
||||
onCopyOutput: () => void;
|
||||
onReRun: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface LogBlockData {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'command' | 'tool' | 'output' | 'error' | 'warning' | 'info';
|
||||
status: 'running' | 'completed' | 'error' | 'pending';
|
||||
toolName?: string;
|
||||
lineCount: number;
|
||||
duration?: number;
|
||||
lines: LogLine[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface LogLine {
|
||||
type: 'stdout' | 'stderr' | 'thought' | 'system' | 'metadata' | 'tool_call';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { StatCard } from '@/components/shared/StatCard';
|
||||
import { StatCard, StatCardSkeleton } from '@/components/shared/StatCard';
|
||||
import { MessageSquare, FileType, Hash } from 'lucide-react';
|
||||
|
||||
export interface PromptStatsProps {
|
||||
|
||||
@@ -211,7 +211,7 @@ export function TaskDrawer({ task, isOpen, onClose }: TaskDrawerProps) {
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{flowControl.pre_analysis.map((step, index) => (
|
||||
<div key={index} className="p-3 bg-secondary rounded-md">
|
||||
<div key={index} className="p-3 bg-card rounded-md border border-border shadow-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-medium">
|
||||
{index + 1}
|
||||
@@ -221,7 +221,7 @@ export function TaskDrawer({ task, isOpen, onClose }: TaskDrawerProps) {
|
||||
<p className="text-xs text-muted-foreground mt-1">{step.action}</p>
|
||||
{step.commands && step.commands.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<code className="text-xs bg-background px-2 py-1 rounded border">
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded border">
|
||||
{step.commands.join('; ')}
|
||||
</code>
|
||||
</div>
|
||||
@@ -241,40 +241,7 @@ export function TaskDrawer({ task, isOpen, onClose }: TaskDrawerProps) {
|
||||
{formatMessage({ id: 'sessionDetail.taskDrawer.overview.implementationSteps' })}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{flowControl.implementation_approach.map((step, index) => (
|
||||
<div key={index} className="p-3 bg-secondary rounded-md">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-accent text-accent-foreground text-xs font-medium">
|
||||
{step.step || index + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
{step.title && (
|
||||
<p className="text-sm font-medium text-foreground">{step.title}</p>
|
||||
)}
|
||||
{step.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{step.description}</p>
|
||||
)}
|
||||
{step.modification_points && step.modification_points.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||
{formatMessage({ id: 'sessionDetail.taskDrawer.overview.modificationPoints' })}:
|
||||
</p>
|
||||
<ul className="text-xs space-y-1">
|
||||
{step.modification_points.map((point, i) => (
|
||||
<li key={i} className="text-muted-foreground">• {point}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{step.depends_on && step.depends_on.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{formatMessage({ id: 'sessionDetail.taskDrawer.overview.dependsOn' })}: Step {step.depends_on.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -296,25 +263,21 @@ export function TaskDrawer({ task, isOpen, onClose }: TaskDrawerProps) {
|
||||
{/* Flowchart Tab */}
|
||||
{hasFlowchart && (
|
||||
<TabsContent value="flowchart" className="mt-4 pb-6">
|
||||
<div className="bg-secondary rounded-lg p-4 border border-border">
|
||||
<Flowchart flowControl={flowControl!} />
|
||||
</div>
|
||||
<Flowchart flowControl={flowControl!} className="min-h-[400px]" />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Files Tab */}
|
||||
<TabsContent value="files" className="mt-4 pb-6">
|
||||
{hasFiles ? (
|
||||
<div className="space-y-2">
|
||||
{flowControl!.target_files!.map((file, index) => (
|
||||
<div className="space-y-3">
|
||||
{flowControl?.target_files?.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2 p-3 bg-secondary rounded-md border border-border hover:bg-secondary/80 transition-colors"
|
||||
className="flex items-center gap-2 p-3 bg-card rounded-md border border-border shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<Folder className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<code className="text-xs text-foreground flex-1 min-w-0 truncate">
|
||||
{file}
|
||||
</code>
|
||||
<Folder className="h-4 w-4 text-primary flex-shrink-0" />
|
||||
<span className="text-sm font-mono text-foreground">{file.path || file.name || 'Unknown'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
144
ccw/frontend/src/components/ui/AlertDialog.tsx
Normal file
144
ccw/frontend/src/components/ui/AlertDialog.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
// ========================================
|
||||
// AlertDialog Component
|
||||
// ========================================
|
||||
// Dialog component for confirmations and critical actions
|
||||
|
||||
import * as React from "react";
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader";
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter";
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-secondary text-secondary-foreground hover:bg-secondary/80 h-10 px-4 py-2 mt-2 sm:mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
@@ -112,11 +112,31 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle open browse dialog
|
||||
* Handle open browse dialog - tries file dialog first, falls back to manual input
|
||||
*/
|
||||
const handleBrowseFolder = useCallback(() => {
|
||||
setIsBrowseOpen(true);
|
||||
const handleBrowseFolder = useCallback(async () => {
|
||||
setIsDropdownOpen(false);
|
||||
|
||||
// Try to use Electron/Electron-Tauri file dialog API if available
|
||||
if ((window as any).electronAPI?.showOpenDialog) {
|
||||
try {
|
||||
const result = await (window as any).electronAPI.showOpenDialog({
|
||||
properties: ['openDirectory'],
|
||||
});
|
||||
|
||||
if (result && result.filePaths && result.filePaths.length > 0) {
|
||||
const selectedPath = result.filePaths[0];
|
||||
await switchWorkspace(selectedPath);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to open folder dialog:', error);
|
||||
// Fall through to manual input dialog
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: open manual path input dialog
|
||||
setIsBrowseOpen(true);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user