From e5252f8a77f75e2759281ba478be3db36e275be6 Mon Sep 17 00:00:00 2001 From: catlog22 Date: Sun, 1 Feb 2026 23:14:55 +0800 Subject: [PATCH] feat: add useApiSettings hook for managing API settings, including providers, endpoints, cache, and model pools - Implemented hooks for CRUD operations on providers and endpoints. - Added cache management hooks for cache stats and settings. - Introduced model pool management hooks for high availability and load balancing. - Created localization files for English and Chinese translations of API settings. --- ccw/frontend/package-lock.json | 11 + ccw/frontend/package.json | 1 + .../src/components/codexlens/AdvancedTab.tsx | 60 +- .../components/codexlens/IndexOperations.tsx | 286 +++++++ .../src/components/codexlens/ModelsTab.tsx | 32 +- .../src/components/codexlens/OverviewTab.tsx | 78 +- .../src/components/codexlens/SearchTab.tsx | 273 +++++++ .../components/ExecutionTab.tsx | 15 +- .../CliStreamMonitor/utils/jsonDetector.ts | 122 ++- .../shared/CliStreamMonitorLegacy.tsx | 291 +++++-- .../src/hooks/useActiveCliExecutions.ts | 28 +- ccw/frontend/src/hooks/useApiSettings.ts | 623 +++++++++++++++ ccw/frontend/src/hooks/useCodexLens.ts | 325 +++++++- ccw/frontend/src/hooks/useProjectOverview.ts | 40 +- ccw/frontend/src/lib/api.ts | 746 ++++++++++++++++++ ccw/frontend/src/lib/queryKeys.ts | 16 + ccw/frontend/src/locales/en/api-settings.json | 335 ++++++++ ccw/frontend/src/locales/en/codexlens.json | 44 +- ccw/frontend/src/locales/zh/api-settings.json | 335 ++++++++ ccw/frontend/src/locales/zh/codexlens.json | 31 +- .../src/pages/CodexLensManagerPage.tsx | 9 + ccw/frontend/src/stores/cliStreamStore.ts | 34 + .../tests/e2e/codexlens-manager.spec.ts | 569 +++++++++++++ ccw/src/core/routes/ccw-routes.ts | 72 ++ ccw/src/core/routes/cli-routes.ts | 59 +- .../core/routes/codexlens/index-handlers.ts | 102 +++ ccw/src/tools/codex-lens.ts | 34 +- 27 files changed, 4370 insertions(+), 201 deletions(-) create mode 100644 ccw/frontend/src/components/codexlens/IndexOperations.tsx create mode 100644 ccw/frontend/src/components/codexlens/SearchTab.tsx create mode 100644 ccw/frontend/src/hooks/useApiSettings.ts create mode 100644 ccw/frontend/src/locales/en/api-settings.json create mode 100644 ccw/frontend/src/locales/zh/api-settings.json diff --git a/ccw/frontend/package-lock.json b/ccw/frontend/package-lock.json index 8f95adad..b81e8a98 100644 --- a/ccw/frontend/package-lock.json +++ b/ccw/frontend/package-lock.json @@ -35,6 +35,7 @@ "react-router-dom": "^6.28.0", "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", + "sonner": "^2.0.7", "tailwind-merge": "^2.5.0", "zod": "^3.23.8", "zustand": "^5.0.0" @@ -8089,6 +8090,16 @@ "node": ">=18" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/ccw/frontend/package.json b/ccw/frontend/package.json index 21a13179..664b3fff 100644 --- a/ccw/frontend/package.json +++ b/ccw/frontend/package.json @@ -44,6 +44,7 @@ "react-router-dom": "^6.28.0", "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", + "sonner": "^2.0.7", "tailwind-merge": "^2.5.0", "zod": "^3.23.8", "zustand": "^5.0.0" diff --git a/ccw/frontend/src/components/codexlens/AdvancedTab.tsx b/ccw/frontend/src/components/codexlens/AdvancedTab.tsx index 23e0a798..73b30882 100644 --- a/ccw/frontend/src/components/codexlens/AdvancedTab.tsx +++ b/ccw/frontend/src/components/codexlens/AdvancedTab.tsx @@ -5,7 +5,7 @@ import { useState, useEffect } from 'react'; import { useIntl } from 'react-intl'; -import { Save, RefreshCw, AlertTriangle, FileCode } from 'lucide-react'; +import { Save, RefreshCw, AlertTriangle, FileCode, AlertCircle } from 'lucide-react'; import { Card } from '@/components/ui/Card'; import { Textarea } from '@/components/ui/Textarea'; import { Button } from '@/components/ui/Button'; @@ -32,6 +32,7 @@ export function AdvancedTab({ enabled = true }: AdvancedTabProps) { env, settings, isLoading: isLoadingEnv, + error: envError, refetch, } = useCodexLensEnv({ enabled }); @@ -43,23 +44,25 @@ export function AdvancedTab({ enabled = true }: AdvancedTabProps) { const [hasChanges, setHasChanges] = useState(false); const [showWarning, setShowWarning] = useState(false); - // Initialize form from env + // Initialize form from env - handles both undefined (loading) and empty string (empty file) + // The hook returns raw directly, so we check if it's been set (not undefined means data loaded) useEffect(() => { - if (raw !== undefined) { - setEnvInput(raw); + // Initialize when data is loaded (raw may be empty string but not undefined during loading) + // Note: During initial load, raw is undefined. After load completes, raw is set (even if empty string) + if (!isLoadingEnv) { + setEnvInput(raw ?? ''); // Use empty string if raw is undefined/null setErrors({}); setHasChanges(false); setShowWarning(false); } - }, [raw]); + }, [raw, isLoadingEnv]); const handleEnvChange = (value: string) => { setEnvInput(value); - // Check if there are changes - if (raw !== undefined) { - setHasChanges(value !== raw); - setShowWarning(value !== raw); - } + // Check if there are changes - compare with raw value (handle undefined as empty) + const currentRaw = raw ?? ''; + setHasChanges(value !== currentRaw); + setShowWarning(value !== currentRaw); if (errors.env) { setErrors((prev) => ({ ...prev, env: undefined })); } @@ -132,12 +135,11 @@ export function AdvancedTab({ enabled = true }: AdvancedTabProps) { }; const handleReset = () => { - if (raw !== undefined) { - setEnvInput(raw); - setErrors({}); - setHasChanges(false); - setShowWarning(false); - } + // Reset to current raw value (handle undefined as empty) + setEnvInput(raw ?? ''); + setErrors({}); + setHasChanges(false); + setShowWarning(false); }; const isLoading = isLoadingEnv; @@ -154,6 +156,32 @@ export function AdvancedTab({ enabled = true }: AdvancedTabProps) { return (
+ {/* Error Card */} + {envError && ( + +
+ +
+

+ {formatMessage({ id: 'codexlens.advanced.loadError' })} +

+

+ {envError.message || formatMessage({ id: 'codexlens.advanced.loadErrorDesc' })} +

+ +
+
+
+ )} + {/* Sensitivity Warning Card */} {showWarning && ( diff --git a/ccw/frontend/src/components/codexlens/IndexOperations.tsx b/ccw/frontend/src/components/codexlens/IndexOperations.tsx new file mode 100644 index 00000000..aefebcb2 --- /dev/null +++ b/ccw/frontend/src/components/codexlens/IndexOperations.tsx @@ -0,0 +1,286 @@ +// ======================================== +// CodexLens Index Operations Component +// ======================================== +// Index management operations with progress tracking + +import { useIntl } from 'react-intl'; +import { useEffect, useState } from 'react'; +import { + RotateCw, + Zap, + AlertCircle, + CheckCircle2, + X, +} from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { Progress } from '@/components/ui/Progress'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'; +import { cn } from '@/lib/utils'; +import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; +import { + useCodexLensIndexingStatus, + useRebuildIndex, + useUpdateIndex, + useCancelIndexing, +} from '@/hooks'; +import { useNotifications } from '@/hooks/useNotifications'; +import { useWebSocket } from '@/hooks/useWebSocket'; + +interface IndexOperationsProps { + disabled?: boolean; + onRefresh?: () => void; +} + +interface IndexProgress { + stage: string; + message: string; + percent: number; + path?: string; +} + +type IndexOperation = { + id: string; + type: 'fts_full' | 'fts_incremental' | 'vector_full' | 'vector_incremental'; + label: string; + description: string; + icon: React.ReactNode; +}; + +export function IndexOperations({ disabled = false, onRefresh }: IndexOperationsProps) { + const { formatMessage } = useIntl(); + const { success, error: showError } = useNotifications(); + const projectPath = useWorkflowStore(selectProjectPath); + const { inProgress } = useCodexLensIndexingStatus(); + const { rebuildIndex, isRebuilding } = useRebuildIndex(); + const { updateIndex, isUpdating } = useUpdateIndex(); + const { cancelIndexing, isCancelling } = useCancelIndexing(); + const { lastMessage } = useWebSocket(); + + const [indexProgress, setIndexProgress] = useState(null); + const [activeOperation, setActiveOperation] = useState(null); + + // Listen for WebSocket progress updates + useEffect(() => { + if (lastMessage?.type === 'CODEXLENS_INDEX_PROGRESS') { + const progress = lastMessage.payload as IndexProgress; + setIndexProgress(progress); + + // Clear active operation when complete or error + if (progress.stage === 'complete' || progress.stage === 'error' || progress.stage === 'cancelled') { + if (progress.stage === 'complete') { + success( + formatMessage({ id: 'codexlens.index.operationComplete' }), + progress.message + ); + onRefresh?.(); + } else if (progress.stage === 'error') { + showError( + formatMessage({ id: 'codexlens.index.operationFailed' }), + progress.message + ); + } + setActiveOperation(null); + setIndexProgress(null); + } + } + }, [lastMessage, formatMessage, success, showError, onRefresh]); + + const isOperating = isRebuilding || isUpdating || inProgress || !!activeOperation; + + const handleOperation = async (operation: IndexOperation) => { + if (!projectPath) { + showError( + formatMessage({ id: 'codexlens.index.noProject' }), + formatMessage({ id: 'codexlens.index.noProjectDesc' }) + ); + return; + } + + setActiveOperation(operation.id); + setIndexProgress({ stage: 'start', message: formatMessage({ id: 'codexlens.index.starting' }), percent: 0 }); + + try { + // Determine index type and operation + const isVector = operation.type.includes('vector'); + const isIncremental = operation.type.includes('incremental'); + + if (isIncremental) { + const result = await updateIndex(projectPath, { + indexType: isVector ? 'vector' : 'normal', + }); + if (!result.success) { + throw new Error(result.error || 'Update failed'); + } + } else { + const result = await rebuildIndex(projectPath, { + indexType: isVector ? 'vector' : 'normal', + }); + if (!result.success) { + throw new Error(result.error || 'Rebuild failed'); + } + } + } catch (err) { + setActiveOperation(null); + setIndexProgress(null); + showError( + formatMessage({ id: 'codexlens.index.operationFailed' }), + err instanceof Error ? err.message : formatMessage({ id: 'codexlens.index.unknownError' }) + ); + } + }; + + const handleCancel = async () => { + const result = await cancelIndexing(); + if (result.success) { + setActiveOperation(null); + setIndexProgress(null); + } else { + showError( + formatMessage({ id: 'codexlens.index.cancelFailed' }), + result.error || formatMessage({ id: 'codexlens.index.unknownError' }) + ); + } + }; + + const operations: IndexOperation[] = [ + { + id: 'fts_full', + type: 'fts_full', + label: formatMessage({ id: 'codexlens.overview.actions.ftsFull' }), + description: formatMessage({ id: 'codexlens.overview.actions.ftsFullDesc' }), + icon: , + }, + { + id: 'fts_incremental', + type: 'fts_incremental', + label: formatMessage({ id: 'codexlens.overview.actions.ftsIncremental' }), + description: formatMessage({ id: 'codexlens.overview.actions.ftsIncrementalDesc' }), + icon: , + }, + { + id: 'vector_full', + type: 'vector_full', + label: formatMessage({ id: 'codexlens.overview.actions.vectorFull' }), + description: formatMessage({ id: 'codexlens.overview.actions.vectorFullDesc' }), + icon: , + }, + { + id: 'vector_incremental', + type: 'vector_incremental', + label: formatMessage({ id: 'codexlens.overview.actions.vectorIncremental' }), + description: formatMessage({ id: 'codexlens.overview.actions.vectorIncrementalDesc' }), + icon: , + }, + ]; + + if (indexProgress && activeOperation) { + const operation = operations.find((op) => op.id === activeOperation); + const isComplete = indexProgress.stage === 'complete'; + const isError = indexProgress.stage === 'error'; + const isCancelled = indexProgress.stage === 'cancelled'; + + return ( + + + + {operation?.label} + {!isComplete && !isError && !isCancelled && ( + + )} + + + + {/* Status Icon */} +
+ {isComplete ? ( + + ) : isError || isCancelled ? ( + + ) : ( + + )} +
+

+ {isComplete + ? formatMessage({ id: 'codexlens.index.complete' }) + : isError + ? formatMessage({ id: 'codexlens.index.failed' }) + : isCancelled + ? formatMessage({ id: 'codexlens.index.cancelled' }) + : formatMessage({ id: 'codexlens.index.inProgress' })} +

+

{indexProgress.message}

+
+
+ + {/* Progress Bar */} + {!isComplete && !isError && !isCancelled && ( +
+ +

+ {indexProgress.percent}% +

+
+ )} + + {/* Close Button */} + {(isComplete || isError || isCancelled) && ( +
+ +
+ )} +
+
+ ); + } + + return ( + + + + {formatMessage({ id: 'codexlens.overview.actions.title' })} + + + +
+ {operations.map((operation) => ( + + ))} +
+
+
+ ); +} + +export default IndexOperations; diff --git a/ccw/frontend/src/components/codexlens/ModelsTab.tsx b/ccw/frontend/src/components/codexlens/ModelsTab.tsx index bfa5ee12..aa6a837f 100644 --- a/ccw/frontend/src/components/codexlens/ModelsTab.tsx +++ b/ccw/frontend/src/components/codexlens/ModelsTab.tsx @@ -10,6 +10,7 @@ import { RefreshCw, Package, Filter, + AlertCircle, } from 'lucide-react'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; @@ -69,6 +70,7 @@ export function ModelsTab({ installed = false }: ModelsTabProps) { const { models, isLoading, + error, refetch, } = useCodexLensModels({ enabled: installed, @@ -243,7 +245,25 @@ export function ModelsTab({ installed = false }: ModelsTabProps) { /> {/* Model List */} - {isLoading ? ( + {error ? ( + + +

+ {formatMessage({ id: 'codexlens.models.error.title' })} +

+

+ {error.message || formatMessage({ id: 'codexlens.models.error.description' })} +

+ +
+ ) : isLoading ? (

{formatMessage({ id: 'common.actions.loading' })}

@@ -251,10 +271,16 @@ export function ModelsTab({ installed = false }: ModelsTabProps) {

- {formatMessage({ id: 'codexlens.models.empty.title' })} + {models && models.length > 0 + ? formatMessage({ id: 'codexlens.models.empty.filtered' }) + : formatMessage({ id: 'codexlens.models.empty.title' }) + }

- {formatMessage({ id: 'codexlens.models.empty.description' })} + {models && models.length > 0 + ? formatMessage({ id: 'codexlens.models.empty.filteredDesc' }) + : formatMessage({ id: 'codexlens.models.empty.description' }) + }

) : ( diff --git a/ccw/frontend/src/components/codexlens/OverviewTab.tsx b/ccw/frontend/src/components/codexlens/OverviewTab.tsx index 36b20a7c..da943a29 100644 --- a/ccw/frontend/src/components/codexlens/OverviewTab.tsx +++ b/ccw/frontend/src/components/codexlens/OverviewTab.tsx @@ -9,22 +9,22 @@ import { FileText, CheckCircle2, XCircle, - RotateCw, Zap, } from 'lucide-react'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; -import { Button } from '@/components/ui/Button'; import { cn } from '@/lib/utils'; import type { CodexLensVenvStatus, CodexLensConfig } from '@/lib/api'; +import { IndexOperations } from './IndexOperations'; interface OverviewTabProps { installed: boolean; status?: CodexLensVenvStatus; config?: CodexLensConfig; isLoading: boolean; + onRefresh?: () => void; } -export function OverviewTab({ installed, status, config, isLoading }: OverviewTabProps) { +export function OverviewTab({ installed, status, config, isLoading, onRefresh }: OverviewTabProps) { const { formatMessage } = useIntl(); if (isLoading) { @@ -142,42 +142,8 @@ export function OverviewTab({ installed, status, config, isLoading }: OverviewTa
- {/* Quick Actions */} - - - - {formatMessage({ id: 'codexlens.overview.actions.title' })} - - - -
- } - label={formatMessage({ id: 'codexlens.overview.actions.ftsFull' })} - description={formatMessage({ id: 'codexlens.overview.actions.ftsFullDesc' })} - disabled={!isReady} - /> - } - label={formatMessage({ id: 'codexlens.overview.actions.ftsIncremental' })} - description={formatMessage({ id: 'codexlens.overview.actions.ftsIncrementalDesc' })} - disabled={!isReady} - /> - } - label={formatMessage({ id: 'codexlens.overview.actions.vectorFull' })} - description={formatMessage({ id: 'codexlens.overview.actions.vectorFullDesc' })} - disabled={!isReady} - /> - } - label={formatMessage({ id: 'codexlens.overview.actions.vectorIncremental' })} - description={formatMessage({ id: 'codexlens.overview.actions.vectorIncrementalDesc' })} - disabled={!isReady} - /> -
-
-
+ {/* Index Operations */} + {/* Venv Details */} {status && ( @@ -210,37 +176,3 @@ export function OverviewTab({ installed, status, config, isLoading }: OverviewTa ); } - -interface QuickActionButtonProps { - icon: React.ReactNode; - label: string; - description: string; - disabled?: boolean; -} - -function QuickActionButton({ icon, label, description, disabled }: QuickActionButtonProps) { - const { formatMessage } = useIntl(); - - const handleClick = () => { - // TODO: Implement index operations in future tasks - // For now, show a message that this feature is coming soon - alert(formatMessage({ id: 'codexlens.comingSoon' })); - }; - - return ( - - ); -} diff --git a/ccw/frontend/src/components/codexlens/SearchTab.tsx b/ccw/frontend/src/components/codexlens/SearchTab.tsx new file mode 100644 index 00000000..647696ac --- /dev/null +++ b/ccw/frontend/src/components/codexlens/SearchTab.tsx @@ -0,0 +1,273 @@ +// ======================================== +// CodexLens Search Tab +// ======================================== +// Semantic code search interface with multiple search types + +import { useState } from 'react'; +import { useIntl } from 'react-intl'; +import { Search, FileCode, Code } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Label } from '@/components/ui/Label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/Select'; +import { + useCodexLensSearch, + useCodexLensFilesSearch, + useCodexLensSymbolSearch, +} from '@/hooks/useCodexLens'; +import type { CodexLensSearchParams } from '@/lib/api'; +import { cn } from '@/lib/utils'; + +type SearchType = 'search' | 'search_files' | 'symbol'; +type SearchMode = 'dense_rerank' | 'fts' | 'fuzzy'; + +interface SearchTabProps { + enabled: boolean; +} + +export function SearchTab({ enabled }: SearchTabProps) { + const { formatMessage } = useIntl(); + const [searchType, setSearchType] = useState('search'); + const [searchMode, setSearchMode] = useState('dense_rerank'); + const [query, setQuery] = useState(''); + const [hasSearched, setHasSearched] = useState(false); + + // Build search params based on search type + const searchParams: CodexLensSearchParams = { + query, + limit: 20, + mode: searchType !== 'symbol' ? searchMode : undefined, + max_content_length: 200, + extra_files_count: 10, + }; + + // Search hooks - only enable when hasSearched is true and query is not empty + const contentSearch = useCodexLensSearch( + searchParams, + { enabled: enabled && hasSearched && searchType === 'search' && query.trim().length > 0 } + ); + + const fileSearch = useCodexLensFilesSearch( + searchParams, + { enabled: enabled && hasSearched && searchType === 'search_files' && query.trim().length > 0 } + ); + + const symbolSearch = useCodexLensSymbolSearch( + { query, limit: 20 }, + { enabled: enabled && hasSearched && searchType === 'symbol' && query.trim().length > 0 } + ); + + // Get loading state based on search type + const isLoading = searchType === 'search' + ? contentSearch.isLoading + : searchType === 'search_files' + ? fileSearch.isLoading + : symbolSearch.isLoading; + + const handleSearch = () => { + if (query.trim()) { + setHasSearched(true); + } + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSearch(); + } + }; + + const handleSearchTypeChange = (value: SearchType) => { + setSearchType(value); + setHasSearched(false); // Reset search state when changing type + }; + + const handleSearchModeChange = (value: SearchMode) => { + setSearchMode(value); + setHasSearched(false); // Reset search state when changing mode + }; + + const handleQueryChange = (value: string) => { + setQuery(value); + setHasSearched(false); // Reset search state when query changes + }; + + if (!enabled) { + return ( +
+
+ +

+ {formatMessage({ id: 'codexlens.search.notInstalled.title' })} +

+

+ {formatMessage({ id: 'codexlens.search.notInstalled.description' })} +

+
+
+ ); + } + + return ( +
+ {/* Search Options */} +
+ {/* Search Type */} +
+ + +
+ + {/* Search Mode - only for content and file search */} + {searchType !== 'symbol' && ( +
+ + +
+ )} +
+ + {/* Query Input */} +
+ + handleQueryChange(e.target.value)} + onKeyPress={handleKeyPress} + disabled={isLoading} + /> +
+ + {/* Search Button */} + + + {/* Results */} + {hasSearched && !isLoading && ( +
+
+

+ {formatMessage({ id: 'codexlens.search.results' })} +

+ + {searchType === 'symbol' + ? (symbolSearch.data?.success + ? `${symbolSearch.data.symbols?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}` + : '' + ) + : searchType === 'search' + ? (contentSearch.data?.success + ? `${contentSearch.data.results?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}` + : '' + ) + : (fileSearch.data?.success + ? `${fileSearch.data.results?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}` + : '' + ) + } + +
+ + {searchType === 'symbol' && symbolSearch.data && ( + symbolSearch.data.success ? ( +
+
+                  {JSON.stringify(symbolSearch.data.symbols, null, 2)}
+                
+
+ ) : ( +
+ {symbolSearch.data.error || formatMessage({ id: 'common.error' })} +
+ ) + )} + + {searchType === 'search' && contentSearch.data && ( + contentSearch.data.success ? ( +
+
+                  {JSON.stringify(contentSearch.data.results, null, 2)}
+                
+
+ ) : ( +
+ {contentSearch.data.error || formatMessage({ id: 'common.error' })} +
+ ) + )} + + {searchType === 'search_files' && fileSearch.data && ( + fileSearch.data.success ? ( +
+
+                  {JSON.stringify(fileSearch.data.results, null, 2)}
+                
+
+ ) : ( +
+ {fileSearch.data.error || formatMessage({ id: 'common.error' })} +
+ ) + )} +
+ )} +
+ ); +} + +export default SearchTab; diff --git a/ccw/frontend/src/components/shared/CliStreamMonitor/components/ExecutionTab.tsx b/ccw/frontend/src/components/shared/CliStreamMonitor/components/ExecutionTab.tsx index ff583cd1..52354622 100644 --- a/ccw/frontend/src/components/shared/CliStreamMonitor/components/ExecutionTab.tsx +++ b/ccw/frontend/src/components/shared/CliStreamMonitor/components/ExecutionTab.tsx @@ -19,6 +19,9 @@ export function ExecutionTab({ execution, isActive, onClick, onClose }: Executio // Simplify tool name (e.g., gemini-2.5-pro -> gemini) const toolNameShort = execution.tool.split('-')[0]; + // Mode display - use icon for visual clarity + const modeDisplay = execution.mode === 'write' ? '✏️' : '🔍'; + // Status color mapping - using softer, semantic colors const statusColor = { running: 'bg-indigo-500 shadow-[0_0_6px_rgba(99,102,241,0.4)] animate-pulse', @@ -31,7 +34,7 @@ export function ExecutionTab({ execution, isActive, onClick, onClose }: Executio value={execution.id} onClick={onClick} className={cn( - 'gap-1.5 text-xs px-2.5 py-1 rounded-md border border-border/50 group', + 'gap-1.5 text-xs px-2.5 py-1 rounded-md border border-border/50 group shrink-0', isActive ? 'bg-slate-100 dark:bg-slate-800 border-slate-300 dark:border-slate-600 shadow-sm' : 'bg-muted/30 hover:bg-muted/50 border-border/30', @@ -41,14 +44,14 @@ export function ExecutionTab({ execution, isActive, onClick, onClose }: Executio {/* Status indicator dot */} + {/* Mode indicator */} + + {modeDisplay} + + {/* Simplified tool name */} {toolNameShort} - {/* Execution mode - show on hover */} - - {execution.mode} - - {/* Line count statistics - show on hover */} {execution.output.length} diff --git a/ccw/frontend/src/components/shared/CliStreamMonitor/utils/jsonDetector.ts b/ccw/frontend/src/components/shared/CliStreamMonitor/utils/jsonDetector.ts index 08883208..91ec0a11 100644 --- a/ccw/frontend/src/components/shared/CliStreamMonitor/utils/jsonDetector.ts +++ b/ccw/frontend/src/components/shared/CliStreamMonitor/utils/jsonDetector.ts @@ -12,6 +12,114 @@ export interface JsonDetectionResult { error?: string; } +/** + * Try to recover truncated JSON by completing brackets + * This handles cases where JSON is split during streaming + */ +function tryRecoverTruncatedJson(content: string): Record | null { + const trimmed = content.trim(); + + // Must start with { to be recoverable JSON + if (!trimmed.startsWith('{')) { + return null; + } + + // Count opening vs closing braces + let openBraces = 0; + let closeBraces = 0; + let inString = false; + let escapeNext = false; + + for (let i = 0; i < trimmed.length; i++) { + const char = trimmed[i]; + + if (escapeNext) { + escapeNext = false; + continue; + } + + if (char === '\\') { + escapeNext = true; + continue; + } + + if (char === '"') { + inString = !inString; + continue; + } + + if (!inString) { + if (char === '{') openBraces++; + if (char === '}') closeBraces++; + } + } + + // If we're missing closing braces, try to complete them + if (openBraces > closeBraces) { + const missingBraces = openBraces - closeBraces; + const recovered = trimmed + '}'.repeat(missingBraces); + + // Also close any open quote + let finalRecovered = recovered; + if (inString) { + finalRecovered = recovered + '"'; + // Add closing braces after the quote + finalRecovered = finalRecovered + '}'.repeat(missingBraces); + } + + try { + return JSON.parse(finalRecovered) as Record; + } catch { + // Recovery failed, try one more approach + } + } + + // Try parsing as-is first + try { + return JSON.parse(trimmed) as Record; + } catch { + // If still failing, try to close any hanging structures + // Remove trailing incomplete key/value and try again + const lastCommaIndex = trimmed.lastIndexOf(','); + if (lastCommaIndex > 0) { + const truncated = trimmed.substring(0, lastCommaIndex) + '}'; + try { + return JSON.parse(truncated) as Record; + } catch { + // Still failed + } + } + } + + return null; +} + +/** + * Detect token usage stats pattern (common in CLI output) + * Pattern: {"type":"result","status":"success","stats":{"total_tokens":...,"input_tokens":...,...} + */ +function detectTokenStats(content: string): Record | null { + // Check for common token stat patterns + const patterns = [ + /"type"\s*:\s*"result"/, + /"status"\s*:\s*"success"/, + /"stats"\s*:\s*\{/, + /"total_tokens"\s*:\s*\d+/, + ]; + + const matchCount = patterns.filter(p => p.test(content)).length; + + // If at least 3 patterns match, this is likely token stats + if (matchCount >= 3) { + const recovered = tryRecoverTruncatedJson(content); + if (recovered) { + return recovered; + } + } + + return null; +} + /** * Detect if a line contains JSON data * Supports multiple formats: @@ -20,17 +128,29 @@ export interface JsonDetectionResult { * - Tool Result: [Tool Result] status: {...} * - Embedded JSON: trailing JSON object * - Code block JSON: ```json ... ``` + * - Truncated JSON: handles streaming incomplete JSON */ export function detectJsonInLine(content: string): JsonDetectionResult { const trimmed = content.trim(); // 1. Direct JSON object or array if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + // First try normal parse try { const parsed = JSON.parse(trimmed); return { isJson: true, parsed: parsed as Record }; } catch { - // Continue to other patterns + // Normal parse failed, try recovery for truncated JSON + const recovered = tryRecoverTruncatedJson(trimmed); + if (recovered) { + return { isJson: true, parsed: recovered }; + } + + // Check for token stats pattern specifically + const tokenStats = detectTokenStats(trimmed); + if (tokenStats) { + return { isJson: true, parsed: tokenStats }; + } } } diff --git a/ccw/frontend/src/components/shared/CliStreamMonitorLegacy.tsx b/ccw/frontend/src/components/shared/CliStreamMonitorLegacy.tsx index 98d001f1..2c9cba8c 100644 --- a/ccw/frontend/src/components/shared/CliStreamMonitorLegacy.tsx +++ b/ccw/frontend/src/components/shared/CliStreamMonitorLegacy.tsx @@ -3,7 +3,7 @@ // ======================================== // Global CLI streaming monitor with multi-execution support -import { useEffect, useRef, useCallback, useState } from 'react'; +import { useEffect, useRef, useCallback, useState, useMemo, memo } from 'react'; import { useIntl } from 'react-intl'; import { X, @@ -13,6 +13,7 @@ import { RefreshCw, Search, ArrowDownToLine, + Trash2, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/Button'; @@ -26,8 +27,6 @@ import { useActiveCliExecutions, useInvalidateActiveCliExecutions } from '@/hook // New components for Tab + JSON Cards import { ExecutionTab } from './CliStreamMonitor/components/ExecutionTab'; -import { OutputLine } from './CliStreamMonitor/components/OutputLine'; -import { JsonCard } from './CliStreamMonitor/components/JsonCard'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -95,55 +94,99 @@ function getBorderColorForType(type: CliOutputLine['type']): string { } /** - * Render a single output line as a card + * Extract content from a line (handle JSON with 'content' field) */ -interface OutputLineCardProps { - line: CliOutputLine; - onCopy?: (content: string) => void; -} - -function OutputLineCard({ line, onCopy }: OutputLineCardProps) { - const borderColor = getBorderColorForType(line.type); +function extractContentFromLine(line: CliOutputLine): { content: string; isMarkdown: boolean } { const trimmed = line.content.trim(); - // Check if line is JSON with 'content' field - let contentToRender = trimmed; - let isMarkdown = false; - try { if (trimmed.startsWith('{') || trimmed.startsWith('[')) { const parsed = JSON.parse(trimmed); if ('content' in parsed && typeof parsed.content === 'string') { - contentToRender = parsed.content; - // Check if content looks like markdown - isMarkdown = !!contentToRender.match(/^#{1,6}\s|^\*{3,}$|^\s*[-*+]\s+|^\s*\d+\.\s+|\*\*.*?\*\*|`{3,}/m); + const content = parsed.content; + const isMarkdown = !!content.match(/^#{1,6}\s|^\*{3,}$|^\s*[-*+]\s+|^\s*\d+\.\s+|\*\*.*?\*\*|`{3,}/m); + return { content, isMarkdown }; } } } catch { // Not valid JSON, use original content - // Check if original content looks like markdown - isMarkdown = !!trimmed.match(/^#{1,6}\s|^\*{3,}$|^\s*[-*+]\s+|^\s*\d+\.\s+|\*\*.*?\*\*|`{3,}/m); } + // Check if original content looks like markdown + const isMarkdown = !!trimmed.match(/^#{1,6}\s|^\*{3,}$|^\s*[-*+]\s+|^\s*\d+\.\s+|\*\*.*?\*\*|`{3,}/m); + return { content: trimmed, isMarkdown }; +} + +/** + * Group consecutive output lines by type + */ +interface OutputLineGroup { + type: CliOutputLine['type']; + lines: CliOutputLine[]; +} + +function groupConsecutiveLinesByType(lines: CliOutputLine[]): OutputLineGroup[] { + const groups: OutputLineGroup[] = []; + + for (const line of lines) { + // Start new group if type changes + if (groups.length === 0 || groups[groups.length - 1].type !== line.type) { + groups.push({ + type: line.type, + lines: [line], + }); + } else { + // Append to existing group + groups[groups.length - 1].lines.push(line); + } + } + + return groups; +} + +/** + * Render a group of output lines as a merged card + */ +interface OutputLineCardProps { + group: OutputLineGroup; + onCopy?: (content: string) => void; +} + +function OutputLineCard({ group, onCopy }: OutputLineCardProps) { + const borderColor = getBorderColorForType(group.type); + + // Extract content from all lines in the group + const lineContents = group.lines.map(line => extractContentFromLine(line)); + + // Check if any line has markdown + const hasMarkdown = lineContents.some(c => c.isMarkdown); + return ( -
-
- {isMarkdown ? ( -
- - {contentToRender} - +
+
+ {lineContents.map((item, index) => ( +
+ {item.isMarkdown || hasMarkdown ? ( +
+ + {item.content} + +
+ ) : ( +
+ {item.content} +
+ )}
- ) : ( -
- {contentToRender} -
- )} + ))}
); } +// Memoize the OutputLineCard component to prevent unnecessary re-renders +const MemoizedOutputLineCard = memo(OutputLineCard); + // ========== Component ========== export interface CliStreamMonitorProps { @@ -160,11 +203,15 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) { const [isUserScrolling, setIsUserScrolling] = useState(false); const [viewMode, setViewMode] = useState<'list' | 'blocks'>('list'); + // Track last output length to detect new output + const lastOutputLengthRef = useRef>({}); + // Store state const executions = useCliStreamStore((state) => state.executions); const currentExecutionId = useCliStreamStore((state) => state.currentExecutionId); const setCurrentExecution = useCliStreamStore((state) => state.setCurrentExecution); const removeExecution = useCliStreamStore((state) => state.removeExecution); + const markExecutionClosedByUser = useCliStreamStore((state) => state.markExecutionClosedByUser); // Active execution sync const { isLoading: isSyncing, refetch } = useActiveCliExecutions(isOpen); @@ -264,21 +311,42 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) { }); invalidateActive(); } - }, [lastMessage, currentExecutionId, setCurrentExecution, invalidateActive]); + }, [lastMessage, invalidateActive]); - // Auto-scroll to bottom when new output arrives + // Auto-scroll to bottom when new output arrives (optimized - only scroll when output length changes) useEffect(() => { - if (autoScroll && !isUserScrolling && logsEndRef.current) { - logsEndRef.current.scrollIntoView({ behavior: 'smooth' }); - } - }, [executions, autoScroll, isUserScrolling, currentExecutionId]); + if (!currentExecutionId || !autoScroll || isUserScrolling) return; - // Handle scroll to detect user scrolling + const currentExecution = executions[currentExecutionId]; + if (!currentExecution) return; + + const currentLength = currentExecution.output.length; + const lastLength = lastOutputLengthRef.current[currentExecutionId] || 0; + + // Only scroll if new output was added + if (currentLength > lastLength) { + lastOutputLengthRef.current[currentExecutionId] = currentLength; + requestAnimationFrame(() => { + if (logsEndRef.current) { + logsEndRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }); + } + }, [executions, currentExecutionId, autoScroll, isUserScrolling]); + + // Handle scroll to detect user scrolling (with debounce for performance) + const handleScrollRef = useRef(null); const handleScroll = useCallback(() => { - if (!logsContainerRef.current) return; - const { scrollTop, scrollHeight, clientHeight } = logsContainerRef.current; - const isAtBottom = scrollHeight - scrollTop - clientHeight < 50; - setIsUserScrolling(!isAtBottom); + if (handleScrollRef.current) { + clearTimeout(handleScrollRef.current); + } + + handleScrollRef.current = setTimeout(() => { + if (!logsContainerRef.current) return; + const { scrollTop, scrollHeight, clientHeight } = logsContainerRef.current; + const isAtBottom = scrollHeight - scrollTop - clientHeight < 50; + setIsUserScrolling(!isAtBottom); + }, 50); // 50ms debounce }, []); // Scroll to bottom handler @@ -287,6 +355,28 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) { setIsUserScrolling(false); }, []); + // Handle closing an execution tab + const handleCloseExecution = useCallback((executionId: string) => { + // Mark as closed by user so it won't be re-added by server sync + markExecutionClosedByUser(executionId); + // Remove from local state + removeExecution(executionId); + // If this was the current execution, clear current selection + if (currentExecutionId === executionId) { + const remainingIds = Object.keys(executions).filter(id => id !== executionId); + setCurrentExecution(remainingIds.length > 0 ? remainingIds[0] : null); + } + }, [markExecutionClosedByUser, removeExecution, currentExecutionId, executions, setCurrentExecution]); + + // Close all executions + const handleCloseAll = useCallback(() => { + for (const id of Object.keys(executions)) { + markExecutionClosedByUser(id); + removeExecution(id); + } + setCurrentExecution(null); + }, [markExecutionClosedByUser, removeExecution, executions, setCurrentExecution]); + // ESC key to close useEffect(() => { const handleEsc = (e: KeyboardEvent) => { @@ -302,27 +392,67 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) { return () => window.removeEventListener('keydown', handleEsc); }, [isOpen, onClose, searchQuery]); - // Get sorted execution IDs (running first, then by start time) - const sortedExecutionIds = Object.keys(executions).sort((a, b) => { - const execA = executions[a]; - const execB = executions[b]; - if (execA.status === 'running' && execB.status !== 'running') return -1; - if (execA.status !== 'running' && execB.status === 'running') return 1; - return execB.startTime - execA.startTime; - }); + // Cleanup scroll handler timeout on unmount + useEffect(() => { + return () => { + if (handleScrollRef.current) { + clearTimeout(handleScrollRef.current); + } + }; + }, []); - // Active execution count for badge - const activeCount = Object.values(executions).filter(e => e.status === 'running').length; + // Get sorted execution IDs (memoized to avoid unnecessary recalculations) + const sortedExecutionIds = useMemo(() => { + return Object.keys(executions).sort((a, b) => { + const execA = executions[a]; + const execB = executions[b]; + if (execA.status === 'running' && execB.status !== 'running') return -1; + if (execA.status !== 'running' && execB.status === 'running') return 1; + return execB.startTime - execA.startTime; + }); + }, [executions]); - // Current execution - const currentExecution = currentExecutionId ? executions[currentExecutionId] : null; + // Active execution count for badge (memoized) + const activeCount = useMemo(() => { + return Object.values(executions).filter(e => e.status === 'running').length; + }, [executions]); - // Filter output lines based on search - const filteredOutput = currentExecution && searchQuery - ? currentExecution.output.filter(line => + // Current execution (memoized) + const currentExecution = useMemo(() => { + return currentExecutionId ? executions[currentExecutionId] : null; + }, [currentExecutionId, executions]); + + // Maximum lines to display (for performance) + const MAX_DISPLAY_LINES = 1000; + + // Filter output lines based on search (memoized with limit) + const filteredOutput = useMemo(() => { + if (!currentExecution) return []; + + let output = currentExecution.output; + + // Apply search filter + if (searchQuery) { + output = output.filter(line => line.content.toLowerCase().includes(searchQuery.toLowerCase()) - ) - : currentExecution?.output || []; + ); + } + + // Limit display for performance + if (output.length > MAX_DISPLAY_LINES) { + return output.slice(-MAX_DISPLAY_LINES); + } + + return output; + }, [currentExecution, searchQuery]); + + // Check if output was truncated + const isOutputTruncated = currentExecution && currentExecution.output.length > MAX_DISPLAY_LINES; + + // Group output lines by type (memoized for performance) + const groupedOutput = useMemo(() => { + return groupConsecutiveLinesByType(filteredOutput); + }, [filteredOutput]); if (!isOpen) { return null; @@ -367,6 +497,16 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
+ {sortedExecutionIds.length > 0 && ( + + )}