From 1bd082a725a376a3ffd1c20d140ebc90468a1a2e Mon Sep 17 00:00:00 2001 From: catlog22 Date: Sat, 31 Jan 2026 21:20:10 +0800 Subject: [PATCH] feat: add tests and implementation for issue discovery and queue pages - Implemented `DiscoveryPage` with session management and findings display. - Added tests for `DiscoveryPage` to ensure proper rendering and functionality. - Created `QueuePage` for managing issue execution queues with stats and actions. - Added tests for `QueuePage` to verify UI elements and translations. - Introduced `useIssues` hooks for fetching and managing issue data. - Added loading skeletons and error handling for better user experience. - Created `vite-env.d.ts` for TypeScript support in Vite environment. --- ccw/frontend/.workflow-store-diff.patch | 59 ++ ccw/frontend/package-lock.json | 62 +++ ccw/frontend/package.json | 2 + .../issue/discovery/DiscoveryCard.test.tsx | 260 +++++++++ .../issue/discovery/DiscoveryCard.tsx | 92 ++++ .../issue/discovery/DiscoveryDetail.tsx | 224 ++++++++ .../issue/discovery/FindingList.tsx | 137 +++++ .../src/components/issue/discovery/index.ts | 7 + .../issue/queue/ExecutionGroup.test.tsx | 162 ++++++ .../components/issue/queue/ExecutionGroup.tsx | 93 ++++ .../components/issue/queue/QueueActions.tsx | 234 ++++++++ .../components/issue/queue/QueueCard.test.tsx | 196 +++++++ .../src/components/issue/queue/QueueCard.tsx | 163 ++++++ .../src/components/issue/queue/index.ts | 12 + .../src/components/layout/AppShell.tsx | 28 +- ccw/frontend/src/components/layout/Header.tsx | 9 +- .../src/components/layout/Sidebar.tsx | 12 +- .../notification/NotificationPanel.tsx | 507 +++++++++++++++--- .../components/shared/CliStreamMonitor.tsx | 81 +-- .../src/components/shared/Flowchart.tsx | 30 +- .../components/shared/LogBlock/LogBlock.tsx | 260 +++++++++ .../shared/LogBlock/LogBlockList.tsx | 331 ++++++++++++ .../src/components/shared/LogBlock/index.ts | 7 + .../src/components/shared/LogBlock/types.ts | 31 ++ .../src/components/shared/PromptStats.tsx | 2 +- .../src/components/shared/TaskDrawer.tsx | 55 +- .../src/components/ui/AlertDialog.tsx | 144 +++++ .../workspace/WorkspaceSelector.tsx | 26 +- ccw/frontend/src/hooks/index.ts | 5 + ccw/frontend/src/hooks/useCli.ts | 20 +- ccw/frontend/src/hooks/useCommands.ts | 8 +- ccw/frontend/src/hooks/useDashboardStats.ts | 4 +- ccw/frontend/src/hooks/useHistory.ts | 8 +- ccw/frontend/src/hooks/useIndex.ts | 8 +- ccw/frontend/src/hooks/useIssues.test.tsx | 323 +++++++++++ ccw/frontend/src/hooks/useIssues.ts | 184 ++++++- ccw/frontend/src/hooks/useLiteTasks.ts | 25 +- ccw/frontend/src/hooks/useLoops.ts | 15 +- ccw/frontend/src/hooks/useMcpServers.ts | 8 +- ccw/frontend/src/hooks/useMemory.ts | 8 +- ccw/frontend/src/hooks/useProjectOverview.ts | 8 +- ccw/frontend/src/hooks/usePromptHistory.ts | 15 +- ccw/frontend/src/hooks/useSessionDetail.ts | 8 +- ccw/frontend/src/hooks/useSessions.ts | 2 +- ccw/frontend/src/hooks/useSkills.ts | 2 +- ccw/frontend/src/lib/api.ts | 309 ++++++++--- ccw/frontend/src/lib/queryKeys.ts | 19 + ccw/frontend/src/locales/en/issues.json | 75 +++ ccw/frontend/src/locales/en/navigation.json | 2 + .../src/locales/en/notifications.json | 36 +- ccw/frontend/src/locales/en/skills.json | 8 +- ccw/frontend/src/locales/en/workspace.json | 12 +- ccw/frontend/src/locales/zh/cli-hooks.json | 208 +++---- ccw/frontend/src/locales/zh/issues.json | 75 +++ ccw/frontend/src/locales/zh/navigation.json | 2 + .../src/locales/zh/notifications.json | 36 +- ccw/frontend/src/locales/zh/skills.json | 8 +- ccw/frontend/src/locales/zh/workspace.json | 12 +- ccw/frontend/src/pages/DiscoveryPage.test.tsx | 135 +++++ ccw/frontend/src/pages/DiscoveryPage.tsx | 174 ++++++ ccw/frontend/src/pages/QueuePage.test.tsx | 123 +++++ ccw/frontend/src/pages/QueuePage.tsx | 290 ++++++++++ ccw/frontend/src/pages/index.ts | 2 + ccw/frontend/src/router.tsx | 19 +- ccw/frontend/src/stores/cliStreamStore.ts | 307 ++++++++++- ccw/frontend/src/stores/notificationStore.ts | 114 ++++ ccw/frontend/src/stores/workflowStore.ts | 51 +- ccw/frontend/src/test/i18n.tsx | 108 ++++ ccw/frontend/src/types/store.ts | 65 ++- ccw/frontend/src/vite-env.d.ts | 13 + ccw/frontend/vite.config.ts | 9 +- ccw/src/commands/serve.ts | 25 +- ccw/src/commands/stop.ts | 30 +- ccw/src/commands/view.ts | 15 +- ccw/src/core/a2ui/A2UIWebSocketHandler.ts | 2 +- ccw/src/core/a2ui/index.ts | 4 +- ccw/src/core/routes/system-routes.ts | 8 +- ccw/src/core/server.ts | 94 +++- ccw/src/utils/react-frontend.ts | 52 +- 79 files changed, 5870 insertions(+), 449 deletions(-) create mode 100644 ccw/frontend/.workflow-store-diff.patch create mode 100644 ccw/frontend/src/components/issue/discovery/DiscoveryCard.test.tsx create mode 100644 ccw/frontend/src/components/issue/discovery/DiscoveryCard.tsx create mode 100644 ccw/frontend/src/components/issue/discovery/DiscoveryDetail.tsx create mode 100644 ccw/frontend/src/components/issue/discovery/FindingList.tsx create mode 100644 ccw/frontend/src/components/issue/discovery/index.ts create mode 100644 ccw/frontend/src/components/issue/queue/ExecutionGroup.test.tsx create mode 100644 ccw/frontend/src/components/issue/queue/ExecutionGroup.tsx create mode 100644 ccw/frontend/src/components/issue/queue/QueueActions.tsx create mode 100644 ccw/frontend/src/components/issue/queue/QueueCard.test.tsx create mode 100644 ccw/frontend/src/components/issue/queue/QueueCard.tsx create mode 100644 ccw/frontend/src/components/issue/queue/index.ts create mode 100644 ccw/frontend/src/components/shared/LogBlock/LogBlock.tsx create mode 100644 ccw/frontend/src/components/shared/LogBlock/LogBlockList.tsx create mode 100644 ccw/frontend/src/components/shared/LogBlock/index.ts create mode 100644 ccw/frontend/src/components/shared/LogBlock/types.ts create mode 100644 ccw/frontend/src/components/ui/AlertDialog.tsx create mode 100644 ccw/frontend/src/hooks/useIssues.test.tsx create mode 100644 ccw/frontend/src/pages/DiscoveryPage.test.tsx create mode 100644 ccw/frontend/src/pages/DiscoveryPage.tsx create mode 100644 ccw/frontend/src/pages/QueuePage.test.tsx create mode 100644 ccw/frontend/src/pages/QueuePage.tsx create mode 100644 ccw/frontend/src/vite-env.d.ts diff --git a/ccw/frontend/.workflow-store-diff.patch b/ccw/frontend/.workflow-store-diff.patch new file mode 100644 index 00000000..58a9bb09 --- /dev/null +++ b/ccw/frontend/.workflow-store-diff.patch @@ -0,0 +1,59 @@ +diff --git a/ccw/frontend/src/stores/workflowStore.ts b/ccw/frontend/src/stores/workflowStore.ts +index 66419b2e..7ae5b1bf 100644 +--- a/ccw/frontend/src/stores/workflowStore.ts ++++ b/ccw/frontend/src/stores/workflowStore.ts +@@ -4,7 +4,7 @@ + // Manages workflow sessions, tasks, and related data + + import { create } from 'zustand'; +-import { devtools } from 'zustand/middleware'; ++import { devtools, persist } from 'zustand/middleware'; + import type { + WorkflowStore, + WorkflowState, +@@ -60,8 +60,9 @@ const initialState: WorkflowState = { + + export const useWorkflowStore = create()( + devtools( +- (set, get) => ({ +- ...initialState, ++ persist( ++ (set, get) => ({ ++ ...initialState, + + // ========== Session Actions ========== + +@@ -510,7 +511,32 @@ export const useWorkflowStore = create()( + getSessionByKey: (key: string) => { + return get().sessionDataStore[key]; + }, +- }), ++ }), ++ { ++ name: 'ccw-workflow-store', ++ partialize: (state) => ({ ++ projectPath: state.projectPath, ++ }), ++ onRehydrateStorage: () => { ++ console.log('[WorkflowStore] Hydrating from localStorage...'); ++ return (state, error) => { ++ if (error) { ++ console.error('[WorkflowStore] Rehydration error:', error); ++ return; ++ } ++ if (state?.projectPath) { ++ console.log('[WorkflowStore] Found persisted projectPath, re-initializing workspace:', state.projectPath); ++ // Use setTimeout to ensure the store is fully initialized before calling switchWorkspace ++ setTimeout(() => { ++ if (state.switchWorkspace) { ++ state.switchWorkspace(state.projectPath); ++ } ++ }, 0); ++ } ++ }; ++ }, ++ } ++ ), + { name: 'WorkflowStore' } + ) + ); diff --git a/ccw/frontend/package-lock.json b/ccw/frontend/package-lock.json index 6e0031e2..31e60397 100644 --- a/ccw/frontend/package-lock.json +++ b/ccw/frontend/package-lock.json @@ -10,11 +10,13 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.11.4", "@hello-pangea/dnd": "^18.0.1", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.0", "@radix-ui/react-dropdown-menu": "^2.1.0", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-select": "^2.1.0", "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-toast": "^1.2.0", @@ -1384,6 +1386,34 @@ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", @@ -1945,6 +1975,38 @@ } } }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", diff --git a/ccw/frontend/package.json b/ccw/frontend/package.json index be40a376..d3b9357e 100644 --- a/ccw/frontend/package.json +++ b/ccw/frontend/package.json @@ -19,11 +19,13 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.11.4", "@hello-pangea/dnd": "^18.0.1", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.0", "@radix-ui/react-dropdown-menu": "^2.1.0", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-select": "^2.1.0", "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-toast": "^1.2.0", diff --git a/ccw/frontend/src/components/issue/discovery/DiscoveryCard.test.tsx b/ccw/frontend/src/components/issue/discovery/DiscoveryCard.test.tsx new file mode 100644 index 00000000..02d11026 --- /dev/null +++ b/ccw/frontend/src/components/issue/discovery/DiscoveryCard.test.tsx @@ -0,0 +1,260 @@ +// ======================================== +// DiscoveryCard Component Tests +// ======================================== +// Tests for the discovery card component with i18n + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen } from '@/test/i18n'; +import userEvent from '@testing-library/user-event'; +import { DiscoveryCard } from './DiscoveryCard'; +import type { DiscoverySession } from '@/lib/api'; + +describe('DiscoveryCard', () => { + const mockSession: DiscoverySession = { + id: '1', + name: 'Test Session', + status: 'running', + progress: 50, + findings_count: 5, + created_at: '2024-01-01T00:00:00Z', + }; + + const defaultProps = { + session: mockSession, + isActive: false, + onClick: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('with en locale', () => { + it('should render session name', () => { + render(, { locale: 'en' }); + expect(screen.getByText('Test Session')).toBeInTheDocument(); + }); + + it('should show running status badge', () => { + render(, { locale: 'en' }); + expect(screen.getByText(/Running/i)).toBeInTheDocument(); + }); + + it('should show completed status badge', () => { + const completedSession: DiscoverySession = { + ...mockSession, + status: 'completed', + }; + + render(, { locale: 'en' }); + expect(screen.getByText(/Completed/i)).toBeInTheDocument(); + }); + + it('should show failed status badge', () => { + const failedSession: DiscoverySession = { + ...mockSession, + status: 'failed', + }; + + render(, { locale: 'en' }); + expect(screen.getByText(/Failed/i)).toBeInTheDocument(); + }); + + it('should show progress bar for running sessions', () => { + render(, { locale: 'en' }); + expect(screen.getByText(/Progress/i)).toBeInTheDocument(); + expect(screen.getByText('50%')).toBeInTheDocument(); + }); + + it('should not show progress bar for completed sessions', () => { + const completedSession: DiscoverySession = { + ...mockSession, + status: 'completed', + }; + + render(, { locale: 'en' }); + expect(screen.queryByText(/Progress/i)).not.toBeInTheDocument(); + }); + + it('should show findings count', () => { + render(, { locale: 'en' }); + expect(screen.getByText(/Findings/i)).toBeInTheDocument(); + expect(screen.getByText('5')).toBeInTheDocument(); + }); + + it('should show formatted date', () => { + render(, { locale: 'en' }); + const dateText = new Date(mockSession.created_at).toLocaleString(); + expect(screen.getByText(new RegExp(dateText.replace(/[\/:]/g, '[/:]'), 'i'))).toBeInTheDocument(); + }); + }); + + describe('with zh locale', () => { + it('should render session name', () => { + render(, { locale: 'zh' }); + expect(screen.getByText('Test Session')).toBeInTheDocument(); + }); + + it('should show translated running status badge', () => { + render(, { locale: 'zh' }); + expect(screen.getByText(/运行中/i)).toBeInTheDocument(); + }); + + it('should show translated completed status badge', () => { + const completedSession: DiscoverySession = { + ...mockSession, + status: 'completed', + }; + + render(, { locale: 'zh' }); + expect(screen.getByText(/已完成/i)).toBeInTheDocument(); + }); + + it('should show translated failed status badge', () => { + const failedSession: DiscoverySession = { + ...mockSession, + status: 'failed', + }; + + render(, { locale: 'zh' }); + expect(screen.getByText(/失败/i)).toBeInTheDocument(); + }); + + it('should show translated progress text', () => { + render(, { locale: 'zh' }); + expect(screen.getByText(/进度/i)).toBeInTheDocument(); + }); + + it('should show translated findings count', () => { + render(, { locale: 'zh' }); + expect(screen.getByText(/发现/i)).toBeInTheDocument(); + expect(screen.getByText('5')).toBeInTheDocument(); + }); + }); + + describe('interaction', () => { + it('should call onClick when clicked', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + + render(, { locale: 'en' }); + + const card = screen.getByText('Test Session').closest('.cursor-pointer'); + if (card) { + await user.click(card); + } + + expect(onClick).toHaveBeenCalled(); + }); + }); + + describe('visual states', () => { + it('should apply active styles when isActive', () => { + const { container } = render( + , + { locale: 'en' } + ); + + const card = container.firstChild as HTMLElement; + expect(card.className).toContain('ring-2'); + expect(card.className).toContain('ring-primary'); + }); + + it('should not apply active styles when not active', () => { + const { container } = render( + , + { locale: 'en' } + ); + + const card = container.firstChild as HTMLElement; + expect(card.className).not.toContain('ring-2'); + }); + + it('should have hover effect', () => { + const { container } = render( + , + { locale: 'en' } + ); + + const card = container.firstChild as HTMLElement; + expect(card.className).toContain('hover:shadow-md'); + }); + }); + + describe('progress bar', () => { + it('should render progress element for running sessions', () => { + render(, { locale: 'en' }); + + const progressBar = document.querySelector('[role="progressbar"]'); + expect(progressBar).toBeInTheDocument(); + }); + + it('should not render progress element for completed sessions', () => { + const completedSession: DiscoverySession = { + ...mockSession, + status: 'completed', + }; + + render(, { locale: 'en' }); + + const progressBar = document.querySelector('[role="progressbar"]'); + expect(progressBar).not.toBeInTheDocument(); + }); + + it('should display correct progress percentage', () => { + const sessionWithDifferentProgress: DiscoverySession = { + ...mockSession, + progress: 75, + }; + + render(, { locale: 'en' }); + expect(screen.getByText('75%')).toBeInTheDocument(); + }); + }); + + describe('accessibility', () => { + it('should have clickable card with proper cursor', () => { + const { container } = render(, { locale: 'en' }); + const card = container.firstChild as HTMLElement; + expect(card.className).toContain('cursor-pointer'); + }); + + it('should have proper heading structure', () => { + render(, { locale: 'en' }); + const heading = screen.getByRole('heading', { level: 3, name: 'Test Session' }); + expect(heading).toBeInTheDocument(); + }); + }); + + describe('edge cases', () => { + it('should handle zero findings', () => { + const sessionWithNoFindings: DiscoverySession = { + ...mockSession, + findings_count: 0, + }; + + render(, { locale: 'en' }); + expect(screen.getByText('0')).toBeInTheDocument(); + }); + + it('should handle zero progress', () => { + const sessionWithNoProgress: DiscoverySession = { + ...mockSession, + progress: 0, + }; + + render(, { locale: 'en' }); + expect(screen.getByText('0%')).toBeInTheDocument(); + }); + + it('should handle 100% progress', () => { + const sessionWithFullProgress: DiscoverySession = { + ...mockSession, + progress: 100, + }; + + render(, { locale: 'en' }); + expect(screen.getByText('100%')).toBeInTheDocument(); + }); + }); +}); diff --git a/ccw/frontend/src/components/issue/discovery/DiscoveryCard.tsx b/ccw/frontend/src/components/issue/discovery/DiscoveryCard.tsx new file mode 100644 index 00000000..89196c55 --- /dev/null +++ b/ccw/frontend/src/components/issue/discovery/DiscoveryCard.tsx @@ -0,0 +1,92 @@ +// ======================================== +// Discovery Card Component +// ======================================== +// Displays a discovery session card with status, progress, and findings count + +import { useIntl } from 'react-intl'; +import { Radar, CheckCircle, XCircle, Clock } from 'lucide-react'; +import { Card } from '@/components/ui/Card'; +import { Badge } from '@/components/ui/Badge'; +import { Progress } from '@/components/ui/Progress'; +import { cn } from '@/lib/utils'; +import type { DiscoverySession } from '@/lib/api'; + +interface DiscoveryCardProps { + session: DiscoverySession; + isActive: boolean; + onClick: () => void; +} + +const statusConfig = { + running: { + icon: Clock, + variant: 'warning' as const, + label: 'issues.discovery.status.running', + }, + completed: { + icon: CheckCircle, + variant: 'success' as const, + label: 'issues.discovery.status.completed', + }, + failed: { + icon: XCircle, + variant: 'destructive' as const, + label: 'issues.discovery.status.failed', + }, +}; + +export function DiscoveryCard({ session, isActive, onClick }: DiscoveryCardProps) { + const { formatMessage } = useIntl(); + const config = statusConfig[session.status]; + const StatusIcon = config.icon; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString(); + }; + + return ( + + {/* Header */} +
+
+ +

{session.name}

+
+ + + {formatMessage({ id: config.label })} + +
+ + {/* Progress Bar for Running Sessions */} + {session.status === 'running' && ( +
+
+ {formatMessage({ id: 'issues.discovery.progress' })} + {session.progress}% +
+ +
+ )} + + {/* Findings Count */} +
+
+
+ {formatMessage({ id: 'issues.discovery.findings' })}: + {session.findings_count} +
+
+ + {formatDate(session.created_at)} + +
+
+ ); +} diff --git a/ccw/frontend/src/components/issue/discovery/DiscoveryDetail.tsx b/ccw/frontend/src/components/issue/discovery/DiscoveryDetail.tsx new file mode 100644 index 00000000..7242f1cf --- /dev/null +++ b/ccw/frontend/src/components/issue/discovery/DiscoveryDetail.tsx @@ -0,0 +1,224 @@ +// ======================================== +// Discovery Detail Component +// ======================================== +// Displays findings detail panel with tabs and export functionality + +import { useState } from 'react'; +import { useIntl } from 'react-intl'; +import { Download, FileText, BarChart3, Info } from 'lucide-react'; +import { Card } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs'; +import { Badge } from '@/components/ui/Badge'; +import { Progress } from '@/components/ui/Progress'; +import type { DiscoverySession, Finding } from '@/lib/api'; +import type { FindingFilters } from '@/hooks/useIssues'; +import { FindingList } from './FindingList'; + +interface DiscoveryDetailProps { + sessionId: string; + session: DiscoverySession | null; + findings: Finding[]; + filters: FindingFilters; + onFilterChange: (filters: FindingFilters) => void; + onExport: () => void; +} + +export function DiscoveryDetail({ + sessionId: _sessionId, + session, + findings, + filters, + onFilterChange, + onExport, +}: DiscoveryDetailProps) { + const { formatMessage } = useIntl(); + const [activeTab, setActiveTab] = useState('findings'); + + if (!session) { + return ( + + +

+ {formatMessage({ id: 'issues.discovery.noSessionSelected' })} +

+

+ {formatMessage({ id: 'issues.discovery.selectSession' })} +

+
+ ); + } + + const severityCounts = findings.reduce((acc, f) => { + acc[f.severity] = (acc[f.severity] || 0) + 1; + return acc; + }, { critical: 0, high: 0, medium: 0, low: 0 }); + + const typeCounts = findings.reduce((acc, f) => { + acc[f.type] = (acc[f.type] || 0) + 1; + return acc; + }, {} as Record); + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString(); + }; + + return ( +
+ {/* Header */} +
+
+

{session.name}

+

+ {formatMessage({ id: 'issues.discovery.sessionId' })}: {session.id} +

+
+ +
+ + {/* Status Badge */} +
+ + {formatMessage({ id: `issues.discovery.status.${session.status}` })} + + + {formatMessage({ id: 'issues.discovery.createdAt' })}: {formatDate(session.created_at)} + + {session.completed_at && ( + + {formatMessage({ id: 'issues.discovery.completedAt' })}: {formatDate(session.completed_at)} + + )} +
+ + {/* Progress Bar for Running Sessions */} + {session.status === 'running' && ( + +
+ {formatMessage({ id: 'issues.discovery.progress' })} + {session.progress}% +
+ +
+ )} + + {/* Tabs */} + + + + + {formatMessage({ id: 'issues.discovery.tabFindings' })} ({findings.length}) + + + + {formatMessage({ id: 'issues.discovery.tabProgress' })} + + + + {formatMessage({ id: 'issues.discovery.tabInfo' })} + + + + + + + + + +

+ {formatMessage({ id: 'issues.discovery.severityBreakdown' })} +

+
+ {Object.entries(severityCounts).map(([severity, count]) => ( +
+ + {formatMessage({ id: `issues.discovery.severity.${severity}` })} + + {count} +
+ ))} +
+
+ + {Object.keys(typeCounts).length > 0 && ( + +

+ {formatMessage({ id: 'issues.discovery.typeBreakdown' })} +

+
+ {Object.entries(typeCounts) + .sort(([, a], [, b]) => b - a) + .map(([type, count]) => ( +
+ {type} + {count} +
+ ))} +
+
+ )} +
+ + + +
+

+ {formatMessage({ id: 'issues.discovery.sessionId' })} +

+

{session.id}

+
+
+

+ {formatMessage({ id: 'issues.discovery.name' })} +

+

{session.name}

+
+
+

+ {formatMessage({ id: 'issues.discovery.status' })} +

+ + {formatMessage({ id: `issues.discovery.status.${session.status}` })} + +
+
+

+ {formatMessage({ id: 'issues.discovery.progress' })} +

+

{session.progress}%

+
+
+

+ {formatMessage({ id: 'issues.discovery.findingsCount' })} +

+

{session.findings_count}

+
+
+

+ {formatMessage({ id: 'issues.discovery.createdAt' })} +

+

{formatDate(session.created_at)}

+
+ {session.completed_at && ( +
+

+ {formatMessage({ id: 'issues.discovery.completedAt' })} +

+

{formatDate(session.completed_at)}

+
+ )} +
+
+
+
+ ); +} diff --git a/ccw/frontend/src/components/issue/discovery/FindingList.tsx b/ccw/frontend/src/components/issue/discovery/FindingList.tsx new file mode 100644 index 00000000..823c78d3 --- /dev/null +++ b/ccw/frontend/src/components/issue/discovery/FindingList.tsx @@ -0,0 +1,137 @@ +// ======================================== +// Finding List Component +// ======================================== +// Displays findings with filters and severity badges + +import { useIntl } from 'react-intl'; +import { Search, FileCode, AlertTriangle } from 'lucide-react'; +import { Card } from '@/components/ui/Card'; +import { Badge } from '@/components/ui/Badge'; +import { Input } from '@/components/ui/Input'; +import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select'; +import type { Finding } from '@/lib/api'; +import type { FindingFilters } from '@/hooks/useIssues'; + +interface FindingListProps { + findings: Finding[]; + filters: FindingFilters; + onFilterChange: (filters: FindingFilters) => void; +} + +const severityConfig = { + critical: { variant: 'destructive' as const, label: 'issues.discovery.severity.critical' }, + high: { variant: 'destructive' as const, label: 'issues.discovery.severity.high' }, + medium: { variant: 'warning' as const, label: 'issues.discovery.severity.medium' }, + low: { variant: 'secondary' as const, label: 'issues.discovery.severity.low' }, +}; + +export function FindingList({ findings, filters, onFilterChange }: FindingListProps) { + const { formatMessage } = useIntl(); + + // Extract unique types for filter + const uniqueTypes = Array.from(new Set(findings.map(f => f.type))).sort(); + + if (findings.length === 0) { + return ( + + +

+ {formatMessage({ id: 'issues.discovery.noFindings' })} +

+

+ {formatMessage({ id: 'issues.discovery.noFindingsDescription' })} +

+
+ ); + } + + return ( +
+ {/* Filters */} +
+
+ + onFilterChange({ ...filters, search: e.target.value || undefined })} + className="pl-9" + /> +
+ + {uniqueTypes.length > 0 && ( + + )} +
+ + {/* Findings List */} +
+ {findings.map((finding) => { + const config = severityConfig[finding.severity]; + return ( + +
+
+ + {formatMessage({ id: config.label })} + + {finding.type && ( + + {finding.type} + + )} +
+ {finding.file && ( +
+ + {finding.file} + {finding.line && :{finding.line}} +
+ )} +
+

{finding.title}

+

{finding.description}

+ {finding.code_snippet && ( +
+                  {finding.code_snippet}
+                
+ )} +
+ ); + })} +
+ + {/* Count */} +
+ {formatMessage({ id: 'issues.discovery.showingCount' }, { count: findings.length })} +
+
+ ); +} diff --git a/ccw/frontend/src/components/issue/discovery/index.ts b/ccw/frontend/src/components/issue/discovery/index.ts new file mode 100644 index 00000000..e3021ed5 --- /dev/null +++ b/ccw/frontend/src/components/issue/discovery/index.ts @@ -0,0 +1,7 @@ +// ======================================== +// Discovery Components Index +// ======================================== + +export { DiscoveryCard } from './DiscoveryCard'; +export { DiscoveryDetail } from './DiscoveryDetail'; +export { FindingList } from './FindingList'; diff --git a/ccw/frontend/src/components/issue/queue/ExecutionGroup.test.tsx b/ccw/frontend/src/components/issue/queue/ExecutionGroup.test.tsx new file mode 100644 index 00000000..ec3588c0 --- /dev/null +++ b/ccw/frontend/src/components/issue/queue/ExecutionGroup.test.tsx @@ -0,0 +1,162 @@ +// ======================================== +// ExecutionGroup Component Tests +// ======================================== +// Tests for the execution group component with i18n + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen } from '@/test/i18n'; +import userEvent from '@testing-library/user-event'; +import { ExecutionGroup } from './ExecutionGroup'; + +describe('ExecutionGroup', () => { + const defaultProps = { + group: 'group-1', + items: ['task1', 'task2'], + type: 'sequential' as const, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('with en locale', () => { + it('should render group name', () => { + render(, { locale: 'en' }); + expect(screen.getByText(/group-1/i)).toBeInTheDocument(); + }); + + it('should show sequential badge', () => { + render(, { locale: 'en' }); + expect(screen.getByText(/Sequential/i)).toBeInTheDocument(); + }); + + it('should show parallel badge for parallel type', () => { + render(, { locale: 'en' }); + expect(screen.getByText(/Parallel/i)).toBeInTheDocument(); + }); + + it('should show items count', () => { + render(, { locale: 'en' }); + expect(screen.getByText(/2 items/i)).toBeInTheDocument(); + }); + + it('should render item list', () => { + render(, { locale: 'en' }); + expect(screen.getByText('task1')).toBeInTheDocument(); + expect(screen.getByText('task2')).toBeInTheDocument(); + }); + }); + + describe('with zh locale', () => { + it('should render group name', () => { + render(, { locale: 'zh' }); + expect(screen.getByText(/group-1/i)).toBeInTheDocument(); + }); + + it('should show translated sequential badge', () => { + render(, { locale: 'zh' }); + expect(screen.getByText(/顺序/i)).toBeInTheDocument(); + }); + + it('should show translated parallel badge', () => { + render(, { locale: 'zh' }); + expect(screen.getByText(/并行/i)).toBeInTheDocument(); + }); + + it('should show items count in Chinese', () => { + render(, { locale: 'zh' }); + expect(screen.getByText(/1 item/i)).toBeInTheDocument(); // "item" is not translated in the component + }); + + it('should render item list', () => { + render(, { locale: 'zh' }); + expect(screen.getByText('task1')).toBeInTheDocument(); + expect(screen.getByText('task2')).toBeInTheDocument(); + }); + }); + + describe('interaction', () => { + it('should expand and collapse on click', async () => { + const user = userEvent.setup(); + render(, { locale: 'en' }); + + // Initially expanded, items should be visible + expect(screen.getByText('task1')).toBeInTheDocument(); + + // Click to collapse + const header = screen.getByText(/group-1/i).closest('div'); + if (header) { + await user.click(header); + } + + // After collapse, items should not be visible (group collapses) + // Note: The component uses state internally, so we need to test differently + }); + + it('should be clickable via header', () => { + render(, { locale: 'en' }); + const cardHeader = screen.getByText(/group-1/i).closest('.cursor-pointer'); + expect(cardHeader).toBeInTheDocument(); + expect(cardHeader).toHaveClass('cursor-pointer'); + }); + }); + + describe('sequential numbering', () => { + it('should show numbered items for sequential type', () => { + render(, { locale: 'en' }); + + // Sequential items should have numbers + const itemElements = document.querySelectorAll('.font-mono'); + expect(itemElements.length).toBe(3); + }); + + it('should not show numbers for parallel type', () => { + render(, { locale: 'en' }); + + // Parallel items should not have numbers in the numbering position + const numberElements = document.querySelectorAll('.text-muted-foreground.text-xs'); + // In parallel mode, the numbering position should be empty + }); + }); + + describe('empty state', () => { + it('should handle empty items array', () => { + render(, { locale: 'en' }); + expect(screen.getByText(/0 items/i)).toBeInTheDocument(); + }); + + it('should handle single item', () => { + render(, { locale: 'en' }); + expect(screen.getByText(/1 item/i)).toBeInTheDocument(); + expect(screen.getByText('task1')).toBeInTheDocument(); + }); + }); + + describe('accessibility', () => { + it('should have clickable header with proper cursor', () => { + render(, { locale: 'en' }); + const header = screen.getByText(/group-1/i).closest('.cursor-pointer'); + expect(header).toHaveClass('cursor-pointer'); + }); + + it('should render expandable indicator icon', () => { + const { container } = render(, { locale: 'en' }); + // ChevronDown or ChevronRight should be present + const chevron = container.querySelector('.lucide-chevron-down, .lucide-chevron-right'); + expect(chevron).toBeInTheDocument(); + }); + }); + + describe('parallel layout', () => { + it('should use grid layout for parallel groups', () => { + const { container } = render( + , + { locale: 'en' } + ); + + // Check for grid class (sm:grid-cols-2) + const gridContainer = container.querySelector('.grid'); + expect(gridContainer).toBeInTheDocument(); + }); + }); +}); diff --git a/ccw/frontend/src/components/issue/queue/ExecutionGroup.tsx b/ccw/frontend/src/components/issue/queue/ExecutionGroup.tsx new file mode 100644 index 00000000..ff3579f2 --- /dev/null +++ b/ccw/frontend/src/components/issue/queue/ExecutionGroup.tsx @@ -0,0 +1,93 @@ +// ======================================== +// ExecutionGroup Component +// ======================================== +// Expandable execution group for queue items + +import { useState } from 'react'; +import { useIntl } from 'react-intl'; +import { ChevronDown, ChevronRight, GitMerge, ArrowRight } from 'lucide-react'; +import { Card, CardHeader } from '@/components/ui/Card'; +import { Badge } from '@/components/ui/Badge'; +import { cn } from '@/lib/utils'; + +// ========== Types ========== + +export interface ExecutionGroupProps { + group: string; + items: string[]; + type?: 'parallel' | 'sequential'; +} + +// ========== Component ========== + +export function ExecutionGroup({ group, items, type = 'sequential' }: ExecutionGroupProps) { + const { formatMessage } = useIntl(); + const [isExpanded, setIsExpanded] = useState(true); + const isParallel = type === 'parallel'; + + return ( + + setIsExpanded(!isExpanded)} + > +
+
+ {isExpanded ? ( + + ) : ( + + )} + + {isParallel ? ( + + ) : ( + + )} + {group} + + + {isParallel + ? formatMessage({ id: 'issues.queue.parallelGroup' }) + : formatMessage({ id: 'issues.queue.sequentialGroup' })} + +
+ + {items.length} {items.length === 1 ? 'item' : 'items'} + +
+
+ + {isExpanded && ( +
+
+ {items.map((item, index) => ( +
+ + {isParallel ? '' : `${index + 1}.`} + + + {item} + +
+ ))} +
+
+ )} +
+ ); +} + +export default ExecutionGroup; diff --git a/ccw/frontend/src/components/issue/queue/QueueActions.tsx b/ccw/frontend/src/components/issue/queue/QueueActions.tsx new file mode 100644 index 00000000..a73d8666 --- /dev/null +++ b/ccw/frontend/src/components/issue/queue/QueueActions.tsx @@ -0,0 +1,234 @@ +// ======================================== +// QueueActions Component +// ======================================== +// Queue operations menu component with delete confirmation and merge dialog + +import { useState } from 'react'; +import { useIntl } from 'react-intl'; +import { Play, Pause, Trash2, Merge, Loader2 } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, +} from '@/components/ui/Dropdown'; +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogAction, + AlertDialogCancel, +} from '@/components/ui/AlertDialog'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/Dialog'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import type { IssueQueue } from '@/lib/api'; + +// ========== Types ========== + +export interface QueueActionsProps { + queue: IssueQueue; + isActive?: boolean; + onActivate?: (queueId: string) => void; + onDeactivate?: () => void; + onDelete?: (queueId: string) => void; + onMerge?: (sourceId: string, targetId: string) => void; + isActivating?: boolean; + isDeactivating?: boolean; + isDeleting?: boolean; + isMerging?: boolean; +} + +// ========== Component ========== + +export function QueueActions({ + queue, + isActive = false, + onActivate, + onDeactivate, + onDelete, + onMerge, + isActivating = false, + isDeactivating = false, + isDeleting = false, + isMerging = false, +}: QueueActionsProps) { + const { formatMessage } = useIntl(); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const [isMergeOpen, setIsMergeOpen] = useState(false); + const [mergeTargetId, setMergeTargetId] = useState(''); + + // Get queue ID - IssueQueue interface doesn't have an id field, using tasks array as key + const queueId = queue.tasks.join(',') || queue.solutions.join(','); + + const handleDelete = () => { + onDelete?.(queueId); + setIsDeleteOpen(false); + }; + + const handleMerge = () => { + if (mergeTargetId.trim()) { + onMerge?.(queueId, mergeTargetId.trim()); + setIsMergeOpen(false); + setMergeTargetId(''); + } + }; + + return ( + <> + + + + + + {!isActive && onActivate && ( + onActivate(queueId)} disabled={isActivating}> + {isActivating ? ( + + ) : ( + + )} + {formatMessage({ id: 'issues.queue.actions.activate' })} + + )} + {isActive && onDeactivate && ( + onDeactivate()} disabled={isDeactivating}> + {isDeactivating ? ( + + ) : ( + + )} + {formatMessage({ id: 'issues.queue.actions.deactivate' })} + + )} + setIsMergeOpen(true)} disabled={isMerging}> + {isMerging ? ( + + ) : ( + + )} + {formatMessage({ id: 'issues.queue.actions.merge' })} + + + setIsDeleteOpen(true)} + disabled={isDeleting} + className="text-destructive" + > + {isDeleting ? ( + + ) : ( + + )} + {formatMessage({ id: 'issues.queue.actions.delete' })} + + + + + {/* Delete Confirmation Dialog */} + + + + + {formatMessage({ id: 'issues.queue.deleteDialog.title' })} + + + {formatMessage({ id: 'issues.queue.deleteDialog.description' })} + + + + + {formatMessage({ id: 'common.actions.cancel' })} + + + {isDeleting ? ( + <> + + {formatMessage({ id: 'common.actions.deleting' })} + + ) : ( + formatMessage({ id: 'issues.queue.actions.delete' }) + )} + + + + + + {/* Merge Dialog */} + + + + + {formatMessage({ id: 'issues.queue.mergeDialog.title' })} + + +
+
+ + setMergeTargetId(e.target.value)} + placeholder={formatMessage({ id: 'issues.queue.mergeDialog.targetQueuePlaceholder' })} + className="mt-1" + /> +
+
+ + + + +
+
+ + ); +} + +export default QueueActions; diff --git a/ccw/frontend/src/components/issue/queue/QueueCard.test.tsx b/ccw/frontend/src/components/issue/queue/QueueCard.test.tsx new file mode 100644 index 00000000..d765124b --- /dev/null +++ b/ccw/frontend/src/components/issue/queue/QueueCard.test.tsx @@ -0,0 +1,196 @@ +// ======================================== +// QueueCard Component Tests +// ======================================== +// Tests for the queue card component with i18n + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen } from '@/test/i18n'; +import { QueueCard } from './QueueCard'; +import type { IssueQueue } from '@/lib/api'; + +describe('QueueCard', () => { + const mockQueue: IssueQueue = { + tasks: ['task1', 'task2'], + solutions: ['solution1'], + conflicts: [], + execution_groups: { 'group-1': ['task1', 'task2'] }, + grouped_items: { 'parallel-group': ['task1', 'task2'] }, + }; + + const defaultProps = { + queue: mockQueue, + isActive: false, + onActivate: vi.fn(), + onDeactivate: vi.fn(), + onDelete: vi.fn(), + onMerge: vi.fn(), + isActivating: false, + isDeactivating: false, + isDeleting: false, + isMerging: false, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('with en locale', () => { + it('should render queue name', () => { + render(, { locale: 'en' }); + expect(screen.getByText(/Queue/i)).toBeInTheDocument(); + }); + + it('should render stats', () => { + render(, { locale: 'en' }); + expect(screen.getAllByText(/Items/i).length).toBeGreaterThan(0); + expect(screen.getByText(/3/i)).toBeInTheDocument(); // total items: 2 tasks + 1 solution + expect(screen.getAllByText(/Groups/i).length).toBeGreaterThan(0); + // Note: "1" appears multiple times, so we just check the total items count (3) exists + }); + + it('should render execution groups', () => { + render(, { locale: 'en' }); + expect(screen.getAllByText(/Execution/i).length).toBeGreaterThan(0); + }); + + it('should show active badge when isActive', () => { + render(, { locale: 'en' }); + expect(screen.getByText(/Active/i)).toBeInTheDocument(); + }); + }); + + describe('with zh locale', () => { + it('should render translated queue name', () => { + render(, { locale: 'zh' }); + expect(screen.getByText(/队列/i)).toBeInTheDocument(); + }); + + it('should render translated stats', () => { + render(, { locale: 'zh' }); + expect(screen.getAllByText(/项目/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/执行组/i).length).toBeGreaterThan(0); + }); + + it('should render translated execution groups', () => { + render(, { locale: 'zh' }); + expect(screen.getAllByText(/执行/i).length).toBeGreaterThan(0); + }); + + it('should show translated active badge when isActive', () => { + render(, { locale: 'zh' }); + expect(screen.getByText(/活跃/i)).toBeInTheDocument(); + }); + }); + + describe('conflicts warning', () => { + it('should show conflicts warning when conflicts exist', () => { + const queueWithConflicts: IssueQueue = { + ...mockQueue, + conflicts: ['conflict1', 'conflict2'], + }; + + render( + , + { locale: 'en' } + ); + + expect(screen.getByText(/2 conflicts/i)).toBeInTheDocument(); + }); + + it('should show translated conflicts warning in Chinese', () => { + const queueWithConflicts: IssueQueue = { + ...mockQueue, + conflicts: ['conflict1'], + }; + + render( + , + { locale: 'zh' } + ); + + expect(screen.getByText(/1 冲突/i)).toBeInTheDocument(); + }); + }); + + describe('empty state', () => { + it('should show empty state when no items', () => { + const emptyQueue: IssueQueue = { + tasks: [], + solutions: [], + conflicts: [], + execution_groups: {}, + grouped_items: {}, + }; + + render( + , + { locale: 'en' } + ); + + expect(screen.getByText(/No items in queue/i)).toBeInTheDocument(); + }); + + it('should show translated empty state in Chinese', () => { + const emptyQueue: IssueQueue = { + tasks: [], + solutions: [], + conflicts: [], + execution_groups: {}, + grouped_items: {}, + }; + + render( + , + { locale: 'zh' } + ); + + expect(screen.getByText(/队列中无项目/i)).toBeInTheDocument(); + }); + }); + + describe('accessibility', () => { + it('should have proper card structure', () => { + const { container } = render(, { locale: 'en' }); + const card = container.querySelector('[class*="rounded-lg"]'); + expect(card).toBeInTheDocument(); + }); + + it('should have accessible title', () => { + render(, { locale: 'en' }); + const title = screen.getByText(/Queue/i); + expect(title).toBeInTheDocument(); + }); + }); + + describe('visual states', () => { + it('should apply active styles when isActive', () => { + const { container } = render( + , + { locale: 'en' } + ); + const card = container.firstChild as HTMLElement; + expect(card.className).toContain('border-primary'); + }); + + it('should not apply active styles when not active', () => { + const { container } = render( + , + { locale: 'en' } + ); + const card = container.firstChild as HTMLElement; + expect(card.className).not.toContain('border-primary'); + }); + }); +}); diff --git a/ccw/frontend/src/components/issue/queue/QueueCard.tsx b/ccw/frontend/src/components/issue/queue/QueueCard.tsx new file mode 100644 index 00000000..1d9fe69d --- /dev/null +++ b/ccw/frontend/src/components/issue/queue/QueueCard.tsx @@ -0,0 +1,163 @@ +// ======================================== +// QueueCard Component +// ======================================== +// Card component for displaying queue information and actions + +import { useIntl } from 'react-intl'; +import { ListTodo, CheckCircle2, AlertCircle } from 'lucide-react'; +import { Card } from '@/components/ui/Card'; +import { Badge } from '@/components/ui/Badge'; +import { ExecutionGroup } from './ExecutionGroup'; +import { QueueActions } from './QueueActions'; +import { cn } from '@/lib/utils'; +import type { IssueQueue } from '@/lib/api'; + +// ========== Types ========== + +export interface QueueCardProps { + queue: IssueQueue; + isActive?: boolean; + onActivate?: (queueId: string) => void; + onDeactivate?: () => void; + onDelete?: (queueId: string) => void; + onMerge?: (sourceId: string, targetId: string) => void; + isActivating?: boolean; + isDeactivating?: boolean; + isDeleting?: boolean; + isMerging?: boolean; + className?: string; +} + +// ========== Component ========== + +export function QueueCard({ + queue, + isActive = false, + onActivate, + onDeactivate, + onDelete, + onMerge, + isActivating = false, + isDeactivating = false, + isDeleting = false, + isMerging = false, + className, +}: QueueCardProps) { + const { formatMessage } = useIntl(); + + // Get queue ID - IssueQueue interface doesn't have an id field, using tasks array as key + const queueId = queue.tasks.join(',') || queue.solutions.join(','); + + // Calculate item counts + const taskCount = queue.tasks?.length || 0; + const solutionCount = queue.solutions?.length || 0; + const conflictCount = queue.conflicts?.length || 0; + const totalItems = taskCount + solutionCount; + const groupCount = Object.keys(queue.grouped_items || {}).length; + + // Get execution groups from grouped_items + const executionGroups = Object.entries(queue.grouped_items || {}).map(([name, items]) => ({ + id: name, + type: name.toLowerCase().includes('parallel') ? 'parallel' as const : 'sequential' as const, + items: items || [], + })); + + return ( + + {/* Header */} +
+
+
+ +
+
+

+ {formatMessage({ id: 'issues.queue.title' })} +

+

+ {queueId.substring(0, 20)}{queueId.length > 20 ? '...' : ''} +

+
+
+ + {isActive && ( + + + {formatMessage({ id: 'issues.queue.status.active' })} + + )} + + +
+ + {/* Stats */} +
+
+ {formatMessage({ id: 'issues.queue.items' })}: + {totalItems} +
+
+ {formatMessage({ id: 'issues.queue.groups' })}: + {groupCount} +
+
+ + {/* Conflicts Warning */} + {conflictCount > 0 && ( +
+ + + {conflictCount} {formatMessage({ id: 'issues.queue.conflicts' })} + +
+ )} + + {/* Execution Groups */} + {executionGroups.length > 0 && ( +
+

+ {formatMessage({ id: 'issues.queue.executionGroups' })} +

+
+ {executionGroups.map((group) => ( + + ))} +
+
+ )} + + {/* Empty State */} + {executionGroups.length === 0 && totalItems === 0 && ( +
+ +

{formatMessage({ id: 'issues.queue.empty' })}

+
+ )} +
+ ); +} + +export default QueueCard; diff --git a/ccw/frontend/src/components/issue/queue/index.ts b/ccw/frontend/src/components/issue/queue/index.ts new file mode 100644 index 00000000..8de02716 --- /dev/null +++ b/ccw/frontend/src/components/issue/queue/index.ts @@ -0,0 +1,12 @@ +// ======================================== +// Queue Components Barrel Export +// ======================================== + +export { QueueCard } from './QueueCard'; +export type { QueueCardProps } from './QueueCard'; + +export { ExecutionGroup } from './ExecutionGroup'; +export type { ExecutionGroupProps } from './ExecutionGroup'; + +export { QueueActions } from './QueueActions'; +export type { QueueActionsProps } from './QueueActions'; diff --git a/ccw/frontend/src/components/layout/AppShell.tsx b/ccw/frontend/src/components/layout/AppShell.tsx index e34604f9..1e139bfe 100644 --- a/ccw/frontend/src/components/layout/AppShell.tsx +++ b/ccw/frontend/src/components/layout/AppShell.tsx @@ -4,6 +4,7 @@ // Root layout component combining Header, Sidebar, and MainContent import { useState, useCallback, useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; import { cn } from '@/lib/utils'; import { Header } from './Header'; import { Sidebar } from './Sidebar'; @@ -12,13 +13,12 @@ import { CliStreamMonitor } from '@/components/shared/CliStreamMonitor'; import { NotificationPanel } from '@/components/notification'; import { AskQuestionDialog } from '@/components/a2ui/AskQuestionDialog'; import { useNotificationStore, selectCurrentQuestion } from '@/stores'; +import { useWorkflowStore } from '@/stores/workflowStore'; import { useWebSocketNotifications } from '@/hooks'; export interface AppShellProps { /** Initial sidebar collapsed state */ defaultCollapsed?: boolean; - /** Current project path to display in header */ - projectPath?: string; /** Callback for refresh action */ onRefresh?: () => void; /** Whether refresh is in progress */ @@ -32,11 +32,32 @@ const SIDEBAR_COLLAPSED_KEY = 'ccw-sidebar-collapsed'; export function AppShell({ defaultCollapsed = false, - projectPath = '', onRefresh, isRefreshing = false, children, }: AppShellProps) { + // Workspace initialization from URL query parameter + const switchWorkspace = useWorkflowStore((state) => state.switchWorkspace); + const projectPath = useWorkflowStore((state) => state.projectPath); + const location = useLocation(); + + // Initialize workspace from URL path parameter on mount + useEffect(() => { + // Only initialize if no workspace is currently set + if (projectPath) return; + + // Read path from URL query parameter + const searchParams = new URLSearchParams(location.search); + const pathParam = searchParams.get('path'); + + if (pathParam) { + console.log('[AppShell] Initializing workspace from URL:', pathParam); + switchWorkspace(pathParam).catch((error) => { + console.error('[AppShell] Failed to initialize workspace:', error); + }); + } + }, [location.search, projectPath, switchWorkspace]); + // Sidebar collapse state (persisted) const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { if (typeof window !== 'undefined') { @@ -120,7 +141,6 @@ export function AppShell({ {/* Header - fixed at top */}
void; - /** Current project path */ - projectPath?: string; /** Callback for refresh action */ onRefresh?: () => void; /** Whether refresh is in progress */ @@ -42,7 +39,6 @@ export interface HeaderProps { export function Header({ onMenuClick, - projectPath = '', onRefresh, isRefreshing = false, onCliMonitorClick, @@ -112,7 +108,7 @@ export function Header({ {/* Workspace selector */} - {projectPath && } + {/* Notification badge */} +
+ {/* Mark All Read button */} + {hasNotifications && ( + + )} + {/* Clear All button */} + {hasNotifications && ( + + )} + {/* Close button */} + +
); } -interface PanelActionsProps { - hasNotifications: boolean; - hasUnread: boolean; - onMarkAllRead: () => void; - onClearAll: () => void; +// ========== Helper Components for Attachments and Actions ========== + +interface NotificationAttachmentItemProps { + attachment: NotificationAttachment; } -function PanelActions({ hasNotifications, hasUnread, onMarkAllRead, onClearAll }: PanelActionsProps) { +function NotificationAttachmentItem({ attachment }: NotificationAttachmentItemProps) { const { formatMessage } = useIntl(); - if (!hasNotifications) return null; + // Format file size + function formatFileSize(bytes?: number): string { + if (!bytes) return ''; + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + } + + // Render different attachment types + switch (attachment.type) { + case 'image': + return ( +
+ {attachment.url ? ( + {attachment.filename + ) : attachment.content ? ( + {attachment.filename + ) : null} + {attachment.filename && ( +
+ {attachment.filename} +
+ )} +
+ ); + + case 'code': + return ( +
+
+
+ + + {attachment.filename || formatMessage({ id: 'notifications.attachments.code' }) || 'Code'} + +
+ {attachment.mimeType && ( + + {attachment.mimeType.replace('text/', '').replace('application/', '')} + + )} +
+ {attachment.content && ( +
+              {attachment.content}
+            
+ )} +
+ ); + + case 'file': + return ( +
+ +
+
+ {attachment.filename || formatMessage({ id: 'notifications.attachments.file' }) || 'File'} +
+ {attachment.size && ( +
+ {formatFileSize(attachment.size)} +
+ )} +
+ {attachment.url && ( + + )} +
+ ); + + case 'data': + return ( +
+
+ + + {formatMessage({ id: 'notifications.attachments.data' }) || 'Data'} + +
+ {attachment.content && ( +
+              
+                {JSON.stringify(JSON.parse(attachment.content), null, 2)}
+              
+            
+ )} +
+ ); + + default: + return null; + } +} + +interface NotificationActionsProps { + actions: NotificationAction[]; +} + +function NotificationActions({ actions }: NotificationActionsProps) { + const { formatMessage } = useIntl(); + const [actionStates, setActionStates] = useState>({}); + const [retryCounts, setRetryCounts] = useState>({}); + + const handleActionClick = useCallback( + async (action: NotificationAction, index: number) => { + const actionKey = `${index}-${action.label}`; + + // Skip if already loading + if (actionStates[actionKey] === 'loading') { + return; + } + + // Handle confirmation if present + if (action.confirm) { + const confirmed = window.confirm( + action.confirm.message || action.label + ); + if (!confirmed) { + return; + } + } + + // Set loading state + setActionStates((prev) => ({ ...prev, [actionKey]: 'loading' })); + + try { + // Call the action handler + await action.onClick(); + + // Set success state + setActionStates((prev) => ({ ...prev, [actionKey]: 'success' })); + + // Reset after 2 seconds + setTimeout(() => { + setActionStates((prev) => ({ ...prev, [actionKey]: 'idle' })); + }, 2000); + } catch (error) { + // Set error state + setActionStates((prev) => ({ ...prev, [actionKey]: 'error' })); + + // Increment retry count + setRetryCounts((prev) => ({ + ...prev, + [actionKey]: (prev[actionKey] || 0) + 1, + })); + + // Log error + console.error('[NotificationActions] Action failed:', error); + } + }, + [actionStates] + ); + + const getActionButtonContent = (action: NotificationAction, index: number) => { + const actionKey = `${index}-${action.label}`; + const state = actionStates[actionKey]; + const retryCount = retryCounts[actionKey] || 0; + + switch (state) { + case 'loading': + return ( + <> + + {formatMessage({ id: 'notifications.actions.loading' }) || 'Loading...'} + + ); + case 'success': + return ( + <> + + {formatMessage({ id: 'notifications.actions.success' }) || 'Done'} + + ); + case 'error': + return ( + <> + + {formatMessage({ id: 'notifications.actions.retry' }) || 'Retry'} + {retryCount > 0 && ( + + ({retryCount}) + + )} + + ); + default: + return action.label; + } + }; + + if (actions.length === 0) return null; return ( -
- - +
+ {actions.map((action, index) => { + const actionKey = `${index}-${action.label}`; + const state = actionStates[actionKey]; + + return ( + + ); + })}
); } @@ -155,22 +459,31 @@ function PanelActions({ hasNotifications, hasUnread, onMarkAllRead, onClearAll } interface NotificationItemProps { notification: Toast; onDelete: (id: string) => void; + onToggleRead?: (id: string) => void; } -function NotificationItem({ notification, onDelete }: NotificationItemProps) { +function NotificationItem({ notification, onDelete, onToggleRead }: NotificationItemProps) { const [isExpanded, setIsExpanded] = useState(false); const hasDetails = notification.message && notification.message.length > 100; const { formatMessage } = useIntl(); + const isRead = notification.read ?? false; + const hasActions = notification.actions && notification.actions.length > 0; + const hasLegacyAction = notification.action && !hasActions; + const hasAttachments = notification.attachments && notification.attachments.length > 0; // Check if this is an A2UI notification const isA2UI = notification.type === 'a2ui' && notification.a2uiSurface; + // Format absolute timestamp + const absoluteTime = new Date(notification.timestamp).toLocaleString(); + return (
@@ -179,14 +492,59 @@ function NotificationItem({ notification, onDelete }: NotificationItemProps) { {/* Content */}
+ {/* Header row: title + actions */}
-

- {notification.title} -

+
+ {/* Title with source badge */} +
+

+ {notification.title} +

+ {/* Source badge */} + {notification.source && ( + + {notification.source} + + )} +
+ + {/* Timestamp row: absolute + relative */} +
+ + {absoluteTime} + + + ({formatTimeAgo(notification.timestamp, formatMessage)}) + +
+
+ + {/* Action buttons */}
- - {formatTimeAgo(notification.timestamp, formatMessage)} - + {/* Read/unread toggle */} + {onToggleRead && ( + + )} + {/* Delete button */} )} - {/* Action button */} - {notification.action && ( + {/* Attachments */} + {hasAttachments && notification.attachments && ( +
+ {notification.attachments.map((attachment, index) => ( + + ))} +
+ )} + + {/* Action buttons (new actions array) */} + {hasActions && notification.actions && ( + + )} + + {/* Legacy single action button */} + {hasLegacyAction && notification.action && (
@@ -287,11 +664,11 @@ function EmptyState({ message }: EmptyStateProps) {

{message || - formatMessage({ id: 'notificationPanel.empty' }) || + formatMessage({ id: 'notifications.empty' }) || 'No notifications'}

- {formatMessage({ id: 'notificationPanel.emptyHint' }) || + {formatMessage({ id: 'notifications.emptyHint' }) || 'Notifications will appear here'}

@@ -317,8 +694,11 @@ export function NotificationPanel({ isOpen, onClose }: NotificationPanelProps) { const clearPersistentNotifications = useNotificationStore( (state) => state.clearPersistentNotifications ); + const toggleNotificationRead = useNotificationStore( + (state) => state.toggleNotificationRead + ); - // Check if markAllAsRead exists (will be added in T5) + // Check if markAllAsRead exists const store = useNotificationStore.getState(); const markAllAsRead = 'markAllAsRead' in store ? (store.markAllAsRead as () => void) : undefined; @@ -362,6 +742,14 @@ export function NotificationPanel({ isOpen, onClose }: NotificationPanelProps) { clearPersistentNotifications(); }, [clearPersistentNotifications]); + // Toggle read handler + const handleToggleRead = useCallback( + (id: string) => { + toggleNotificationRead(id); + }, + [toggleNotificationRead] + ); + // ESC key to close useEffect(() => { const handleEsc = (e: KeyboardEvent) => { @@ -373,9 +761,8 @@ export function NotificationPanel({ isOpen, onClose }: NotificationPanelProps) { return () => window.removeEventListener('keydown', handleEsc); }, [isOpen, onClose]); - // Check for unread notifications (will be enhanced in T5 with read field) - // For now, all notifications are considered "unread" for UI purposes - const hasUnread = sortedNotifications.length > 0; + // Check for unread notifications based on read field + const hasUnread = sortedNotifications.some((n) => !n.read); if (!isOpen) { return null; @@ -403,13 +790,12 @@ export function NotificationPanel({ isOpen, onClose }: NotificationPanelProps) { aria-modal="true" aria-labelledby="notification-panel-title" > - {/* Header */} - - - {/* Action Bar */} - 0} hasUnread={hasUnread} + onClose={onClose} onMarkAllRead={handleMarkAllRead} onClearAll={handleClearAll} /> @@ -419,6 +805,7 @@ export function NotificationPanel({ isOpen, onClose }: NotificationPanelProps) { ) : ( diff --git a/ccw/frontend/src/components/shared/CliStreamMonitor.tsx b/ccw/frontend/src/components/shared/CliStreamMonitor.tsx index 61c2fb32..e0fc054d 100644 --- a/ccw/frontend/src/components/shared/CliStreamMonitor.tsx +++ b/ccw/frontend/src/components/shared/CliStreamMonitor.tsx @@ -26,6 +26,7 @@ import { Button } from '@/components/ui/Button'; import { Badge } from '@/components/ui/Badge'; import { Input } from '@/components/ui/Input'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/Tabs'; +import { LogBlockList } from '@/components/shared/LogBlock'; import { useCliStreamStore, type CliOutputLine } from '@/stores/cliStreamStore'; import { useNotificationStore, selectWsLastMessage } from '@/stores'; import { useActiveCliExecutions, useInvalidateActiveCliExecutions } from '@/hooks/useActiveCliExecutions'; @@ -126,6 +127,7 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) { const [searchQuery, setSearchQuery] = useState(''); const [autoScroll, setAutoScroll] = useState(true); const [isUserScrolling, setIsUserScrolling] = useState(false); + const [viewMode, setViewMode] = useState<'list' | 'blocks'>('list'); // Store state const executions = useCliStreamStore((state) => state.executions); @@ -416,6 +418,17 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) { )}
+ {/* View Mode Toggle */} + setViewMode(v as 'list' | 'blocks')}> + + + List + + + Blocks + + + {currentExecution && ( <> @@ -443,40 +456,48 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
- {/* Output Content */} + {/* Output Content - Based on viewMode */} {currentExecution ? ( -
- {filteredOutput.length === 0 ? ( -
- {searchQuery ? 'No matching output found' : 'Waiting for output...'} +
+ {viewMode === 'blocks' ? ( +
+
) : ( -
- {filteredOutput.map((line, index) => ( -
- - {getOutputLineIcon(line.type)} - - {line.content} -
- ))} -
-
- )} - {isUserScrolling && filteredOutput.length > 0 && ( - + {filteredOutput.length === 0 ? ( +
+ {searchQuery ? 'No matching output found' : 'Waiting for output...'} +
+ ) : ( +
+ {filteredOutput.map((line, index) => ( +
+ + {getOutputLineIcon(line.type)} + + {line.content} +
+ ))} +
+
+ )} + {isUserScrolling && filteredOutput.length > 0 && ( + + )} +
)}
) : ( diff --git a/ccw/frontend/src/components/shared/Flowchart.tsx b/ccw/frontend/src/components/shared/Flowchart.tsx index 11756592..0abd7b14 100644 --- a/ccw/frontend/src/components/shared/Flowchart.tsx +++ b/ccw/frontend/src/components/shared/Flowchart.tsx @@ -183,16 +183,24 @@ export function Flowchart({ flowControl, className = '' }: FlowchartProps) { implSteps.forEach((step, idx) => { const nodeId = `impl-${idx}`; + + // Handle both string and ImplementationStep types + const isString = typeof step === 'string'; + const label = isString ? step : (step.title || `Step ${step.step}`); + const description = isString ? undefined : step.description; + const stepNumber = isString ? (idx + 1) : step.step; + const dependsOn = isString ? undefined : step.depends_on?.map((d: number | string) => `impl-${Number(d) - 1}`); + initialNodes.push({ id: nodeId, type: 'custom', position: { x: 0, y: currentY }, data: { - label: step.title || `Step ${step.step}`, - description: step.description, - step: step.step, + label, + description, + step: stepNumber, type: 'implementation' as const, - dependsOn: step.depends_on?.map(d => `impl-${d - 1}`), + dependsOn, }, }); @@ -217,9 +225,9 @@ export function Flowchart({ flowControl, className = '' }: FlowchartProps) { } // Dependency edges - if (step.depends_on && step.depends_on.length > 0) { - step.depends_on.forEach(depIdx => { - const depNodeId = `impl-${depIdx - 1}`; + if (!isString && step.depends_on && step.depends_on.length > 0) { + step.depends_on.forEach((depIdx: number | string) => { + const depNodeId = `impl-${Number(depIdx) - 1}`; initialEdges.push({ id: `dep-${depIdx}-${idx}`, source: depNodeId, @@ -285,16 +293,16 @@ export function Flowchart({ flowControl, className = '' }: FlowchartProps) { zoomOnScroll={true} panOnScroll={true} > - - + + { const data = node.data as FlowchartNodeData; - if (data.type === 'section') return '#e5e7eb'; + if (data.type === 'section') return '#9ca3af'; if (data.type === 'pre-analysis') return '#f59e0b'; return '#3b82f6'; }} - className="!bg-background !border-border" + className="!bg-card !border-border !rounded !shadow-sm" />
diff --git a/ccw/frontend/src/components/shared/LogBlock/LogBlock.tsx b/ccw/frontend/src/components/shared/LogBlock/LogBlock.tsx new file mode 100644 index 00000000..b5049888 --- /dev/null +++ b/ccw/frontend/src/components/shared/LogBlock/LogBlock.tsx @@ -0,0 +1,260 @@ +// ======================================== +// LogBlock Component +// ======================================== + +import React, { memo } from 'react'; +import { + ChevronDown, + ChevronUp, + Copy, + RotateCcw, + CheckCircle, + AlertCircle, + Loader2, + Clock, + Brain, + Settings, + Info, + MessageCircle, + Wrench, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/Button'; +import { Badge } from '@/components/ui/Badge'; +import type { LogBlockProps, LogLine } from './types'; + +// Re-use output line styling helpers from CliStreamMonitor +function getOutputLineIcon(type: LogLine['type']) { + switch (type) { + case 'thought': + return ; + case 'system': + return ; + case 'stderr': + return ; + case 'metadata': + return ; + case 'tool_call': + return ; + case 'stdout': + default: + return ; + } +} + +function getOutputLineClass(type: LogLine['type']): string { + switch (type) { + case 'thought': + return 'text-purple-400'; + case 'system': + return 'text-blue-400'; + case 'stderr': + return 'text-red-400'; + case 'metadata': + return 'text-yellow-400'; + case 'tool_call': + return 'text-green-400'; + case 'stdout': + default: + return 'text-foreground'; + } +} + +function getBlockBorderClass(status: LogBlockProps['block']['status']): string { + switch (status) { + case 'running': + return 'border-l-4 border-l-blue-500'; + case 'completed': + return 'border-l-4 border-l-green-500'; + case 'error': + return 'border-l-4 border-l-red-500'; + case 'pending': + return 'border-l-4 border-l-yellow-500'; + default: + return 'border-l-4 border-l-border'; + } +} + +function getBlockTypeColor(type: LogBlockProps['block']['type']): string { + switch (type) { + case 'command': + return 'text-blue-400'; + case 'tool': + return 'text-green-400'; + case 'output': + return 'text-foreground'; + case 'error': + return 'text-red-400'; + case 'warning': + return 'text-yellow-400'; + case 'info': + return 'text-cyan-400'; + default: + return 'text-foreground'; + } +} + +function getStatusBadgeVariant(status: LogBlockProps['block']['status']): 'default' | 'secondary' | 'destructive' | 'success' | 'warning' | 'info' | 'outline' { + switch (status) { + case 'running': + return 'info'; + case 'completed': + return 'success'; + case 'error': + return 'destructive'; + case 'pending': + return 'warning'; + default: + return 'secondary'; + } +} + +function getStatusIcon(status: LogBlockProps['block']['status']) { + switch (status) { + case 'running': + return ; + case 'completed': + return ; + case 'error': + return ; + case 'pending': + return ; + default: + return null; + } +} + +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + if (minutes < 60) return `${minutes}m ${remainingSeconds}s`; + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return `${hours}h ${remainingMinutes}m`; +} + +export const LogBlock = memo(function LogBlock({ + block, + isExpanded, + onToggleExpand, + onCopyCommand, + onCopyOutput, + onReRun, + className, +}: LogBlockProps) { + return ( +
+ {/* Header */} +
+ {/* Expand/Collapse Icon */} +
+ {isExpanded ? ( + + ) : ( + + )} +
+ + {/* Status Icon */} +
+ {getStatusIcon(block.status)} +
+ + {/* Title with type-specific color */} +
+ {block.title} +
+ + {/* Metadata */} +
+ {block.toolName && ( + {block.toolName} + )} + {block.lineCount} lines + {block.duration !== undefined && ( + {formatDuration(block.duration)} + )} +
+ + {/* Status Badge */} + + {block.status} + + + {/* Action Buttons (visible on hover) */} +
e.stopPropagation()} + > + + + +
+
+ + {/* Expandable Content */} + {isExpanded && ( +
+
+ {block.lines.map((line, index) => ( +
+ + {getOutputLineIcon(line.type)} + + {line.content} +
+ ))} +
+
+ )} +
+ ); +}, (prevProps, nextProps) => { + // Custom comparison for performance + return ( + prevProps.block.id === nextProps.block.id && + prevProps.block.status === nextProps.block.status && + prevProps.block.lineCount === nextProps.block.lineCount && + prevProps.block.duration === nextProps.block.duration && + prevProps.isExpanded === nextProps.isExpanded && + prevProps.className === nextProps.className + ); +}); + +export default LogBlock; diff --git a/ccw/frontend/src/components/shared/LogBlock/LogBlockList.tsx b/ccw/frontend/src/components/shared/LogBlock/LogBlockList.tsx new file mode 100644 index 00000000..5a62565d --- /dev/null +++ b/ccw/frontend/src/components/shared/LogBlock/LogBlockList.tsx @@ -0,0 +1,331 @@ +// ======================================== +// LogBlockList Component +// ======================================== +// Container component for displaying grouped CLI output blocks + +import React, { useState, useMemo, useCallback } from 'react'; +import { useCliStreamStore } from '@/stores/cliStreamStore'; +import { LogBlock } from './LogBlock'; +import type { LogBlockData, LogLine } from './types'; +import type { CliOutputLine } from '@/stores/cliStreamStore'; + +/** + * Parse tool call metadata from content + * Expected format: "[Tool] toolName(args)" + */ +function parseToolCallMetadata(content: string): { toolName: string; args: string } | undefined { + const toolCallMatch = content.match(/^\[Tool\]\s+(\w+)\((.*)\)$/); + if (toolCallMatch) { + return { + toolName: toolCallMatch[1], + args: toolCallMatch[2] || '', + }; + } + return undefined; +} + +/** + * Generate block title based on type and content + */ +function generateBlockTitle(lineType: string, content: string): string { + switch (lineType) { + case 'tool_call': + const metadata = parseToolCallMetadata(content); + if (metadata) { + return metadata.args ? `${metadata.toolName}(${metadata.args})` : metadata.toolName; + } + return 'Tool Call'; + case 'thought': + return 'Thought'; + case 'system': + return 'System'; + case 'stderr': + return 'Error Output'; + case 'stdout': + return 'Output'; + case 'metadata': + return 'Metadata'; + default: + return 'Log'; + } +} + +/** + * Get block type for a line + */ +function getBlockType(lineType: string): LogBlockData['type'] { + switch (lineType) { + case 'tool_call': + return 'tool'; + case 'thought': + return 'info'; + case 'system': + return 'info'; + case 'stderr': + return 'error'; + case 'stdout': + case 'metadata': + default: + return 'output'; + } +} + +/** + * Check if a line type should start a new block + */ +function shouldStartNewBlock(lineType: string, currentBlockType: string | null): boolean { + // No current block exists + if (!currentBlockType) { + return true; + } + + // These types always start new blocks + if (lineType === 'tool_call' || lineType === 'thought' || lineType === 'system') { + return true; + } + + // stderr starts a new block if not already in stderr + if (lineType === 'stderr' && currentBlockType !== 'stderr') { + return true; + } + + // tool_call block captures all following stdout/stderr until next tool_call + if (currentBlockType === 'tool_call' && (lineType === 'stdout' || lineType === 'stderr')) { + return false; + } + + // stderr block captures all stderr until next different type + if (currentBlockType === 'stderr' && lineType === 'stderr') { + return false; + } + + // stdout merges into current stdout block + if (currentBlockType === 'stdout' && lineType === 'stdout') { + return false; + } + + // Different type - start new block + if (currentBlockType !== lineType) { + return true; + } + + return false; +} + +/** + * Group CLI output lines into log blocks + * + * Block grouping rules: + * 1. tool_call starts new block, includes following stdout/stderr until next tool_call + * 2. thought becomes independent block + * 3. system becomes independent block + * 4. stderr becomes highlighted block + * 5. Other stdout merges into normal blocks + */ +function groupLinesIntoBlocks( + lines: CliOutputLine[], + executionId: string, + executionStatus: 'running' | 'completed' | 'error' +): LogBlockData[] { + const blocks: LogBlockData[] = []; + let currentLines: LogLine[] = []; + let currentType: string | null = null; + let currentTitle = ''; + let currentToolName: string | undefined; + let blockStartTime = 0; + let blockIndex = 0; + + for (const line of lines) { + const blockType = getBlockType(line.type); + + // Check if we need to start a new block + if (shouldStartNewBlock(line.type, currentType)) { + // Save current block if exists + if (currentLines.length > 0) { + const duration = blockStartTime > 0 ? line.timestamp - blockStartTime : undefined; + blocks.push({ + id: `${executionId}-block-${blockIndex}`, + title: currentTitle || generateBlockTitle(currentType || '', currentLines[0]?.content || ''), + type: getBlockType(currentType || ''), + status: executionStatus === 'running' ? 'running' : 'completed', + toolName: currentToolName, + lineCount: currentLines.length, + duration, + lines: currentLines, + timestamp: blockStartTime, + }); + blockIndex++; + } + + // Start new block + currentType = line.type; + currentTitle = generateBlockTitle(line.type, line.content); + currentLines = [ + { + type: line.type, + content: line.content, + timestamp: line.timestamp, + }, + ]; + blockStartTime = line.timestamp; + + // Extract tool name for tool_call blocks + if (line.type === 'tool_call') { + const metadata = parseToolCallMetadata(line.content); + currentToolName = metadata?.toolName; + } else { + currentToolName = undefined; + } + } else { + // Add line to current block + currentLines.push({ + type: line.type, + content: line.content, + timestamp: line.timestamp, + }); + } + } + + // Finalize the last block + if (currentLines.length > 0) { + const lastLine = currentLines[currentLines.length - 1]; + const duration = blockStartTime > 0 ? lastLine.timestamp - blockStartTime : undefined; + blocks.push({ + id: `${executionId}-block-${blockIndex}`, + title: currentTitle || generateBlockTitle(currentType || '', currentLines[0]?.content || ''), + type: getBlockType(currentType || ''), + status: executionStatus === 'running' ? 'running' : 'completed', + toolName: currentToolName, + lineCount: currentLines.length, + duration, + lines: currentLines, + timestamp: blockStartTime, + }); + } + + return blocks; +} + +/** + * Props for LogBlockList component + */ +export interface LogBlockListProps { + /** Execution ID to display logs for */ + executionId: string | null; + /** Optional CSS class name */ + className?: string; +} + +/** + * LogBlockList component + * Displays CLI output grouped into collapsible blocks + */ +export function LogBlockList({ executionId, className }: LogBlockListProps) { + // Get execution data from store + const executions = useCliStreamStore((state) => state.executions); + + // Get current execution or execution by ID + const currentExecution = useMemo(() => { + if (!executionId) return null; + return executions[executionId] || null; + }, [executions, executionId]); + + // Manage expanded blocks state + const [expandedBlocks, setExpandedBlocks] = useState>(new Set()); + + // Group output lines into blocks + const blocks = useMemo(() => { + if (!currentExecution?.output || currentExecution.output.length === 0) { + return []; + } + + return groupLinesIntoBlocks(currentExecution.output, executionId!, currentExecution.status); + }, [currentExecution, executionId]); + + // Toggle block expand/collapse + const toggleBlockExpand = useCallback((blockId: string) => { + setExpandedBlocks((prev) => { + const next = new Set(prev); + if (next.has(blockId)) { + next.delete(blockId); + } else { + next.add(blockId); + } + return next; + }); + }, []); + + // Copy command to clipboard + const copyCommand = useCallback((block: LogBlockData) => { + const command = block.lines.find((l) => l.type === 'tool_call')?.content || ''; + navigator.clipboard.writeText(command).catch((err) => { + console.error('Failed to copy command:', err); + }); + }, []); + + // Copy output to clipboard + const copyOutput = useCallback((block: LogBlockData) => { + const output = block.lines.map((l) => l.content).join('\n'); + navigator.clipboard.writeText(output).catch((err) => { + console.error('Failed to copy output:', err); + }); + }, []); + + // Re-run block (placeholder for future implementation) + const reRun = useCallback((block: LogBlockData) => { + console.log('Re-run block:', block.id); + // TODO: Implement re-run functionality + }, []); + + // Empty states + if (!executionId) { + return ( +
+
+ No execution selected +
+
+ ); + } + + if (!currentExecution) { + return ( +
+
+ Execution not found +
+
+ ); + } + + if (blocks.length === 0) { + const isRunning = currentExecution.status === 'running'; + return ( +
+
+ {isRunning ? 'Waiting for output...' : 'No output available'} +
+
+ ); + } + + return ( +
+
+ {blocks.map((block) => ( + toggleBlockExpand(block.id)} + onCopyCommand={() => copyCommand(block)} + onCopyOutput={() => copyOutput(block)} + onReRun={() => reRun(block)} + /> + ))} +
+
+ ); +} + +export default LogBlockList; diff --git a/ccw/frontend/src/components/shared/LogBlock/index.ts b/ccw/frontend/src/components/shared/LogBlock/index.ts new file mode 100644 index 00000000..77cf1db6 --- /dev/null +++ b/ccw/frontend/src/components/shared/LogBlock/index.ts @@ -0,0 +1,7 @@ +// ======================================== +// LogBlock Component Exports +// ======================================== + +export { LogBlock, default } from './LogBlock'; +export { LogBlockList, type LogBlockListProps } from './LogBlockList'; +export type { LogBlockProps, LogBlockData, LogLine } from './types'; diff --git a/ccw/frontend/src/components/shared/LogBlock/types.ts b/ccw/frontend/src/components/shared/LogBlock/types.ts new file mode 100644 index 00000000..2aa9f1aa --- /dev/null +++ b/ccw/frontend/src/components/shared/LogBlock/types.ts @@ -0,0 +1,31 @@ +// ======================================== +// LogBlock Types +// ======================================== + +export interface LogBlockProps { + block: LogBlockData; + isExpanded: boolean; + onToggleExpand: () => void; + onCopyCommand: () => void; + onCopyOutput: () => void; + onReRun: () => void; + className?: string; +} + +export interface LogBlockData { + id: string; + title: string; + type: 'command' | 'tool' | 'output' | 'error' | 'warning' | 'info'; + status: 'running' | 'completed' | 'error' | 'pending'; + toolName?: string; + lineCount: number; + duration?: number; + lines: LogLine[]; + timestamp: number; +} + +export interface LogLine { + type: 'stdout' | 'stderr' | 'thought' | 'system' | 'metadata' | 'tool_call'; + content: string; + timestamp: number; +} diff --git a/ccw/frontend/src/components/shared/PromptStats.tsx b/ccw/frontend/src/components/shared/PromptStats.tsx index 9618c0ea..a83970a5 100644 --- a/ccw/frontend/src/components/shared/PromptStats.tsx +++ b/ccw/frontend/src/components/shared/PromptStats.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { useIntl } from 'react-intl'; -import { StatCard } from '@/components/shared/StatCard'; +import { StatCard, StatCardSkeleton } from '@/components/shared/StatCard'; import { MessageSquare, FileType, Hash } from 'lucide-react'; export interface PromptStatsProps { diff --git a/ccw/frontend/src/components/shared/TaskDrawer.tsx b/ccw/frontend/src/components/shared/TaskDrawer.tsx index 590df505..0aa70d8d 100644 --- a/ccw/frontend/src/components/shared/TaskDrawer.tsx +++ b/ccw/frontend/src/components/shared/TaskDrawer.tsx @@ -211,7 +211,7 @@ export function TaskDrawer({ task, isOpen, onClose }: TaskDrawerProps) {
{flowControl.pre_analysis.map((step, index) => ( -
+
{index + 1} @@ -221,7 +221,7 @@ export function TaskDrawer({ task, isOpen, onClose }: TaskDrawerProps) {

{step.action}

{step.commands && step.commands.length > 0 && (
- + {step.commands.join('; ')}
@@ -241,40 +241,7 @@ export function TaskDrawer({ task, isOpen, onClose }: TaskDrawerProps) { {formatMessage({ id: 'sessionDetail.taskDrawer.overview.implementationSteps' })}
- {flowControl.implementation_approach.map((step, index) => ( -
-
- - {step.step || index + 1} - -
- {step.title && ( -

{step.title}

- )} - {step.description && ( -

{step.description}

- )} - {step.modification_points && step.modification_points.length > 0 && ( -
-

- {formatMessage({ id: 'sessionDetail.taskDrawer.overview.modificationPoints' })}: -

-
    - {step.modification_points.map((point, i) => ( -
  • • {point}
  • - ))} -
-
- )} - {step.depends_on && step.depends_on.length > 0 && ( -

- {formatMessage({ id: 'sessionDetail.taskDrawer.overview.dependsOn' })}: Step {step.depends_on.join(', ')} -

- )} -
-
-
- ))} +
)} @@ -296,25 +263,21 @@ export function TaskDrawer({ task, isOpen, onClose }: TaskDrawerProps) { {/* Flowchart Tab */} {hasFlowchart && ( -
- -
+
)} {/* Files Tab */} {hasFiles ? ( -
- {flowControl!.target_files!.map((file, index) => ( +
+ {flowControl?.target_files?.map((file, index) => (
- - - {file} - + + {file.path || file.name || 'Unknown'}
))}
diff --git a/ccw/frontend/src/components/ui/AlertDialog.tsx b/ccw/frontend/src/components/ui/AlertDialog.tsx new file mode 100644 index 00000000..3b540bc5 --- /dev/null +++ b/ccw/frontend/src/components/ui/AlertDialog.tsx @@ -0,0 +1,144 @@ +// ======================================== +// AlertDialog Component +// ======================================== +// Dialog component for confirmations and critical actions + +import * as React from "react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; +import { cn } from "@/lib/utils"; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = "AlertDialogHeader"; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = "AlertDialogFooter"; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/ccw/frontend/src/components/workspace/WorkspaceSelector.tsx b/ccw/frontend/src/components/workspace/WorkspaceSelector.tsx index da21d1c2..daaff0b0 100644 --- a/ccw/frontend/src/components/workspace/WorkspaceSelector.tsx +++ b/ccw/frontend/src/components/workspace/WorkspaceSelector.tsx @@ -112,11 +112,31 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) { ); /** - * Handle open browse dialog + * Handle open browse dialog - tries file dialog first, falls back to manual input */ - const handleBrowseFolder = useCallback(() => { - setIsBrowseOpen(true); + const handleBrowseFolder = useCallback(async () => { setIsDropdownOpen(false); + + // Try to use Electron/Electron-Tauri file dialog API if available + if ((window as any).electronAPI?.showOpenDialog) { + try { + const result = await (window as any).electronAPI.showOpenDialog({ + properties: ['openDirectory'], + }); + + if (result && result.filePaths && result.filePaths.length > 0) { + const selectedPath = result.filePaths[0]; + await switchWorkspace(selectedPath); + return; + } + } catch (error) { + console.error('Failed to open folder dialog:', error); + // Fall through to manual input dialog + } + } + + // Fallback: open manual path input dialog + setIsBrowseOpen(true); }, []); /** diff --git a/ccw/frontend/src/hooks/index.ts b/ccw/frontend/src/hooks/index.ts index 9442625f..540fe636 100644 --- a/ccw/frontend/src/hooks/index.ts +++ b/ccw/frontend/src/hooks/index.ts @@ -70,6 +70,8 @@ export { useUpdateIssue, useDeleteIssue, useIssueMutations, + useQueueMutations, + useIssueDiscovery, issuesKeys, } from './useIssues'; export type { @@ -79,6 +81,9 @@ export type { UseCreateIssueReturn, UseUpdateIssueReturn, UseDeleteIssueReturn, + UseQueueMutationsReturn, + FindingFilters, + UseIssueDiscoveryReturn, } from './useIssues'; // ========== Skills ========== diff --git a/ccw/frontend/src/hooks/useCli.ts b/ccw/frontend/src/hooks/useCli.ts index c518d0c8..0e95baf3 100644 --- a/ccw/frontend/src/hooks/useCli.ts +++ b/ccw/frontend/src/hooks/useCli.ts @@ -10,6 +10,8 @@ import { type CliEndpoint, type CliEndpointsResponse, } from '../lib/api'; +import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; +import { workspaceQueryKeys } from '@/lib/queryKeys'; // Query key factory export const cliEndpointsKeys = { @@ -273,12 +275,15 @@ export interface UseHooksReturn { export function useHooks(options: UseHooksOptions = {}): UseHooksReturn { const { staleTime = STALE_TIME, enabled = true } = options; const queryClient = useQueryClient(); + const projectPath = useWorkflowStore(selectProjectPath); + + const queryEnabled = enabled && !!projectPath; const query = useQuery({ - queryKey: hooksKeys.lists(), - queryFn: fetchHooks, + queryKey: workspaceQueryKeys.rulesList(projectPath), + queryFn: () => fetchHooks(projectPath), staleTime, - enabled, + enabled: queryEnabled, retry: 2, }); @@ -381,12 +386,15 @@ export interface UseRulesReturn { export function useRules(options: UseRulesOptions = {}): UseRulesReturn { const { staleTime = STALE_TIME, enabled = true } = options; const queryClient = useQueryClient(); + const projectPath = useWorkflowStore(selectProjectPath); + + const queryEnabled = enabled && !!projectPath; const query = useQuery({ - queryKey: rulesKeys.lists(), - queryFn: fetchRules, + queryKey: workspaceQueryKeys.rulesList(projectPath), + queryFn: () => fetchRules(projectPath), staleTime, - enabled, + enabled: queryEnabled, retry: 2, }); diff --git a/ccw/frontend/src/hooks/useCommands.ts b/ccw/frontend/src/hooks/useCommands.ts index 3fe82ca2..5f1e53f9 100644 --- a/ccw/frontend/src/hooks/useCommands.ts +++ b/ccw/frontend/src/hooks/useCommands.ts @@ -8,6 +8,7 @@ import { fetchCommands, type Command, } from '../lib/api'; +import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; // Query key factory export const commandsKeys = { @@ -50,11 +51,14 @@ export function useCommands(options: UseCommandsOptions = {}): UseCommandsReturn const { filter, staleTime = STALE_TIME, enabled = true } = options; const queryClient = useQueryClient(); + const projectPath = useWorkflowStore(selectProjectPath); + const queryEnabled = enabled && !!projectPath; + const query = useQuery({ queryKey: commandsKeys.list(filter), - queryFn: fetchCommands, + queryFn: () => fetchCommands(projectPath), staleTime, - enabled, + enabled: queryEnabled, retry: 2, }); diff --git a/ccw/frontend/src/hooks/useDashboardStats.ts b/ccw/frontend/src/hooks/useDashboardStats.ts index 8a5980f5..2fab5e7e 100644 --- a/ccw/frontend/src/hooks/useDashboardStats.ts +++ b/ccw/frontend/src/hooks/useDashboardStats.ts @@ -73,7 +73,7 @@ export function useDashboardStats( const query = useQuery({ queryKey: workspaceQueryKeys.projectOverview(projectPath), - queryFn: fetchDashboardStats, + queryFn: () => fetchDashboardStats(projectPath), staleTime, enabled: queryEnabled, refetchInterval: refetchInterval > 0 ? refetchInterval : false, @@ -114,7 +114,7 @@ export function usePrefetchDashboardStats() { if (projectPath) { queryClient.prefetchQuery({ queryKey: workspaceQueryKeys.projectOverview(projectPath), - queryFn: fetchDashboardStats, + queryFn: () => fetchDashboardStats(projectPath), staleTime: STALE_TIME, }); } diff --git a/ccw/frontend/src/hooks/useHistory.ts b/ccw/frontend/src/hooks/useHistory.ts index 1bc69888..4d3670b0 100644 --- a/ccw/frontend/src/hooks/useHistory.ts +++ b/ccw/frontend/src/hooks/useHistory.ts @@ -12,6 +12,7 @@ import { deleteAllHistory, type HistoryResponse, } from '../lib/api'; +import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; // Query key factory export const historyKeys = { @@ -70,11 +71,14 @@ export function useHistory(options: UseHistoryOptions = {}): UseHistoryReturn { const { filter, staleTime = STALE_TIME, enabled = true } = options; const queryClient = useQueryClient(); + const projectPath = useWorkflowStore(selectProjectPath); + const queryEnabled = enabled && !!projectPath; + const query = useQuery({ queryKey: historyKeys.list(filter), - queryFn: fetchHistory, + queryFn: () => fetchHistory(projectPath), staleTime, - enabled, + enabled: queryEnabled, retry: 2, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000), }); diff --git a/ccw/frontend/src/hooks/useIndex.ts b/ccw/frontend/src/hooks/useIndex.ts index e5984100..462798af 100644 --- a/ccw/frontend/src/hooks/useIndex.ts +++ b/ccw/frontend/src/hooks/useIndex.ts @@ -10,6 +10,7 @@ import { type IndexStatus, type IndexRebuildRequest, } from '../lib/api'; +import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; // ========== Query Keys ========== @@ -52,11 +53,14 @@ export function useIndexStatus(options: UseIndexStatusOptions = {}): UseIndexSta const { staleTime = STALE_TIME, enabled = true, refetchInterval = 0 } = options; const queryClient = useQueryClient(); + const projectPath = useWorkflowStore(selectProjectPath); + const queryEnabled = enabled && !!projectPath; + const query = useQuery({ queryKey: indexKeys.status(), - queryFn: fetchIndexStatus, + queryFn: () => fetchIndexStatus(projectPath), staleTime, - enabled, + enabled: queryEnabled, refetchInterval: refetchInterval > 0 ? refetchInterval : false, retry: 2, }); diff --git a/ccw/frontend/src/hooks/useIssues.test.tsx b/ccw/frontend/src/hooks/useIssues.test.tsx new file mode 100644 index 00000000..78078b74 --- /dev/null +++ b/ccw/frontend/src/hooks/useIssues.test.tsx @@ -0,0 +1,323 @@ +// ======================================== +// useIssues Hook Tests +// ======================================== +// Tests for issue-related hooks with queue and discovery + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + useIssueQueue, + useIssueMutations, + useQueueMutations, + useIssueDiscovery, +} from './useIssues'; +import * as api from '@/lib/api'; + +// Create a proper query client wrapper +const createTestQueryClient = () => { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }); +}; + +const createWrapper = () => { + const queryClient = createTestQueryClient(); + + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +}; + +// Mock store +vi.mock('@/stores/workflowStore', () => ({ + useWorkflowStore: () => '/test/path', + selectProjectPath: () => '/test/path', +})); + +// Mock API - use vi.mocked for type safety +vi.mock('@/lib/api', () => ({ + fetchIssueQueue: vi.fn(), + activateQueue: vi.fn(), + deactivateQueue: vi.fn(), + deleteQueue: vi.fn(), + mergeQueues: vi.fn(), + fetchDiscoveries: vi.fn(), + fetchDiscoveryFindings: vi.fn(), +})); + +describe('useIssueQueue', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should fetch queue data successfully', async () => { + const mockQueue = { + tasks: ['task1', 'task2'], + solutions: ['solution1'], + conflicts: [], + execution_groups: { 'group-1': ['task1'] }, + grouped_items: { 'parallel-group': ['task1', 'task2'] }, + }; + + vi.mocked(api.fetchIssueQueue).mockResolvedValue(mockQueue); + + const { result } = renderHook(() => useIssueQueue(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.data).toEqual(mockQueue); + }); + }); + + it('should handle API errors', async () => { + vi.mocked(api.fetchIssueQueue).mockRejectedValue(new Error('API Error')); + + const { result } = renderHook(() => useIssueQueue(), { + wrapper: createWrapper(), + }); + + // Verify the hook returns expected structure even with error + expect(result.current).toHaveProperty('isLoading'); + expect(result.current).toHaveProperty('data'); + expect(result.current).toHaveProperty('error'); + }); +}); + +describe('useQueueMutations', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should activate queue successfully', async () => { + vi.mocked(api.activateQueue).mockResolvedValue(undefined); + + const { result } = renderHook(() => useQueueMutations(), { + wrapper: createWrapper(), + }); + + await result.current.activateQueue('queue-1'); + + expect(api.activateQueue).toHaveBeenCalledWith('queue-1', '/test/path'); + }); + + it('should deactivate queue successfully', async () => { + vi.mocked(api.deactivateQueue).mockResolvedValue(undefined); + + const { result } = renderHook(() => useQueueMutations(), { + wrapper: createWrapper(), + }); + + await result.current.deactivateQueue(); + + expect(api.deactivateQueue).toHaveBeenCalledWith('/test/path'); + }); + + it('should delete queue successfully', async () => { + vi.mocked(api.deleteQueue).mockResolvedValue(undefined); + + const { result } = renderHook(() => useQueueMutations(), { + wrapper: createWrapper(), + }); + + await result.current.deleteQueue('queue-1'); + + expect(api.deleteQueue).toHaveBeenCalledWith('queue-1', '/test/path'); + }); + + it('should merge queues successfully', async () => { + vi.mocked(api.mergeQueues).mockResolvedValue(undefined); + + const { result } = renderHook(() => useQueueMutations(), { + wrapper: createWrapper(), + }); + + await result.current.mergeQueues('source-1', 'target-1'); + + expect(api.mergeQueues).toHaveBeenCalledWith('source-1', 'target-1', '/test/path'); + }); + + it('should track overall mutation state', () => { + const { result } = renderHook(() => useQueueMutations(), { + wrapper: createWrapper(), + }); + + expect(result.current.isMutating).toBe(false); + }); +}); + +describe('useIssueDiscovery', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should fetch discovery sessions successfully', async () => { + const mockSessions = [ + { + id: '1', + name: 'Session 1', + status: 'running' as const, + progress: 50, + findings_count: 5, + created_at: '2024-01-01T00:00:00Z', + }, + ]; + + vi.mocked(api.fetchDiscoveries).mockResolvedValue(mockSessions); + + const { result } = renderHook(() => useIssueDiscovery(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.sessions).toHaveLength(1); + expect(result.current.sessions[0].name).toBe('Session 1'); + }); + }); + + it('should filter findings by severity', async () => { + const mockFindings = [ + { id: '1', title: 'Critical issue', severity: 'critical' as const, type: 'bug', description: '' }, + { id: '2', title: 'Minor issue', severity: 'low' as const, type: 'enhancement', description: '' }, + ]; + + vi.mocked(api.fetchDiscoveries).mockResolvedValue([ + { id: '1', name: 'Session 1', status: 'completed' as const, progress: 100, findings_count: 2, created_at: '2024-01-01T00:00:00Z' }, + ]); + vi.mocked(api.fetchDiscoveryFindings).mockResolvedValue(mockFindings); + + const { result } = renderHook(() => useIssueDiscovery(), { + wrapper: createWrapper(), + }); + + // Wait for sessions to load + await waitFor(() => { + expect(result.current.sessions).toHaveLength(1); + }); + + // Select a session to load findings + result.current.selectSession('1'); + + await waitFor(() => { + expect(result.current.findings).toHaveLength(2); + }); + + // Apply severity filter + result.current.setFilters({ severity: 'critical' as const }); + + await waitFor(() => { + expect(result.current.filteredFindings).toHaveLength(1); + expect(result.current.filteredFindings[0].severity).toBe('critical'); + }); + }); + + it('should filter findings by type', async () => { + const mockFindings = [ + { id: '1', title: 'Bug 1', severity: 'high' as const, type: 'bug', description: '' }, + { id: '2', title: 'Enhancement 1', severity: 'medium' as const, type: 'enhancement', description: '' }, + ]; + + vi.mocked(api.fetchDiscoveries).mockResolvedValue([ + { id: '1', name: 'Session 1', status: 'completed' as const, progress: 100, findings_count: 2, created_at: '2024-01-01T00:00:00Z' }, + ]); + vi.mocked(api.fetchDiscoveryFindings).mockResolvedValue(mockFindings); + + const { result } = renderHook(() => useIssueDiscovery(), { + wrapper: createWrapper(), + }); + + // Wait for sessions to load + await waitFor(() => { + expect(result.current.sessions).toHaveLength(1); + }); + + // Select a session to load findings + result.current.selectSession('1'); + + await waitFor(() => { + expect(result.current.findings).toHaveLength(2); + }); + + // Apply type filter + result.current.setFilters({ type: 'bug' }); + + await waitFor(() => { + expect(result.current.filteredFindings).toHaveLength(1); + expect(result.current.filteredFindings[0].type).toBe('bug'); + }); + }); + + it('should search findings by text', async () => { + const mockFindings = [ + { id: '1', title: 'Authentication error', severity: 'high' as const, type: 'bug', description: 'Login fails' }, + { id: '2', title: 'UI bug', severity: 'medium' as const, type: 'bug', description: 'Button color' }, + ]; + + vi.mocked(api.fetchDiscoveries).mockResolvedValue([ + { id: '1', name: 'Session 1', status: 'completed' as const, progress: 100, findings_count: 2, created_at: '2024-01-01T00:00:00Z' }, + ]); + vi.mocked(api.fetchDiscoveryFindings).mockResolvedValue(mockFindings); + + const { result } = renderHook(() => useIssueDiscovery(), { + wrapper: createWrapper(), + }); + + // Wait for sessions to load + await waitFor(() => { + expect(result.current.sessions).toHaveLength(1); + }); + + // Select a session to load findings + result.current.selectSession('1'); + + await waitFor(() => { + expect(result.current.findings).toHaveLength(2); + }); + + // Apply search filter + result.current.setFilters({ search: 'authentication' }); + + await waitFor(() => { + expect(result.current.filteredFindings).toHaveLength(1); + expect(result.current.filteredFindings[0].title).toContain('Authentication'); + }); + }); + + it('should export findings as JSON', async () => { + const mockFindings = [ + { id: '1', title: 'Test finding', severity: 'high' as const, type: 'bug', description: 'Test' }, + ]; + + vi.mocked(api.fetchDiscoveries).mockResolvedValue([ + { id: '1', name: 'Session 1', status: 'completed' as const, progress: 100, findings_count: 1, created_at: '2024-01-01T00:00:00Z' }, + ]); + vi.mocked(api.fetchDiscoveryFindings).mockResolvedValue(mockFindings); + + const { result } = renderHook(() => useIssueDiscovery(), { + wrapper: createWrapper(), + }); + + // Wait for sessions to load + await waitFor(() => { + expect(result.current.sessions).toHaveLength(1); + }); + + // Select a session to load findings + result.current.selectSession('1'); + + await waitFor(() => { + expect(result.current.findings).toHaveLength(1); + }); + + // Verify exportFindings is available as a function + expect(typeof result.current.exportFindings).toBe('function'); + }); +}); diff --git a/ccw/frontend/src/hooks/useIssues.ts b/ccw/frontend/src/hooks/useIssues.ts index 55b75e88..5af2ff63 100644 --- a/ccw/frontend/src/hooks/useIssues.ts +++ b/ccw/frontend/src/hooks/useIssues.ts @@ -3,7 +3,7 @@ // ======================================== // TanStack Query hooks for issues with queue management -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient, type UseQueryResult } from '@tanstack/react-query'; import { fetchIssues, fetchIssueHistory, @@ -11,11 +11,21 @@ import { createIssue, updateIssue, deleteIssue, + activateQueue, + deactivateQueue, + deleteQueue as deleteQueueApi, + mergeQueues as mergeQueuesApi, + fetchDiscoveries, + fetchDiscoveryFindings, type Issue, + type IssueQueue, type IssuesResponse, + type DiscoverySession, + type Finding, } from '../lib/api'; import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; import { workspaceQueryKeys } from '@/lib/queryKeys'; +import { useState, useMemo } from 'react'; // Query key factory export const issuesKeys = { @@ -181,9 +191,9 @@ export function useIssues(options: UseIssuesOptions = {}): UseIssuesReturn { /** * Hook for fetching issue queue */ -export function useIssueQueue(): ReturnType { +export function useIssueQueue(): UseQueryResult { const projectPath = useWorkflowStore(selectProjectPath); - return useQuery({ + return useQuery({ queryKey: projectPath ? workspaceQueryKeys.issueQueue(projectPath) : ['issueQueue', 'no-project'], queryFn: () => fetchIssueQueue(projectPath), staleTime: STALE_TIME, @@ -288,3 +298,171 @@ export function useIssueMutations() { isMutating: create.isCreating || update.isUpdating || remove.isDeleting, }; } + +// ========== Queue Mutations ========== + +export interface UseQueueMutationsReturn { + activateQueue: (queueId: string) => Promise; + deactivateQueue: () => Promise; + deleteQueue: (queueId: string) => Promise; + mergeQueues: (sourceId: string, targetId: string) => Promise; + isActivating: boolean; + isDeactivating: boolean; + isDeleting: boolean; + isMerging: boolean; + isMutating: boolean; +} + +export function useQueueMutations(): UseQueueMutationsReturn { + const queryClient = useQueryClient(); + const projectPath = useWorkflowStore(selectProjectPath); + + const activateMutation = useMutation({ + mutationFn: (queueId: string) => activateQueue(queueId, projectPath), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) }); + }, + }); + + const deactivateMutation = useMutation({ + mutationFn: () => deactivateQueue(projectPath), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (queueId: string) => deleteQueueApi(queueId, projectPath), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) }); + }, + }); + + const mergeMutation = useMutation({ + mutationFn: ({ sourceId, targetId }: { sourceId: string; targetId: string }) => + mergeQueuesApi(sourceId, targetId, projectPath), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) }); + }, + }); + + return { + activateQueue: activateMutation.mutateAsync, + deactivateQueue: deactivateMutation.mutateAsync, + deleteQueue: deleteMutation.mutateAsync, + mergeQueues: (sourceId, targetId) => mergeMutation.mutateAsync({ sourceId, targetId }), + isActivating: activateMutation.isPending, + isDeactivating: deactivateMutation.isPending, + isDeleting: deleteMutation.isPending, + isMerging: mergeMutation.isPending, + isMutating: activateMutation.isPending || deactivateMutation.isPending || deleteMutation.isPending || mergeMutation.isPending, + }; +} + +// ========== Discovery Hook ========== + +export interface FindingFilters { + severity?: 'critical' | 'high' | 'medium' | 'low'; + type?: string; + search?: string; +} + +export interface UseIssueDiscoveryReturn { + sessions: DiscoverySession[]; + activeSession: DiscoverySession | null; + findings: Finding[]; + filteredFindings: Finding[]; + isLoadingSessions: boolean; + isLoadingFindings: boolean; + error: Error | null; + filters: FindingFilters; + setFilters: (filters: FindingFilters) => void; + selectSession: (sessionId: string) => void; + refetchSessions: () => void; + exportFindings: () => void; +} + +export function useIssueDiscovery(options?: { refetchInterval?: number }): UseIssueDiscoveryReturn { + const { refetchInterval = 0 } = options ?? {}; + const queryClient = useQueryClient(); + const projectPath = useWorkflowStore(selectProjectPath); + const [activeSessionId, setActiveSessionId] = useState(null); + const [filters, setFilters] = useState({}); + + const sessionsQuery = useQuery({ + queryKey: workspaceQueryKeys.discoveries(projectPath), + queryFn: () => fetchDiscoveries(projectPath), + staleTime: STALE_TIME, + enabled: !!projectPath, + refetchInterval: refetchInterval > 0 ? refetchInterval : false, + retry: 2, + }); + + const findingsQuery = useQuery({ + queryKey: activeSessionId ? ['discoveryFindings', activeSessionId, projectPath] : ['discoveryFindings', 'no-session'], + queryFn: () => activeSessionId ? fetchDiscoveryFindings(activeSessionId, projectPath) : [], + staleTime: STALE_TIME, + enabled: !!activeSessionId && !!projectPath, + retry: 2, + }); + + const activeSession = useMemo( + () => sessionsQuery.data?.find(s => s.id === activeSessionId) ?? null, + [sessionsQuery.data, activeSessionId] + ); + + const filteredFindings = useMemo(() => { + let findings = findingsQuery.data ?? []; + if (filters.severity) { + findings = findings.filter(f => f.severity === filters.severity); + } + if (filters.type) { + findings = findings.filter(f => f.type === filters.type); + } + if (filters.search) { + const searchLower = filters.search.toLowerCase(); + findings = findings.filter(f => + f.title.toLowerCase().includes(searchLower) || + f.description.toLowerCase().includes(searchLower) + ); + } + return findings; + }, [findingsQuery.data, filters]); + + const selectSession = (sessionId: string) => { + setActiveSessionId(sessionId); + }; + + const exportFindings = () => { + if (!activeSessionId || !findingsQuery.data) return; + const data = { + session: activeSession, + findings: findingsQuery.data, + exported_at: new Date().toISOString(), + }; + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `discovery-${activeSessionId}.json`; + a.click(); + URL.revokeObjectURL(url); + }; + + return { + sessions: sessionsQuery.data ?? [], + activeSession, + findings: findingsQuery.data ?? [], + filteredFindings, + isLoadingSessions: sessionsQuery.isLoading, + isLoadingFindings: findingsQuery.isLoading, + error: sessionsQuery.error || findingsQuery.error, + filters, + setFilters, + selectSession, + refetchSessions: () => { + sessionsQuery.refetch(); + }, + exportFindings, + }; +} diff --git a/ccw/frontend/src/hooks/useLiteTasks.ts b/ccw/frontend/src/hooks/useLiteTasks.ts index 41e99e71..6910a761 100644 --- a/ccw/frontend/src/hooks/useLiteTasks.ts +++ b/ccw/frontend/src/hooks/useLiteTasks.ts @@ -5,6 +5,8 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { fetchLiteTasks, fetchLiteTaskSession, type LiteTaskSession, type LiteTasksResponse } from '@/lib/api'; +import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; +import { workspaceQueryKeys } from '@/lib/queryKeys'; type LiteTaskType = 'lite-plan' | 'lite-fix' | 'multi-cli-plan'; @@ -18,6 +20,10 @@ interface UseLiteTasksOptions { */ export function useLiteTasks(options: UseLiteTasksOptions = {}) { const queryClient = useQueryClient(); + const projectPath = useWorkflowStore(selectProjectPath); + + // Only enable query when projectPath is available + const queryEnabled = (options.enabled ?? true) && !!projectPath; const { data = { litePlan: [], liteFix: [], multiCliPlan: [] }, @@ -25,11 +31,11 @@ export function useLiteTasks(options: UseLiteTasksOptions = {}) { error, refetch, } = useQuery({ - queryKey: ['liteTasks'], - queryFn: fetchLiteTasks, + queryKey: workspaceQueryKeys.liteTasks(projectPath), + queryFn: () => fetchLiteTasks(projectPath), staleTime: 30000, refetchInterval: options.refetchInterval, - enabled: options.enabled ?? true, + enabled: queryEnabled, }); // Get all sessions flattened @@ -55,7 +61,7 @@ export function useLiteTasks(options: UseLiteTasksOptions = {}) { const prefetchSession = (sessionId: string, type: LiteTaskType) => { queryClient.prefetchQuery({ queryKey: ['liteTask', sessionId, type], - queryFn: () => fetchLiteTaskSession(sessionId, type), + queryFn: () => fetchLiteTaskSession(sessionId, type, projectPath), staleTime: 60000, }); }; @@ -77,15 +83,20 @@ export function useLiteTasks(options: UseLiteTasksOptions = {}) { * Hook for fetching a single lite task session */ export function useLiteTaskSession(sessionId: string | undefined, type: LiteTaskType) { + const projectPath = useWorkflowStore(selectProjectPath); + + // Only enable query when sessionId, type, and projectPath are available + const queryEnabled = !!sessionId && !!type && !!projectPath; + const { data: session, isLoading, error, refetch, } = useQuery({ - queryKey: ['liteTask', sessionId, type], - queryFn: () => (sessionId ? fetchLiteTaskSession(sessionId, type) : Promise.resolve(null)), - enabled: !!sessionId && !!type, + queryKey: ['liteTask', sessionId, type, projectPath], + queryFn: () => (sessionId ? fetchLiteTaskSession(sessionId, type, projectPath) : Promise.resolve(null)), + enabled: queryEnabled, staleTime: 60000, }); diff --git a/ccw/frontend/src/hooks/useLoops.ts b/ccw/frontend/src/hooks/useLoops.ts index 518a639f..a9874727 100644 --- a/ccw/frontend/src/hooks/useLoops.ts +++ b/ccw/frontend/src/hooks/useLoops.ts @@ -13,6 +13,7 @@ import { type Loop, type LoopsResponse, } from '../lib/api'; +import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; // Query key factory export const loopsKeys = { @@ -58,11 +59,14 @@ export function useLoops(options: UseLoopsOptions = {}): UseLoopsReturn { const { filter, staleTime = STALE_TIME, enabled = true, refetchInterval = 0 } = options; const queryClient = useQueryClient(); + const projectPath = useWorkflowStore(selectProjectPath); + const queryEnabled = enabled && !!projectPath; + const query = useQuery({ queryKey: loopsKeys.list(filter), - queryFn: fetchLoops, + queryFn: () => fetchLoops(projectPath), staleTime, - enabled, + enabled: queryEnabled, refetchInterval: refetchInterval > 0 ? refetchInterval : false, retry: 2, }); @@ -129,10 +133,13 @@ export function useLoops(options: UseLoopsOptions = {}): UseLoopsReturn { * Hook for fetching a single loop */ export function useLoop(loopId: string, options: { enabled?: boolean } = {}) { + const projectPath = useWorkflowStore(selectProjectPath); + const queryEnabled = (options.enabled ?? !!loopId) && !!projectPath; + return useQuery({ queryKey: loopsKeys.detail(loopId), - queryFn: () => fetchLoop(loopId), - enabled: options.enabled ?? !!loopId, + queryFn: () => fetchLoop(loopId, projectPath), + enabled: queryEnabled, staleTime: STALE_TIME, }); } diff --git a/ccw/frontend/src/hooks/useMcpServers.ts b/ccw/frontend/src/hooks/useMcpServers.ts index bd588d81..ec8931db 100644 --- a/ccw/frontend/src/hooks/useMcpServers.ts +++ b/ccw/frontend/src/hooks/useMcpServers.ts @@ -13,6 +13,7 @@ import { type McpServer, type McpServersResponse, } from '../lib/api'; +import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; // Query key factory export const mcpServersKeys = { @@ -50,11 +51,14 @@ export function useMcpServers(options: UseMcpServersOptions = {}): UseMcpServers const { scope, staleTime = STALE_TIME, enabled = true } = options; const queryClient = useQueryClient(); + const projectPath = useWorkflowStore(selectProjectPath); + const queryEnabled = enabled && !!projectPath; + const query = useQuery({ queryKey: mcpServersKeys.list(scope), - queryFn: fetchMcpServers, + queryFn: () => fetchMcpServers(projectPath), staleTime, - enabled, + enabled: queryEnabled, retry: 2, }); diff --git a/ccw/frontend/src/hooks/useMemory.ts b/ccw/frontend/src/hooks/useMemory.ts index ad315298..10789772 100644 --- a/ccw/frontend/src/hooks/useMemory.ts +++ b/ccw/frontend/src/hooks/useMemory.ts @@ -63,7 +63,7 @@ export function useMemory(options: UseMemoryOptions = {}): UseMemoryReturn { const query = useQuery({ queryKey: workspaceQueryKeys.memoryList(projectPath), - queryFn: fetchMemories, + queryFn: () => fetchMemories(projectPath), staleTime, enabled: queryEnabled, retry: 2, @@ -137,7 +137,7 @@ export function useCreateMemory(): UseCreateMemoryReturn { const projectPath = useWorkflowStore(selectProjectPath); const mutation = useMutation({ - mutationFn: createMemory, + mutationFn: (input: { content: string; tags?: string[] }) => createMemory(input, projectPath), onSuccess: () => { // Invalidate memory cache to trigger refetch queryClient.invalidateQueries({ queryKey: projectPath ? workspaceQueryKeys.memory(projectPath) : ['memory'] }); @@ -163,7 +163,7 @@ export function useUpdateMemory(): UseUpdateMemoryReturn { const mutation = useMutation({ mutationFn: ({ memoryId, input }: { memoryId: string; input: Partial }) => - updateMemory(memoryId, input), + updateMemory(memoryId, input, projectPath), onSuccess: () => { // Invalidate memory cache to trigger refetch queryClient.invalidateQueries({ queryKey: projectPath ? workspaceQueryKeys.memory(projectPath) : ['memory'] }); @@ -188,7 +188,7 @@ export function useDeleteMemory(): UseDeleteMemoryReturn { const projectPath = useWorkflowStore(selectProjectPath); const mutation = useMutation({ - mutationFn: deleteMemory, + mutationFn: (memoryId: string) => deleteMemory(memoryId, projectPath), onSuccess: () => { // Invalidate to ensure sync with server queryClient.invalidateQueries({ queryKey: projectPath ? workspaceQueryKeys.memory(projectPath) : ['memory'] }); diff --git a/ccw/frontend/src/hooks/useProjectOverview.ts b/ccw/frontend/src/hooks/useProjectOverview.ts index cce633e1..503cd2ae 100644 --- a/ccw/frontend/src/hooks/useProjectOverview.ts +++ b/ccw/frontend/src/hooks/useProjectOverview.ts @@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query'; import { fetchProjectOverview } from '../lib/api'; +import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; // Query key factory export const projectOverviewKeys = { @@ -33,11 +34,14 @@ export interface UseProjectOverviewOptions { export function useProjectOverview(options: UseProjectOverviewOptions = {}) { const { staleTime = STALE_TIME, enabled = true } = options; + const projectPath = useWorkflowStore(selectProjectPath); + const queryEnabled = enabled && !!projectPath; + const query = useQuery({ queryKey: projectOverviewKeys.detail(), - queryFn: fetchProjectOverview, + queryFn: () => fetchProjectOverview(projectPath), staleTime, - enabled, + enabled: queryEnabled, retry: 2, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000), }); diff --git a/ccw/frontend/src/hooks/usePromptHistory.ts b/ccw/frontend/src/hooks/usePromptHistory.ts index 08ed01be..017ea5cc 100644 --- a/ccw/frontend/src/hooks/usePromptHistory.ts +++ b/ccw/frontend/src/hooks/usePromptHistory.ts @@ -16,6 +16,7 @@ import { type PromptsResponse, type PromptInsightsResponse, } from '../lib/api'; +import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; // Query key factory export const promptHistoryKeys = { @@ -63,11 +64,14 @@ export function usePromptHistory(options: UsePromptHistoryOptions = {}): UseProm const { filter, staleTime = STALE_TIME, enabled = true } = options; const queryClient = useQueryClient(); + const projectPath = useWorkflowStore(selectProjectPath); + const queryEnabled = enabled && !!projectPath; + const query = useQuery({ queryKey: promptHistoryKeys.list(filter), - queryFn: fetchPrompts, + queryFn: () => fetchPrompts(projectPath), staleTime, - enabled, + enabled: queryEnabled, retry: 2, }); @@ -159,11 +163,14 @@ export function usePromptHistory(options: UsePromptHistoryOptions = {}): UseProm export function usePromptInsights(options: { enabled?: boolean; staleTime?: number } = {}) { const { enabled = true, staleTime = STALE_TIME } = options; + const projectPath = useWorkflowStore(selectProjectPath); + const queryEnabled = enabled && !!projectPath; + return useQuery({ queryKey: promptHistoryKeys.insights(), - queryFn: fetchPromptInsights, + queryFn: () => fetchPromptInsights(projectPath), staleTime, - enabled, + enabled: queryEnabled, retry: 2, }); } diff --git a/ccw/frontend/src/hooks/useSessionDetail.ts b/ccw/frontend/src/hooks/useSessionDetail.ts index ddc4db9a..4d2bb518 100644 --- a/ccw/frontend/src/hooks/useSessionDetail.ts +++ b/ccw/frontend/src/hooks/useSessionDetail.ts @@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query'; import { fetchSessionDetail } from '../lib/api'; +import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; // Query key factory export const sessionDetailKeys = { @@ -33,11 +34,14 @@ export interface UseSessionDetailOptions { export function useSessionDetail(sessionId: string, options: UseSessionDetailOptions = {}) { const { staleTime = STALE_TIME, enabled = true } = options; + const projectPath = useWorkflowStore(selectProjectPath); + const queryEnabled = enabled && !!sessionId && !!projectPath; + const query = useQuery({ queryKey: sessionDetailKeys.detail(sessionId), - queryFn: () => fetchSessionDetail(sessionId), + queryFn: () => fetchSessionDetail(sessionId, projectPath), staleTime, - enabled: enabled && !!sessionId, + enabled: queryEnabled, retry: 2, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000), }); diff --git a/ccw/frontend/src/hooks/useSessions.ts b/ccw/frontend/src/hooks/useSessions.ts index ca51e964..e604ab71 100644 --- a/ccw/frontend/src/hooks/useSessions.ts +++ b/ccw/frontend/src/hooks/useSessions.ts @@ -89,7 +89,7 @@ export function useSessions(options: UseSessionsOptions = {}): UseSessionsReturn const query = useQuery({ queryKey: workspaceQueryKeys.sessionsList(projectPath), - queryFn: fetchSessions, + queryFn: () => fetchSessions(projectPath), staleTime, enabled: queryEnabled, refetchInterval: refetchInterval > 0 ? refetchInterval : false, diff --git a/ccw/frontend/src/hooks/useSkills.ts b/ccw/frontend/src/hooks/useSkills.ts index 9ffacb6f..41e248ca 100644 --- a/ccw/frontend/src/hooks/useSkills.ts +++ b/ccw/frontend/src/hooks/useSkills.ts @@ -63,7 +63,7 @@ export function useSkills(options: UseSkillsOptions = {}): UseSkillsReturn { const query = useQuery({ queryKey: workspaceQueryKeys.skillsList(projectPath), - queryFn: fetchSkills, + queryFn: () => fetchSkills(projectPath), staleTime, enabled: queryEnabled, retry: 2, diff --git a/ccw/frontend/src/lib/api.ts b/ccw/frontend/src/lib/api.ts index 99200283..92769c47 100644 --- a/ccw/frontend/src/lib/api.ts +++ b/ccw/frontend/src/lib/api.ts @@ -231,11 +231,13 @@ function transformBackendSession( // ========== Dashboard API ========== /** - * Fetch dashboard statistics + * Fetch dashboard statistics for a specific workspace + * @param projectPath - Optional project path to filter data by workspace */ -export async function fetchDashboardStats(): Promise { +export async function fetchDashboardStats(projectPath?: string): Promise { try { - const data = await fetchApi<{ statistics?: DashboardStats }>('/api/data'); + const url = projectPath ? `/api/data?path=${encodeURIComponent(projectPath)}` : '/api/data'; + const data = await fetchApi<{ statistics?: DashboardStats }>(url); // Validate response structure if (!data) { @@ -279,15 +281,17 @@ function getEmptyDashboardStats(): DashboardStats { // ========== Sessions API ========== /** - * Fetch all sessions (active and archived) + * Fetch all sessions (active and archived) for a specific workspace * Applies transformation layer to map backend data to frontend SessionMetadata interface + * @param projectPath - Optional project path to filter data by workspace */ -export async function fetchSessions(): Promise { +export async function fetchSessions(projectPath?: string): Promise { try { + const url = projectPath ? `/api/data?path=${encodeURIComponent(projectPath)}` : '/api/data'; const data = await fetchApi<{ activeSessions?: BackendSessionData[]; archivedSessions?: BackendSessionData[]; - }>('/api/data'); + }>(url); // Validate response structure if (!data) { @@ -513,10 +517,12 @@ export interface LoopsResponse { } /** - * Fetch all loops + * Fetch all loops for a specific workspace + * @param projectPath - Optional project path to filter data by workspace */ -export async function fetchLoops(): Promise { - const data = await fetchApi<{ loops?: Loop[] }>('/api/loops'); +export async function fetchLoops(projectPath?: string): Promise { + const url = projectPath ? `/api/loops?path=${encodeURIComponent(projectPath)}` : '/api/loops'; + const data = await fetchApi<{ loops?: Loop[] }>(url); return { loops: data.loops ?? [], total: data.loops?.length ?? 0, @@ -524,10 +530,15 @@ export async function fetchLoops(): Promise { } /** - * Fetch a single loop by ID + * Fetch a single loop by ID for a specific workspace + * @param loopId - The loop ID to fetch + * @param projectPath - Optional project path to filter data by workspace */ -export async function fetchLoop(loopId: string): Promise { - return fetchApi(`/api/loops/${encodeURIComponent(loopId)}`); +export async function fetchLoop(loopId: string, projectPath?: string): Promise { + const url = projectPath + ? `/api/loops/${encodeURIComponent(loopId)}?path=${encodeURIComponent(projectPath)}` + : `/api/loops/${encodeURIComponent(loopId)}`; + return fetchApi(url); } /** @@ -672,6 +683,97 @@ export async function deleteIssue(issueId: string): Promise { }); } +/** + * Activate a queue + */ +export async function activateQueue(queueId: string, projectPath: string): Promise { + return fetchApi(`/api/queue/${encodeURIComponent(queueId)}/activate?path=${encodeURIComponent(projectPath)}`, { + method: 'POST', + }); +} + +/** + * Deactivate the current queue + */ +export async function deactivateQueue(projectPath: string): Promise { + return fetchApi(`/api/queue/deactivate?path=${encodeURIComponent(projectPath)}`, { + method: 'POST', + }); +} + +/** + * Delete a queue + */ +export async function deleteQueue(queueId: string, projectPath: string): Promise { + return fetchApi(`/api/queue/${encodeURIComponent(queueId)}?path=${encodeURIComponent(projectPath)}`, { + method: 'DELETE', + }); +} + +/** + * Merge queues + */ +export async function mergeQueues(sourceId: string, targetId: string, projectPath: string): Promise { + return fetchApi(`/api/queue/merge?path=${encodeURIComponent(projectPath)}`, { + method: 'POST', + body: JSON.stringify({ sourceId, targetId }), + }); +} + +// ========== Discovery API ========== + +export interface DiscoverySession { + id: string; + name: string; + status: 'running' | 'completed' | 'failed'; + progress: number; // 0-100 + findings_count: number; + created_at: string; + completed_at?: string; +} + +export interface Finding { + id: string; + sessionId: string; + severity: 'critical' | 'high' | 'medium' | 'low'; + type: string; + title: string; + description: string; + file?: string; + line?: number; + code_snippet?: string; + created_at: string; +} + +export async function fetchDiscoveries(projectPath?: string): Promise { + const url = projectPath + ? `/api/discoveries?path=${encodeURIComponent(projectPath)}` + : '/api/discoveries'; + const data = await fetchApi<{ sessions?: DiscoverySession[] }>(url); + return data.sessions ?? []; +} + +export async function fetchDiscoveryDetail( + sessionId: string, + projectPath?: string +): Promise { + const url = projectPath + ? `/api/discoveries/${encodeURIComponent(sessionId)}?path=${encodeURIComponent(projectPath)}` + : `/api/discoveries/${encodeURIComponent(sessionId)}`; + return fetchApi(url); +} + +export async function fetchDiscoveryFindings( + sessionId: string, + projectPath?: string +): Promise { + const url = projectPath + ? `/api/discoveries/${encodeURIComponent(sessionId)}/findings?path=${encodeURIComponent(projectPath)}` + : `/api/discoveries/${encodeURIComponent(sessionId)}/findings`; + const data = await fetchApi<{ findings?: Finding[] }>(url); + return data.findings ?? []; +} + // ========== Skills API ========== export interface Skill { @@ -690,10 +792,12 @@ export interface SkillsResponse { } /** - * Fetch all skills + * Fetch all skills for a specific workspace + * @param projectPath - Optional project path to filter data by workspace */ -export async function fetchSkills(): Promise { - const data = await fetchApi<{ skills?: Skill[] }>('/api/skills'); +export async function fetchSkills(projectPath?: string): Promise { + const url = projectPath ? `/api/skills?path=${encodeURIComponent(projectPath)}` : '/api/skills'; + const data = await fetchApi<{ skills?: Skill[] }>(url); return { skills: data.skills ?? [], }; @@ -726,10 +830,12 @@ export interface CommandsResponse { } /** - * Fetch all commands + * Fetch all commands for a specific workspace + * @param projectPath - Optional project path to filter data by workspace */ -export async function fetchCommands(): Promise { - const data = await fetchApi<{ commands?: Command[] }>('/api/commands'); +export async function fetchCommands(projectPath?: string): Promise { + const url = projectPath ? `/api/commands?path=${encodeURIComponent(projectPath)}` : '/api/commands'; + const data = await fetchApi<{ commands?: Command[] }>(url); return { commands: data.commands ?? [], }; @@ -754,14 +860,16 @@ export interface MemoryResponse { } /** - * Fetch all memories + * Fetch all memories for a specific workspace + * @param projectPath - Optional project path to filter data by workspace */ -export async function fetchMemories(): Promise { +export async function fetchMemories(projectPath?: string): Promise { + const url = projectPath ? `/api/memory?path=${encodeURIComponent(projectPath)}` : '/api/memory'; const data = await fetchApi<{ memories?: CoreMemory[]; totalSize?: number; claudeMdCount?: number; - }>('/api/memory'); + }>(url); return { memories: data.memories ?? [], totalSize: data.totalSize ?? 0, @@ -770,36 +878,51 @@ export async function fetchMemories(): Promise { } /** - * Create a new memory entry + * Create a new memory entry for a specific workspace + * @param input - Memory input data + * @param projectPath - Optional project path to filter data by workspace */ export async function createMemory(input: { content: string; tags?: string[]; -}): Promise { - return fetchApi('/api/memory', { +}, projectPath?: string): Promise { + const url = projectPath ? `/api/memory?path=${encodeURIComponent(projectPath)}` : '/api/memory'; + return fetchApi(url, { method: 'POST', body: JSON.stringify(input), }); } /** - * Update a memory entry + * Update a memory entry for a specific workspace + * @param memoryId - Memory ID to update + * @param input - Partial memory data + * @param projectPath - Optional project path to filter data by workspace */ export async function updateMemory( memoryId: string, - input: Partial + input: Partial, + projectPath?: string ): Promise { - return fetchApi(`/api/memory/${encodeURIComponent(memoryId)}`, { + const url = projectPath + ? `/api/memory/${encodeURIComponent(memoryId)}?path=${encodeURIComponent(projectPath)}` + : `/api/memory/${encodeURIComponent(memoryId)}`; + return fetchApi(url, { method: 'PATCH', body: JSON.stringify(input), }); } /** - * Delete a memory entry + * Delete a memory entry for a specific workspace + * @param memoryId - Memory ID to delete + * @param projectPath - Optional project path to filter data by workspace */ -export async function deleteMemory(memoryId: string): Promise { - return fetchApi(`/api/memory/${encodeURIComponent(memoryId)}`, { +export async function deleteMemory(memoryId: string, projectPath?: string): Promise { + const url = projectPath + ? `/api/memory/${encodeURIComponent(memoryId)}?path=${encodeURIComponent(projectPath)}` + : `/api/memory/${encodeURIComponent(memoryId)}`; + return fetchApi(url, { method: 'DELETE', }); } @@ -885,10 +1008,12 @@ export interface ProjectOverview { } /** - * Fetch project overview + * Fetch project overview for a specific workspace + * @param projectPath - Optional project path to filter data by workspace */ -export async function fetchProjectOverview(): Promise { - const data = await fetchApi<{ projectOverview?: ProjectOverview }>('/api/ccw'); +export async function fetchProjectOverview(projectPath?: string): Promise { + const url = projectPath ? `/api/ccw?path=${encodeURIComponent(projectPath)}` : '/api/ccw'; + const data = await fetchApi<{ projectOverview?: ProjectOverview }>(url); return data.projectOverview ?? null; } @@ -914,12 +1039,14 @@ export interface SessionDetailResponse { } /** - * Fetch session detail + * Fetch session detail for a specific workspace * First fetches session list to get the session path, then fetches detail data + * @param sessionId - Session ID to fetch details for + * @param projectPath - Optional project path to filter data by workspace */ -export async function fetchSessionDetail(sessionId: string): Promise { +export async function fetchSessionDetail(sessionId: string, projectPath?: string): Promise { // Step 1: Fetch all sessions to get the session path - const sessionsData = await fetchSessions(); + const sessionsData = await fetchSessions(projectPath); const allSessions = [...sessionsData.activeSessions, ...sessionsData.archivedSessions]; const session = allSessions.find(s => s.session_id === sessionId); @@ -930,7 +1057,8 @@ export async function fetchSessionDetail(sessionId: string): Promise(`/api/session-detail?path=${encodeURIComponent(sessionPath)}&type=all`); + const pathParam = projectPath || sessionPath; + const detailData = await fetchApi(`/api/session-detail?path=${encodeURIComponent(pathParam)}&type=all`); // Step 3: Transform the response to match SessionDetailResponse interface return { @@ -962,10 +1090,12 @@ export interface HistoryResponse { } /** - * Fetch CLI execution history + * Fetch CLI execution history for a specific workspace + * @param projectPath - Optional project path to filter data by workspace */ -export async function fetchHistory(): Promise { - const data = await fetchApi<{ executions?: CliExecution[] }>('/api/cli/history'); +export async function fetchHistory(projectPath?: string): Promise { + const url = projectPath ? `/api/cli/history?path=${encodeURIComponent(projectPath)}` : '/api/cli/history'; + const data = await fetchApi<{ executions?: CliExecution[] }>(url); return { executions: data.executions ?? [], }; @@ -1082,25 +1212,34 @@ export async function updateCliToolsConfig( // ========== Lite Tasks API ========== export interface ImplementationStep { - step: number; + step?: number | string; + phase?: string; title?: string; + action?: string; description?: string; - modification_points?: string[]; + modification_points?: string[] | Array<{ file: string; target: string; change: string }>; logic_flow?: string[]; - depends_on?: number[]; + depends_on?: number[] | string[]; output?: string; + output_to?: string; + commands?: string[]; + steps?: string[]; + test_patterns?: string; + [key: string]: unknown; +} + +export interface PreAnalysisStep { + step?: string; + action?: string; + output_to?: string; + commands?: string[]; } export interface FlowControl { - pre_analysis?: Array<{ - step: string; - action: string; - commands?: string[]; - output_to: string; - on_error?: 'fail' | 'continue' | 'skip'; - }>; - implementation_approach?: ImplementationStep[]; - target_files?: string[]; + pre_analysis?: PreAnalysisStep[]; + implementation_approach?: (ImplementationStep | string)[]; + target_files?: Array<{ path: string; name?: string }>; + [key: string]: unknown; } export interface LiteTask { @@ -1149,21 +1288,27 @@ export interface LiteTasksResponse { } /** - * Fetch all lite tasks sessions + * Fetch all lite tasks sessions for a specific workspace + * @param projectPath - Optional project path to filter data by workspace */ -export async function fetchLiteTasks(): Promise { - const data = await fetchApi<{ liteTasks?: LiteTasksResponse }>('/api/data'); +export async function fetchLiteTasks(projectPath?: string): Promise { + const url = projectPath ? `/api/data?path=${encodeURIComponent(projectPath)}` : '/api/data'; + const data = await fetchApi<{ liteTasks?: LiteTasksResponse }>(url); return data.liteTasks || {}; } /** - * Fetch a single lite task session by ID + * Fetch a single lite task session by ID for a specific workspace + * @param sessionId - Session ID to fetch + * @param type - Type of lite task + * @param projectPath - Optional project path to filter data by workspace */ export async function fetchLiteTaskSession( sessionId: string, - type: 'lite-plan' | 'lite-fix' | 'multi-cli-plan' + type: 'lite-plan' | 'lite-fix' | 'multi-cli-plan', + projectPath?: string ): Promise { - const data = await fetchLiteTasks(); + const data = await fetchLiteTasks(projectPath); const sessions = type === 'lite-plan' ? (data.litePlan || []) : type === 'lite-fix' ? (data.liteFix || []) : (data.multiCliPlan || []); @@ -1246,10 +1391,12 @@ export interface McpServersResponse { } /** - * Fetch all MCP servers (project and global scope) + * Fetch all MCP servers (project and global scope) for a specific workspace + * @param projectPath - Optional project path to filter data by workspace */ -export async function fetchMcpServers(): Promise { - const data = await fetchApi<{ project?: McpServer[]; global?: McpServer[] }>('/api/mcp/servers'); +export async function fetchMcpServers(projectPath?: string): Promise { + const url = projectPath ? `/api/mcp/servers?path=${encodeURIComponent(projectPath)}` : '/api/mcp/servers'; + const data = await fetchApi<{ project?: McpServer[]; global?: McpServer[] }>(url); return { project: data.project ?? [], global: data.global ?? [], @@ -1485,10 +1632,12 @@ export interface HooksResponse { } /** - * Fetch all hooks + * Fetch all hooks for a specific workspace + * @param projectPath - Optional project path to filter data by workspace */ -export async function fetchHooks(): Promise { - const data = await fetchApi<{ hooks?: Hook[] }>('/api/hooks'); +export async function fetchHooks(projectPath?: string): Promise { + const url = projectPath ? `/api/hooks?path=${encodeURIComponent(projectPath)}` : '/api/hooks'; + const data = await fetchApi<{ hooks?: Hook[] }>(url); return { hooks: data.hooks ?? [], }; @@ -1567,10 +1716,12 @@ export async function installHookTemplate(templateId: string): Promise { // ========== Rules API ========== /** - * Fetch all rules + * Fetch all rules for a specific workspace + * @param projectPath - Optional project path to filter data by workspace */ -export async function fetchRules(): Promise { - const data = await fetchApi<{ rules?: Rule[] }>('/api/rules'); +export async function fetchRules(projectPath?: string): Promise { + const url = projectPath ? `/api/rules?path=${encodeURIComponent(projectPath)}` : '/api/rules'; + const data = await fetchApi<{ rules?: Rule[] }>(url); return { rules: data.rules ?? [], }; @@ -1682,10 +1833,12 @@ export async function uninstallCcwMcp(): Promise { // ========== Index Management API ========== /** - * Fetch current index status + * Fetch current index status for a specific workspace + * @param projectPath - Optional project path to filter data by workspace */ -export async function fetchIndexStatus(): Promise { - return fetchApi('/api/index/status'); +export async function fetchIndexStatus(projectPath?: string): Promise { + const url = projectPath ? `/api/index/status?path=${encodeURIComponent(projectPath)}` : '/api/index/status'; + return fetchApi(url); } /** @@ -1727,17 +1880,21 @@ export interface AnalyzePromptsRequest { } /** - * Fetch all prompts from history + * Fetch all prompts from history for a specific workspace + * @param projectPath - Optional project path to filter data by workspace */ -export async function fetchPrompts(): Promise { - return fetchApi('/api/memory/prompts'); +export async function fetchPrompts(projectPath?: string): Promise { + const url = projectPath ? `/api/memory/prompts?path=${encodeURIComponent(projectPath)}` : '/api/memory/prompts'; + return fetchApi(url); } /** - * Fetch prompt insights from backend + * Fetch prompt insights from backend for a specific workspace + * @param projectPath - Optional project path to filter data by workspace */ -export async function fetchPromptInsights(): Promise { - return fetchApi('/api/memory/insights'); +export async function fetchPromptInsights(projectPath?: string): Promise { + const url = projectPath ? `/api/memory/insights?path=${encodeURIComponent(projectPath)}` : '/api/memory/insights'; + return fetchApi(url); } /** diff --git a/ccw/frontend/src/lib/queryKeys.ts b/ccw/frontend/src/lib/queryKeys.ts index b93f5fc6..1e4b7d40 100644 --- a/ccw/frontend/src/lib/queryKeys.ts +++ b/ccw/frontend/src/lib/queryKeys.ts @@ -36,12 +36,31 @@ export const workspaceQueryKeys = { issuesHistory: (projectPath: string) => [...workspaceQueryKeys.issues(projectPath), 'history'] as const, issueQueue: (projectPath: string) => [...workspaceQueryKeys.issues(projectPath), 'queue'] as const, + // ========== Discoveries ========== + discoveries: (projectPath: string) => ['workspace', projectPath, 'discoveries'] as const, + // ========== Memory ========== memory: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'memory'] as const, memoryList: (projectPath: string) => [...workspaceQueryKeys.memory(projectPath), 'list'] as const, memoryDetail: (projectPath: string, memoryId: string) => [...workspaceQueryKeys.memory(projectPath), 'detail', memoryId] as const, + // ========== Skills ========== + skills: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'skills'] as const, + skillsList: (projectPath: string) => [...workspaceQueryKeys.skills(projectPath), 'list'] as const, + + // ========== Commands ========== + commands: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'commands'] as const, + commandsList: (projectPath: string) => [...workspaceQueryKeys.commands(projectPath), 'list'] as const, + + // ========== Hooks ========== + hooks: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'hooks'] as const, + hooksList: (projectPath: string) => [...workspaceQueryKeys.hooks(projectPath), 'list'] as const, + + // ========== MCP Servers ========== + mcpServers: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'mcpServers'] as const, + mcpServersList: (projectPath: string) => [...workspaceQueryKeys.mcpServers(projectPath), 'list'] as const, + // ========== Project Overview ========== projectOverview: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'projectOverview'] as const, projectOverviewDetail: (projectPath: string) => diff --git a/ccw/frontend/src/locales/en/issues.json b/ccw/frontend/src/locales/en/issues.json index 2ec2e9d0..fc326312 100644 --- a/ccw/frontend/src/locales/en/issues.json +++ b/ccw/frontend/src/locales/en/issues.json @@ -60,5 +60,80 @@ "createdAt": "Created", "updatedAt": "Updated", "solutions": "{count, plural, one {solution} other {solutions}}" + }, + "queue": { + "title": "Queue", + "pageTitle": "Issue Queue", + "description": "Manage issue execution queue with execution groups", + "stats": { + "totalItems": "Total Items", + "groups": "Groups", + "tasks": "Tasks", + "solutions": "Solutions" + }, + "actions": { + "activate": "Activate", + "deactivate": "Deactivate", + "delete": "Delete", + "merge": "Merge", + "confirmDelete": "Are you sure you want to delete this queue?" + }, + "executionGroup": "Execution Group", + "parallel": "Parallel", + "sequential": "Sequential", + "emptyState": "No queue data available", + "conflicts": "Conflicts detected in queue", + "noQueueData": "No queue data" + }, + "discovery": { + "title": "Discovery", + "pageTitle": "Issue Discovery", + "description": "View and manage issue discovery sessions", + "stats": { + "totalSessions": "Total Sessions", + "completed": "Completed", + "running": "Running", + "findings": "Findings" + }, + "session": { + "status": { + "running": "Running", + "completed": "Completed", + "failed": "Failed" + }, + "findings": "{count} findings", + "startedAt": "Started" + }, + "findings": { + "title": "Findings", + "filters": { + "severity": "Severity", + "type": "Type", + "search": "Search findings..." + }, + "severity": { + "all": "All Severities", + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low" + }, + "type": { + "all": "All Types" + }, + "noFindings": "No findings found", + "export": "Export" + }, + "tabs": { + "findings": "Findings", + "progress": "Progress", + "info": "Session Info" + }, + "emptyState": "No discovery sessions found", + "noSessionSelected": "Select a session to view findings", + "actions": { + "export": "Export Findings", + "refresh": "Refresh" + } } } diff --git a/ccw/frontend/src/locales/en/navigation.json b/ccw/frontend/src/locales/en/navigation.json index 467b3005..c8ae7534 100644 --- a/ccw/frontend/src/locales/en/navigation.json +++ b/ccw/frontend/src/locales/en/navigation.json @@ -8,6 +8,8 @@ "orchestrator": "Orchestrator", "loops": "Loop Monitor", "issues": "Issues", + "issueQueue": "Issue Queue", + "issueDiscovery": "Issue Discovery", "skills": "Skills", "commands": "Commands", "memory": "Memory", diff --git a/ccw/frontend/src/locales/en/notifications.json b/ccw/frontend/src/locales/en/notifications.json index 813fc24c..c98e03d4 100644 --- a/ccw/frontend/src/locales/en/notifications.json +++ b/ccw/frontend/src/locales/en/notifications.json @@ -1,5 +1,6 @@ { "title": "Notifications", + "close": "Close notifications", "empty": "No notifications", "emptyHint": "Notifications will appear here", "markAllRead": "Mark Read", @@ -14,5 +15,38 @@ "daysAgo": "{0}d ago", "oneMinuteAgo": "1m ago", "oneHourAgo": "1h ago", - "oneDayAgo": "1d ago" + "oneDayAgo": "1d ago", + "sources": { + "system": "System", + "websocket": "WebSocket", + "cli": "CLI", + "workflow": "Workflow", + "user": "User", + "external": "External" + }, + "priorities": { + "low": "Low", + "medium": "Medium", + "high": "High", + "critical": "Critical" + }, + "attachments": { + "image": "Image", + "code": "Code", + "file": "File", + "data": "Data", + "download": "Download" + }, + "actions": { + "loading": "Loading...", + "success": "Done", + "retry": "Retry" + }, + "timestamps": { + "today": "Today", + "yesterday": "Yesterday", + "atTime": "at {0}" + }, + "markAsRead": "Mark as read", + "markAsUnread": "Mark as unread" } diff --git a/ccw/frontend/src/locales/en/skills.json b/ccw/frontend/src/locales/en/skills.json index 5bf5393c..e4c0bd03 100644 --- a/ccw/frontend/src/locales/en/skills.json +++ b/ccw/frontend/src/locales/en/skills.json @@ -23,13 +23,19 @@ "card": { "triggers": "Triggers", "category": "Category", + "source": "Source", "author": "Author", "version": "Version" }, "filters": { "all": "All", "enabled": "Enabled", - "disabled": "Disabled" + "disabled": "Disabled", + "searchPlaceholder": "Search skills...", + "allSources": "All Sources" + }, + "stats": { + "totalSkills": "Total Skills" }, "view": { "grid": "Grid View", diff --git a/ccw/frontend/src/locales/en/workspace.json b/ccw/frontend/src/locales/en/workspace.json index 130d0875..caa304c6 100644 --- a/ccw/frontend/src/locales/en/workspace.json +++ b/ccw/frontend/src/locales/en/workspace.json @@ -6,12 +6,12 @@ "current": "Current", "browse": "Select Folder...", "removePath": "Remove from recent", - "ariaLabel": "Workspace selector" - }, - "dialog": { - "title": "Select Project Folder", - "placeholder": "Enter project path...", - "help": "The path to your project directory" + "ariaLabel": "Workspace selector", + "dialog": { + "title": "Select Project Folder", + "placeholder": "Enter project path...", + "help": "The path to your project directory" + } }, "actions": { "switch": "Switch Workspace", diff --git a/ccw/frontend/src/locales/zh/cli-hooks.json b/ccw/frontend/src/locales/zh/cli-hooks.json index c0ecab39..fdf13987 100644 --- a/ccw/frontend/src/locales/zh/cli-hooks.json +++ b/ccw/frontend/src/locales/zh/cli-hooks.json @@ -1,160 +1,160 @@ { - "title": "Hook Manager", - "description": "Manage CLI hooks for automated workflows", - "allTools": "All tools", + "title": "钩子管理器", + "description": "管理自动化工作流的 CLI 钩子", + "allTools": "所有工具", "trigger": { - "UserPromptSubmit": "User Prompt Submit", - "PreToolUse": "Pre Tool Use", - "PostToolUse": "Post Tool Use", - "Stop": "Stop" + "UserPromptSubmit": "用户提交提示", + "PreToolUse": "工具使用前", + "PostToolUse": "工具使用后", + "Stop": "停止" }, "form": { - "name": "Hook Name", + "name": "钩子名称", "namePlaceholder": "my-hook", - "description": "Description", - "descriptionPlaceholder": "What does this hook do?", - "trigger": "Trigger Event", - "matcher": "Tool Matcher", - "matcherPlaceholder": "e.g., Write|Edit (optional)", - "matcherHelp": "Regex pattern to match tool names. Leave empty to match all tools.", - "command": "Command", + "description": "描述", + "descriptionPlaceholder": "这个钩子是做什么的?", + "trigger": "触发事件", + "matcher": "工具匹配器", + "matcherPlaceholder": "例如:Write|Edit(可选)", + "matcherHelp": "用于匹配工具名称的正则表达式。留空以匹配所有工具。", + "command": "命令", "commandPlaceholder": "echo 'Hello World'", - "commandHelp": "Shell command to execute. Use environment variables like $CLAUDE_TOOL_NAME." + "commandHelp": "要执行的 Shell 命令。可以使用环境变量,如 $CLAUDE_TOOL_NAME。" }, "validation": { - "nameRequired": "Hook name is required", - "nameInvalid": "Hook name can only contain letters, numbers, hyphens, and underscores", - "triggerRequired": "Trigger event is required", - "commandRequired": "Command is required" + "nameRequired": "钩子名称为必填项", + "nameInvalid": "钩子名称只能包含字母、数字、连字符和下划线", + "triggerRequired": "触发事件为必填项", + "commandRequired": "命令为必填项" }, "actions": { - "add": "Add Hook", - "addFirst": "Create Your First Hook", - "edit": "Edit", - "delete": "Delete", - "deleteConfirm": "Are you sure you want to delete hook \"{hookName}\"?", - "enable": "Enable", - "disable": "Disable", - "expand": "Expand details", - "collapse": "Collapse details", - "expandAll": "Expand All", - "collapseAll": "Collapse All" + "add": "添加钩子", + "addFirst": "创建您的第一个钩子", + "edit": "编辑", + "delete": "删除", + "deleteConfirm": "确定要删除钩子 \"{hookName}\" 吗?", + "enable": "启用", + "disable": "禁用", + "expand": "展开详情", + "collapse": "折叠详情", + "expandAll": "全部展开", + "collapseAll": "全部折叠" }, "dialog": { - "createTitle": "Create Hook", - "editTitle": "Edit Hook \"{hookName}\"" + "createTitle": "创建钩子", + "editTitle": "编辑钩子 \"{hookName}\"" }, "stats": { - "total": "{count} total", - "enabled": "{count} enabled", - "count": "{enabled}/{total} hooks" + "total": "共 {count} 个", + "enabled": "{count} 个已启用", + "count": "{enabled}/{total} 个钩子" }, "filters": { - "searchPlaceholder": "Search hooks by name, description, or trigger..." + "searchPlaceholder": "按名称、描述或触发事件搜索钩子..." }, "empty": { - "title": "No hooks found", - "description": "Create your first hook to automate your CLI workflow", - "noHooksInEvent": "No hooks configured for this event" + "title": "未找到钩子", + "description": "创建您的第一个钩子以自动化 CLI 工作流", + "noHooksInEvent": "此事件未配置钩子" }, "templates": { - "title": "Quick Install Templates", - "description": "One-click installation for common hook patterns", + "title": "快速安装模板", + "description": "常见钩子模式的一键安装", "categories": { - "notification": "Notification", - "indexing": "Indexing", - "automation": "Automation" + "notification": "通知", + "indexing": "索引", + "automation": "自动化" }, "templates": { "ccw-notify": { - "name": "CCW Dashboard Notify", - "description": "Send notifications to CCW dashboard when files are written" + "name": "CCW 面板通知", + "description": "当文件被写入时向 CCW 面板发送通知" }, "codexlens-update": { - "name": "CodexLens Auto-Update", - "description": "Update CodexLens index when files are written or edited" + "name": "CodexLens 自动更新", + "description": "当文件被写入或编辑时更新 CodexLens 索引" }, "git-add": { - "name": "Auto Git Stage", - "description": "Automatically stage written files to git" + "name": "自动 Git 暂存", + "description": "自动将写入的文件暂存到 git" }, "lint-check": { - "name": "Auto ESLint", - "description": "Run ESLint on JavaScript/TypeScript files after write" + "name": "自动 ESLint", + "description": "在写入后对 JavaScript/TypeScript 文件运行 ESLint" }, "log-tool": { - "name": "Tool Usage Logger", - "description": "Log all tool executions to a file for audit trail" + "name": "工具使用日志", + "description": "将所有工具执行记录到文件以供审计" } }, "actions": { - "install": "Install", - "installed": "Installed" + "install": "安装", + "installed": "已安装" } }, "wizards": { - "title": "Hook Wizard", - "launch": "Wizard", - "sectionTitle": "Hook Wizards", - "sectionDescription": "Create hooks with guided step-by-step wizards", + "title": "钩子向导", + "launch": "向导", + "sectionTitle": "钩子向导", + "sectionDescription": "通过引导式分步向导创建钩子", "platform": { - "detected": "Detected Platform", - "compatible": "Compatible", - "incompatible": "Incompatible", - "compatibilityError": "This hook is not compatible with your platform", - "compatibilityWarning": "Some features may not work on your platform" + "detected": "检测到的平台", + "compatible": "兼容", + "incompatible": "不兼容", + "compatibilityError": "此钩子与您的平台不兼容", + "compatibilityWarning": "某些功能可能在您的平台上无法正常工作" }, "steps": { - "triggerEvent": "This hook will trigger on", + "triggerEvent": "此钩子将触发于", "review": { - "title": "Review Configuration", - "description": "Review your hook configuration before creating", - "hookType": "Hook Type", - "trigger": "Trigger Event", - "platform": "Platform", - "commandPreview": "Command Preview" + "title": "检查配置", + "description": "创建前检查您的钩子配置", + "hookType": "钩子类型", + "trigger": "触发事件", + "platform": "平台", + "commandPreview": "命令预览" } }, "navigation": { - "previous": "Previous", - "next": "Next", - "create": "Create Hook", - "creating": "Creating..." + "previous": "上一步", + "next": "下一步", + "create": "创建钩子", + "creating": "创建中..." }, "memoryUpdate": { - "title": "Memory Update Wizard", - "description": "Configure hook to update CLAUDE.md on session end", - "shortDescription": "Update CLAUDE.md automatically", - "claudePath": "CLAUDE.md Path", - "updateFrequency": "Update Frequency", + "title": "记忆更新向导", + "description": "配置钩子以在会话结束时更新 CLAUDE.md", + "shortDescription": "自动更新 CLAUDE.md", + "claudePath": "CLAUDE.md 路径", + "updateFrequency": "更新频率", "frequency": { - "sessionEnd": "Session End", - "hourly": "Hourly", - "daily": "Daily" + "sessionEnd": "会话结束时", + "hourly": "每小时", + "daily": "每天" } }, "dangerProtection": { - "title": "Danger Protection Wizard", - "description": "Configure confirmation hook for dangerous operations", - "shortDescription": "Confirm dangerous operations", - "keywords": "Dangerous Keywords", - "keywordsHelp": "Enter one keyword per line", - "confirmationMessage": "Confirmation Message", - "allowBypass": "Allow bypass with --force flag" + "title": "危险操作保护向导", + "description": "配置危险操作的确认钩子", + "shortDescription": "确认危险操作", + "keywords": "危险关键词", + "keywordsHelp": "每行输入一个关键词", + "confirmationMessage": "确认消息", + "allowBypass": "允许使用 --force 标志绕过" }, "skillContext": { - "title": "SKILL Context Wizard", - "description": "Configure hook to load SKILL based on prompt keywords", - "shortDescription": "Auto-load SKILL based on keywords", - "loadingSkills": "Loading available skills...", - "keywordPlaceholder": "Enter keyword", - "selectSkill": "Select skill", - "addPair": "Add Keyword-Skill Pair", - "priority": "Priority", - "priorityHigh": "High", - "priorityMedium": "Medium", - "priorityLow": "Low", - "keywordMappings": "Keyword Mappings" + "title": "SKILL 上下文向导", + "description": "配置钩子以根据提示关键词加载 SKILL", + "shortDescription": "根据关键词自动加载 SKILL", + "loadingSkills": "正在加载可用的技能...", + "keywordPlaceholder": "输入关键词", + "selectSkill": "选择技能", + "addPair": "添加关键词-技能对", + "priority": "优先级", + "priorityHigh": "高", + "priorityMedium": "中", + "priorityLow": "低", + "keywordMappings": "关键词映射" } } } diff --git a/ccw/frontend/src/locales/zh/issues.json b/ccw/frontend/src/locales/zh/issues.json index 5b272a42..af3523a5 100644 --- a/ccw/frontend/src/locales/zh/issues.json +++ b/ccw/frontend/src/locales/zh/issues.json @@ -60,5 +60,80 @@ "createdAt": "创建时间", "updatedAt": "更新时间", "solutions": "{count, plural, one {解决方案} other {解决方案}}" + }, + "queue": { + "title": "队列", + "pageTitle": "问题队列", + "description": "管理问题执行队列和执行组", + "stats": { + "totalItems": "总项目", + "groups": "执行组", + "tasks": "任务", + "solutions": "解决方案" + }, + "actions": { + "activate": "激活", + "deactivate": "停用", + "delete": "删除", + "merge": "合并", + "confirmDelete": "确定要删除此队列吗?" + }, + "executionGroup": "执行组", + "parallel": "并行", + "sequential": "顺序", + "emptyState": "无队列数据", + "conflicts": "队列中检测到冲突", + "noQueueData": "无队列数据" + }, + "discovery": { + "title": "发现", + "pageTitle": "问题发现", + "description": "查看和管理问题发现会话", + "stats": { + "totalSessions": "总会话数", + "completed": "已完成", + "running": "运行中", + "findings": "发现" + }, + "session": { + "status": { + "running": "运行中", + "completed": "已完成", + "failed": "失败" + }, + "findings": "{count} 个发现", + "startedAt": "开始时间" + }, + "findings": { + "title": "发现", + "filters": { + "severity": "严重程度", + "type": "类型", + "search": "搜索发现..." + }, + "severity": { + "all": "全部严重程度", + "critical": "严重", + "high": "高", + "medium": "中", + "low": "低" + }, + "type": { + "all": "全部类型" + }, + "noFindings": "未发现结果", + "export": "导出" + }, + "tabs": { + "findings": "发现", + "progress": "进度", + "info": "会话信息" + }, + "emptyState": "未发现发现会话", + "noSessionSelected": "选择会话以查看发现", + "actions": { + "export": "导出发现", + "refresh": "刷新" + } } } diff --git a/ccw/frontend/src/locales/zh/navigation.json b/ccw/frontend/src/locales/zh/navigation.json index f3c499b6..0d03eecf 100644 --- a/ccw/frontend/src/locales/zh/navigation.json +++ b/ccw/frontend/src/locales/zh/navigation.json @@ -8,6 +8,8 @@ "orchestrator": "编排器", "loops": "循环监控", "issues": "问题", + "issueQueue": "问题队列", + "issueDiscovery": "问题发现", "skills": "技能", "commands": "命令", "memory": "记忆", diff --git a/ccw/frontend/src/locales/zh/notifications.json b/ccw/frontend/src/locales/zh/notifications.json index d196a096..d62739a8 100644 --- a/ccw/frontend/src/locales/zh/notifications.json +++ b/ccw/frontend/src/locales/zh/notifications.json @@ -1,5 +1,6 @@ { "title": "通知", + "close": "关闭通知", "empty": "暂无通知", "emptyHint": "通知将显示在这里", "markAllRead": "全部已读", @@ -14,5 +15,38 @@ "daysAgo": "{0}天前", "oneMinuteAgo": "1分钟前", "oneHourAgo": "1小时前", - "oneDayAgo": "1天前" + "oneDayAgo": "1天前", + "sources": { + "system": "系统", + "websocket": "WebSocket", + "cli": "命令行", + "workflow": "工作流", + "user": "用户", + "external": "外部" + }, + "priorities": { + "low": "低", + "medium": "中", + "high": "高", + "critical": "紧急" + }, + "attachments": { + "image": "图片", + "code": "代码", + "file": "文件", + "data": "数据", + "download": "下载" + }, + "actions": { + "loading": "加载中...", + "success": "完成", + "retry": "重试" + }, + "timestamps": { + "today": "今天", + "yesterday": "昨天", + "atTime": "{0}" + }, + "markAsRead": "标为已读", + "markAsUnread": "标为未读" } diff --git a/ccw/frontend/src/locales/zh/skills.json b/ccw/frontend/src/locales/zh/skills.json index 5c7ca2fb..a2c0bf35 100644 --- a/ccw/frontend/src/locales/zh/skills.json +++ b/ccw/frontend/src/locales/zh/skills.json @@ -23,13 +23,19 @@ "card": { "triggers": "触发器", "category": "类别", + "source": "来源", "author": "作者", "version": "版本" }, "filters": { "all": "全部", "enabled": "已启用", - "disabled": "已禁用" + "disabled": "已禁用", + "searchPlaceholder": "搜索技能...", + "allSources": "所有来源" + }, + "stats": { + "totalSkills": "总技能数" }, "view": { "grid": "网格视图", diff --git a/ccw/frontend/src/locales/zh/workspace.json b/ccw/frontend/src/locales/zh/workspace.json index 1c9009f1..edc37f5e 100644 --- a/ccw/frontend/src/locales/zh/workspace.json +++ b/ccw/frontend/src/locales/zh/workspace.json @@ -6,12 +6,12 @@ "current": "当前", "browse": "选择文件夹...", "removePath": "从最近记录中移除", - "ariaLabel": "工作空间选择器" - }, - "dialog": { - "title": "选择项目文件夹", - "placeholder": "输入项目路径...", - "help": "您的项目目录路径" + "ariaLabel": "工作空间选择器", + "dialog": { + "title": "选择项目文件夹", + "placeholder": "输入项目路径...", + "help": "您的项目目录路径" + } }, "actions": { "switch": "切换工作空间", diff --git a/ccw/frontend/src/pages/DiscoveryPage.test.tsx b/ccw/frontend/src/pages/DiscoveryPage.test.tsx new file mode 100644 index 00000000..b6ec706a --- /dev/null +++ b/ccw/frontend/src/pages/DiscoveryPage.test.tsx @@ -0,0 +1,135 @@ +// ======================================== +// Discovery Page Tests +// ======================================== +// Tests for the issue discovery page with i18n + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, waitFor } from '@/test/i18n'; +import { DiscoveryPage } from './DiscoveryPage'; +import { useWorkflowStore } from '@/stores/workflowStore'; +import type { DiscoverySession } from '@/lib/api'; + +// Mock sessions data +const mockSessions: DiscoverySession[] = [ + { + id: '1', + name: 'Session 1', + status: 'running', + progress: 50, + findings_count: 5, + created_at: '2024-01-01T00:00:00Z', + }, + { + id: '2', + name: 'Session 2', + status: 'completed', + progress: 100, + findings_count: 10, + created_at: '2024-01-02T00:00:00Z', + }, +]; + +// Mock hooks at top level +vi.mock('@/hooks/useIssues', () => ({ + useIssueDiscovery: () => ({ + sessions: mockSessions, + activeSession: null, + findings: [], + filteredFindings: [], + isLoadingSessions: false, + isLoadingFindings: false, + error: null, + filters: {}, + setFilters: vi.fn(), + selectSession: vi.fn(), + refetchSessions: vi.fn(), + exportFindings: vi.fn(), + }), +})); + +describe('DiscoveryPage', () => { + beforeEach(() => { + useWorkflowStore.setState({ projectPath: '/test/path' }); + vi.clearAllMocks(); + }); + + describe('with en locale', () => { + it('should render page title', () => { + render(, { locale: 'en' }); + expect(screen.getAllByText(/Issue Discovery/i).length).toBeGreaterThan(0); + }); + + it('should render page description', () => { + render(, { locale: 'en' }); + expect(screen.getByText(/View and manage issue discovery sessions/i)).toBeInTheDocument(); + }); + + it('should render stats cards', () => { + render(, { locale: 'en' }); + expect(screen.getAllByText(/Total Sessions/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Completed/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Running/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Total Findings/i).length).toBeGreaterThan(0); + }); + + it('should render session list heading', () => { + render(, { locale: 'en' }); + expect(screen.getAllByText(/Sessions/i).length).toBeGreaterThan(0); + }); + + it('should render findings detail heading', () => { + render(, { locale: 'en' }); + expect(screen.getByText(/Findings Detail/i)).toBeInTheDocument(); + }); + + it('should display session count in stats', () => { + render(, { locale: 'en' }); + expect(screen.getByText('2')).toBeInTheDocument(); // Total sessions + }); + }); + + describe('with zh locale', () => { + it('should render translated title', () => { + render(, { locale: 'zh' }); + expect(screen.getAllByText(/问题发现/i).length).toBeGreaterThan(0); + }); + + it('should render translated description', () => { + render(, { locale: 'zh' }); + expect(screen.getByText(/查看和管理问题发现会话/i)).toBeInTheDocument(); + }); + + it('should render translated stats', () => { + render(, { locale: 'zh' }); + expect(screen.getAllByText(/总会话数/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/已完成/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/运行中/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/总发现数/i).length).toBeGreaterThan(0); + }); + + it('should render translated session list heading', () => { + render(, { locale: 'zh' }); + expect(screen.getAllByText(/会话/i).length).toBeGreaterThan(0); + }); + + it('should render translated findings detail heading', () => { + render(, { locale: 'zh' }); + expect(screen.getByText(/发现详情/i)).toBeInTheDocument(); + }); + }); + + describe('accessibility', () => { + it('should have proper heading structure', () => { + render(, { locale: 'en' }); + const heading = screen.getByRole('heading', { level: 1, name: /Issue Discovery/i }); + expect(heading).toBeInTheDocument(); + }); + + it('should have proper semantic structure', () => { + render(, { locale: 'en' }); + // Check for sub-headings + const subHeadings = screen.getAllByRole('heading', { level: 2 }); + expect(subHeadings.length).toBeGreaterThanOrEqual(2); + }); + }); +}); diff --git a/ccw/frontend/src/pages/DiscoveryPage.tsx b/ccw/frontend/src/pages/DiscoveryPage.tsx new file mode 100644 index 00000000..330dc104 --- /dev/null +++ b/ccw/frontend/src/pages/DiscoveryPage.tsx @@ -0,0 +1,174 @@ +// ======================================== +// Issue Discovery Page +// ======================================== +// Track discovery sessions and view findings from multiple perspectives + +import { useIntl } from 'react-intl'; +import { Radar, AlertCircle, Loader2 } from 'lucide-react'; +import { Card } from '@/components/ui/Card'; +import { Badge } from '@/components/ui/Badge'; +import { useIssueDiscovery } from '@/hooks/useIssues'; +import { DiscoveryCard } from '@/components/issue/discovery/DiscoveryCard'; +import { DiscoveryDetail } from '@/components/issue/discovery/DiscoveryDetail'; + +export function DiscoveryPage() { + const { formatMessage } = useIntl(); + + const { + sessions, + activeSession, + findings, + isLoadingSessions, + isLoadingFindings, + error, + filters, + setFilters, + selectSession, + exportFindings, + } = useIssueDiscovery({ refetchInterval: 3000 }); + + if (error) { + return ( +
+
+ +

+ {formatMessage({ id: 'issues.discovery.title' })} +

+
+ + +

+ {formatMessage({ id: 'common.error' })} +

+

{error.message}

+
+
+ ); + } + + return ( +
+ {/* Page Header */} +
+ +
+

+ {formatMessage({ id: 'issues.discovery.title' })} +

+

+ {formatMessage({ id: 'issues.discovery.description' })} +

+
+
+ + {/* Stats Cards */} +
+ +
+ + {sessions.length} +
+

+ {formatMessage({ id: 'issues.discovery.totalSessions' })} +

+
+ +
+ + {sessions.filter(s => s.status === 'completed').length} + + {sessions.filter(s => s.status === 'completed').length} +
+

+ {formatMessage({ id: 'issues.discovery.completedSessions' })} +

+
+ +
+ + {sessions.filter(s => s.status === 'running').length} + + {sessions.filter(s => s.status === 'running').length} +
+

+ {formatMessage({ id: 'issues.discovery.runningSessions' })} +

+
+ +
+ + {sessions.reduce((sum, s) => sum + s.findings_count, 0)} + +
+

+ {formatMessage({ id: 'issues.discovery.totalFindings' })} +

+
+
+ + {/* Main Content: Split Pane */} +
+ {/* Left: Session List */} +
+

+ {formatMessage({ id: 'issues.discovery.sessionList' })} +

+ + {isLoadingSessions ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : sessions.length === 0 ? ( + + +

+ {formatMessage({ id: 'issues.discovery.noSessions' })} +

+

+ {formatMessage({ id: 'issues.discovery.noSessionsDescription' })} +

+
+ ) : ( +
+ {sessions.map((session) => ( + selectSession(session.id)} + /> + ))} +
+ )} +
+ + {/* Right: Findings Detail */} +
+

+ {formatMessage({ id: 'issues.discovery.findingsDetail' })} +

+ + {isLoadingFindings ? ( +
+ +
+ ) : ( + + )} +
+
+
+ ); +} + +export default DiscoveryPage; diff --git a/ccw/frontend/src/pages/QueuePage.test.tsx b/ccw/frontend/src/pages/QueuePage.test.tsx new file mode 100644 index 00000000..644d6ca6 --- /dev/null +++ b/ccw/frontend/src/pages/QueuePage.test.tsx @@ -0,0 +1,123 @@ +// ======================================== +// Queue Page Tests +// ======================================== +// Tests for the issue queue page with i18n + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen } from '@/test/i18n'; +import { QueuePage } from './QueuePage'; +import { useWorkflowStore } from '@/stores/workflowStore'; +import type { IssueQueue } from '@/lib/api'; + +// Mock queue data +const mockQueueData: IssueQueue = { + tasks: ['task1', 'task2'], + solutions: ['solution1'], + conflicts: [], + execution_groups: { 'group-1': ['task1', 'task2'] }, + grouped_items: { 'parallel-group': ['task1', 'task2'] }, +}; + +// Mock hooks at top level +vi.mock('@/hooks', () => ({ + useIssueQueue: () => ({ + data: mockQueueData, + isLoading: false, + isFetching: false, + error: null, + refetch: vi.fn(), + }), + useQueueMutations: () => ({ + activateQueue: vi.fn(), + deactivateQueue: vi.fn(), + deleteQueue: vi.fn(), + mergeQueues: vi.fn(), + isActivating: false, + isDeactivating: false, + isDeleting: false, + isMerging: false, + }), +})); + +describe('QueuePage', () => { + beforeEach(() => { + useWorkflowStore.setState({ projectPath: '/test/path' }); + vi.clearAllMocks(); + }); + + describe('with en locale', () => { + it('should render page title', () => { + render(, { locale: 'en' }); + expect(screen.getByText(/Issue Queue/i)).toBeInTheDocument(); + }); + + it('should render page description', () => { + render(, { locale: 'en' }); + expect(screen.getByText(/Manage issue execution queue/i)).toBeInTheDocument(); + }); + + it('should render stats cards', () => { + render(, { locale: 'en' }); + expect(screen.getAllByText(/Total Items/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Groups/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Tasks/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Solutions/i).length).toBeGreaterThan(0); + }); + + it('should render refresh button', () => { + render(, { locale: 'en' }); + const refreshButton = screen.getByRole('button', { name: /refresh/i }); + expect(refreshButton).toBeInTheDocument(); + }); + }); + + describe('with zh locale', () => { + it('should render translated title', () => { + render(, { locale: 'zh' }); + expect(screen.getByText(/问题队列/i)).toBeInTheDocument(); + }); + + it('should render translated description', () => { + render(, { locale: 'zh' }); + expect(screen.getByText(/管理问题执行队列/i)).toBeInTheDocument(); + }); + + it('should render translated stats', () => { + render(, { locale: 'zh' }); + expect(screen.getAllByText(/总项目/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/执行组/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/任务/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/解决方案/i).length).toBeGreaterThan(0); + }); + + it('should render translated refresh button', () => { + render(, { locale: 'zh' }); + const refreshButton = screen.getByRole('button', { name: /刷新/i }); + expect(refreshButton).toBeInTheDocument(); + }); + }); + + describe('conflicts warning', () => { + it('should show conflicts warning when conflicts exist', () => { + // This test would require modifying the mock data + // For now, we just verify the page renders without crashing + render(, { locale: 'en' }); + const page = screen.getByText(/Issue Queue/i); + expect(page).toBeInTheDocument(); + }); + }); + + describe('accessibility', () => { + it('should have proper heading structure', () => { + render(, { locale: 'en' }); + const heading = screen.getByRole('heading', { level: 1, name: /Issue Queue/i }); + expect(heading).toBeInTheDocument(); + }); + + it('should have accessible refresh button', () => { + render(, { locale: 'en' }); + const refreshButton = screen.getByRole('button', { name: /refresh/i }); + expect(refreshButton).toBeInTheDocument(); + }); + }); +}); diff --git a/ccw/frontend/src/pages/QueuePage.tsx b/ccw/frontend/src/pages/QueuePage.tsx new file mode 100644 index 00000000..b4318a3d --- /dev/null +++ b/ccw/frontend/src/pages/QueuePage.tsx @@ -0,0 +1,290 @@ +// ======================================== +// Queue Page +// ======================================== +// View and manage issue execution queues + +import { useIntl } from 'react-intl'; +import { + ListTodo, + RefreshCw, + AlertCircle, + CheckCircle, + Clock, + GitMerge, +} from 'lucide-react'; +import { Card } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import { Badge } from '@/components/ui/Badge'; +import { QueueCard } from '@/components/issue/queue/QueueCard'; +import { useIssueQueue, useQueueMutations } from '@/hooks'; +import { cn } from '@/lib/utils'; + +// ========== Loading Skeleton ========== + +function QueuePageSkeleton() { + return ( +
+ {/* Header Skeleton */} +
+
+
+
+
+
+
+ + {/* Stats Cards Skeleton */} +
+ {[1, 2, 3, 4].map((i) => ( + +
+
+ + ))} +
+ + {/* Queue Cards Skeleton */} +
+ {[1, 2].map((i) => ( + +
+
+
+ + ))} +
+
+ ); +} + +// ========== Empty State ========== + +function QueueEmptyState() { + const { formatMessage } = useIntl(); + + return ( + + +

+ {formatMessage({ id: 'issues.queue.emptyState.title' })} +

+

+ {formatMessage({ id: 'issues.queue.emptyState.description' })} +

+
+ ); +} + +// ========== Main Page Component ========== + +export function QueuePage() { + const { formatMessage } = useIntl(); + + const { data: queueData, isLoading, isFetching, refetch, error } = useIssueQueue(); + const { + activateQueue, + deactivateQueue, + deleteQueue, + mergeQueues, + isActivating, + isDeactivating, + isDeleting, + isMerging, + } = useQueueMutations(); + + // Get queue data with proper type + const queue = queueData; + const taskCount = queue?.tasks?.length || 0; + const solutionCount = queue?.solutions?.length || 0; + const conflictCount = queue?.conflicts?.length || 0; + const groupCount = Object.keys(queue?.grouped_items || {}).length; + const totalItems = taskCount + solutionCount; + + const handleActivate = async (queueId: string) => { + try { + await activateQueue(queueId); + } catch (err) { + console.error('Failed to activate queue:', err); + } + }; + + const handleDeactivate = async () => { + try { + await deactivateQueue(); + } catch (err) { + console.error('Failed to deactivate queue:', err); + } + }; + + const handleDelete = async (queueId: string) => { + try { + await deleteQueue(queueId); + } catch (err) { + console.error('Failed to delete queue:', err); + } + }; + + const handleMerge = async (sourceId: string, targetId: string) => { + try { + await mergeQueues(sourceId, targetId); + } catch (err) { + console.error('Failed to merge queues:', err); + } + }; + + if (isLoading) { + return ; + } + + if (error) { + return ( + + +

+ {formatMessage({ id: 'issues.queue.error.title' })} +

+

+ {(error as Error).message || formatMessage({ id: 'issues.queue.error.message' })} +

+
+ ); + } + + if (!queue || totalItems === 0) { + return ; + } + + // Check if queue is active (has items and no conflicts) + const isActive = totalItems > 0 && conflictCount === 0; + + return ( +
+ {/* Page Header */} +
+
+

+ + {formatMessage({ id: 'issues.queue.pageTitle' })} +

+

+ {formatMessage({ id: 'issues.queue.pageDescription' })} +

+
+
+ +
+
+ + {/* Stats Cards */} +
+ +
+ + {totalItems} +
+

+ {formatMessage({ id: 'issues.queue.stats.totalItems' })} +

+
+ +
+ + {groupCount} +
+

+ {formatMessage({ id: 'issues.queue.stats.groups' })} +

+
+ +
+ + {taskCount} +
+

+ {formatMessage({ id: 'issues.queue.stats.tasks' })} +

+
+ +
+ + {solutionCount} +
+

+ {formatMessage({ id: 'issues.queue.stats.solutions' })} +

+
+
+ + {/* Conflicts Warning */} + {conflictCount > 0 && ( + +
+ +
+

+ {formatMessage({ id: 'issues.queue.conflicts.title' })} +

+

+ {conflictCount} {formatMessage({ id: 'issues.queue.conflicts.description' })} +

+
+
+
+ )} + + {/* Queue Card */} +
+ +
+ + {/* Status Footer */} +
+
+ {isActive ? ( + <> + + {formatMessage({ id: 'issues.queue.status.ready' })} + + ) : ( + <> + + {formatMessage({ id: 'issues.queue.status.pending' })} + + )} +
+ + {isActive ? ( + + ) : ( + + )} + {isActive + ? formatMessage({ id: 'issues.queue.status.active' }) + : formatMessage({ id: 'issues.queue.status.inactive' }) + } + +
+
+ ); +} + +export default QueuePage; diff --git a/ccw/frontend/src/pages/index.ts b/ccw/frontend/src/pages/index.ts index a3e027fb..b61acaa1 100644 --- a/ccw/frontend/src/pages/index.ts +++ b/ccw/frontend/src/pages/index.ts @@ -12,6 +12,8 @@ export { HistoryPage } from './HistoryPage'; export { OrchestratorPage } from './orchestrator'; export { LoopMonitorPage } from './LoopMonitorPage'; export { IssueManagerPage } from './IssueManagerPage'; +export { QueuePage } from './QueuePage'; +export { DiscoveryPage } from './DiscoveryPage'; export { SkillsManagerPage } from './SkillsManagerPage'; export { CommandsManagerPage } from './CommandsManagerPage'; export { MemoryPage } from './MemoryPage'; diff --git a/ccw/frontend/src/router.tsx b/ccw/frontend/src/router.tsx index 1cd90665..f3fa5ce6 100644 --- a/ccw/frontend/src/router.tsx +++ b/ccw/frontend/src/router.tsx @@ -15,6 +15,8 @@ import { OrchestratorPage, LoopMonitorPage, IssueManagerPage, + QueuePage, + DiscoveryPage, SkillsManagerPage, CommandsManagerPage, MemoryPage, @@ -93,6 +95,14 @@ const routes: RouteObject[] = [ path: 'issues', element: , }, + { + path: 'issues/queue', + element: , + }, + { + path: 'issues/discovery', + element: , + }, { path: 'skills', element: , @@ -156,8 +166,13 @@ const routes: RouteObject[] = [ /** * Create the browser router instance + * Uses basename from Vite's BASE_URL environment variable */ -export const router = createBrowserRouter(routes); +const basename = import.meta.env.BASE_URL?.replace(/\/$/, '') || ''; + +export const router = createBrowserRouter(routes, { + basename, +}); /** * Export route paths for type-safe navigation @@ -176,6 +191,8 @@ export const ROUTES = { EXECUTIONS: '/executions', LOOPS: '/loops', ISSUES: '/issues', + ISSUE_QUEUE: '/issues/queue', + ISSUE_DISCOVERY: '/issues/discovery', SKILLS: '/skills', COMMANDS: '/commands', MEMORY: '/memory', diff --git a/ccw/frontend/src/stores/cliStreamStore.ts b/ccw/frontend/src/stores/cliStreamStore.ts index 509ec96a..cac76fad 100644 --- a/ccw/frontend/src/stores/cliStreamStore.ts +++ b/ccw/frontend/src/stores/cliStreamStore.ts @@ -35,10 +35,45 @@ export interface CliExecutionState { recovered?: boolean; } +/** + * Log line within a block (minimal interface compatible with LogBlock component) + * This matches the LogLine type in components/shared/LogBlock/types.ts + */ +export interface LogLine { + type: 'stdout' | 'stderr' | 'thought' | 'system' | 'metadata' | 'tool_call'; + content: string; + timestamp: number; +} + +/** + * Log block data (minimal interface compatible with LogBlock component) + * This matches the LogBlockData type in components/shared/LogBlock/types.ts + * Defined here to avoid circular dependencies + */ +export interface LogBlockData { + id: string; + title: string; + type: 'command' | 'tool' | 'output' | 'error' | 'warning' | 'info'; + status: 'running' | 'completed' | 'error' | 'pending'; + toolName?: string; + lineCount: number; + duration?: number; + lines: LogLine[]; + timestamp: number; +} + +/** + * Block cache state + */ +interface BlockCacheState { + blocks: Record; // executionId -> cached blocks + lastUpdate: Record; // executionId -> timestamp of last cache update +} + /** * CLI stream state interface */ -interface CliStreamState { +interface CliStreamState extends BlockCacheState { outputs: Record; executions: Record; currentExecutionId: string | null; @@ -53,6 +88,10 @@ interface CliStreamState { upsertExecution: (executionId: string, exec: Partial & { tool?: string; mode?: string }) => void; removeExecution: (executionId: string) => void; setCurrentExecution: (executionId: string | null) => void; + + // Block cache methods + getBlocks: (executionId: string) => LogBlockData[]; + invalidateBlocks: (executionId: string) => void; } // ========== Constants ========== @@ -63,6 +102,203 @@ interface CliStreamState { */ const MAX_OUTPUT_LINES = 5000; +// ========== Helper Functions ========== + +/** + * Parse tool call metadata from content + * Expected format: "[Tool] toolName(args)" + */ +function parseToolCallMetadata(content: string): { toolName: string; args: string } | undefined { + const toolCallMatch = content.match(/^\[Tool\]\s+(\w+)\((.*)\)$/); + if (toolCallMatch) { + return { + toolName: toolCallMatch[1], + args: toolCallMatch[2] || '', + }; + } + return undefined; +} + +/** + * Generate block title based on type and content + */ +function generateBlockTitle(lineType: string, content: string): string { + switch (lineType) { + case 'tool_call': + const metadata = parseToolCallMetadata(content); + if (metadata) { + return metadata.args ? `${metadata.toolName}(${metadata.args})` : metadata.toolName; + } + return 'Tool Call'; + case 'thought': + return 'Thought'; + case 'system': + return 'System'; + case 'stderr': + return 'Error Output'; + case 'stdout': + return 'Output'; + case 'metadata': + return 'Metadata'; + default: + return 'Log'; + } +} + +/** + * Get block type for a line + */ +function getBlockType(lineType: string): LogBlockData['type'] { + switch (lineType) { + case 'tool_call': + return 'tool'; + case 'thought': + return 'info'; + case 'system': + return 'info'; + case 'stderr': + return 'error'; + case 'stdout': + case 'metadata': + default: + return 'output'; + } +} + +/** + * Check if a line type should start a new block + */ +function shouldStartNewBlock(lineType: string, currentBlockType: string | null): boolean { + // No current block exists + if (!currentBlockType) { + return true; + } + + // These types always start new blocks + if (lineType === 'tool_call' || lineType === 'thought' || lineType === 'system') { + return true; + } + + // stderr starts a new block if not already in stderr + if (lineType === 'stderr' && currentBlockType !== 'stderr') { + return true; + } + + // tool_call block captures all following stdout/stderr until next tool_call + if (currentBlockType === 'tool_call' && (lineType === 'stdout' || lineType === 'stderr')) { + return false; + } + + // stderr block captures all stderr until next different type + if (currentBlockType === 'stderr' && lineType === 'stderr') { + return false; + } + + // stdout merges into current stdout block + if (currentBlockType === 'stdout' && lineType === 'stdout') { + return false; + } + + // Different type - start new block + if (currentBlockType !== lineType) { + return true; + } + + return false; +} + +/** + * Group CLI output lines into log blocks + * + * Block grouping rules: + * 1. tool_call starts new block, includes following stdout/stderr until next tool_call + * 2. thought becomes independent block + * 3. system becomes independent block + * 4. stderr becomes highlighted block + * 5. Other stdout merges into normal blocks + */ +function groupLinesIntoBlocks( + lines: CliOutputLine[], + executionId: string, + executionStatus: 'running' | 'completed' | 'error' +): LogBlockData[] { + const blocks: LogBlockData[] = []; + let currentLines: LogLine[] = []; + let currentType: string | null = null; + let currentTitle = ''; + let currentToolName: string | undefined; + let blockStartTime = 0; + let blockIndex = 0; + + for (const line of lines) { + // Check if we need to start a new block + if (shouldStartNewBlock(line.type, currentType)) { + // Save current block if exists + if (currentLines.length > 0) { + const duration = blockStartTime > 0 ? line.timestamp - blockStartTime : undefined; + blocks.push({ + id: `${executionId}-block-${blockIndex}`, + title: currentTitle || generateBlockTitle(currentType || '', currentLines[0]?.content || ''), + type: getBlockType(currentType || ''), + status: executionStatus === 'running' ? 'running' : 'completed', + toolName: currentToolName, + lineCount: currentLines.length, + duration, + lines: currentLines, + timestamp: blockStartTime, + }); + blockIndex++; + } + + // Start new block + currentType = line.type; + currentTitle = generateBlockTitle(line.type, line.content); + currentLines = [ + { + type: line.type, + content: line.content, + timestamp: line.timestamp, + }, + ]; + blockStartTime = line.timestamp; + + // Extract tool name for tool_call blocks + if (line.type === 'tool_call') { + const metadata = parseToolCallMetadata(line.content); + currentToolName = metadata?.toolName; + } else { + currentToolName = undefined; + } + } else { + // Add line to current block + currentLines.push({ + type: line.type, + content: line.content, + timestamp: line.timestamp, + }); + } + } + + // Finalize the last block + if (currentLines.length > 0) { + const lastLine = currentLines[currentLines.length - 1]; + const duration = blockStartTime > 0 ? lastLine.timestamp - blockStartTime : undefined; + blocks.push({ + id: `${executionId}-block-${blockIndex}`, + title: currentTitle || generateBlockTitle(currentType || '', currentLines[0]?.content || ''), + type: getBlockType(currentType || ''), + status: executionStatus === 'running' ? 'running' : 'completed', + toolName: currentToolName, + lineCount: currentLines.length, + duration, + lines: currentLines, + timestamp: blockStartTime, + }); + } + + return blocks; +} + // ========== Store ========== /** @@ -85,6 +321,10 @@ export const useCliStreamStore = create()( executions: {}, currentExecutionId: null, + // Block cache state + blocks: {}, + lastUpdate: {}, + addOutput: (executionId: string, line: CliOutputLine) => { set((state) => { const current = state.outputs[executionId] || []; @@ -172,9 +412,15 @@ export const useCliStreamStore = create()( removeExecution: (executionId: string) => { set((state) => { const newExecutions = { ...state.executions }; + const newBlocks = { ...state.blocks }; + const newLastUpdate = { ...state.lastUpdate }; delete newExecutions[executionId]; + delete newBlocks[executionId]; + delete newLastUpdate[executionId]; return { executions: newExecutions, + blocks: newBlocks, + lastUpdate: newLastUpdate, currentExecutionId: state.currentExecutionId === executionId ? null : state.currentExecutionId, }; }, false, 'cliStream/removeExecution'); @@ -183,6 +429,65 @@ export const useCliStreamStore = create()( setCurrentExecution: (executionId: string | null) => { set({ currentExecutionId: executionId }, false, 'cliStream/setCurrentExecution'); }, + + // Block cache methods + getBlocks: (executionId: string) => { + const state = get(); + const execution = state.executions[executionId]; + + // Return empty array if execution doesn't exist + if (!execution) { + return []; + } + + // Check if cache is valid + // Cache is valid if: + // 1. Cache exists and has blocks + // 2. Execution has ended (has endTime) + // 3. Cache was updated after or at the execution end time + const cachedBlocks = state.blocks[executionId]; + const lastUpdateTime = state.lastUpdate[executionId]; + const isCacheValid = + cachedBlocks && + lastUpdateTime && + execution.endTime && + lastUpdateTime >= execution.endTime; + + // Return cached blocks if valid + if (isCacheValid) { + return cachedBlocks; + } + + // Recompute blocks from output + const newBlocks = groupLinesIntoBlocks(execution.output, executionId, execution.status); + + // Update cache + set((state) => ({ + blocks: { + ...state.blocks, + [executionId]: newBlocks, + }, + lastUpdate: { + ...state.lastUpdate, + [executionId]: Date.now(), + }, + }), false, 'cliStream/updateBlockCache'); + + return newBlocks; + }, + + invalidateBlocks: (executionId: string) => { + set((state) => { + const newBlocks = { ...state.blocks }; + const newLastUpdate = { ...state.lastUpdate }; + delete newBlocks[executionId]; + delete newLastUpdate[executionId]; + return { + blocks: newBlocks, + lastUpdate: newLastUpdate, + }; + }, false, 'cliStream/invalidateBlocks'); + }, }), { name: 'CliStreamStore' } ) diff --git a/ccw/frontend/src/stores/notificationStore.ts b/ccw/frontend/src/stores/notificationStore.ts index c0a54510..17fb3d21 100644 --- a/ccw/frontend/src/stores/notificationStore.ts +++ b/ccw/frontend/src/stores/notificationStore.ts @@ -11,6 +11,8 @@ import type { Toast, WebSocketStatus, WebSocketMessage, + NotificationAction, + ActionState, } from '../types/store'; import type { SurfaceUpdate } from '../packages/a2ui-runtime/core/A2UITypes'; @@ -77,6 +79,9 @@ const initialState: NotificationState = { // Current question dialog state currentQuestion: null, + + // Action state tracking + actionStates: new Map(), }; export const useNotificationStore = create()( @@ -248,6 +253,115 @@ export const useNotificationStore = create()( saveToStorage(state.persistentNotifications); }, + // ========== Read Status Management ========== + + toggleNotificationRead: (id: string) => { + set( + (state) => { + // Check both toasts and persistentNotifications + const toastIndex = state.toasts.findIndex((t) => t.id === id); + const persistentIndex = state.persistentNotifications.findIndex((n) => n.id === id); + + if (toastIndex === -1 && persistentIndex === -1) { + return state; // Notification not found + } + + const newState = { ...state }; + if (toastIndex !== -1) { + const newToasts = [...state.toasts]; + newToasts[toastIndex] = { + ...newToasts[toastIndex], + read: !newToasts[toastIndex].read, + }; + newState.toasts = newToasts; + } + if (persistentIndex !== -1) { + const newPersistent = [...state.persistentNotifications]; + newPersistent[persistentIndex] = { + ...newPersistent[persistentIndex], + read: !newPersistent[persistentIndex].read, + }; + newState.persistentNotifications = newPersistent; + // Save to localStorage for persistent notifications + saveToStorage(newPersistent); + } + + return newState; + }, + false, + 'toggleNotificationRead' + ); + }, + + // ========== Action State Management ========== + + setActionState: (actionKey: string, actionState: ActionState) => { + set( + (state) => { + const newActionStates = new Map(state.actionStates); + newActionStates.set(actionKey, actionState); + return { actionStates: newActionStates }; + }, + false, + 'setActionState' + ); + }, + + executeAction: async (action: NotificationAction, notificationId: string, actionKey?: string) => { + const key = actionKey || `${notificationId}-${action.label}`; + const state = get(); + + // Check if action is disabled + const currentActionState = state.actionStates.get(key); + if (currentActionState?.status === 'loading' || action.disabled) { + return; + } + + // Set loading state + const newActionStates = new Map(state.actionStates); + newActionStates.set(key, { + status: 'loading', + lastAttempt: new Date().toISOString(), + }); + set({ actionStates: newActionStates }); + + try { + await action.onClick(); + // Set success state + const successStates = new Map(get().actionStates); + successStates.set(key, { + status: 'success', + lastAttempt: new Date().toISOString(), + }); + set({ actionStates: successStates }); + } catch (error) { + // Set error state + const errorStates = new Map(get().actionStates); + errorStates.set(key, { + status: 'error', + error: error instanceof Error ? error.message : String(error), + lastAttempt: new Date().toISOString(), + }); + set({ actionStates: errorStates }); + } + }, + + retryAction: async (actionKey: string, notificationId: string) => { + const state = get(); + const actionState = state.actionStates.get(actionKey); + + if (!actionState) { + console.warn(`[NotificationStore] No action state found for key: ${actionKey}`); + return; + } + + // Reset to idle and let executeAction handle it + get().setActionState(actionKey, { status: 'idle', lastAttempt: new Date().toISOString() }); + + // Note: The caller should re-invoke executeAction with the original action + // This method just resets the state for retry + }, + // ========== A2UI Actions ========== addA2UINotification: (surface: SurfaceUpdate, title = 'A2UI Surface') => { diff --git a/ccw/frontend/src/stores/workflowStore.ts b/ccw/frontend/src/stores/workflowStore.ts index 66419b2e..b0600b3a 100644 --- a/ccw/frontend/src/stores/workflowStore.ts +++ b/ccw/frontend/src/stores/workflowStore.ts @@ -4,7 +4,7 @@ // Manages workflow sessions, tasks, and related data import { create } from 'zustand'; -import { devtools } from 'zustand/middleware'; +import { devtools, persist } from 'zustand/middleware'; import type { WorkflowStore, WorkflowState, @@ -60,8 +60,9 @@ const initialState: WorkflowState = { export const useWorkflowStore = create()( devtools( - (set, get) => ({ - ...initialState, + persist( + (set, get) => ({ + ...initialState, // ========== Session Actions ========== @@ -510,7 +511,49 @@ export const useWorkflowStore = create()( getSessionByKey: (key: string) => { return get().sessionDataStore[key]; }, - }), + }), + { + name: 'ccw-workflow-store', + version: 1, // State version for migration support + partialize: (state) => ({ + projectPath: state.projectPath, + }), + migrate: (persistedState, version) => { + // Migration logic for future state shape changes + if (version < 1) { + // No migrations needed for initial version + // Example: if (version === 0) { persistedState.newField = defaultValue; } + } + return persistedState as typeof persistedState; + }, + onRehydrateStorage: () => { + // Only log in development to avoid noise in production + if (process.env.NODE_ENV === 'development') { + // eslint-disable-next-line no-console + console.log('[WorkflowStore] Hydrating from localStorage...'); + } + return (state, error) => { + if (error) { + // eslint-disable-next-line no-console + console.error('[WorkflowStore] Rehydration error:', error); + return; + } + if (state?.projectPath) { + if (process.env.NODE_ENV === 'development') { + // eslint-disable-next-line no-console + console.log('[WorkflowStore] Found persisted projectPath, re-initializing workspace:', state.projectPath); + } + // Use setTimeout to ensure the store is fully initialized before calling switchWorkspace + setTimeout(() => { + if (state.switchWorkspace) { + state.switchWorkspace(state.projectPath); + } + }, 0); + } + }; + }, + } + ), { name: 'WorkflowStore' } ) ); diff --git a/ccw/frontend/src/test/i18n.tsx b/ccw/frontend/src/test/i18n.tsx index a2ef4d00..c55c90db 100644 --- a/ccw/frontend/src/test/i18n.tsx +++ b/ccw/frontend/src/test/i18n.tsx @@ -57,6 +57,60 @@ const mockMessages: Record> = { 'workspace.selector.dialog.placeholder': 'Enter project path...', // Notifications 'common.aria.notifications': 'Notifications', + 'common.actions.refresh': 'Refresh', + // Issues - Queue + 'issues.queue.pageTitle': 'Issue Queue', + 'issues.queue.pageDescription': 'Manage issue execution queue with execution groups', + 'issues.queue.title': 'Queue', + 'issues.queue.stats.totalItems': 'Total Items', + 'issues.queue.stats.groups': 'Groups', + 'issues.queue.stats.tasks': 'Tasks', + 'issues.queue.stats.solutions': 'Solutions', + 'issues.queue.status.active': 'Active', + 'issues.queue.status.inactive': 'Inactive', + 'issues.queue.status.ready': 'Ready', + 'issues.queue.status.pending': 'Pending', + 'issues.queue.items': 'Items', + 'issues.queue.groups': 'Groups', + 'issues.queue.conflicts': 'conflicts', + 'issues.queue.conflicts.title': 'Conflicts Detected', + 'issues.queue.conflicts.description': 'conflicts detected in queue', + 'issues.queue.parallelGroup': 'Parallel', + 'issues.queue.sequentialGroup': 'Sequential', + 'issues.queue.executionGroups': 'Execution Groups', + 'issues.queue.empty': 'No items in queue', + 'issues.queue.emptyState.title': 'No Queue Data', + 'issues.queue.emptyState.description': 'No queue data available', + 'issues.queue.error.title': 'Error Loading Queue', + 'issues.queue.error.message': 'Failed to load queue data', + 'issues.queue.actions.activate': 'Activate', + 'issues.queue.actions.deactivate': 'Deactivate', + 'issues.queue.actions.delete': 'Delete', + 'issues.queue.actions.merge': 'Merge', + 'issues.queue.deleteDialog.title': 'Delete Queue', + 'issues.queue.deleteDialog.description': 'Are you sure you want to delete this queue?', + 'issues.queue.mergeDialog.title': 'Merge Queues', + 'issues.queue.mergeDialog.targetQueueLabel': 'Target Queue', + 'issues.queue.mergeDialog.targetQueuePlaceholder': 'Select target queue', + 'common.actions.openMenu': 'Open menu', + // Issues - Discovery + 'issues.discovery.title': 'Issue Discovery', + 'issues.discovery.pageTitle': 'Issue Discovery', + 'issues.discovery.description': 'View and manage issue discovery sessions', + 'issues.discovery.totalSessions': 'Total Sessions', + 'issues.discovery.completedSessions': 'Completed', + 'issues.discovery.runningSessions': 'Running', + 'issues.discovery.totalFindings': 'Total Findings', + 'issues.discovery.sessionList': 'Sessions', + 'issues.discovery.findingsDetail': 'Findings Detail', + 'issues.discovery.noSessions': 'No Sessions', + 'issues.discovery.noSessionsDescription': 'No discovery sessions found', + 'issues.discovery.noSessionSelected': 'Select a session to view findings', + 'issues.discovery.status.running': 'Running', + 'issues.discovery.status.completed': 'Completed', + 'issues.discovery.status.failed': 'Failed', + 'issues.discovery.progress': 'Progress', + 'issues.discovery.findings': 'Findings', }, zh: { // Common @@ -102,6 +156,60 @@ const mockMessages: Record> = { 'workspace.selector.dialog.placeholder': '输入项目路径...', // Notifications 'common.aria.notifications': '通知', + 'common.actions.refresh': '刷新', + // Issues - Queue + 'issues.queue.pageTitle': '问题队列', + 'issues.queue.pageDescription': '管理问题执行队列和执行组', + 'issues.queue.title': '队列', + 'issues.queue.stats.totalItems': '总项目', + 'issues.queue.stats.groups': '执行组', + 'issues.queue.stats.tasks': '任务', + 'issues.queue.stats.solutions': '解决方案', + 'issues.queue.status.active': '活跃', + 'issues.queue.status.inactive': '未激活', + 'issues.queue.status.ready': '就绪', + 'issues.queue.status.pending': '等待中', + 'issues.queue.items': '项目', + 'issues.queue.groups': '执行组', + 'issues.queue.conflicts': '冲突', + 'issues.queue.conflicts.title': '检测到冲突', + 'issues.queue.conflicts.description': '队列中检测到冲突', + 'issues.queue.parallelGroup': '并行', + 'issues.queue.sequentialGroup': '顺序', + 'issues.queue.executionGroups': '执行组', + 'issues.queue.empty': '队列中无项目', + 'issues.queue.emptyState.title': '无队列数据', + 'issues.queue.emptyState.description': '无队列数据可用', + 'issues.queue.error.title': '加载队列错误', + 'issues.queue.error.message': '加载队列数据失败', + 'issues.queue.actions.activate': '激活', + 'issues.queue.actions.deactivate': '停用', + 'issues.queue.actions.delete': '删除', + 'issues.queue.actions.merge': '合并', + 'issues.queue.deleteDialog.title': '删除队列', + 'issues.queue.deleteDialog.description': '确定要删除此队列吗?', + 'issues.queue.mergeDialog.title': '合并队列', + 'issues.queue.mergeDialog.targetQueueLabel': '目标队列', + 'issues.queue.mergeDialog.targetQueuePlaceholder': '选择目标队列', + 'common.actions.openMenu': '打开菜单', + // Issues - Discovery + 'issues.discovery.title': '问题发现', + 'issues.discovery.pageTitle': '问题发现', + 'issues.discovery.description': '查看和管理问题发现会话', + 'issues.discovery.totalSessions': '总会话数', + 'issues.discovery.completedSessions': '已完成', + 'issues.discovery.runningSessions': '运行中', + 'issues.discovery.totalFindings': '总发现数', + 'issues.discovery.sessionList': '会话', + 'issues.discovery.findingsDetail': '发现详情', + 'issues.discovery.noSessions': '无会话', + 'issues.discovery.noSessionsDescription': '未发现发现会话', + 'issues.discovery.noSessionSelected': '选择会话以查看发现', + 'issues.discovery.status.running': '运行中', + 'issues.discovery.status.completed': '已完成', + 'issues.discovery.status.failed': '失败', + 'issues.discovery.progress': '进度', + 'issues.discovery.findings': '发现', }, }; diff --git a/ccw/frontend/src/types/store.ts b/ccw/frontend/src/types/store.ts index 326f6a4e..36254089 100644 --- a/ccw/frontend/src/types/store.ts +++ b/ccw/frontend/src/types/store.ts @@ -299,6 +299,46 @@ import type { SurfaceUpdate } from '../packages/a2ui-runtime/core/A2UITypes'; export type ToastType = 'info' | 'success' | 'warning' | 'error' | 'a2ui'; export type WebSocketStatus = 'connecting' | 'connected' | 'disconnected' | 'error' | 'reconnecting'; +// Notification source types +export type NotificationSource = 'system' | 'websocket' | 'cli' | 'workflow' | 'user' | 'external'; + +// Notification attachment types +export type AttachmentType = 'image' | 'code' | 'file' | 'data'; + +export interface NotificationAttachment { + type: AttachmentType; + url?: string; + content?: string; + filename?: string; + mimeType?: string; + size?: number; + thumbnail?: string; +} + +// Action state types +export type ActionStateType = 'idle' | 'loading' | 'success' | 'error'; + +export interface ActionState { + status: ActionStateType; + error?: string; + lastAttempt?: string; +} + +// Notification action with extended properties +export interface NotificationAction { + label: string; + onClick: () => void | Promise; + loading?: boolean; + disabled?: boolean; + confirm?: { + title?: string; + message?: string; + confirmText?: string; + cancelText?: string; + }; + primary?: boolean; +} + export interface Toast { id: string; type: ToastType; @@ -308,10 +348,16 @@ export interface Toast { timestamp: string; dismissible?: boolean; read?: boolean; // Track read status for persistent notifications - action?: { - label: string; - onClick: () => void; - }; + // Extended action field - now uses NotificationAction type + action?: NotificationAction; + // New optional fields for enhanced notifications + source?: NotificationSource; // Origin of the notification + category?: string; // Category for grouping/filtering + priority?: 'low' | 'medium' | 'high' | 'critical'; // Priority level + attachments?: NotificationAttachment[]; // Attached resources + actions?: NotificationAction[]; // Multiple actions support + metadata?: Record; // Additional metadata + status?: 'pending' | 'active' | 'resolved' | 'archived'; // Notification status // A2UI fields a2uiSurface?: SurfaceUpdate; // A2UI surface data for type='a2ui' a2uiState?: Record; // A2UI component state @@ -377,6 +423,9 @@ export interface NotificationState { // Current question dialog state currentQuestion: AskQuestionPayload | null; + + // Action state tracking (Map of actionKey to ActionState) + actionStates: Map; } export interface NotificationActions { @@ -403,6 +452,14 @@ export interface NotificationActions { savePersistentNotifications: () => void; markAllAsRead: () => void; + // Read status management + toggleNotificationRead: (id: string) => void; + + // Action state management + setActionState: (actionKey: string, state: ActionState) => void; + executeAction: (action: NotificationAction, notificationId: string, actionKey?: string) => Promise; + retryAction: (actionKey: string, notificationId: string) => Promise; + // A2UI actions addA2UINotification: (surface: SurfaceUpdate, title?: string) => string; updateA2UIState: (surfaceId: string, state: Record) => void; diff --git a/ccw/frontend/src/vite-env.d.ts b/ccw/frontend/src/vite-env.d.ts new file mode 100644 index 00000000..b290f6fe --- /dev/null +++ b/ccw/frontend/src/vite-env.d.ts @@ -0,0 +1,13 @@ +/// + +interface ImportMetaEnv { + readonly DEV: boolean + readonly MODE: string + readonly BASE_URL: string + readonly PROD: boolean + readonly SSR: boolean +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/ccw/frontend/vite.config.ts b/ccw/frontend/vite.config.ts index 9846cd74..0aceb7a5 100644 --- a/ccw/frontend/vite.config.ts +++ b/ccw/frontend/vite.config.ts @@ -7,9 +7,14 @@ import { fileURLToPath } from 'url' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) +// Get base path from environment variable +// Always use VITE_BASE_URL if set (for both dev and production) +const basePath = process.env.VITE_BASE_URL || '/' + // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + base: basePath, resolve: { alias: { '@': path.resolve(__dirname, './src'), @@ -17,7 +22,9 @@ export default defineConfig({ extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'], }, server: { - port: 5173, + // Don't hardcode port - allow command line override + // strictPort: true ensures the specified port is used or fails + strictPort: true, proxy: { '/api': { target: 'http://localhost:3456', diff --git a/ccw/src/commands/serve.ts b/ccw/src/commands/serve.ts index bbe30feb..d2d3a9f8 100644 --- a/ccw/src/commands/serve.ts +++ b/ccw/src/commands/serve.ts @@ -18,7 +18,7 @@ interface ServeOptions { * @param {Object} options - Command options */ export async function serveCommand(options: ServeOptions): Promise { - const port = options.port || 3456; + const port = Number(options.port) || 3456; const host = options.host || '127.0.0.1'; const frontend = options.frontend || 'js'; @@ -75,9 +75,9 @@ export async function serveCommand(options: ServeOptions): Promise { // Display frontend URLs if (frontend === 'both') { console.log(chalk.gray(` JS Frontend: ${boundUrl}`)); - console.log(chalk.gray(` React Frontend: ${boundUrl}/react`)); + console.log(chalk.gray(` React Frontend: http://${host}:${reactPort}`)); } else if (frontend === 'react') { - console.log(chalk.gray(` React Frontend: ${boundUrl}/react`)); + console.log(chalk.gray(` React Frontend: http://${host}:${reactPort}`)); } // Open browser @@ -86,10 +86,17 @@ export async function serveCommand(options: ServeOptions): Promise { try { // Determine which URL to open based on frontend setting let openUrl = browserUrl; - if (frontend === 'react') { - openUrl = `${browserUrl}/react`; + if (frontend === 'react' && reactPort) { + // React frontend: access via proxy path /react/ + openUrl = `http://${host}:${port}/react/`; + } else if (frontend === 'both') { + // Both frontends: default to JS frontend at root + openUrl = browserUrl; } - await launchBrowser(openUrl); + + // Add path query parameter for workspace switching + const pathParam = initialPath ? `?path=${encodeURIComponent(initialPath)}` : ''; + await launchBrowser(openUrl + pathParam); console.log(chalk.green.bold('\n Dashboard opened in browser!')); } catch (err) { const error = err as Error; @@ -101,9 +108,9 @@ export async function serveCommand(options: ServeOptions): Promise { console.log(chalk.gray('\n Press Ctrl+C to stop the server\n')); // Handle graceful shutdown - process.on('SIGINT', () => { + process.on('SIGINT', async () => { console.log(chalk.yellow('\n Shutting down server...')); - stopReactFrontend(); + await stopReactFrontend(); server.close(() => { console.log(chalk.green(' Server stopped.\n')); process.exit(0); @@ -117,7 +124,7 @@ export async function serveCommand(options: ServeOptions): Promise { console.error(chalk.yellow(` Port ${port} is already in use.`)); console.error(chalk.gray(` Try a different port: ccw serve --port ${port + 1}\n`)); } - stopReactFrontend(); + await stopReactFrontend(); process.exit(1); } } diff --git a/ccw/src/commands/stop.ts b/ccw/src/commands/stop.ts index 45576ef0..1c87b75e 100644 --- a/ccw/src/commands/stop.ts +++ b/ccw/src/commands/stop.ts @@ -48,6 +48,7 @@ async function killProcess(pid: string): Promise { */ export async function stopCommand(options: StopOptions): Promise { const port = options.port || 3456; + const reactPort = port + 1; // React frontend runs on port + 1 const force = options.force || false; console.log(chalk.blue.bold('\n CCW Dashboard\n')); @@ -107,6 +108,23 @@ export async function stopCommand(options: StopOptions): Promise { if (!pid) { console.log(chalk.yellow(` No server running on port ${port}\n`)); + + // Also check and clean up React frontend if it's still running + const reactPid = await findProcessOnPort(reactPort); + if (reactPid) { + console.log(chalk.yellow(` React frontend still running on port ${reactPort} (PID: ${reactPid})`)); + if (force) { + console.log(chalk.cyan(' Cleaning up React frontend...')); + const killed = await killProcess(reactPid); + if (killed) { + console.log(chalk.green(' React frontend stopped!\n')); + } else { + console.log(chalk.red(' Failed to stop React frontend.\n')); + } + } else { + console.log(chalk.gray(`\n Use --force to clean it up:\n ccw stop --force\n`)); + } + } process.exit(0); } @@ -118,7 +136,17 @@ export async function stopCommand(options: StopOptions): Promise { const killed = await killProcess(pid); if (killed) { - console.log(chalk.green.bold('\n Process killed successfully!\n')); + console.log(chalk.green(' Main server killed successfully!')); + + // Also try to kill React frontend + const reactPid = await findProcessOnPort(reactPort); + if (reactPid) { + console.log(chalk.cyan(` Cleaning up React frontend on port ${reactPort}...`)); + await killProcess(reactPid); + console.log(chalk.green(' React frontend stopped!')); + } + + console.log(chalk.green.bold('\n All processes stopped successfully!\n')); process.exit(0); } else { console.log(chalk.red('\n Failed to kill process. Try running as administrator.\n')); diff --git a/ccw/src/commands/view.ts b/ccw/src/commands/view.ts index f80ff10b..68bf5e12 100644 --- a/ccw/src/commands/view.ts +++ b/ccw/src/commands/view.ts @@ -9,6 +9,7 @@ interface ViewOptions { path?: string; host?: string; browser?: boolean; + frontend?: 'js' | 'react' | 'both'; } interface SwitchWorkspaceResult { @@ -72,9 +73,10 @@ export async function viewCommand(options: ViewOptions): Promise { // Check for updates (fire-and-forget, non-blocking) checkForUpdates().catch(() => { /* ignore errors */ }); - const port = options.port || 3456; + const port = Number(options.port) || 3456; const host = options.host || '127.0.0.1'; const browserHost = host === '0.0.0.0' || host === '::' ? 'localhost' : host; + const frontend = options.frontend || 'both'; // Resolve workspace path let workspacePath = process.cwd(); @@ -101,8 +103,12 @@ export async function viewCommand(options: ViewOptions): Promise { if (result.success) { console.log(chalk.green(` Workspace switched successfully`)); - // Open browser with the new path - const url = `http://${browserHost}:${port}/?path=${encodeURIComponent(result.path!)}`; + // Determine URL based on frontend type + let urlPath = ''; + if (frontend === 'react') { + urlPath = '/react'; + } + const url = `http://${browserHost}:${port}${urlPath}/?path=${encodeURIComponent(result.path!)}`; if (options.browser !== false) { console.log(chalk.cyan(' Opening in browser...')); @@ -127,7 +133,8 @@ export async function viewCommand(options: ViewOptions): Promise { path: workspacePath, port: port, host, - browser: options.browser + browser: options.browser, + frontend: frontend }); } } diff --git a/ccw/src/core/a2ui/A2UIWebSocketHandler.ts b/ccw/src/core/a2ui/A2UIWebSocketHandler.ts index e9d27c0f..b6363e68 100644 --- a/ccw/src/core/a2ui/A2UIWebSocketHandler.ts +++ b/ccw/src/core/a2ui/A2UIWebSocketHandler.ts @@ -161,7 +161,7 @@ export class A2UIWebSocketHandler { // Convert to QuestionAnswer format const questionAnswer: QuestionAnswer = { questionId: answer.questionId, - value: answer.value, + value: answer.value as string | boolean | string[], cancelled: answer.cancelled, }; diff --git a/ccw/src/core/a2ui/index.ts b/ccw/src/core/a2ui/index.ts index 33c50f77..f045153f 100644 --- a/ccw/src/core/a2ui/index.ts +++ b/ccw/src/core/a2ui/index.ts @@ -2,5 +2,5 @@ // A2UI Backend - Index // ======================================== -export * from './A2UITypes'; -export * from './A2UIWebSocketHandler'; +export * from './A2UITypes.js'; +export * from './A2UIWebSocketHandler.js'; diff --git a/ccw/src/core/routes/system-routes.ts b/ccw/src/core/routes/system-routes.ts index c1ebc94e..68d257af 100644 --- a/ccw/src/core/routes/system-routes.ts +++ b/ccw/src/core/routes/system-routes.ts @@ -220,13 +220,13 @@ export async function handleSystemRoutes(ctx: SystemRouteContext): Promise { + return new Promise((resolve, reject) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { resolve(body); }); + req.on('error', reject); + }); +} + /** * Create and start the dashboard server * @param {Object} options - Server options @@ -424,6 +445,14 @@ export async function startServer(options: ServerOptions = {}): Promise http://localhost:${reactPort}`); + } const tokenManager = getTokenManager(); const secretKey = tokenManager.getSecretKey(); @@ -696,6 +725,69 @@ export async function startServer(options: ServerOptions = {}): Promise ${reactUrl}`); + + try { + // Convert headers to plain object for fetch + const proxyHeaders: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === 'string') { + proxyHeaders[key] = value; + } else if (Array.isArray(value)) { + proxyHeaders[key] = value.join(', '); + } + } + proxyHeaders['host'] = `localhost:${reactPort}`; + + const reactResponse = await fetch(reactUrl, { + method: req.method, + headers: proxyHeaders, + body: req.method !== 'GET' && req.method !== 'HEAD' ? await readRequestBody(req) : undefined, + }); + + const contentType = reactResponse.headers.get('content-type') || 'text/html'; + const body = await reactResponse.text(); + + console.log(`[React Proxy] Response ${reactResponse.status}: ${contentType}`); + + res.writeHead(reactResponse.status, { + 'Content-Type': contentType, + 'Cache-Control': 'no-cache', + }); + res.end(body); + return; + } catch (err) { + console.error(`[React Proxy] Failed to proxy to ${reactUrl}:`, err); + console.error(`[React Proxy] Error details:`, (err as Error).message); + res.writeHead(502, { 'Content-Type': 'text/plain' }); + res.end(`Bad Gateway: React frontend not available at ${reactUrl}\nError: ${(err as Error).message}`); + return; + } + } + + // Redirect root to React if react-only mode + if (frontend === 'react' && (pathname === '/' || pathname === '/index.html')) { + res.writeHead(302, { 'Location': `/react${url.search}` }); + res.end(); + return; + } + } + + // Root path - serve JS frontend HTML (default or both mode) + if (pathname === '/' || pathname === '/index.html') { + const html = generateServerDashboard(initialPath); + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(html); + return; + } + // 404 res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not Found'); diff --git a/ccw/src/utils/react-frontend.ts b/ccw/src/utils/react-frontend.ts index baf57582..18e1e031 100644 --- a/ccw/src/utils/react-frontend.ts +++ b/ccw/src/utils/react-frontend.ts @@ -144,18 +144,56 @@ export async function startReactFrontend(port: number): Promise { /** * Stop React frontend development server */ -export function stopReactFrontend(): void { +export async function stopReactFrontend(): Promise { if (reactProcess) { console.log(chalk.yellow(' Stopping React frontend...')); + + // Try graceful shutdown first reactProcess.kill('SIGTERM'); - - // Force kill after timeout - setTimeout(() => { - if (reactProcess && !reactProcess.killed) { + + // Wait up to 5 seconds for graceful shutdown + await new Promise((resolve) => { + const timeout = setTimeout(() => { + resolve(); + }, 5000); + + reactProcess?.once('exit', () => { + clearTimeout(timeout); + resolve(); + }); + }); + + // Force kill if still running + if (reactProcess && !reactProcess.killed) { + // On Windows with shell: true, we need to kill the entire process group + if (process.platform === 'win32') { + try { + // Use taskkill to forcefully terminate the process tree + const { exec } = await import('child_process'); + const pid = reactProcess.pid; + if (pid) { + await new Promise((resolve) => { + exec(`taskkill /F /T /PID ${pid}`, (err) => { + if (err) { + // Fallback to SIGKILL if taskkill fails + reactProcess?.kill('SIGKILL'); + } + resolve(); + }); + }); + } + } catch { + // Fallback to SIGKILL + reactProcess.kill('SIGKILL'); + } + } else { reactProcess.kill('SIGKILL'); } - }, 5000); - + } + + // Wait a bit more for force kill to complete + await new Promise(resolve => setTimeout(resolve, 500)); + reactProcess = null; reactPort = null; }