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:
catlog22
2026-02-01 17:45:38 +08:00
parent 8dc115a894
commit d46406df4a
79 changed files with 11819 additions and 2455 deletions

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

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

View File

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

View File

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

View File

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

View File

@@ -33,3 +33,4 @@ export { RulesManagerPage } from './RulesManagerPage';
export { PromptHistoryPage } from './PromptHistoryPage';
export { ExplorerPage } from './ExplorerPage';
export { GraphExplorerPage } from './GraphExplorerPage';
export { CodexLensManagerPage } from './CodexLensManagerPage';

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

View File

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

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

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

View File

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

View File

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