feat: enhance RecommendedMcpWizard with icon mapping; improve ExecutionTab accessibility; refine NavGroup path matching; update fetchSkills to include enabled status; add loading and error messages to localization

This commit is contained in:
catlog22
2026-02-04 16:38:18 +08:00
parent ac95ee3161
commit 2bfce150ec
16 changed files with 385 additions and 71 deletions

View File

@@ -3,8 +3,9 @@
// ========================================
// Multi-pane CLI output viewer with configurable layouts
// Integrates with viewerStore for state management
// Includes WebSocket integration and execution recovery
import { useEffect, useCallback, useMemo } from 'react';
import { useEffect, useCallback, useMemo, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useIntl } from 'react-intl';
import {
@@ -34,6 +35,9 @@ import {
useFocusedPaneId,
type AllotmentLayout,
} from '@/stores/viewerStore';
import { useCliStreamStore, type CliOutputLine } from '@/stores/cliStreamStore';
import { useNotificationStore, selectWsLastMessage } from '@/stores';
import { useActiveCliExecutions, useInvalidateActiveCliExecutions } from '@/hooks/useActiveCliExecutions';
// ========================================
// Types
@@ -47,6 +51,37 @@ interface LayoutOption {
labelKey: string;
}
// CLI WebSocket message types (matching CliStreamMonitorLegacy)
interface CliStreamStartedPayload {
executionId: string;
tool: string;
mode: string;
timestamp: string;
}
interface CliStreamOutputPayload {
executionId: string;
chunkType: string;
data: unknown;
unit?: {
content: unknown;
type?: string;
};
}
interface CliStreamCompletedPayload {
executionId: string;
success: boolean;
duration?: number;
timestamp: string;
}
interface CliStreamErrorPayload {
executionId: string;
error?: string;
timestamp: string;
}
// ========================================
// Constants
// ========================================
@@ -64,6 +99,18 @@ const DEFAULT_LAYOUT: LayoutType = 'split-h';
// Helper Functions
// ========================================
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours}h ${remainingMinutes}m`;
}
/**
* Detect layout type from AllotmentLayout structure
*/
@@ -131,6 +178,19 @@ export function CliViewerPage() {
const focusedPaneId = useFocusedPaneId();
const { initializeDefaultLayout, addTab, reset } = useViewerStore();
// CLI Stream Store hooks
const executions = useCliStreamStore((state) => state.executions);
// Track last processed WebSocket message to prevent duplicate processing
const lastProcessedMsgRef = useRef<unknown>(null);
// WebSocket last message from notification store
const lastMessage = useNotificationStore(selectWsLastMessage);
// Active execution sync from server
const { isLoading: isSyncing } = useActiveCliExecutions(true); // Always sync when page is open
const invalidateActive = useInvalidateActiveCliExecutions();
// Detect current layout type from store
const currentLayoutType = useMemo(() => detectLayoutType(layout), [layout]);
@@ -139,6 +199,117 @@ export function CliViewerPage() {
return Object.values(panes).reduce((count, pane) => count + pane.tabs.length, 0);
}, [panes]);
// Get execution count for display
const executionCount = useMemo(() => Object.keys(executions).length, [executions]);
const runningCount = useMemo(
() => Object.values(executions).filter(e => e.status === 'running').length,
[executions]
);
// Handle WebSocket messages for CLI stream (same logic as CliStreamMonitorLegacy)
useEffect(() => {
if (!lastMessage || lastMessage === lastProcessedMsgRef.current) return;
lastProcessedMsgRef.current = lastMessage;
const { type, payload } = lastMessage;
if (type === 'CLI_STARTED') {
const p = payload as CliStreamStartedPayload;
const startTime = p.timestamp ? new Date(p.timestamp).getTime() : Date.now();
useCliStreamStore.getState().upsertExecution(p.executionId, {
tool: p.tool || 'cli',
mode: p.mode || 'analysis',
status: 'running',
startTime,
output: [
{
type: 'system',
content: `[${new Date(startTime).toLocaleTimeString()}] CLI execution started: ${p.tool} (${p.mode} mode)`,
timestamp: startTime
}
]
});
invalidateActive();
} else if (type === 'CLI_OUTPUT') {
const p = payload as CliStreamOutputPayload;
const unitContent = p.unit?.content;
const unitType = p.unit?.type || p.chunkType;
let content: string;
if (unitType === 'tool_call' && typeof unitContent === 'object' && unitContent !== null) {
const toolCall = unitContent as { action?: string; toolName?: string; parameters?: unknown; status?: string; output?: string };
if (toolCall.action === 'invoke') {
const params = toolCall.parameters ? JSON.stringify(toolCall.parameters) : '';
content = `[Tool] ${toolCall.toolName}(${params})`;
} else if (toolCall.action === 'result') {
const status = toolCall.status || 'unknown';
const output = toolCall.output ? `: ${toolCall.output.substring(0, 200)}${toolCall.output.length > 200 ? '...' : ''}` : '';
content = `[Tool Result] ${status}${output}`;
} else {
content = JSON.stringify(unitContent);
}
} else {
content = typeof p.data === 'string' ? p.data : JSON.stringify(p.data);
}
const lines = content.split('\n');
const addOutput = useCliStreamStore.getState().addOutput;
lines.forEach(line => {
if (line.trim() || lines.length === 1) {
addOutput(p.executionId, {
type: (unitType as CliOutputLine['type']) || 'stdout',
content: line,
timestamp: Date.now()
});
}
});
} else if (type === 'CLI_COMPLETED') {
const p = payload as CliStreamCompletedPayload;
const endTime = p.timestamp ? new Date(p.timestamp).getTime() : Date.now();
useCliStreamStore.getState().upsertExecution(p.executionId, {
status: p.success ? 'completed' : 'error',
endTime,
output: [
{
type: 'system',
content: `[${new Date(endTime).toLocaleTimeString()}] CLI execution ${p.success ? 'completed successfully' : 'failed'}${p.duration ? ` (${formatDuration(p.duration)})` : ''}`,
timestamp: endTime
}
]
});
invalidateActive();
} else if (type === 'CLI_ERROR') {
const p = payload as CliStreamErrorPayload;
const endTime = p.timestamp ? new Date(p.timestamp).getTime() : Date.now();
useCliStreamStore.getState().upsertExecution(p.executionId, {
status: 'error',
endTime,
output: [
{
type: 'stderr',
content: `[ERROR] ${p.error || 'Unknown error occurred'}`,
timestamp: endTime
}
]
});
invalidateActive();
}
}, [lastMessage, invalidateActive]);
// Auto-add new executions as tabs when they appear
const addedExecutionsRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!focusedPaneId) return;
for (const executionId of Object.keys(executions)) {
if (!addedExecutionsRef.current.has(executionId)) {
addedExecutionsRef.current.add(executionId);
const exec = executions[executionId];
const toolShort = exec.tool.split('-')[0];
addTab(focusedPaneId, executionId, `${toolShort} (${exec.mode})`);
}
}
}, [executions, focusedPaneId, addTab]);
// Initialize layout if empty
useEffect(() => {
const paneCount = countPanes(layout);
@@ -192,14 +363,23 @@ export function CliViewerPage() {
<div className="flex items-center gap-2 min-w-0">
<Terminal className="w-5 h-5 text-primary flex-shrink-0" />
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium text-foreground">
{formatMessage({ id: 'cliViewer.page.title' })}
</span>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">
{formatMessage({ id: 'cliViewer.page.title' })}
</span>
{runningCount > 0 && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-green-500/10 text-green-600 dark:text-green-400 text-xs font-medium">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
{runningCount} active
</span>
)}
</div>
<span className="text-xs text-muted-foreground">
{formatMessage(
{ id: 'cliViewer.page.subtitle' },
{ count: activeSessionCount }
)}
{executionCount > 0 && ` · ${executionCount} executions`}
</span>
</div>
</div>