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 && (
+
+ )}