feat(ui): 实现 LSP UI 集成

- 添加 LSP 相关类型定义 (LSPServer, FileDiagnostic, DiagnosticsResponse)
- 添加 LSP API 函数 (listLSPServers, installLSPServer, startLSPServer 等)
- 创建 LSPPanel 组件: 语言服务器管理面板
- 创建 DiagnosticsPanel 组件: 诊断详情面板 (含搜索过滤)
- 创建 DiagnosticsIndicator 组件: 状态栏指示器
- 集成到 Web 模块 (App.tsx, Chat.tsx)
This commit is contained in:
2025-12-17 11:17:00 +08:00
parent c5b92e740c
commit 1019ba7c9c
8 changed files with 1673 additions and 1 deletions
+96
View File
@@ -50,6 +50,9 @@ import type {
CompressionResult,
// File search types
FileSearchResponse,
// LSP types
LSPServer,
DiagnosticsResponse,
} from './types.js';
// Re-export types
@@ -135,6 +138,13 @@ export type {
QuestionOption,
Question,
QuestionMessagePart,
// LSP types
LSPServer,
FileDiagnostic,
DiagnosticsSummary,
SingleFileDiagnosticsResponse,
AllFilesDiagnosticsResponse,
DiagnosticsResponse,
} from './types.js';
// API Configuration
@@ -1003,3 +1013,89 @@ export async function searchFiles(
});
return request('GET', `/files/search?${params}`);
}
// ============ LSP API ============
/**
* 获取所有语言服务器列表
*/
export async function listLSPServers(): Promise<{
success: boolean;
data: LSPServer[];
error?: string;
}> {
return request('GET', '/lsp/servers');
}
/**
* 获取单个语言服务器详情
*/
export async function getLSPServer(id: string): Promise<{
success: boolean;
data?: LSPServer;
error?: string;
}> {
return request('GET', `/lsp/servers/${encodeURIComponent(id)}`);
}
/**
* 安装语言服务器
*/
export async function installLSPServer(id: string): Promise<{
success: boolean;
data?: { message: string; server: LSPServer };
error?: string;
}> {
return request('POST', `/lsp/servers/${encodeURIComponent(id)}/install`);
}
/**
* 启动语言服务器
* @param id 服务器 ID
* @param filePath 需要提供一个文件路径来触发对应语言的服务器
*/
export async function startLSPServer(
id: string,
filePath: string
): Promise<{
success: boolean;
data?: { message: string; isFirstStart: boolean; runningServers: string[] };
error?: string;
}> {
return request('POST', `/lsp/servers/${encodeURIComponent(id)}/start`, { filePath });
}
/**
* 停止语言服务器
*/
export async function stopLSPServer(id: string): Promise<{
success: boolean;
data?: { message: string; runningServers: string[] };
error?: string;
}> {
return request('POST', `/lsp/servers/${encodeURIComponent(id)}/stop`);
}
/**
* 获取正在运行的语言服务器列表
*/
export async function getRunningLSPServers(): Promise<{
success: boolean;
data: string[];
error?: string;
}> {
return request('GET', '/lsp/running');
}
/**
* 获取诊断信息
* @param file 可选,指定文件路径获取单个文件的诊断
*/
export async function getLSPDiagnostics(file?: string): Promise<{
success: boolean;
data: DiagnosticsResponse;
error?: string;
}> {
const params = file ? `?file=${encodeURIComponent(file)}` : '';
return request('GET', `/lsp/diagnostics${params}`);
}
+69
View File
@@ -1058,3 +1058,72 @@ export interface SubagentState {
error?: string;
}
// ============ LSP 相关 ============
/** 语言服务器信息 */
export interface LSPServer {
/** 服务器 ID */
id: string;
/** 显示名称 */
displayName: string;
/** 支持的语言列表 */
languages: string[];
/** 是否已安装 */
installed: boolean;
/** 是否正在运行 */
running: boolean;
/** 安装命令 */
installCommand?: string;
}
/** 文件诊断信息 */
export interface FileDiagnostic {
/** 行号(1-based */
line: number;
/** 列号(1-based */
column: number;
/** 结束行号 */
endLine?: number;
/** 结束列号 */
endColumn?: number;
/** 诊断消息 */
message: string;
/** 严重程度 */
severity: 'error' | 'warning' | 'info' | 'hint';
/** 来源(如 typescript, eslint */
source?: string;
/** 错误代码 */
code?: string | number;
}
/** 诊断摘要 */
export interface DiagnosticsSummary {
/** 总文件数 */
totalFiles?: number;
/** 错误总数 */
totalErrors: number;
/** 警告总数 */
totalWarnings: number;
}
/** 单文件诊断响应 */
export interface SingleFileDiagnosticsResponse {
/** 文件路径 */
file: string;
/** 诊断列表 */
diagnostics: FileDiagnostic[];
/** 摘要 */
summary: DiagnosticsSummary;
}
/** 所有文件诊断响应 */
export interface AllFilesDiagnosticsResponse {
/** 所有文件的诊断信息 */
files: Array<{ file: string; diagnostics: FileDiagnostic[] }>;
/** 摘要 */
summary: DiagnosticsSummary;
}
/** 诊断响应(联合类型) */
export type DiagnosticsResponse = SingleFileDiagnosticsResponse | AllFilesDiagnosticsResponse;
@@ -0,0 +1,267 @@
/**
* DiagnosticsIndicator Component
*
* Compact diagnostics status indicator for status bar
*/
import { useState, useEffect, useCallback } from 'react';
import {
AlertCircle,
AlertTriangle,
Server,
Loader2,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { cn } from '../utils/cn';
import { Button } from '../primitives/Button';
import {
getLSPDiagnostics,
getRunningLSPServers,
type DiagnosticsResponse,
type AllFilesDiagnosticsResponse,
} from '../api/client.js';
interface DiagnosticsIndicatorProps {
/** Callback when diagnostics section is clicked */
onClickDiagnostics?: () => void;
/** Callback when LSP section is clicked */
onClickLSP?: () => void;
/** Refresh interval in ms (default: 30000) */
refreshInterval?: number;
/** Additional class names */
className?: string;
}
/** Check if diagnostics is all files response */
function isAllFilesResponse(data: DiagnosticsResponse): data is AllFilesDiagnosticsResponse {
return 'files' in data;
}
export function DiagnosticsIndicator({
onClickDiagnostics,
onClickLSP,
refreshInterval = 30000,
className,
}: DiagnosticsIndicatorProps) {
// Data state
const [diagnostics, setDiagnostics] = useState<{
errors: number;
warnings: number;
} | null>(null);
const [runningServers, setRunningServers] = useState<number>(0);
// UI state
const [loading, setLoading] = useState(true);
// Load data
const loadData = useCallback(async () => {
try {
const [diagResult, serversResult] = await Promise.all([
getLSPDiagnostics(),
getRunningLSPServers(),
]);
if (diagResult.success && isAllFilesResponse(diagResult.data)) {
setDiagnostics({
errors: diagResult.data.summary.totalErrors,
warnings: diagResult.data.summary.totalWarnings,
});
}
if (serversResult.success) {
setRunningServers(serversResult.data.length);
}
} catch {
// Silently ignore errors
} finally {
setLoading(false);
}
}, []);
// Initial load and refresh interval
useEffect(() => {
loadData();
const interval = setInterval(loadData, refreshInterval);
return () => clearInterval(interval);
}, [loadData, refreshInterval]);
// No data yet
if (loading) {
return (
<div className={cn('flex items-center gap-2 text-fg-subtle', className)}>
<Loader2 size={14} className="animate-spin" />
</div>
);
}
const hasErrors = diagnostics && diagnostics.errors > 0;
const hasWarnings = diagnostics && diagnostics.warnings > 0;
const hasIssues = hasErrors || hasWarnings;
return (
<div className={cn('flex items-center gap-1', className)}>
{/* Diagnostics */}
{diagnostics && (
<Button
variant="ghost"
size="sm"
onClick={onClickDiagnostics}
className={cn(
'flex items-center gap-2 px-2 py-1 h-auto min-h-0',
'hover:bg-surface-base/50 transition-colors'
)}
title="View Diagnostics"
>
<AnimatePresence mode="wait">
{hasErrors && (
<motion.span
key="errors"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="flex items-center gap-1 text-red-400"
>
<AlertCircle size={14} />
<span className="text-xs font-medium">{diagnostics.errors}</span>
</motion.span>
)}
</AnimatePresence>
<AnimatePresence mode="wait">
{hasWarnings && (
<motion.span
key="warnings"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="flex items-center gap-1 text-amber-400"
>
<AlertTriangle size={14} />
<span className="text-xs font-medium">{diagnostics.warnings}</span>
</motion.span>
)}
</AnimatePresence>
{!hasIssues && (
<span className="flex items-center gap-1 text-green-400">
<AlertCircle size={14} />
<span className="text-xs">0</span>
</span>
)}
</Button>
)}
{/* LSP Status */}
<Button
variant="ghost"
size="sm"
onClick={onClickLSP}
className={cn(
'flex items-center gap-1 px-2 py-1 h-auto min-h-0',
'hover:bg-surface-base/50 transition-colors',
runningServers > 0 ? 'text-green-400' : 'text-fg-subtle'
)}
title={`${runningServers} Language Server${runningServers !== 1 ? 's' : ''} Running`}
>
<Server size={14} />
{runningServers > 0 && <span className="text-xs font-medium">{runningServers}</span>}
</Button>
</div>
);
}
/**
* Compact version for very limited space
*/
export function DiagnosticsIndicatorCompact({
onClickDiagnostics,
onClickLSP,
refreshInterval = 30000,
className,
}: DiagnosticsIndicatorProps) {
// Data state
const [diagnostics, setDiagnostics] = useState<{
errors: number;
warnings: number;
} | null>(null);
const [runningServers, setRunningServers] = useState<number>(0);
// UI state
const [loading, setLoading] = useState(true);
// Load data
const loadData = useCallback(async () => {
try {
const [diagResult, serversResult] = await Promise.all([
getLSPDiagnostics(),
getRunningLSPServers(),
]);
if (diagResult.success && isAllFilesResponse(diagResult.data)) {
setDiagnostics({
errors: diagResult.data.summary.totalErrors,
warnings: diagResult.data.summary.totalWarnings,
});
}
if (serversResult.success) {
setRunningServers(serversResult.data.length);
}
} catch {
// Silently ignore errors
} finally {
setLoading(false);
}
}, []);
// Initial load and refresh interval
useEffect(() => {
loadData();
const interval = setInterval(loadData, refreshInterval);
return () => clearInterval(interval);
}, [loadData, refreshInterval]);
// No data yet
if (loading) {
return null;
}
const hasErrors = diagnostics && diagnostics.errors > 0;
const hasWarnings = diagnostics && diagnostics.warnings > 0;
return (
<div className={cn('flex items-center', className)}>
{/* Combined indicator */}
<button
onClick={onClickDiagnostics || onClickLSP}
className={cn(
'flex items-center gap-1 px-1.5 py-0.5 rounded text-xs',
'hover:bg-surface-base/50 transition-colors',
hasErrors
? 'text-red-400'
: hasWarnings
? 'text-amber-400'
: runningServers > 0
? 'text-green-400'
: 'text-fg-subtle'
)}
title={`${diagnostics?.errors || 0} errors, ${diagnostics?.warnings || 0} warnings, ${runningServers} LSP servers`}
>
{hasErrors ? (
<AlertCircle size={12} />
) : hasWarnings ? (
<AlertTriangle size={12} />
) : (
<Server size={12} />
)}
{(hasErrors || hasWarnings) && (
<span className="font-medium">
{(diagnostics?.errors || 0) + (diagnostics?.warnings || 0)}
</span>
)}
</button>
</div>
);
}
@@ -0,0 +1,568 @@
/**
* DiagnosticsPanel Component
*
* Display detailed diagnostics information from LSP servers
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import {
X,
RefreshCw,
AlertCircle,
AlertTriangle,
Info,
Lightbulb,
ChevronDown,
ChevronRight,
FileText,
Search,
Filter,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { toast } from 'sonner';
import { cn } from '../utils/cn';
import { modalOverlay, modalContent, smoothTransition } from '../utils/animations';
import { Button } from '../primitives/Button';
import { Input } from '../primitives/Input';
import { Skeleton } from './Skeleton';
import {
getLSPDiagnostics,
type FileDiagnostic,
type DiagnosticsResponse,
type AllFilesDiagnosticsResponse,
} from '../api/client.js';
interface DiagnosticsPanelProps {
onClose: () => void;
/** Callback when a file is clicked */
onFileClick?: (file: string, line?: number) => void;
/** Enable responsive layout */
responsive?: boolean;
}
type SeverityFilter = 'all' | 'error' | 'warning' | 'info' | 'hint';
/** Check if diagnostics is all files response */
function isAllFilesResponse(data: DiagnosticsResponse): data is AllFilesDiagnosticsResponse {
return 'files' in data;
}
/** Get severity icon */
function SeverityIcon({ severity, size = 14 }: { severity: FileDiagnostic['severity']; size?: number }) {
switch (severity) {
case 'error':
return <AlertCircle size={size} className="text-red-400" />;
case 'warning':
return <AlertTriangle size={size} className="text-amber-400" />;
case 'info':
return <Info size={size} className="text-blue-400" />;
case 'hint':
return <Lightbulb size={size} className="text-fg-muted" />;
default:
return <Info size={size} className="text-fg-subtle" />;
}
}
/** Get severity color class */
function getSeverityColor(severity: FileDiagnostic['severity']): string {
switch (severity) {
case 'error':
return 'text-red-400';
case 'warning':
return 'text-amber-400';
case 'info':
return 'text-blue-400';
case 'hint':
return 'text-fg-muted';
default:
return 'text-fg-subtle';
}
}
export function DiagnosticsPanel({ onClose, onFileClick, responsive = false }: DiagnosticsPanelProps) {
// Data state
const [files, setFiles] = useState<Array<{ file: string; diagnostics: FileDiagnostic[] }>>([]);
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
const [summary, setSummary] = useState<{
totalErrors: number;
totalWarnings: number;
totalFiles: number;
} | null>(null);
// Filter state
const [searchQuery, setSearchQuery] = useState('');
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>('all');
const [showFilterMenu, setShowFilterMenu] = useState(false);
// UI state
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
// Load diagnostics
const loadDiagnostics = useCallback(async (showToast = false) => {
try {
const result = await getLSPDiagnostics();
if (result.success && isAllFilesResponse(result.data)) {
setFiles(result.data.files);
setSummary({
totalErrors: result.data.summary.totalErrors,
totalWarnings: result.data.summary.totalWarnings,
totalFiles: result.data.summary.totalFiles || result.data.files.length,
});
// Auto-expand files with errors
const filesWithErrors = result.data.files
.filter((f) => f.diagnostics.some((d) => d.severity === 'error'))
.map((f) => f.file);
setExpandedFiles(new Set(filesWithErrors));
if (showToast) {
toast.success('Diagnostics refreshed');
}
} else {
toast.error(result.error || 'Failed to load diagnostics');
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to load diagnostics');
}
}, []);
// Initial load
useEffect(() => {
setLoading(true);
loadDiagnostics().finally(() => setLoading(false));
}, [loadDiagnostics]);
// Refresh
const handleRefresh = async () => {
setRefreshing(true);
await loadDiagnostics(true);
setRefreshing(false);
};
// Toggle expanded
const toggleExpanded = (file: string) => {
const newExpanded = new Set(expandedFiles);
if (newExpanded.has(file)) {
newExpanded.delete(file);
} else {
newExpanded.add(file);
}
setExpandedFiles(newExpanded);
};
// Filtered files
const filteredFiles = useMemo(() => {
return files
.map((f) => ({
...f,
diagnostics: f.diagnostics.filter((d) => {
// Severity filter
if (severityFilter !== 'all' && d.severity !== severityFilter) {
return false;
}
// Search filter
if (searchQuery) {
const query = searchQuery.toLowerCase();
return (
d.message.toLowerCase().includes(query) ||
f.file.toLowerCase().includes(query) ||
(d.source && d.source.toLowerCase().includes(query))
);
}
return true;
}),
}))
.filter((f) => {
// File name search
if (searchQuery && !f.file.toLowerCase().includes(searchQuery.toLowerCase())) {
return f.diagnostics.length > 0;
}
return f.diagnostics.length > 0;
});
}, [files, searchQuery, severityFilter]);
// Filtered summary
const filteredSummary = useMemo(() => {
const errors = filteredFiles.reduce(
(sum, f) => sum + f.diagnostics.filter((d) => d.severity === 'error').length,
0
);
const warnings = filteredFiles.reduce(
(sum, f) => sum + f.diagnostics.filter((d) => d.severity === 'warning').length,
0
);
return { errors, warnings, files: filteredFiles.length };
}, [filteredFiles]);
// Loading skeleton
const LoadingSkeleton = () => (
<div className="space-y-3 p-4">
{[1, 2, 3].map((i) => (
<div key={i} className="space-y-2">
<div className="flex items-center gap-3 p-3 bg-surface-base/50 rounded-lg">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-16 ml-auto" />
</div>
<div className="ml-6 space-y-1">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
</div>
))}
</div>
);
// File item component
const FileItem = ({ file, diagnostics }: { file: string; diagnostics: FileDiagnostic[] }) => {
const isExpanded = expandedFiles.has(file);
const errorCount = diagnostics.filter((d) => d.severity === 'error').length;
const warningCount = diagnostics.filter((d) => d.severity === 'warning').length;
// Get file name from path
const fileName = file.split('/').pop() || file;
const filePath = file.split('/').slice(0, -1).join('/');
return (
<motion.div
layout
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-surface-base/50 rounded-lg overflow-hidden"
>
{/* File Header */}
<div
className={cn(
'flex items-center gap-3 p-3',
'hover:bg-surface-base/80 transition-colors cursor-pointer'
)}
onClick={() => toggleExpanded(file)}
>
{/* Expand Icon */}
<button className="text-fg-subtle hover:text-fg-secondary">
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
{/* File Icon with severity */}
{errorCount > 0 ? (
<AlertCircle size={16} className="text-red-400" />
) : warningCount > 0 ? (
<AlertTriangle size={16} className="text-amber-400" />
) : (
<FileText size={16} className="text-fg-muted" />
)}
{/* File Info */}
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onFileClick?.(file);
}}
>
<div className="flex items-center gap-1">
<span className="font-medium text-fg-secondary truncate">{fileName}</span>
{filePath && <span className="text-xs text-fg-subtle truncate">({filePath})</span>}
</div>
</div>
{/* Counts */}
<div className="flex items-center gap-2 text-xs">
{errorCount > 0 && (
<span className="flex items-center gap-1 text-red-400">
<AlertCircle size={12} />
{errorCount}
</span>
)}
{warningCount > 0 && (
<span className="flex items-center gap-1 text-amber-400">
<AlertTriangle size={12} />
{warningCount}
</span>
)}
</div>
</div>
{/* Diagnostics List */}
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="border-t border-line/50">
{diagnostics.map((diagnostic, idx) => (
<div
key={`${diagnostic.line}-${diagnostic.column}-${idx}`}
className={cn(
'flex items-start gap-2 px-4 py-2 text-sm',
'hover:bg-surface-base/60 cursor-pointer transition-colors',
idx < diagnostics.length - 1 && 'border-b border-line/30'
)}
onClick={() => onFileClick?.(file, diagnostic.line)}
>
{/* Severity Icon */}
<SeverityIcon severity={diagnostic.severity} size={14} />
{/* Line/Column */}
<span className="text-xs text-fg-subtle whitespace-nowrap font-mono">
{diagnostic.line}:{diagnostic.column}
</span>
{/* Message */}
<span className={cn('flex-1', getSeverityColor(diagnostic.severity))}>
{diagnostic.message}
</span>
{/* Source & Code */}
<div className="flex items-center gap-2 text-xs text-fg-subtle">
{diagnostic.source && (
<span className="bg-surface-subtle px-1 rounded">{diagnostic.source}</span>
)}
{diagnostic.code && (
<span className="bg-surface-subtle px-1 rounded font-mono">
{diagnostic.code}
</span>
)}
</div>
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
};
return (
<AnimatePresence>
<motion.div
variants={modalOverlay}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.2 }}
className={cn(
'fixed inset-0 bg-black/50 flex z-50',
responsive ? 'items-end md:items-center justify-center' : 'items-center justify-center'
)}
onClick={onClose}
>
<motion.div
variants={modalContent}
initial="initial"
animate="animate"
exit="exit"
transition={smoothTransition}
onClick={(e) => e.stopPropagation()}
className={cn(
'bg-surface-subtle max-h-[90vh] overflow-hidden flex flex-col',
responsive
? 'w-full md:w-full md:max-w-3xl md:mx-4 rounded-t-2xl md:rounded-lg'
: 'rounded-lg w-full max-w-3xl mx-4'
)}
>
{/* Header */}
<div
className={cn(
'flex items-center justify-between border-b border-line',
responsive ? 'px-4 md:px-6 py-4' : 'px-6 py-4'
)}
>
{responsive && (
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-10 h-1 bg-surface-emphasis rounded-full md:hidden" />
)}
<div className={cn(responsive && 'mt-2 md:mt-0')}>
<h2 className="text-lg font-semibold flex items-center gap-2">
<AlertCircle size={20} className="text-red-400" />
Diagnostics
</h2>
{summary && (
<p className="text-xs text-fg-subtle">
{summary.totalErrors} errors, {summary.totalWarnings} warnings in{' '}
{summary.totalFiles} files
</p>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={handleRefresh}
disabled={refreshing}
title="Refresh"
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
>
<RefreshCw size={18} className={cn(refreshing && 'animate-spin')} />
</Button>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
>
<X size={20} />
</Button>
</div>
</div>
{/* Search & Filter */}
<div
className={cn(
'flex items-center gap-3 border-b border-line',
responsive ? 'px-4 py-3' : 'px-6 py-3'
)}
>
{/* Search */}
<div className="flex-1 relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-fg-subtle" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search diagnostics..."
className="pl-9"
/>
</div>
{/* Filter */}
<div className="relative">
<Button
variant="ghost"
size="sm"
onClick={() => setShowFilterMenu(!showFilterMenu)}
className={cn(
'flex items-center gap-1',
severityFilter !== 'all' && 'text-primary-400'
)}
>
<Filter size={14} />
<span className="hidden sm:inline">
{severityFilter === 'all' ? 'All' : severityFilter}
</span>
<ChevronDown size={12} />
</Button>
{/* Filter Menu */}
<AnimatePresence>
{showFilterMenu && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="absolute right-0 top-full mt-1 bg-surface-base rounded-lg shadow-lg border border-line z-10 py-1 min-w-[120px]"
>
{(['all', 'error', 'warning', 'info', 'hint'] as SeverityFilter[]).map(
(filter) => (
<button
key={filter}
onClick={() => {
setSeverityFilter(filter);
setShowFilterMenu(false);
}}
className={cn(
'w-full px-3 py-2 text-left text-sm flex items-center gap-2',
'hover:bg-surface-subtle transition-colors',
severityFilter === filter && 'text-primary-400 bg-surface-subtle'
)}
>
{filter === 'all' ? (
<span className="w-4" />
) : (
<SeverityIcon severity={filter} size={14} />
)}
<span className="capitalize">{filter}</span>
</button>
)
)}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Filtered Summary */}
{(searchQuery || severityFilter !== 'all') && (
<div
className={cn(
'flex items-center gap-4 text-xs text-fg-subtle border-b border-line',
responsive ? 'px-4 py-2' : 'px-6 py-2'
)}
>
<span>Showing:</span>
<span className="text-red-400">{filteredSummary.errors} errors</span>
<span className="text-amber-400">{filteredSummary.warnings} warnings</span>
<span>{filteredSummary.files} files</span>
{(searchQuery || severityFilter !== 'all') && (
<button
onClick={() => {
setSearchQuery('');
setSeverityFilter('all');
}}
className="text-primary-400 hover:text-primary-300"
>
Clear filters
</button>
)}
</div>
)}
{/* Diagnostics List */}
<div className="flex-1 overflow-y-auto">
{loading ? (
<LoadingSkeleton />
) : filteredFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-fg-subtle">
{files.length === 0 ? (
<>
<AlertCircle size={48} className="mb-4 opacity-50" />
<p className="text-center">No diagnostics available</p>
<p className="text-xs text-center mt-2">
Start a language server to see diagnostics
</p>
</>
) : (
<>
<Search size={48} className="mb-4 opacity-50" />
<p className="text-center">No matching diagnostics</p>
<button
onClick={() => {
setSearchQuery('');
setSeverityFilter('all');
}}
className="text-primary-400 hover:text-primary-300 mt-2"
>
Clear filters
</button>
</>
)}
</div>
) : (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className={cn('space-y-2', responsive ? 'p-4' : 'p-4')}
>
{filteredFiles.map((f) => (
<FileItem key={f.file} file={f.file} diagnostics={f.diagnostics} />
))}
</motion.div>
)}
</div>
{/* Footer */}
<div
className={cn(
'border-t border-line text-xs text-fg-subtle text-center',
responsive ? 'px-4 py-3 safe-area-pb' : 'px-6 py-3'
)}
>
Click on a diagnostic to navigate to the source location
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
}
+610
View File
@@ -0,0 +1,610 @@
/**
* LSPPanel Component
*
* LSP server management panel: list servers, install/start/stop, view diagnostics
*/
import { useState, useEffect, useCallback } from 'react';
import {
X,
RefreshCw,
Server,
Play,
Square,
Download,
ChevronDown,
ChevronRight,
AlertCircle,
AlertTriangle,
Info,
Loader2,
Code,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { toast } from 'sonner';
import { cn } from '../utils/cn';
import { modalOverlay, modalContent, smoothTransition } from '../utils/animations';
import { Button } from '../primitives/Button';
import { Input } from '../primitives/Input';
import { Skeleton } from './Skeleton';
import {
listLSPServers,
installLSPServer,
startLSPServer,
stopLSPServer,
getLSPDiagnostics,
type LSPServer,
type DiagnosticsResponse,
type AllFilesDiagnosticsResponse,
} from '../api/client.js';
interface LSPPanelProps {
onClose: () => void;
/** Callback to open diagnostics panel */
onOpenDiagnostics?: () => void;
/** Enable responsive layout */
responsive?: boolean;
}
/** Check if diagnostics is all files response */
function isAllFilesResponse(data: DiagnosticsResponse): data is AllFilesDiagnosticsResponse {
return 'files' in data;
}
export function LSPPanel({ onClose, onOpenDiagnostics, responsive = false }: LSPPanelProps) {
// Data state
const [servers, setServers] = useState<LSPServer[]>([]);
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
const [diagnosticsSummary, setDiagnosticsSummary] = useState<{
totalErrors: number;
totalWarnings: number;
totalFiles: number;
} | null>(null);
// UI state
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [installingServer, setInstallingServer] = useState<string | null>(null);
const [startingServer, setStartingServer] = useState<string | null>(null);
const [stoppingServer, setStoppingServer] = useState<string | null>(null);
// Start server dialog
const [showStartDialog, setShowStartDialog] = useState<string | null>(null);
const [startFilePath, setStartFilePath] = useState('');
// Load server list
const loadServers = useCallback(async (showToast = false) => {
try {
const result = await listLSPServers();
if (result.success) {
setServers(result.data);
if (showToast) {
toast.success('Servers refreshed');
}
} else {
toast.error(result.error || 'Failed to load servers');
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to load servers');
}
}, []);
// Load diagnostics summary
const loadDiagnosticsSummary = useCallback(async () => {
try {
const result = await getLSPDiagnostics();
if (result.success && isAllFilesResponse(result.data)) {
setDiagnosticsSummary({
totalErrors: result.data.summary.totalErrors,
totalWarnings: result.data.summary.totalWarnings,
totalFiles: result.data.summary.totalFiles || 0,
});
}
} catch {
// Silently ignore diagnostics errors
}
}, []);
// Initial load
useEffect(() => {
setLoading(true);
Promise.all([loadServers(), loadDiagnosticsSummary()]).finally(() => setLoading(false));
}, [loadServers, loadDiagnosticsSummary]);
// Refresh
const handleRefresh = async () => {
setRefreshing(true);
await Promise.all([loadServers(true), loadDiagnosticsSummary()]);
setRefreshing(false);
};
// Toggle expanded
const toggleExpanded = (id: string) => {
const newExpanded = new Set(expandedServers);
if (newExpanded.has(id)) {
newExpanded.delete(id);
} else {
newExpanded.add(id);
}
setExpandedServers(newExpanded);
};
// Install server
const handleInstall = async (id: string) => {
setInstallingServer(id);
try {
const result = await installLSPServer(id);
if (result.success) {
toast.success(result.data?.message || `Server ${id} installed`);
await loadServers();
} else {
toast.error(result.error || 'Installation failed');
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Installation failed');
} finally {
setInstallingServer(null);
}
};
// Start server
const handleStart = async (id: string) => {
if (!startFilePath) {
toast.error('Please provide a file path');
return;
}
setStartingServer(id);
try {
const result = await startLSPServer(id, startFilePath);
if (result.success) {
toast.success(result.data?.message || `Server ${id} started`);
setShowStartDialog(null);
setStartFilePath('');
await loadServers();
// Refresh diagnostics after a short delay
setTimeout(() => loadDiagnosticsSummary(), 1000);
} else {
toast.error(result.error || 'Start failed');
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Start failed');
} finally {
setStartingServer(null);
}
};
// Stop server
const handleStop = async (id: string) => {
setStoppingServer(id);
try {
const result = await stopLSPServer(id);
if (result.success) {
toast.success(result.data?.message || `Server ${id} stopped`);
await loadServers();
} else {
toast.error(result.error || 'Stop failed');
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Stop failed');
} finally {
setStoppingServer(null);
}
};
// Statistics
const runningServers = servers.filter((s) => s.running);
const installedServers = servers.filter((s) => s.installed && !s.running);
const notInstalledServers = servers.filter((s) => !s.installed);
// Loading skeleton
const LoadingSkeleton = () => (
<div className="space-y-3 p-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-3 p-3 bg-surface-base/50 rounded-lg">
<Skeleton className="h-4 w-4" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48" />
</div>
<Skeleton className="h-6 w-16 rounded-full" />
</div>
))}
</div>
);
// Server item component
const ServerItem = ({ server }: { server: LSPServer }) => {
const isExpanded = expandedServers.has(server.id);
const isInstalling = installingServer === server.id;
const isStopping = stoppingServer === server.id;
// Status color
const statusColor = server.running
? 'text-green-400'
: server.installed
? 'text-fg-muted'
: 'text-fg-subtle';
// Status icon
const StatusIcon = server.running ? (
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
) : server.installed ? (
<span className="w-2 h-2 rounded-full bg-gray-500" />
) : (
<span className="w-2 h-2 rounded-full bg-gray-700" />
);
return (
<motion.div
layout
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-surface-base/50 rounded-lg overflow-hidden"
>
{/* Server Header */}
<div
className={cn(
'flex items-center gap-3 p-3',
'hover:bg-surface-base/80 transition-colors cursor-pointer'
)}
onClick={() => toggleExpanded(server.id)}
>
{/* Expand Icon */}
<button className="text-fg-subtle hover:text-fg-secondary">
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
{/* Status Indicator */}
{StatusIcon}
{/* Icon */}
<Server size={16} className={statusColor} />
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-fg-secondary">{server.displayName}</span>
<span className="text-xs text-fg-subtle">({server.id})</span>
</div>
<div className="text-xs text-fg-subtle flex items-center gap-2">
<Code size={10} />
<span>{server.languages.join(', ')}</span>
</div>
</div>
{/* Status & Actions */}
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
{/* Status Badge */}
<span
className={cn(
'text-xs px-2 py-0.5 rounded-full',
server.running
? 'bg-green-500/20 text-green-400'
: server.installed
? 'bg-gray-500/20 text-gray-400'
: 'bg-gray-700/20 text-gray-500'
)}
>
{server.running ? 'Running' : server.installed ? 'Installed' : 'Not Installed'}
</span>
{/* Action Button */}
{server.running ? (
<Button
variant="ghost"
size="sm"
onClick={() => handleStop(server.id)}
disabled={isStopping}
className="text-red-400 hover:text-red-300"
title="Stop Server"
>
{isStopping ? <Loader2 size={14} className="animate-spin" /> : <Square size={14} />}
</Button>
) : server.installed ? (
<Button
variant="ghost"
size="sm"
onClick={() => setShowStartDialog(server.id)}
className="text-green-400 hover:text-green-300"
title="Start Server"
>
<Play size={14} />
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => handleInstall(server.id)}
disabled={isInstalling}
className="text-blue-400 hover:text-blue-300"
title="Install Server"
>
{isInstalling ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Download size={14} />
)}
</Button>
)}
</div>
</div>
{/* Expanded Content */}
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="px-4 pb-3 pt-1 border-t border-line/50 space-y-2">
{/* Languages */}
<div className="text-xs">
<span className="text-fg-muted">Languages:</span>{' '}
<span className="text-fg-secondary">{server.languages.join(', ')}</span>
</div>
{/* Install Command */}
{server.installCommand && (
<div className="text-xs">
<span className="text-fg-muted">Install:</span>{' '}
<code className="text-fg-secondary bg-surface-subtle px-1 rounded text-[10px]">
{server.installCommand}
</code>
</div>
)}
{/* Status Details */}
<div className="text-xs">
<span className="text-fg-muted">Status:</span>{' '}
<span className={statusColor}>
{server.running
? 'Running'
: server.installed
? 'Installed, not running'
: 'Not installed'}
</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
};
return (
<AnimatePresence>
<motion.div
variants={modalOverlay}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.2 }}
className={cn(
'fixed inset-0 bg-black/50 flex z-50',
responsive ? 'items-end md:items-center justify-center' : 'items-center justify-center'
)}
onClick={onClose}
>
<motion.div
variants={modalContent}
initial="initial"
animate="animate"
exit="exit"
transition={smoothTransition}
onClick={(e) => e.stopPropagation()}
className={cn(
'bg-surface-subtle max-h-[90vh] overflow-hidden flex flex-col',
responsive
? 'w-full md:w-full md:max-w-2xl md:mx-4 rounded-t-2xl md:rounded-lg'
: 'rounded-lg w-full max-w-2xl mx-4'
)}
>
{/* Header */}
<div
className={cn(
'flex items-center justify-between border-b border-line',
responsive ? 'px-4 md:px-6 py-4' : 'px-6 py-4'
)}
>
{responsive && (
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-10 h-1 bg-surface-emphasis rounded-full md:hidden" />
)}
<div className={cn(responsive && 'mt-2 md:mt-0')}>
<h2 className="text-lg font-semibold flex items-center gap-2">
<Server size={20} className="text-primary-400" />
Language Servers
</h2>
<p className="text-xs text-fg-subtle">
{servers.length} servers ({runningServers.length} running)
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={handleRefresh}
disabled={refreshing}
title="Refresh"
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
>
<RefreshCw size={18} className={cn(refreshing && 'animate-spin')} />
</Button>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
>
<X size={20} />
</Button>
</div>
</div>
{/* Server List */}
<div className="flex-1 overflow-y-auto">
{loading ? (
<LoadingSkeleton />
) : servers.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-fg-subtle">
<Server size={48} className="mb-4 opacity-50" />
<p className="text-center">No language servers available</p>
</div>
) : (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className={cn('space-y-4', responsive ? 'p-4' : 'p-4')}
>
{/* Running Servers */}
{runningServers.length > 0 && (
<div>
<h3 className="text-xs font-medium text-fg-muted uppercase tracking-wide mb-2 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
Running ({runningServers.length})
</h3>
<div className="space-y-2">
{runningServers.map((server) => (
<ServerItem key={server.id} server={server} />
))}
</div>
</div>
)}
{/* Installed Servers */}
{installedServers.length > 0 && (
<div>
<h3 className="text-xs font-medium text-fg-muted uppercase tracking-wide mb-2 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-gray-500" />
Installed ({installedServers.length})
</h3>
<div className="space-y-2">
{installedServers.map((server) => (
<ServerItem key={server.id} server={server} />
))}
</div>
</div>
)}
{/* Not Installed Servers */}
{notInstalledServers.length > 0 && (
<div>
<h3 className="text-xs font-medium text-fg-muted uppercase tracking-wide mb-2 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-gray-700" />
Available ({notInstalledServers.length})
</h3>
<div className="space-y-2">
{notInstalledServers.map((server) => (
<ServerItem key={server.id} server={server} />
))}
</div>
</div>
)}
</motion.div>
)}
</div>
{/* Diagnostics Summary Footer */}
<div
className={cn(
'border-t border-line',
responsive ? 'px-4 py-3 safe-area-pb' : 'px-6 py-3'
)}
>
{diagnosticsSummary ? (
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 text-sm">
<span className="text-fg-muted">Diagnostics:</span>
<span className="flex items-center gap-1 text-red-400">
<AlertCircle size={14} />
{diagnosticsSummary.totalErrors} errors
</span>
<span className="flex items-center gap-1 text-amber-400">
<AlertTriangle size={14} />
{diagnosticsSummary.totalWarnings} warnings
</span>
<span className="flex items-center gap-1 text-fg-subtle">
<Info size={14} />
{diagnosticsSummary.totalFiles} files
</span>
</div>
{onOpenDiagnostics && (
<Button variant="ghost" size="sm" onClick={onOpenDiagnostics}>
View All
</Button>
)}
</div>
) : (
<div className="text-sm text-fg-subtle">No diagnostics available</div>
)}
</div>
</motion.div>
{/* Start Server Dialog */}
<AnimatePresence>
{showStartDialog && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/70 flex items-center justify-center z-60"
onClick={() => setShowStartDialog(null)}
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
onClick={(e) => e.stopPropagation()}
className="bg-surface-subtle rounded-lg p-6 w-full max-w-md mx-4 space-y-4"
>
<h3 className="text-lg font-semibold">Start Language Server</h3>
<p className="text-sm text-fg-muted">
Provide a file path to start the language server for{' '}
<span className="text-fg-secondary font-medium">{showStartDialog}</span>
</p>
<div className="space-y-3">
<div>
<label className="text-xs text-fg-muted">File Path</label>
<Input
value={startFilePath}
onChange={(e) => setStartFilePath(e.target.value)}
placeholder="src/index.ts"
onKeyDown={(e) => {
if (e.key === 'Enter' && showStartDialog) {
handleStart(showStartDialog);
}
}}
/>
<p className="text-xs text-fg-subtle mt-1">
Enter a file path relative to the project root
</p>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={() => setShowStartDialog(null)}>
Cancel
</Button>
<Button
onClick={() => showStartDialog && handleStart(showStartDialog)}
disabled={startingServer === showStartDialog || !startFilePath}
>
{startingServer === showStartDialog ? (
<Loader2 size={14} className="animate-spin mr-2" />
) : (
<Play size={14} className="mr-2" />
)}
Start
</Button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</AnimatePresence>
);
}
+18
View File
@@ -95,6 +95,14 @@ export {
compressContext,
// File Search API
searchFiles,
// LSP API
listLSPServers,
getLSPServer,
installLSPServer,
startLSPServer,
stopLSPServer,
getRunningLSPServers,
getLSPDiagnostics,
} from './api/client.js';
// Types
@@ -180,6 +188,13 @@ export type {
QuestionOption,
Question,
QuestionMessagePart,
// LSP types
LSPServer,
FileDiagnostic,
DiagnosticsSummary,
SingleFileDiagnosticsResponse,
AllFilesDiagnosticsResponse,
DiagnosticsResponse,
} from './api/client.js';
// Primitives (shadcn/ui style)
@@ -227,6 +242,9 @@ export { Markdown } from './components/Markdown.js';
export { CodeBlock, InlineCode } from './components/CodeBlock.js';
export { SubagentProgress, SubagentProgressCompact } from './components/SubagentProgress.js';
export { AskUserQuestion } from './components/AskUserQuestion.js';
export { LSPPanel } from './components/LSPPanel.js';
export { DiagnosticsPanel } from './components/DiagnosticsPanel.js';
export { DiagnosticsIndicator, DiagnosticsIndicatorCompact } from './components/DiagnosticsIndicator.js';
// Toast function (re-export from sonner)
export { toast } from 'sonner';
+30
View File
@@ -14,6 +14,8 @@ import {
AgentsPanel,
CheckpointPanel,
ProvidersPanel,
LSPPanel,
DiagnosticsPanel,
Toaster,
ThemeProvider,
listSessions,
@@ -33,6 +35,8 @@ export function App() {
const [showAgents, setShowAgents] = useState(false);
const [showCheckpoints, setShowCheckpoints] = useState(false);
const [showProviders, setShowProviders] = useState(false);
const [showLSP, setShowLSP] = useState(false);
const [showDiagnostics, setShowDiagnostics] = useState(false);
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
const [workingDirectory, setWorkingDirectory] = useState<string>('');
@@ -147,6 +151,8 @@ export function App() {
onOpenAgents={() => setShowAgents(true)}
onOpenCheckpoints={() => setShowCheckpoints(true)}
onOpenProviders={() => setShowProviders(true)}
onOpenLSP={() => setShowLSP(true)}
onOpenDiagnostics={() => setShowDiagnostics(true)}
workingDirectory={workingDirectory}
/>
) : (
@@ -211,6 +217,30 @@ export function App() {
{/* Providers 面板 */}
{showProviders && <ProvidersPanel onClose={() => setShowProviders(false)} responsive />}
{/* LSP 面板 */}
{showLSP && (
<LSPPanel
onClose={() => setShowLSP(false)}
onOpenDiagnostics={() => {
setShowLSP(false);
setShowDiagnostics(true);
}}
responsive
/>
)}
{/* Diagnostics 面板 */}
{showDiagnostics && (
<DiagnosticsPanel
onClose={() => setShowDiagnostics(false)}
onFileClick={(file, line) => {
console.log('Navigate to:', file, line);
// TODO: Integrate with file browser or editor
}}
responsive
/>
)}
{/* 移动端底部文件按钮 */}
<button
onClick={() => setShowFileBrowser(true)}
+15 -1
View File
@@ -14,6 +14,7 @@ import {
PermissionDialog,
ContextUsage,
SubagentProgress,
DiagnosticsIndicator,
} from '@ai-assistant/ui';
interface ChatPageProps {
@@ -30,6 +31,8 @@ interface ChatPageProps {
onOpenAgents?: () => void;
onOpenCheckpoints?: () => void;
onOpenProviders?: () => void;
onOpenLSP?: () => void;
onOpenDiagnostics?: () => void;
// Working Directory
workingDirectory?: string;
}
@@ -47,6 +50,8 @@ export function ChatPage({
onOpenAgents,
onOpenCheckpoints,
onOpenProviders,
onOpenLSP,
onOpenDiagnostics,
workingDirectory,
}: ChatPageProps) {
const {
@@ -184,8 +189,17 @@ export function ChatPage({
<ConnectionStatus />
{/* 工具栏按钮 */}
{(onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks || onOpenAgents || onOpenCheckpoints || onOpenProviders) && (
{(onToggleFileBrowser || onOpenCommands || onOpenMCP || onOpenHooks || onOpenAgents || onOpenCheckpoints || onOpenProviders || onOpenLSP || onOpenDiagnostics) && (
<div className="flex items-center gap-1.5 border-l border-line-muted pl-3">
{/* LSP 诊断指示器 */}
{(onOpenLSP || onOpenDiagnostics) && (
<DiagnosticsIndicator
onClickDiagnostics={onOpenDiagnostics}
onClickLSP={onOpenLSP}
refreshInterval={30000}
/>
)}
{/* Checkpoints 按钮 */}
{onOpenCheckpoints && (
<motion.button