mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
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.
This commit is contained in:
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
{/* Error Card */}
|
||||
{envError && (
|
||||
<Card className="p-4 bg-destructive/10 border-destructive/20">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-destructive-foreground">
|
||||
{formatMessage({ id: 'codexlens.advanced.loadError' })}
|
||||
</h4>
|
||||
<p className="text-xs text-destructive-foreground/80 mt-1">
|
||||
{envError.message || formatMessage({ id: 'codexlens.advanced.loadErrorDesc' })}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
className="mt-2"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3 mr-1" />
|
||||
{formatMessage({ id: 'common.actions.retry' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Sensitivity Warning Card */}
|
||||
{showWarning && (
|
||||
<Card className="p-4 bg-warning/10 border-warning/20">
|
||||
|
||||
286
ccw/frontend/src/components/codexlens/IndexOperations.tsx
Normal file
286
ccw/frontend/src/components/codexlens/IndexOperations.tsx
Normal file
@@ -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<IndexProgress | null>(null);
|
||||
const [activeOperation, setActiveOperation] = useState<string | null>(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: <RotateCw className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
id: 'fts_incremental',
|
||||
type: 'fts_incremental',
|
||||
label: formatMessage({ id: 'codexlens.overview.actions.ftsIncremental' }),
|
||||
description: formatMessage({ id: 'codexlens.overview.actions.ftsIncrementalDesc' }),
|
||||
icon: <Zap className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
id: 'vector_full',
|
||||
type: 'vector_full',
|
||||
label: formatMessage({ id: 'codexlens.overview.actions.vectorFull' }),
|
||||
description: formatMessage({ id: 'codexlens.overview.actions.vectorFullDesc' }),
|
||||
icon: <RotateCw className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
id: 'vector_incremental',
|
||||
type: 'vector_incremental',
|
||||
label: formatMessage({ id: 'codexlens.overview.actions.vectorIncremental' }),
|
||||
description: formatMessage({ id: 'codexlens.overview.actions.vectorIncrementalDesc' }),
|
||||
icon: <Zap className="w-4 h-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center justify-between">
|
||||
<span>{operation?.label}</span>
|
||||
{!isComplete && !isError && !isCancelled && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
disabled={isCancelling}
|
||||
>
|
||||
<X className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'common.actions.cancel' })}
|
||||
</Button>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Status Icon */}
|
||||
<div className="flex items-center gap-3">
|
||||
{isComplete ? (
|
||||
<CheckCircle2 className="w-6 h-6 text-success" />
|
||||
) : isError || isCancelled ? (
|
||||
<AlertCircle className="w-6 h-6 text-destructive" />
|
||||
) : (
|
||||
<RotateCw className="w-6 h-6 text-primary animate-spin" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{isComplete
|
||||
? formatMessage({ id: 'codexlens.index.complete' })
|
||||
: isError
|
||||
? formatMessage({ id: 'codexlens.index.failed' })
|
||||
: isCancelled
|
||||
? formatMessage({ id: 'codexlens.index.cancelled' })
|
||||
: formatMessage({ id: 'codexlens.index.inProgress' })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{indexProgress.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{!isComplete && !isError && !isCancelled && (
|
||||
<div className="space-y-2">
|
||||
<Progress value={indexProgress.percent} className="h-2" />
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
{indexProgress.percent}%
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Close Button */}
|
||||
{(isComplete || isError || isCancelled) && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setActiveOperation(null);
|
||||
setIndexProgress(null);
|
||||
}}
|
||||
>
|
||||
{formatMessage({ id: 'common.actions.close' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{formatMessage({ id: 'codexlens.overview.actions.title' })}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{operations.map((operation) => (
|
||||
<Button
|
||||
key={operation.id}
|
||||
variant="outline"
|
||||
className="h-auto p-4 flex flex-col items-start gap-2 text-left"
|
||||
onClick={() => handleOperation(operation)}
|
||||
disabled={disabled || isOperating}
|
||||
>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<span className={cn('text-muted-foreground', (disabled || isOperating) && 'opacity-50')}>
|
||||
{operation.icon}
|
||||
</span>
|
||||
<span className="font-medium">{operation.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{operation.description}</p>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default IndexOperations;
|
||||
@@ -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 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<AlertCircle className="w-12 h-12 mx-auto text-destructive/50 mb-3" />
|
||||
<h3 className="text-sm font-medium text-destructive-foreground mb-1">
|
||||
{formatMessage({ id: 'codexlens.models.error.title' })}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
{error.message || formatMessage({ id: 'codexlens.models.error.description' })}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
<RefreshCw className="w-3 h-3 mr-1" />
|
||||
{formatMessage({ id: 'common.actions.retry' })}
|
||||
</Button>
|
||||
</Card>
|
||||
) : isLoading ? (
|
||||
<Card className="p-8 text-center">
|
||||
<p className="text-muted-foreground">{formatMessage({ id: 'common.actions.loading' })}</p>
|
||||
</Card>
|
||||
@@ -251,10 +271,16 @@ export function ModelsTab({ installed = false }: ModelsTabProps) {
|
||||
<Card className="p-8 text-center">
|
||||
<Package className="w-12 h-12 mx-auto text-muted-foreground/30 mb-3" />
|
||||
<h3 className="text-sm font-medium text-foreground mb-1">
|
||||
{formatMessage({ id: 'codexlens.models.empty.title' })}
|
||||
{models && models.length > 0
|
||||
? formatMessage({ id: 'codexlens.models.empty.filtered' })
|
||||
: formatMessage({ id: 'codexlens.models.empty.title' })
|
||||
}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'codexlens.models.empty.description' })}
|
||||
{models && models.length > 0
|
||||
? formatMessage({ id: 'codexlens.models.empty.filteredDesc' })
|
||||
: formatMessage({ id: 'codexlens.models.empty.description' })
|
||||
}
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
|
||||
@@ -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
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{formatMessage({ id: 'codexlens.overview.actions.title' })}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<QuickActionButton
|
||||
icon={<RotateCw className="w-4 h-4" />}
|
||||
label={formatMessage({ id: 'codexlens.overview.actions.ftsFull' })}
|
||||
description={formatMessage({ id: 'codexlens.overview.actions.ftsFullDesc' })}
|
||||
disabled={!isReady}
|
||||
/>
|
||||
<QuickActionButton
|
||||
icon={<Zap className="w-4 h-4" />}
|
||||
label={formatMessage({ id: 'codexlens.overview.actions.ftsIncremental' })}
|
||||
description={formatMessage({ id: 'codexlens.overview.actions.ftsIncrementalDesc' })}
|
||||
disabled={!isReady}
|
||||
/>
|
||||
<QuickActionButton
|
||||
icon={<RotateCw className="w-4 h-4" />}
|
||||
label={formatMessage({ id: 'codexlens.overview.actions.vectorFull' })}
|
||||
description={formatMessage({ id: 'codexlens.overview.actions.vectorFullDesc' })}
|
||||
disabled={!isReady}
|
||||
/>
|
||||
<QuickActionButton
|
||||
icon={<Zap className="w-4 h-4" />}
|
||||
label={formatMessage({ id: 'codexlens.overview.actions.vectorIncremental' })}
|
||||
description={formatMessage({ id: 'codexlens.overview.actions.vectorIncrementalDesc' })}
|
||||
disabled={!isReady}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Index Operations */}
|
||||
<IndexOperations disabled={!isReady} onRefresh={onRefresh} />
|
||||
|
||||
{/* Venv Details */}
|
||||
{status && (
|
||||
@@ -210,37 +176,3 @@ export function OverviewTab({ installed, status, config, isLoading }: OverviewTa
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto p-4 flex flex-col items-start gap-2 text-left"
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<span className={cn('text-muted-foreground', disabled && 'opacity-50')}>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="font-medium">{label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
273
ccw/frontend/src/components/codexlens/SearchTab.tsx
Normal file
273
ccw/frontend/src/components/codexlens/SearchTab.tsx
Normal file
@@ -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<SearchType>('search');
|
||||
const [searchMode, setSearchMode] = useState<SearchMode>('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 (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<div className="text-center">
|
||||
<Search className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{formatMessage({ id: 'codexlens.search.notInstalled.title' })}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{formatMessage({ id: 'codexlens.search.notInstalled.description' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Search Options */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Search Type */}
|
||||
<div className="space-y-2">
|
||||
<Label>{formatMessage({ id: 'codexlens.search.type' })}</Label>
|
||||
<Select value={searchType} onValueChange={handleSearchTypeChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="search">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="w-4 h-4" />
|
||||
{formatMessage({ id: 'codexlens.search.content' })}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="search_files">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCode className="w-4 h-4" />
|
||||
{formatMessage({ id: 'codexlens.search.files' })}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="symbol">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="w-4 h-4" />
|
||||
{formatMessage({ id: 'codexlens.search.symbol' })}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Search Mode - only for content and file search */}
|
||||
{searchType !== 'symbol' && (
|
||||
<div className="space-y-2">
|
||||
<Label>{formatMessage({ id: 'codexlens.search.mode' })}</Label>
|
||||
<Select value={searchMode} onValueChange={handleSearchModeChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="dense_rerank">
|
||||
{formatMessage({ id: 'codexlens.search.mode.semantic' })}
|
||||
</SelectItem>
|
||||
<SelectItem value="fts">
|
||||
{formatMessage({ id: 'codexlens.search.mode.exact' })}
|
||||
</SelectItem>
|
||||
<SelectItem value="fuzzy">
|
||||
{formatMessage({ id: 'codexlens.search.mode.fuzzy' })}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Query Input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="search-query">{formatMessage({ id: 'codexlens.search.query' })}</Label>
|
||||
<Input
|
||||
id="search-query"
|
||||
placeholder={formatMessage({ id: 'codexlens.search.queryPlaceholder' })}
|
||||
value={query}
|
||||
onChange={(e) => handleQueryChange(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Search Button */}
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
disabled={!query.trim() || isLoading}
|
||||
className="w-full"
|
||||
>
|
||||
<Search className={cn('w-4 h-4 mr-2', isLoading && 'animate-spin')} />
|
||||
{isLoading
|
||||
? formatMessage({ id: 'codexlens.search.searching' })
|
||||
: formatMessage({ id: 'codexlens.search.button' })
|
||||
}
|
||||
</Button>
|
||||
|
||||
{/* Results */}
|
||||
{hasSearched && !isLoading && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">
|
||||
{formatMessage({ id: 'codexlens.search.results' })}
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{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' })}`
|
||||
: ''
|
||||
)
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{searchType === 'symbol' && symbolSearch.data && (
|
||||
symbolSearch.data.success ? (
|
||||
<div className="rounded-lg border bg-muted/50 p-4">
|
||||
<pre className="text-xs overflow-auto max-h-96">
|
||||
{JSON.stringify(symbolSearch.data.symbols, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-destructive">
|
||||
{symbolSearch.data.error || formatMessage({ id: 'common.error' })}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{searchType === 'search' && contentSearch.data && (
|
||||
contentSearch.data.success ? (
|
||||
<div className="rounded-lg border bg-muted/50 p-4">
|
||||
<pre className="text-xs overflow-auto max-h-96">
|
||||
{JSON.stringify(contentSearch.data.results, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-destructive">
|
||||
{contentSearch.data.error || formatMessage({ id: 'common.error' })}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{searchType === 'search_files' && fileSearch.data && (
|
||||
fileSearch.data.success ? (
|
||||
<div className="rounded-lg border bg-muted/50 p-4">
|
||||
<pre className="text-xs overflow-auto max-h-96">
|
||||
{JSON.stringify(fileSearch.data.results, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-destructive">
|
||||
{fileSearch.data.error || formatMessage({ id: 'common.error' })}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchTab;
|
||||
@@ -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 */}
|
||||
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0', statusColor)} />
|
||||
|
||||
{/* Mode indicator */}
|
||||
<span className="text-[10px]" title={execution.mode}>
|
||||
{modeDisplay}
|
||||
</span>
|
||||
|
||||
{/* Simplified tool name */}
|
||||
<span className="font-medium text-[11px]">{toolNameShort}</span>
|
||||
|
||||
{/* Execution mode - show on hover */}
|
||||
<span className="opacity-0 group-hover:opacity-70 text-[10px] transition-opacity">
|
||||
{execution.mode}
|
||||
</span>
|
||||
|
||||
{/* Line count statistics - show on hover */}
|
||||
<span className="opacity-0 group-hover:opacity-50 text-[9px] tabular-nums transition-opacity">
|
||||
{execution.output.length}
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown>;
|
||||
} catch {
|
||||
// Recovery failed, try one more approach
|
||||
}
|
||||
}
|
||||
|
||||
// Try parsing as-is first
|
||||
try {
|
||||
return JSON.parse(trimmed) as Record<string, unknown>;
|
||||
} 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<string, unknown>;
|
||||
} 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<string, unknown> | 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<string, unknown> };
|
||||
} 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className={`border-l-2 rounded-r my-1 py-1 px-2 group relative bg-background ${borderColor}`}>
|
||||
<div className="pr-6">
|
||||
{isMarkdown ? (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none text-xs leading-relaxed">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{contentToRender}
|
||||
</ReactMarkdown>
|
||||
<div className={`border-l-2 rounded-r my-1 py-1 px-2 group relative bg-background contain-content ${borderColor}`}>
|
||||
<div className="pr-6 space-y-1">
|
||||
{lineContents.map((item, index) => (
|
||||
<div key={index} className="contain-layout">
|
||||
{item.isMarkdown || hasMarkdown ? (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none text-xs leading-relaxed contain-layout">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{item.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs whitespace-pre-wrap break-words leading-relaxed contain-layout">
|
||||
{item.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs whitespace-pre-wrap break-words leading-relaxed">
|
||||
{contentToRender}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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<Record<string, number>>({});
|
||||
|
||||
// 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<NodeJS.Timeout | null>(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) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{sortedExecutionIds.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCloseAll}
|
||||
title="Close all executions"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -390,7 +530,7 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
|
||||
onValueChange={(v) => setCurrentExecution(v || null)}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="w-full h-auto flex-wrap gap-1 bg-secondary/50 p-1">
|
||||
<TabsList className="w-full h-auto gap-1 bg-secondary/50 p-1 overflow-x-auto overflow-y-hidden no-scrollbar">
|
||||
{sortedExecutionIds.map((id) => (
|
||||
<ExecutionTab
|
||||
key={id}
|
||||
@@ -399,7 +539,7 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
|
||||
onClick={() => setCurrentExecution(id)}
|
||||
onClose={(e) => {
|
||||
e.stopPropagation();
|
||||
removeExecution(id);
|
||||
handleCloseExecution(id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -472,26 +612,27 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
|
||||
) : (
|
||||
<div
|
||||
ref={logsContainerRef}
|
||||
className="h-full overflow-y-auto p-3 font-mono text-xs bg-background"
|
||||
className="h-full overflow-y-auto p-3 font-mono text-xs bg-background contain-strict"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{isOutputTruncated && (
|
||||
<div className="mb-2 p-2 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded text-amber-800 dark:text-amber-200 text-xs">
|
||||
Showing last {MAX_DISPLAY_LINES} lines of {currentExecution?.output.length} total lines. Use search to find specific content.
|
||||
</div>
|
||||
)}
|
||||
{filteredOutput.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
{searchQuery ? 'No matching output found' : 'Waiting for output...'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{(() => {
|
||||
// Group output lines by type
|
||||
const groupedOutput = groupOutputLines(filteredOutput);
|
||||
return groupedOutput.map((group, groupIndex) => (
|
||||
<OutputGroupRenderer
|
||||
key={`group-${group.type}-${groupIndex}`}
|
||||
group={group}
|
||||
onCopy={(content) => navigator.clipboard.writeText(content)}
|
||||
/>
|
||||
));
|
||||
})()}
|
||||
<div>
|
||||
{groupedOutput.map((group, groupIndex) => (
|
||||
<MemoizedOutputLineCard
|
||||
key={`group-${group.type}-${groupIndex}`}
|
||||
group={group}
|
||||
onCopy={(content) => navigator.clipboard.writeText(content)}
|
||||
/>
|
||||
))}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user