mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
feat: Add CodexLens Manager Page with tabbed interface for managing CodexLens features
feat: Implement ConflictTab component to display conflict resolution decisions in session detail feat: Create ImplPlanTab component to show implementation plan with modal viewer in session detail feat: Develop ReviewTab component to display review findings by dimension in session detail test: Add end-to-end tests for CodexLens Manager functionality including navigation, tab switching, and settings validation
This commit is contained in:
364
ccw/frontend/src/pages/CodexLensManagerPage.test.tsx
Normal file
364
ccw/frontend/src/pages/CodexLensManagerPage.test.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
// ========================================
|
||||
// CodexLens Manager Page Tests
|
||||
// ========================================
|
||||
// Integration tests for CodexLens manager page with tabs
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@/test/i18n';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { CodexLensManagerPage } from './CodexLensManagerPage';
|
||||
import * as api from '@/lib/api';
|
||||
|
||||
// Mock api module
|
||||
vi.mock('@/lib/api', () => ({
|
||||
fetchCodexLensDashboardInit: vi.fn(),
|
||||
bootstrapCodexLens: vi.fn(),
|
||||
uninstallCodexLens: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock hooks
|
||||
vi.mock('@/hooks/useCodexLens', () => ({
|
||||
useCodexLensDashboard: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useCodexLens', () => ({
|
||||
useCodexLensDashboard: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useNotifications', () => ({
|
||||
useNotifications: vi.fn(() => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
toasts: [],
|
||||
wsStatus: 'disconnected' as const,
|
||||
wsLastMessage: null,
|
||||
isWsConnected: false,
|
||||
addToast: vi.fn(),
|
||||
removeToast: vi.fn(),
|
||||
clearAllToasts: vi.fn(),
|
||||
connectWebSocket: vi.fn(),
|
||||
disconnectWebSocket: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock the mutations hook separately
|
||||
vi.mock('@/hooks/useCodexLens', async () => {
|
||||
return {
|
||||
useCodexLensDashboard: (await import('@/hooks/useCodexLens')).useCodexLensDashboard,
|
||||
useCodexLensMutations: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock window.confirm
|
||||
global.confirm = vi.fn(() => true);
|
||||
|
||||
const mockDashboardData = {
|
||||
installed: true,
|
||||
status: {
|
||||
ready: true,
|
||||
installed: true,
|
||||
version: '1.0.0',
|
||||
pythonVersion: '3.11.0',
|
||||
venvPath: '/path/to/venv',
|
||||
},
|
||||
config: {
|
||||
index_dir: '~/.codexlens/indexes',
|
||||
index_count: 100,
|
||||
api_max_workers: 4,
|
||||
api_batch_size: 8,
|
||||
},
|
||||
semantic: { available: true },
|
||||
};
|
||||
|
||||
const mockMutations = {
|
||||
bootstrap: vi.fn().mockResolvedValue({ success: true }),
|
||||
uninstall: vi.fn().mockResolvedValue({ success: true }),
|
||||
isBootstrapping: false,
|
||||
isUninstalling: false,
|
||||
};
|
||||
|
||||
import { useCodexLensDashboard, useCodexLensMutations } from '@/hooks';
|
||||
|
||||
describe('CodexLensManagerPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.confirm as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe('when installed', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useCodexLensDashboard).mockReturnValue({
|
||||
installed: true,
|
||||
status: mockDashboardData.status,
|
||||
config: mockDashboardData.config,
|
||||
semantic: mockDashboardData.semantic,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
|
||||
});
|
||||
|
||||
it('should render page title and description', () => {
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
expect(screen.getByText(/CodexLens/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Semantic code search engine/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all tabs', () => {
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
expect(screen.getByText(/Overview/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Settings/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Models/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Advanced/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show uninstall button when installed', () => {
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
expect(screen.getByText(/Uninstall/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should switch between tabs', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
const settingsTab = screen.getByText(/Settings/i);
|
||||
await user.click(settingsTab);
|
||||
|
||||
expect(settingsTab).toHaveAttribute('data-state', 'active');
|
||||
});
|
||||
|
||||
it('should call refresh on button click', async () => {
|
||||
const refetch = vi.fn();
|
||||
vi.mocked(useCodexLensDashboard).mockReturnValue({
|
||||
installed: true,
|
||||
status: mockDashboardData.status,
|
||||
config: mockDashboardData.config,
|
||||
semantic: mockDashboardData.semantic,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
refetch,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
const refreshButton = screen.getByText(/Refresh/i);
|
||||
await user.click(refreshButton);
|
||||
|
||||
expect(refetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when not installed', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useCodexLensDashboard).mockReturnValue({
|
||||
installed: false,
|
||||
status: undefined,
|
||||
config: undefined,
|
||||
semantic: undefined,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
|
||||
});
|
||||
|
||||
it('should show bootstrap button', () => {
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
expect(screen.getByText(/Bootstrap/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show not installed alert', () => {
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
expect(screen.getByText(/CodexLens is not installed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call bootstrap on button click', async () => {
|
||||
const bootstrap = vi.fn().mockResolvedValue({ success: true });
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue({
|
||||
...mockMutations,
|
||||
bootstrap,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
const bootstrapButton = screen.getByText(/Bootstrap/i);
|
||||
await user.click(bootstrapButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(bootstrap).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('uninstall flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useCodexLensDashboard).mockReturnValue({
|
||||
installed: true,
|
||||
status: mockDashboardData.status,
|
||||
config: mockDashboardData.config,
|
||||
semantic: mockDashboardData.semantic,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should show confirmation dialog on uninstall', async () => {
|
||||
const uninstall = vi.fn().mockResolvedValue({ success: true });
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue({
|
||||
...mockMutations,
|
||||
uninstall,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
const uninstallButton = screen.getByText(/Uninstall/i);
|
||||
await user.click(uninstallButton);
|
||||
|
||||
expect(global.confirm).toHaveBeenCalledWith(expect.stringContaining('uninstall'));
|
||||
});
|
||||
|
||||
it('should call uninstall when confirmed', async () => {
|
||||
const uninstall = vi.fn().mockResolvedValue({ success: true });
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue({
|
||||
...mockMutations,
|
||||
uninstall,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
const uninstallButton = screen.getByText(/Uninstall/i);
|
||||
await user.click(uninstallButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(uninstall).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not call uninstall when cancelled', async () => {
|
||||
(global.confirm as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||||
const uninstall = vi.fn().mockResolvedValue({ success: true });
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue({
|
||||
...mockMutations,
|
||||
uninstall,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
const uninstallButton = screen.getByText(/Uninstall/i);
|
||||
await user.click(uninstallButton);
|
||||
|
||||
expect(uninstall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading states', () => {
|
||||
it('should show loading skeleton when loading', () => {
|
||||
vi.mocked(useCodexLensDashboard).mockReturnValue({
|
||||
installed: false,
|
||||
status: undefined,
|
||||
config: undefined,
|
||||
semantic: undefined,
|
||||
isLoading: true,
|
||||
isFetching: true,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
|
||||
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
// Check for skeleton or loading indicator
|
||||
const refreshButton = screen.getByText(/Refresh/i);
|
||||
expect(refreshButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should disable refresh button when fetching', () => {
|
||||
vi.mocked(useCodexLensDashboard).mockReturnValue({
|
||||
installed: true,
|
||||
status: mockDashboardData.status,
|
||||
config: mockDashboardData.config,
|
||||
semantic: mockDashboardData.semantic,
|
||||
isLoading: false,
|
||||
isFetching: true,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
|
||||
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
const refreshButton = screen.getByText(/Refresh/i);
|
||||
expect(refreshButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('i18n - Chinese locale', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useCodexLensDashboard).mockReturnValue({
|
||||
installed: true,
|
||||
status: mockDashboardData.status,
|
||||
config: mockDashboardData.config,
|
||||
semantic: mockDashboardData.semantic,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
|
||||
});
|
||||
|
||||
it('should display translated text in Chinese', () => {
|
||||
render(<CodexLensManagerPage />, { locale: 'zh' });
|
||||
|
||||
expect(screen.getByText(/CodexLens/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/语义代码搜索引擎/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/概览/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/设置/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/模型/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/高级/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display translated uninstall button', () => {
|
||||
render(<CodexLensManagerPage />, { locale: 'zh' });
|
||||
|
||||
expect(screen.getByText(/卸载/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error states', () => {
|
||||
it('should handle API errors gracefully', () => {
|
||||
vi.mocked(useCodexLensDashboard).mockReturnValue({
|
||||
installed: false,
|
||||
status: undefined,
|
||||
config: undefined,
|
||||
semantic: undefined,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: new Error('API Error'),
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
|
||||
|
||||
render(<CodexLensManagerPage />);
|
||||
|
||||
// Page should still render even with error
|
||||
expect(screen.getByText(/CodexLens/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
205
ccw/frontend/src/pages/CodexLensManagerPage.tsx
Normal file
205
ccw/frontend/src/pages/CodexLensManagerPage.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
// ========================================
|
||||
// CodexLens Manager Page
|
||||
// ========================================
|
||||
// Manage CodexLens semantic code search with tabbed interface
|
||||
// Supports Overview, Settings, Models, and Advanced tabs
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Sparkles,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Trash2,
|
||||
} 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 {
|
||||
AlertDialog,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
} from '@/components/ui/AlertDialog';
|
||||
import { OverviewTab } from '@/components/codexlens/OverviewTab';
|
||||
import { SettingsTab } from '@/components/codexlens/SettingsTab';
|
||||
import { AdvancedTab } from '@/components/codexlens/AdvancedTab';
|
||||
import { GpuSelector } from '@/components/codexlens/GpuSelector';
|
||||
import { ModelsTab } from '@/components/codexlens/ModelsTab';
|
||||
import { useCodexLensDashboard, useCodexLensMutations } from '@/hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function CodexLensManagerPage() {
|
||||
const { formatMessage } = useIntl();
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [isUninstallDialogOpen, setIsUninstallDialogOpen] = useState(false);
|
||||
|
||||
const {
|
||||
installed,
|
||||
status,
|
||||
config,
|
||||
isLoading,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useCodexLensDashboard();
|
||||
|
||||
const {
|
||||
bootstrap,
|
||||
isBootstrapping,
|
||||
uninstall,
|
||||
isUninstalling,
|
||||
} = useCodexLensMutations();
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleBootstrap = async () => {
|
||||
const result = await bootstrap();
|
||||
if (result.success) {
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUninstall = async () => {
|
||||
const result = await uninstall();
|
||||
if (result.success) {
|
||||
refetch();
|
||||
}
|
||||
setIsUninstallDialogOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||
<Sparkles className="w-6 h-6 text-primary" />
|
||||
{formatMessage({ id: 'codexlens.title' })}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'codexlens.description' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRefresh}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
|
||||
{formatMessage({ id: 'common.actions.refresh' })}
|
||||
</Button>
|
||||
{!installed ? (
|
||||
<Button
|
||||
onClick={handleBootstrap}
|
||||
disabled={isBootstrapping}
|
||||
>
|
||||
<Download className={cn('w-4 h-4 mr-2', isBootstrapping && 'animate-spin')} />
|
||||
{isBootstrapping
|
||||
? formatMessage({ id: 'codexlens.bootstrapping' })
|
||||
: formatMessage({ id: 'codexlens.bootstrap' })
|
||||
}
|
||||
</Button>
|
||||
) : (
|
||||
<AlertDialog open={isUninstallDialogOpen} onOpenChange={setIsUninstallDialogOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={isUninstalling}
|
||||
>
|
||||
<Trash2 className={cn('w-4 h-4 mr-2', isUninstalling && 'animate-spin')} />
|
||||
{isUninstalling
|
||||
? formatMessage({ id: 'codexlens.uninstalling' })
|
||||
: formatMessage({ id: 'codexlens.uninstall' })
|
||||
}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{formatMessage({ id: 'codexlens.confirmUninstallTitle' })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{formatMessage({ id: 'codexlens.confirmUninstall' })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isUninstalling}>
|
||||
{formatMessage({ id: 'common.actions.cancel' })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleUninstall}
|
||||
disabled={isUninstalling}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isUninstalling
|
||||
? formatMessage({ id: 'codexlens.uninstalling' })
|
||||
: formatMessage({ id: 'common.actions.confirm' })
|
||||
}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Installation Status Alert */}
|
||||
{!installed && !isLoading && (
|
||||
<Card className="p-4 bg-warning/10 border-warning/20">
|
||||
<p className="text-sm text-warning-foreground">
|
||||
{formatMessage({ id: 'codexlens.notInstalled' })}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tabbed Interface */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">
|
||||
{formatMessage({ id: 'codexlens.tabs.overview' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings">
|
||||
{formatMessage({ id: 'codexlens.tabs.settings' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="models">
|
||||
{formatMessage({ id: 'codexlens.tabs.models' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="advanced">
|
||||
{formatMessage({ id: 'codexlens.tabs.advanced' })}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<OverviewTab
|
||||
installed={installed}
|
||||
status={status}
|
||||
config={config}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings">
|
||||
<SettingsTab enabled={installed} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="models">
|
||||
<ModelsTab installed={installed} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advanced">
|
||||
<AdvancedTab enabled={installed} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CodexLensManagerPage;
|
||||
@@ -25,6 +25,8 @@ export function DiscoveryPage() {
|
||||
setFilters,
|
||||
selectSession,
|
||||
exportFindings,
|
||||
exportSelectedFindings,
|
||||
isExporting,
|
||||
} = useIssueDiscovery({ refetchInterval: 3000 });
|
||||
|
||||
if (error) {
|
||||
@@ -163,6 +165,8 @@ export function DiscoveryPage() {
|
||||
filters={filters}
|
||||
onFilterChange={setFilters}
|
||||
onExport={exportFindings}
|
||||
onExportSelected={exportSelectedFindings}
|
||||
isExporting={isExporting}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,28 +3,219 @@
|
||||
// ========================================
|
||||
// Unified page for issues, queue, and discovery with tab navigation
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Github,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { IssueHubHeader } from '@/components/issue/hub/IssueHubHeader';
|
||||
import { IssueHubTabs, type IssueTab } from '@/components/issue/hub/IssueHubTabs';
|
||||
import { IssuesPanel } from '@/components/issue/hub/IssuesPanel';
|
||||
import { QueuePanel } from '@/components/issue/hub/QueuePanel';
|
||||
import { DiscoveryPanel } from '@/components/issue/hub/DiscoveryPanel';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select';
|
||||
import { useIssues, useIssueMutations, useIssueQueue } from '@/hooks';
|
||||
import { pullIssuesFromGitHub } from '@/lib/api';
|
||||
import type { Issue } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function NewIssueDialog({ open, onOpenChange, onSubmit, isCreating }: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (data: { title: string; context?: string; priority?: Issue['priority'] }) => void;
|
||||
isCreating: boolean;
|
||||
}) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [title, setTitle] = useState('');
|
||||
const [context, setContext] = useState('');
|
||||
const [priority, setPriority] = useState<Issue['priority']>('medium');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (title.trim()) {
|
||||
onSubmit({ title: title.trim(), context: context.trim() || undefined, priority });
|
||||
setTitle('');
|
||||
setContext('');
|
||||
setPriority('medium');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{formatMessage({ id: 'issues.createDialog.title' })}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'issues.createDialog.labels.title' })}</label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'issues.createDialog.placeholders.title' })}
|
||||
className="mt-1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'issues.createDialog.labels.context' })}</label>
|
||||
<textarea
|
||||
value={context}
|
||||
onChange={(e) => setContext(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'issues.createDialog.placeholders.context' })}
|
||||
className="mt-1 w-full min-h-[100px] p-3 bg-background border border-input rounded-md text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'issues.createDialog.labels.priority' })}</label>
|
||||
<Select value={priority} onValueChange={(v) => setPriority(v as Issue['priority'])}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">{formatMessage({ id: 'issues.priority.low' })}</SelectItem>
|
||||
<SelectItem value="medium">{formatMessage({ id: 'issues.priority.medium' })}</SelectItem>
|
||||
<SelectItem value="high">{formatMessage({ id: 'issues.priority.high' })}</SelectItem>
|
||||
<SelectItem value="critical">{formatMessage({ id: 'issues.priority.critical' })}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{formatMessage({ id: 'issues.createDialog.buttons.cancel' })}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isCreating || !title.trim()}>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{formatMessage({ id: 'issues.createDialog.buttons.creating' })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.createDialog.buttons.create' })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssueHubPage() {
|
||||
const { formatMessage } = useIntl();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const currentTab = (searchParams.get('tab') as IssueTab) || 'issues';
|
||||
const [isNewIssueOpen, setIsNewIssueOpen] = useState(false);
|
||||
const [isGithubSyncing, setIsGithubSyncing] = useState(false);
|
||||
|
||||
// Issues data
|
||||
const { refetch: refetchIssues, isFetching: isFetchingIssues } = useIssues();
|
||||
// Queue data
|
||||
const { refetch: refetchQueue, isFetching: isFetchingQueue } = useIssueQueue();
|
||||
|
||||
const { createIssue, isCreating } = useIssueMutations();
|
||||
|
||||
const setCurrentTab = (tab: IssueTab) => {
|
||||
setSearchParams({ tab });
|
||||
};
|
||||
|
||||
// Issues tab handlers
|
||||
const handleIssuesRefresh = useCallback(() => {
|
||||
refetchIssues();
|
||||
}, [refetchIssues]);
|
||||
|
||||
const handleGithubSync = useCallback(async () => {
|
||||
setIsGithubSyncing(true);
|
||||
try {
|
||||
const result = await pullIssuesFromGitHub({ state: 'open', limit: 100 });
|
||||
console.log('GitHub sync result:', result);
|
||||
await refetchIssues();
|
||||
} catch (error) {
|
||||
console.error('GitHub sync failed:', error);
|
||||
} finally {
|
||||
setIsGithubSyncing(false);
|
||||
}
|
||||
}, [refetchIssues]);
|
||||
|
||||
const handleCreateIssue = async (data: { title: string; context?: string; priority?: Issue['priority'] }) => {
|
||||
await createIssue(data);
|
||||
setIsNewIssueOpen(false);
|
||||
};
|
||||
|
||||
// Queue tab handler
|
||||
const handleQueueRefresh = useCallback(() => {
|
||||
refetchQueue();
|
||||
}, [refetchQueue]);
|
||||
|
||||
// Render action buttons based on current tab
|
||||
const renderActionButtons = () => {
|
||||
switch (currentTab) {
|
||||
case 'issues':
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleIssuesRefresh} disabled={isFetchingIssues}>
|
||||
<RefreshCw className={cn('w-4 h-4 mr-2', isFetchingIssues && 'animate-spin')} />
|
||||
{formatMessage({ id: 'common.actions.refresh' })}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleGithubSync} disabled={isGithubSyncing}>
|
||||
<Github className={cn('w-4 h-4 mr-2', isGithubSyncing && 'animate-spin')} />
|
||||
{formatMessage({ id: 'issues.actions.github' })}
|
||||
</Button>
|
||||
<Button onClick={() => setIsNewIssueOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'issues.actions.create' })}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'queue':
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleQueueRefresh} disabled={isFetchingQueue}>
|
||||
<RefreshCw className={cn('w-4 h-4 mr-2', isFetchingQueue && 'animate-spin')} />
|
||||
{formatMessage({ id: 'common.actions.refresh' })}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'discovery':
|
||||
return null; // Discovery panel has its own controls
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<IssueHubHeader currentTab={currentTab} />
|
||||
{/* Header and action buttons on same row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<IssueHubHeader currentTab={currentTab} />
|
||||
|
||||
{/* Action buttons - dynamic based on current tab */}
|
||||
{renderActionButtons() && (
|
||||
<div className="flex gap-2">
|
||||
{renderActionButtons()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<IssueHubTabs currentTab={currentTab} onTabChange={setCurrentTab} />
|
||||
{currentTab === 'issues' && <IssuesPanel />}
|
||||
{currentTab === 'issues' && <IssuesPanel onCreateIssue={() => setIsNewIssueOpen(true)} />}
|
||||
{currentTab === 'queue' && <QueuePanel />}
|
||||
{currentTab === 'discovery' && <DiscoveryPanel />}
|
||||
|
||||
<NewIssueDialog open={isNewIssueOpen} onOpenChange={setIsNewIssueOpen} onSubmit={handleCreateIssue} isCreating={isCreating} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// ========================================
|
||||
// SessionDetailPage Component
|
||||
// ========================================
|
||||
// Session detail page with tabs for tasks, context, and summary
|
||||
// Session detail page with tabs for tasks, context, summary, impl-plan, conflict, and review
|
||||
|
||||
import * as React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
@@ -13,18 +13,24 @@ import {
|
||||
Package,
|
||||
FileText,
|
||||
XCircle,
|
||||
Ruler,
|
||||
Scale,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { useSessionDetail } from '@/hooks/useSessionDetail';
|
||||
import { TaskListTab } from './session-detail/TaskListTab';
|
||||
import { ContextTab } from './session-detail/ContextTab';
|
||||
import { SummaryTab } from './session-detail/SummaryTab';
|
||||
import ImplPlanTab from './session-detail/ImplPlanTab';
|
||||
import { ConflictTab } from './session-detail/ConflictTab';
|
||||
import { ReviewTab } from './session-detail/ReviewTab';
|
||||
import { TaskDrawer } from '@/components/shared/TaskDrawer';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
||||
import type { TaskData } from '@/types/store';
|
||||
|
||||
type TabValue = 'tasks' | 'context' | 'summary';
|
||||
type TabValue = 'tasks' | 'context' | 'summary' | 'impl-plan' | 'conflict' | 'review';
|
||||
|
||||
/**
|
||||
* SessionDetailPage component - Main session detail page with tabs
|
||||
@@ -92,9 +98,10 @@ export function SessionDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const { session, context, summary } = sessionDetail;
|
||||
const { session, context, summary, summaries, implPlan, conflicts, review } = sessionDetail;
|
||||
const tasks = session.tasks || [];
|
||||
const completedTasks = tasks.filter((t) => t.status === 'completed').length;
|
||||
const hasReview = session.has_review || session.review;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -158,6 +165,20 @@ export function SessionDetailPage() {
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'sessionDetail.tabs.summary' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="impl-plan">
|
||||
<Ruler className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'sessionDetail.tabs.implPlan' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="conflict">
|
||||
<Scale className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'sessionDetail.tabs.conflict' })}
|
||||
</TabsTrigger>
|
||||
{hasReview && (
|
||||
<TabsTrigger value="review">
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'sessionDetail.tabs.review' })}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="tasks" className="mt-4">
|
||||
@@ -169,8 +190,22 @@ export function SessionDetailPage() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="summary" className="mt-4">
|
||||
<SummaryTab summary={summary} />
|
||||
<SummaryTab summary={summary} summaries={summaries} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="impl-plan" className="mt-4">
|
||||
<ImplPlanTab implPlan={implPlan} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="conflict" className="mt-4">
|
||||
<ConflictTab conflicts={conflicts as any} />
|
||||
</TabsContent>
|
||||
|
||||
{hasReview && (
|
||||
<TabsContent value="review" className="mt-4">
|
||||
<ReviewTab review={review as any} />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
{/* Description (if exists) */}
|
||||
|
||||
@@ -33,3 +33,4 @@ export { RulesManagerPage } from './RulesManagerPage';
|
||||
export { PromptHistoryPage } from './PromptHistoryPage';
|
||||
export { ExplorerPage } from './ExplorerPage';
|
||||
export { GraphExplorerPage } from './GraphExplorerPage';
|
||||
export { CodexLensManagerPage } from './CodexLensManagerPage';
|
||||
|
||||
176
ccw/frontend/src/pages/session-detail/ConflictTab.tsx
Normal file
176
ccw/frontend/src/pages/session-detail/ConflictTab.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
// ========================================
|
||||
// ConflictTab Component
|
||||
// ========================================
|
||||
// Conflict tab for session detail page - displays conflict resolution decisions
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Scale,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/Collapsible';
|
||||
|
||||
// Type definitions for conflict resolution data
|
||||
export interface UserDecision {
|
||||
choice: string;
|
||||
description?: string;
|
||||
implications?: string;
|
||||
}
|
||||
|
||||
export interface ResolvedConflict {
|
||||
id: string;
|
||||
category?: string;
|
||||
brief?: string;
|
||||
strategy?: string;
|
||||
}
|
||||
|
||||
export interface ConflictResolutionData {
|
||||
session_id: string;
|
||||
resolved_at?: string;
|
||||
user_decisions?: Record<string, UserDecision>;
|
||||
resolved_conflicts?: ResolvedConflict[];
|
||||
}
|
||||
|
||||
export interface ConflictTabProps {
|
||||
conflicts?: ConflictResolutionData;
|
||||
}
|
||||
|
||||
/**
|
||||
* ConflictTab component - Display conflict resolution decisions
|
||||
*/
|
||||
export function ConflictTab({ conflicts }: ConflictTabProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
if (!conflicts) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Scale className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{formatMessage({ id: 'sessionDetail.conflict.empty.title' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'sessionDetail.conflict.empty.message' })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasUserDecisions = conflicts.user_decisions && Object.keys(conflicts.user_decisions).length > 0;
|
||||
const hasResolvedConflicts = conflicts.resolved_conflicts && conflicts.resolved_conflicts.length > 0;
|
||||
|
||||
if (!hasUserDecisions && !hasResolvedConflicts) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Scale className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{formatMessage({ id: 'sessionDetail.conflict.empty.title' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'sessionDetail.conflict.empty.message' })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Resolved At */}
|
||||
{conflicts.resolved_at && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle2 className="h-4 w-4 text-success" />
|
||||
<span>
|
||||
{formatMessage({ id: 'sessionDetail.conflict.resolvedAt' })}:{' '}
|
||||
{new Date(conflicts.resolved_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Decisions Section */}
|
||||
{hasUserDecisions && (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">
|
||||
{formatMessage({ id: 'sessionDetail.conflict.userDecisions' })}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(conflicts.user_decisions!).map(([key, decision], index) => (
|
||||
<Collapsible key={key} defaultOpen={index < 3}>
|
||||
<CollapsibleTrigger className="flex items-center gap-2 w-full text-left p-3 rounded-lg hover:bg-accent/50 transition-colors">
|
||||
<ChevronRight className="h-4 w-4 transition-transform data-[state=open]:rotate-90" />
|
||||
<span className="font-medium text-sm flex-1">{key}</span>
|
||||
<Badge variant="success" className="text-xs">
|
||||
{decision.choice}
|
||||
</Badge>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pl-6 pr-3 pb-3 space-y-2">
|
||||
{decision.description && (
|
||||
<div className="text-sm text-foreground">
|
||||
<span className="font-medium">{formatMessage({ id: 'sessionDetail.conflict.description' })}:</span>{' '}
|
||||
{decision.description}
|
||||
</div>
|
||||
)}
|
||||
{decision.implications && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="font-medium">{formatMessage({ id: 'sessionDetail.conflict.implications' })}:</span>{' '}
|
||||
{decision.implications}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Resolved Conflicts Section */}
|
||||
{hasResolvedConflicts && (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">
|
||||
{formatMessage({ id: 'sessionDetail.conflict.resolvedConflicts' })}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{conflicts.resolved_conflicts!.map((conflict) => (
|
||||
<Collapsible key={conflict.id}>
|
||||
<CollapsibleTrigger className="flex items-center gap-2 w-full text-left p-3 rounded-lg hover:bg-accent/50 transition-colors">
|
||||
<ChevronDown className="h-4 w-4 transition-transform data-[state=closed]:rotate-[-90deg]" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{conflict.id}</span>
|
||||
{conflict.category && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{conflict.category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{conflict.brief && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{conflict.brief}</p>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pl-6 pr-3 pb-3">
|
||||
{conflict.strategy && (
|
||||
<div className="text-sm text-foreground">
|
||||
<span className="font-medium">{formatMessage({ id: 'sessionDetail.conflict.strategy' })}:</span>{' '}
|
||||
{conflict.strategy}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,13 @@ import {
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import type { SessionDetailContext } from '@/lib/api';
|
||||
import {
|
||||
ExplorationsSection,
|
||||
AssetsCard,
|
||||
DependenciesCard,
|
||||
TestContextCard,
|
||||
ConflictDetectionCard,
|
||||
} from '@/components/session-detail/context';
|
||||
|
||||
export interface ContextTabProps {
|
||||
context?: SessionDetailContext;
|
||||
@@ -44,12 +51,16 @@ export function ContextTab({ context }: ContextTabProps) {
|
||||
const hasFocusPaths = context.focus_paths && context.focus_paths.length > 0;
|
||||
const hasArtifacts = context.artifacts && context.artifacts.length > 0;
|
||||
const hasSharedContext = context.shared_context;
|
||||
const hasExtendedContext = context.context;
|
||||
const hasExplorations = context.explorations;
|
||||
|
||||
if (
|
||||
!hasRequirements &&
|
||||
!hasFocusPaths &&
|
||||
!hasArtifacts &&
|
||||
!hasSharedContext
|
||||
!hasSharedContext &&
|
||||
!hasExtendedContext &&
|
||||
!hasExplorations
|
||||
) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
@@ -66,7 +77,7 @@ export function ContextTab({ context }: ContextTabProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Requirements */}
|
||||
{/* Original Context Sections - Maintained for backward compatibility */}
|
||||
{hasRequirements && (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
@@ -90,7 +101,6 @@ export function ContextTab({ context }: ContextTabProps) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Focus Paths */}
|
||||
{hasFocusPaths && (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
@@ -113,7 +123,6 @@ export function ContextTab({ context }: ContextTabProps) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Artifacts */}
|
||||
{hasArtifacts && (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
@@ -133,7 +142,6 @@ export function ContextTab({ context }: ContextTabProps) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Shared Context */}
|
||||
{hasSharedContext && (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
@@ -142,7 +150,6 @@ export function ContextTab({ context }: ContextTabProps) {
|
||||
{formatMessage({ id: 'sessionDetail.context.sharedContext' })}
|
||||
</h3>
|
||||
|
||||
{/* Tech Stack */}
|
||||
{context.shared_context!.tech_stack && context.shared_context!.tech_stack.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
||||
@@ -158,7 +165,6 @@ export function ContextTab({ context }: ContextTabProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conventions */}
|
||||
{context.shared_context!.conventions && context.shared_context!.conventions.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
||||
@@ -177,6 +183,25 @@ export function ContextTab({ context }: ContextTabProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* New Extended Context Sections from context-package.json */}
|
||||
{hasExplorations && <ExplorationsSection data={context.explorations} />}
|
||||
|
||||
{hasExtendedContext && context.context!.assets && (
|
||||
<AssetsCard data={context.context!.assets} />
|
||||
)}
|
||||
|
||||
{hasExtendedContext && context.context!.dependencies && (
|
||||
<DependenciesCard data={context.context!.dependencies} />
|
||||
)}
|
||||
|
||||
{hasExtendedContext && context.context!.test_context && (
|
||||
<TestContextCard data={context.context!.test_context} />
|
||||
)}
|
||||
|
||||
{hasExtendedContext && context.context!.conflict_detection && (
|
||||
<ConflictDetectionCard data={context.context!.conflict_detection} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
113
ccw/frontend/src/pages/session-detail/ImplPlanTab.tsx
Normal file
113
ccw/frontend/src/pages/session-detail/ImplPlanTab.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
// ========================================
|
||||
// ImplPlanTab Component
|
||||
// ========================================
|
||||
// IMPL Plan tab for session detail page
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Ruler, Eye } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import MarkdownModal from '@/components/shared/MarkdownModal';
|
||||
|
||||
// ========================================
|
||||
// Types
|
||||
// ========================================
|
||||
|
||||
export interface ImplPlanTabProps {
|
||||
implPlan?: string;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Component
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* ImplPlanTab component - Display IMPL_PLAN.md content with modal viewer
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ImplPlanTab
|
||||
* implPlan="# Implementation Plan\n\n## Steps..."}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function ImplPlanTab({ implPlan }: ImplPlanTabProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [isModalOpen, setIsModalOpen] = React.useState(false);
|
||||
|
||||
if (!implPlan) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Ruler className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{formatMessage({ id: 'sessionDetail.implPlan.empty.title' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'sessionDetail.implPlan.empty.message' })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Get preview (first 5 lines)
|
||||
const lines = implPlan.split('\n');
|
||||
const preview = lines.slice(0, 5).join('\n');
|
||||
const hasMore = lines.length > 5;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Ruler className="w-5 h-5" />
|
||||
{formatMessage({ id: 'sessionDetail.implPlan.title' })}
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'common.actions.view' })}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{preview}{hasMore && '\n...'}
|
||||
</pre>
|
||||
{hasMore && (
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="w-full"
|
||||
>
|
||||
{formatMessage({ id: 'sessionDetail.implPlan.viewFull' }, { count: lines.length })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Modal Viewer */}
|
||||
<MarkdownModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
title="IMPL_PLAN.md"
|
||||
content={implPlan}
|
||||
contentType="markdown"
|
||||
maxWidth="3xl"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Exports
|
||||
// ========================================
|
||||
|
||||
export default ImplPlanTab;
|
||||
227
ccw/frontend/src/pages/session-detail/ReviewTab.tsx
Normal file
227
ccw/frontend/src/pages/session-detail/ReviewTab.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
// ========================================
|
||||
// ReviewTab Component
|
||||
// ========================================
|
||||
// Review tab for session detail page - displays review findings by dimension
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Search,
|
||||
ChevronRight,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from '@/components/ui/Select';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/Collapsible';
|
||||
|
||||
// Type definitions for review data
|
||||
export interface ReviewFinding {
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
title: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface ReviewDimension {
|
||||
name: string;
|
||||
findings?: ReviewFinding[];
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export interface ReviewTabProps {
|
||||
review?: {
|
||||
dimensions?: ReviewDimension[];
|
||||
};
|
||||
}
|
||||
|
||||
type SeverityFilter = 'all' | 'critical' | 'high' | 'medium' | 'low';
|
||||
|
||||
/**
|
||||
* Get severity color variant for badges
|
||||
*/
|
||||
function getSeverityVariant(severity: string): 'destructive' | 'warning' | 'default' | 'secondary' {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
return 'destructive';
|
||||
case 'high':
|
||||
return 'warning';
|
||||
case 'medium':
|
||||
return 'default';
|
||||
case 'low':
|
||||
return 'secondary';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get border color class for severity
|
||||
*/
|
||||
function getSeverityBorderClass(severity: string): string {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
return 'border-destructive';
|
||||
case 'high':
|
||||
return 'border-orange-500';
|
||||
case 'medium':
|
||||
return 'border-yellow-500';
|
||||
case 'low':
|
||||
return 'border-blue-500';
|
||||
default:
|
||||
return 'border-border';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get severity icon
|
||||
*/
|
||||
function getSeverityIcon(severity: string) {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
case 'high':
|
||||
return <AlertCircle className="h-4 w-4" />;
|
||||
case 'medium':
|
||||
return <AlertTriangle className="h-4 w-4" />;
|
||||
case 'low':
|
||||
return <Info className="h-4 w-4" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ReviewTab component - Display review findings by dimension
|
||||
*/
|
||||
export function ReviewTab({ review }: ReviewTabProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>('all');
|
||||
|
||||
if (!review || !review.dimensions || review.dimensions.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Search className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{formatMessage({ id: 'sessionDetail.review.empty.title' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'sessionDetail.review.empty.message' })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Filter findings by severity
|
||||
const filteredDimensions = review.dimensions.map((dimension) => ({
|
||||
...dimension,
|
||||
findings: dimension.findings?.filter((finding) =>
|
||||
severityFilter === 'all' || finding.severity === severityFilter
|
||||
),
|
||||
})).filter((dimension) => dimension.findings && dimension.findings.length > 0);
|
||||
|
||||
const hasFindings = filteredDimensions.some((d) => d.findings && d.findings.length > 0);
|
||||
|
||||
if (!hasFindings) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Search className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{formatMessage({ id: 'sessionDetail.review.noFindings.title' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'sessionDetail.review.noFindings.message' })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Severity Filter */}
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'sessionDetail.review.filterBySeverity' })}
|
||||
</span>
|
||||
<Select value={severityFilter} onValueChange={(v) => setSeverityFilter(v as SeverityFilter)}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{formatMessage({ id: 'sessionDetail.review.severity.all' })}</SelectItem>
|
||||
<SelectItem value="critical">{formatMessage({ id: 'sessionDetail.review.severity.critical' })}</SelectItem>
|
||||
<SelectItem value="high">{formatMessage({ id: 'sessionDetail.review.severity.high' })}</SelectItem>
|
||||
<SelectItem value="medium">{formatMessage({ id: 'sessionDetail.review.severity.medium' })}</SelectItem>
|
||||
<SelectItem value="low">{formatMessage({ id: 'sessionDetail.review.severity.low' })}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Dimensions with Findings */}
|
||||
{filteredDimensions.map((dimension) => {
|
||||
if (!dimension.findings || dimension.findings.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card key={dimension.name}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-foreground">{dimension.name}</h3>
|
||||
<Badge variant="secondary">{dimension.findings.length}</Badge>
|
||||
</div>
|
||||
|
||||
{dimension.summary && (
|
||||
<p className="text-sm text-muted-foreground mb-4">{dimension.summary}</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{dimension.findings.map((finding, findingIndex) => (
|
||||
<Collapsible key={`${dimension.name}-${findingIndex}`} className={`border-l-4 ${getSeverityBorderClass(finding.severity)} rounded-r-lg`}>
|
||||
<CollapsibleTrigger className="flex items-center gap-2 w-full text-left p-3 hover:bg-accent/50 transition-colors">
|
||||
<ChevronRight className="h-4 w-4 transition-transform data-[state=open]:rotate-90" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{getSeverityIcon(finding.severity)}
|
||||
<span className="font-medium text-sm">{finding.title}</span>
|
||||
<Badge variant={getSeverityVariant(finding.severity)} className="text-xs">
|
||||
{formatMessage({ id: `sessionDetail.review.severity.${finding.severity}` })}
|
||||
</Badge>
|
||||
</div>
|
||||
{finding.location && (
|
||||
<p className="text-xs text-muted-foreground mt-1 font-mono">{finding.location}</p>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="px-6 pb-3 space-y-2">
|
||||
{finding.description && (
|
||||
<div className="text-sm text-foreground">
|
||||
{finding.description}
|
||||
</div>
|
||||
)}
|
||||
{finding.code && (
|
||||
<pre className="text-xs bg-muted p-2 rounded overflow-x-auto">
|
||||
<code>{finding.code}</code>
|
||||
</pre>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,60 @@
|
||||
// ========================================
|
||||
// SummaryTab Component
|
||||
// ========================================
|
||||
// Summary tab for session detail page
|
||||
// Summary tab for session detail page with multiple summaries support
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { FileText } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { FileText, Eye } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import MarkdownModal from '@/components/shared/MarkdownModal';
|
||||
|
||||
// ========================================
|
||||
// Types
|
||||
// ========================================
|
||||
|
||||
export interface SummaryItem {
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface SummaryTabProps {
|
||||
summary?: string;
|
||||
summaries?: SummaryItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* SummaryTab component - Display session summary
|
||||
*/
|
||||
export function SummaryTab({ summary }: SummaryTabProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
// ========================================
|
||||
// Component
|
||||
// ========================================
|
||||
|
||||
if (!summary) {
|
||||
/**
|
||||
* SummaryTab component - Display session summary/summaries with modal viewer
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SummaryTab
|
||||
* summaries={[{ name: 'Plan Summary', content: '...' }]}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function SummaryTab({ summary, summaries }: SummaryTabProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [selectedSummary, setSelectedSummary] = React.useState<SummaryItem | null>(null);
|
||||
|
||||
// Use summaries array if available, otherwise fallback to single summary
|
||||
const summaryList: SummaryItem[] = React.useMemo(() => {
|
||||
if (summaries && summaries.length > 0) {
|
||||
return summaries;
|
||||
}
|
||||
if (summary) {
|
||||
return [{ name: formatMessage({ id: 'sessionDetail.summary.default' }), content: summary }];
|
||||
}
|
||||
return [];
|
||||
}, [summaries, summary, formatMessage]);
|
||||
|
||||
if (summaryList.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<FileText className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
@@ -32,16 +69,97 @@ export function SummaryTab({ summary }: SummaryTabProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
{formatMessage({ id: 'sessionDetail.summary.title' })}
|
||||
</h3>
|
||||
<div className="prose prose-sm max-w-none text-foreground">
|
||||
<p className="whitespace-pre-wrap">{summary}</p>
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{summaryList.length === 1 ? (
|
||||
// Single summary - inline display
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
{summaryList[0].name}
|
||||
</h3>
|
||||
<div className="prose prose-sm max-w-none text-foreground">
|
||||
<p className="whitespace-pre-wrap">{summaryList[0].content}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
// Multiple summaries - card list with modal viewer
|
||||
summaryList.map((item, index) => (
|
||||
<SummaryCard
|
||||
key={index}
|
||||
summary={item}
|
||||
onClick={() => setSelectedSummary(item)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Viewer */}
|
||||
<MarkdownModal
|
||||
isOpen={!!selectedSummary}
|
||||
onClose={() => setSelectedSummary(null)}
|
||||
title={selectedSummary?.name || ''}
|
||||
content={selectedSummary?.content || ''}
|
||||
contentType="markdown"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Sub-Components
|
||||
// ========================================
|
||||
|
||||
interface SummaryCardProps {
|
||||
summary: SummaryItem;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function SummaryCard({ summary, onClick }: SummaryCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// Get preview (first 3 lines)
|
||||
const lines = summary.content.split('\n');
|
||||
const preview = lines.slice(0, 3).join('\n');
|
||||
const hasMore = lines.length > 3;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="cursor-pointer hover:shadow-md transition-shadow"
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<FileText className="w-5 h-5" />
|
||||
{summary.name}
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'common.actions.view' })}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{preview}{hasMore && '\n...'}
|
||||
</pre>
|
||||
{hasMore && (
|
||||
<div className="mt-2 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Badge variant="secondary">
|
||||
{lines.length} {formatMessage({ id: 'sessionDetail.summary.lines' })}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Exports
|
||||
// ========================================
|
||||
|
||||
export default SummaryTab;
|
||||
|
||||
@@ -3,51 +3,27 @@
|
||||
// ========================================
|
||||
// Tasks tab for session detail page
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
ListChecks,
|
||||
Loader2,
|
||||
Circle,
|
||||
CheckCircle,
|
||||
Code,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { TaskStatsBar, TaskStatusDropdown } from '@/components/session-detail/tasks';
|
||||
import type { SessionMetadata, TaskData } from '@/types/store';
|
||||
import type { TaskStatus } from '@/lib/api';
|
||||
import { bulkUpdateTaskStatus, updateTaskStatus } from '@/lib/api';
|
||||
|
||||
export interface TaskListTabProps {
|
||||
session: SessionMetadata;
|
||||
onTaskClick?: (task: TaskData) => void;
|
||||
}
|
||||
|
||||
// Status configuration
|
||||
const taskStatusConfig: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'info' | null; icon: React.ComponentType<{ className?: string }> }> = {
|
||||
pending: {
|
||||
label: 'sessionDetail.tasks.status.pending',
|
||||
variant: 'secondary',
|
||||
icon: Circle,
|
||||
},
|
||||
in_progress: {
|
||||
label: 'sessionDetail.tasks.status.inProgress',
|
||||
variant: 'warning',
|
||||
icon: Loader2,
|
||||
},
|
||||
completed: {
|
||||
label: 'sessionDetail.tasks.status.completed',
|
||||
variant: 'success',
|
||||
icon: CheckCircle,
|
||||
},
|
||||
blocked: {
|
||||
label: 'sessionDetail.tasks.status.blocked',
|
||||
variant: 'destructive',
|
||||
icon: Circle,
|
||||
},
|
||||
skipped: {
|
||||
label: 'sessionDetail.tasks.status.skipped',
|
||||
variant: 'default',
|
||||
icon: Circle,
|
||||
},
|
||||
};
|
||||
export interface TaskListTabProps {
|
||||
session: SessionMetadata;
|
||||
onTaskClick?: (task: TaskData) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskListTab component - Display tasks in a list format
|
||||
@@ -59,34 +35,129 @@ export function TaskListTab({ session, onTaskClick }: TaskListTabProps) {
|
||||
const completed = tasks.filter((t) => t.status === 'completed').length;
|
||||
const inProgress = tasks.filter((t) => t.status === 'in_progress').length;
|
||||
const pending = tasks.filter((t) => t.status === 'pending').length;
|
||||
const blocked = tasks.filter((t) => t.status === 'blocked').length;
|
||||
|
||||
// Loading states for bulk actions
|
||||
const [isLoadingPending, setIsLoadingPending] = useState(false);
|
||||
const [isLoadingInProgress, setIsLoadingInProgress] = useState(false);
|
||||
const [isLoadingCompleted, setIsLoadingCompleted] = useState(false);
|
||||
|
||||
// Local task state for optimistic updates
|
||||
const [localTasks, setLocalTasks] = useState<TaskData[]>(tasks);
|
||||
|
||||
// Update local tasks when session tasks change
|
||||
if (tasks !== localTasks && !isLoadingPending && !isLoadingInProgress && !isLoadingCompleted) {
|
||||
setLocalTasks(tasks);
|
||||
}
|
||||
|
||||
// Get session path for API calls
|
||||
const sessionPath = (session as any).path || session.session_id;
|
||||
|
||||
// Bulk action handlers
|
||||
const handleMarkAllPending = async () => {
|
||||
const targetTasks = localTasks.filter((t) => t.status === 'pending');
|
||||
if (targetTasks.length === 0) return;
|
||||
|
||||
setIsLoadingPending(true);
|
||||
try {
|
||||
const taskIds = targetTasks.map((t) => t.task_id);
|
||||
const result = await bulkUpdateTaskStatus(sessionPath, taskIds, 'pending');
|
||||
if (result.success) {
|
||||
// Optimistic update - will be refreshed when parent re-renders
|
||||
} else {
|
||||
console.error('[TaskListTab] Failed to mark all as pending:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[TaskListTab] Failed to mark all as pending:', error);
|
||||
} finally {
|
||||
setIsLoadingPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllInProgress = async () => {
|
||||
const targetTasks = localTasks.filter((t) => t.status === 'in_progress');
|
||||
if (targetTasks.length === 0) return;
|
||||
|
||||
setIsLoadingInProgress(true);
|
||||
try {
|
||||
const taskIds = targetTasks.map((t) => t.task_id);
|
||||
const result = await bulkUpdateTaskStatus(sessionPath, taskIds, 'in_progress');
|
||||
if (result.success) {
|
||||
// Optimistic update - will be refreshed when parent re-renders
|
||||
} else {
|
||||
console.error('[TaskListTab] Failed to mark all as in_progress:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[TaskListTab] Failed to mark all as in_progress:', error);
|
||||
} finally {
|
||||
setIsLoadingInProgress(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllCompleted = async () => {
|
||||
const targetTasks = localTasks.filter((t) => t.status === 'completed');
|
||||
if (targetTasks.length === 0) return;
|
||||
|
||||
setIsLoadingCompleted(true);
|
||||
try {
|
||||
const taskIds = targetTasks.map((t) => t.task_id);
|
||||
const result = await bulkUpdateTaskStatus(sessionPath, taskIds, 'completed');
|
||||
if (result.success) {
|
||||
// Optimistic update - will be refreshed when parent re-renders
|
||||
} else {
|
||||
console.error('[TaskListTab] Failed to mark all as completed:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[TaskListTab] Failed to mark all as completed:', error);
|
||||
} finally {
|
||||
setIsLoadingCompleted(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Individual task status change handler
|
||||
const handleTaskStatusChange = async (taskId: string, newStatus: TaskStatus) => {
|
||||
const previousTasks = [...localTasks];
|
||||
const previousTask = previousTasks.find((t) => t.task_id === taskId);
|
||||
|
||||
if (!previousTask) return;
|
||||
|
||||
// Optimistic update
|
||||
setLocalTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
t.task_id === taskId ? { ...t, status: newStatus } : t
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await updateTaskStatus(sessionPath, taskId, newStatus);
|
||||
if (!result.success) {
|
||||
// Rollback on error
|
||||
setLocalTasks(previousTasks);
|
||||
console.error('[TaskListTab] Failed to update task status:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
// Rollback on error
|
||||
setLocalTasks(previousTasks);
|
||||
console.error('[TaskListTab] Failed to update task status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Stats Bar */}
|
||||
<div className="flex flex-wrap items-center gap-4 p-4 bg-background rounded-lg border">
|
||||
<span className="flex items-center gap-1 text-sm">
|
||||
<CheckCircle className="h-4 w-4 text-success" />
|
||||
<strong>{completed}</strong> {formatMessage({ id: 'sessionDetail.tasks.completed' })}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-sm">
|
||||
<Loader2 className="h-4 w-4 text-warning" />
|
||||
<strong>{inProgress}</strong> {formatMessage({ id: 'sessionDetail.tasks.inProgress' })}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-sm">
|
||||
<Circle className="h-4 w-4 text-muted-foreground" />
|
||||
<strong>{pending}</strong> {formatMessage({ id: 'sessionDetail.tasks.pending' })}
|
||||
</span>
|
||||
{blocked > 0 && (
|
||||
<span className="flex items-center gap-1 text-sm">
|
||||
<Circle className="h-4 w-4 text-destructive" />
|
||||
<strong>{blocked}</strong> {formatMessage({ id: 'sessionDetail.tasks.blocked' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Stats Bar with Bulk Actions */}
|
||||
<TaskStatsBar
|
||||
completed={completed}
|
||||
inProgress={inProgress}
|
||||
pending={pending}
|
||||
onMarkAllPending={handleMarkAllPending}
|
||||
onMarkAllInProgress={handleMarkAllInProgress}
|
||||
onMarkAllCompleted={handleMarkAllCompleted}
|
||||
isLoadingPending={isLoadingPending}
|
||||
isLoadingInProgress={isLoadingInProgress}
|
||||
isLoadingCompleted={isLoadingCompleted}
|
||||
/>
|
||||
|
||||
{/* Tasks List */}
|
||||
{tasks.length === 0 ? (
|
||||
{localTasks.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<ListChecks className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
@@ -98,10 +169,7 @@ export function TaskListTab({ session, onTaskClick }: TaskListTabProps) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{tasks.map((task, index) => {
|
||||
const currentStatusConfig = task.status ? taskStatusConfig[task.status] : taskStatusConfig.pending;
|
||||
const StatusIcon = currentStatusConfig.icon;
|
||||
|
||||
{localTasks.map((task, index) => {
|
||||
return (
|
||||
<Card
|
||||
key={task.task_id || index}
|
||||
@@ -111,18 +179,19 @@ export function TaskListTab({ session, onTaskClick }: TaskListTabProps) {
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{task.task_id}
|
||||
</span>
|
||||
<Badge variant={currentStatusConfig.variant} className="gap-1">
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{formatMessage({ id: currentStatusConfig.label })}
|
||||
</Badge>
|
||||
<TaskStatusDropdown
|
||||
currentStatus={task.status as TaskStatus}
|
||||
onStatusChange={(newStatus) => handleTaskStatusChange(task.task_id, newStatus)}
|
||||
size="sm"
|
||||
/>
|
||||
{task.priority && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{task.priority}
|
||||
</Badge>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="font-medium text-foreground text-sm">
|
||||
|
||||
Reference in New Issue
Block a user