mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
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:
@@ -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>
|
||||
|
||||
@@ -240,18 +240,31 @@ function NewMemoryDialog({
|
||||
|
||||
// Initialize from editing memory metadata
|
||||
useEffect(() => {
|
||||
if (editingMemory && editingMemory.metadata) {
|
||||
try {
|
||||
const metadata = typeof editingMemory.metadata === 'string'
|
||||
? JSON.parse(editingMemory.metadata)
|
||||
: editingMemory.metadata;
|
||||
setIsFavorite(metadata.favorite === true);
|
||||
setPriority(metadata.priority || 'medium');
|
||||
} catch {
|
||||
if (editingMemory) {
|
||||
// Sync content and tags
|
||||
setContent(editingMemory.content || '');
|
||||
setTagsInput(editingMemory.tags?.join(', ') || '');
|
||||
|
||||
// Sync metadata
|
||||
if (editingMemory.metadata) {
|
||||
try {
|
||||
const metadata = typeof editingMemory.metadata === 'string'
|
||||
? JSON.parse(editingMemory.metadata)
|
||||
: editingMemory.metadata;
|
||||
setIsFavorite(metadata.favorite === true);
|
||||
setPriority(metadata.priority || 'medium');
|
||||
} catch {
|
||||
setIsFavorite(false);
|
||||
setPriority('medium');
|
||||
}
|
||||
} else {
|
||||
setIsFavorite(false);
|
||||
setPriority('medium');
|
||||
}
|
||||
} else {
|
||||
// New mode: reset all state
|
||||
setContent('');
|
||||
setTagsInput('');
|
||||
setIsFavorite(false);
|
||||
setPriority('medium');
|
||||
}
|
||||
@@ -410,7 +423,7 @@ export function MemoryPage() {
|
||||
await updateMemory(editingMemory.id, data);
|
||||
setEditingMemory(null);
|
||||
} else {
|
||||
await createMemory(data as any); // TODO: update createMemory type to accept metadata
|
||||
await createMemory(data);
|
||||
}
|
||||
setIsNewMemoryOpen(false);
|
||||
};
|
||||
@@ -427,19 +440,44 @@ export function MemoryPage() {
|
||||
};
|
||||
|
||||
const handleToggleFavorite = async (memory: CoreMemory) => {
|
||||
const currentMetadata = memory.metadata ? (typeof memory.metadata === 'string' ? JSON.parse(memory.metadata) : memory.metadata) : {};
|
||||
const newFavorite = !(currentMetadata.favorite === true);
|
||||
await updateMemory(memory.id, {
|
||||
metadata: JSON.stringify({ ...currentMetadata, favorite: newFavorite }),
|
||||
} as any); // TODO: update updateMemory to accept metadata field
|
||||
try {
|
||||
const currentMetadata = memory.metadata
|
||||
? (typeof memory.metadata === 'string' ? JSON.parse(memory.metadata) : memory.metadata)
|
||||
: {};
|
||||
const newFavorite = !(currentMetadata.favorite === true);
|
||||
await updateMemory(memory.id, {
|
||||
content: memory.content,
|
||||
metadata: { ...currentMetadata, favorite: newFavorite },
|
||||
});
|
||||
toast.success(
|
||||
formatMessage({
|
||||
id: newFavorite ? 'memory.actions.favoriteAdded' : 'memory.actions.favoriteRemoved',
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle favorite:', err);
|
||||
toast.error(formatMessage({ id: 'memory.actions.favoriteError' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async (memory: CoreMemory) => {
|
||||
await archiveMemory(memory.id);
|
||||
try {
|
||||
await archiveMemory(memory.id);
|
||||
toast.success(formatMessage({ id: 'memory.actions.archiveSuccess' }));
|
||||
} catch (err) {
|
||||
console.error('Failed to archive:', err);
|
||||
toast.error(formatMessage({ id: 'memory.actions.archiveError' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnarchive = async (memory: CoreMemory) => {
|
||||
await unarchiveMemory(memory.id);
|
||||
try {
|
||||
await unarchiveMemory(memory.id);
|
||||
toast.success(formatMessage({ id: 'memory.actions.unarchiveSuccess' }));
|
||||
} catch (err) {
|
||||
console.error('Failed to unarchive:', err);
|
||||
toast.error(formatMessage({ id: 'memory.actions.unarchiveError' }));
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async (content: string) => {
|
||||
|
||||
@@ -122,8 +122,6 @@ export function SkillsManagerPage() {
|
||||
const {
|
||||
skills,
|
||||
categories,
|
||||
totalCount,
|
||||
enabledCount,
|
||||
projectSkills,
|
||||
userSkills,
|
||||
isLoading,
|
||||
@@ -141,9 +139,6 @@ export function SkillsManagerPage() {
|
||||
|
||||
const { toggleSkill, isToggling } = useSkillMutations();
|
||||
|
||||
// Calculate disabled count
|
||||
const disabledCount = totalCount - enabledCount;
|
||||
|
||||
// Filter skills based on enabled filter
|
||||
const filteredSkills = useMemo(() => {
|
||||
if (enabledFilter === 'disabled') {
|
||||
@@ -152,10 +147,32 @@ export function SkillsManagerPage() {
|
||||
return skills;
|
||||
}, [skills, enabledFilter]);
|
||||
|
||||
// Calculate counts based on current location filter (from skills, not allSkills)
|
||||
const currentLocationEnabledCount = useMemo(() => skills.filter(s => s.enabled).length, [skills]);
|
||||
const currentLocationTotalCount = skills.length;
|
||||
const currentLocationDisabledCount = currentLocationTotalCount - currentLocationEnabledCount;
|
||||
|
||||
const handleToggle = async (skill: Skill, enabled: boolean) => {
|
||||
// Use the skill's location property
|
||||
const location = skill.location || 'project';
|
||||
await toggleSkill(skill.name, enabled, location);
|
||||
// Use folderName for API calls (actual folder name), fallback to name if not available
|
||||
const skillIdentifier = skill.folderName || skill.name;
|
||||
|
||||
// Debug logging
|
||||
console.log('[SkillToggle] Toggling skill:', {
|
||||
name: skill.name,
|
||||
folderName: skill.folderName,
|
||||
location,
|
||||
enabled,
|
||||
skillIdentifier
|
||||
});
|
||||
|
||||
try {
|
||||
await toggleSkill(skillIdentifier, enabled, location);
|
||||
} catch (error) {
|
||||
console.error('[SkillToggle] Toggle failed:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleWithConfirm = (skill: Skill, enabled: boolean) => {
|
||||
@@ -244,21 +261,21 @@ export function SkillsManagerPage() {
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-primary" />
|
||||
<span className="text-2xl font-bold">{totalCount}</span>
|
||||
<span className="text-2xl font-bold">{currentLocationTotalCount}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'common.stats.totalSkills' })}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Power className="w-5 h-5 text-success" />
|
||||
<span className="text-2xl font-bold">{enabledCount}</span>
|
||||
<span className="text-2xl font-bold">{currentLocationEnabledCount}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'skills.state.enabled' })}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<PowerOff className="w-5 h-5 text-muted-foreground" />
|
||||
<span className="text-2xl font-bold">{totalCount - enabledCount}</span>
|
||||
<span className="text-2xl font-bold">{currentLocationDisabledCount}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'skills.state.disabled' })}</p>
|
||||
</Card>
|
||||
@@ -327,7 +344,7 @@ export function SkillsManagerPage() {
|
||||
className={enabledFilter === 'all' ? 'bg-primary text-primary-foreground' : ''}
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'skills.filters.all' })} ({totalCount})
|
||||
{formatMessage({ id: 'skills.filters.all' })} ({currentLocationTotalCount})
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -336,7 +353,7 @@ export function SkillsManagerPage() {
|
||||
className={enabledFilter === 'enabled' ? 'bg-primary text-primary-foreground' : ''}
|
||||
>
|
||||
<Power className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'skills.state.enabled' })} ({enabledCount})
|
||||
{formatMessage({ id: 'skills.state.enabled' })} ({currentLocationEnabledCount})
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -345,7 +362,7 @@ export function SkillsManagerPage() {
|
||||
className={enabledFilter === 'disabled' ? 'bg-primary text-primary-foreground' : ''}
|
||||
>
|
||||
<PowerOff className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'skills.state.disabled' })} ({disabledCount})
|
||||
{formatMessage({ id: 'skills.state.disabled' })} ({currentLocationDisabledCount})
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
@@ -364,12 +381,12 @@ export function SkillsManagerPage() {
|
||||
isLoading={isLoading}
|
||||
onToggle={handleToggleWithConfirm}
|
||||
onClick={handleSkillClick}
|
||||
isToggling={isToggling}
|
||||
isToggling={isToggling || !!confirmDisable}
|
||||
compact={viewMode === 'compact'}
|
||||
/>
|
||||
|
||||
{/* Disabled Skills Section */}
|
||||
{enabledFilter === 'all' && disabledCount > 0 && (
|
||||
{enabledFilter === 'all' && currentLocationDisabledCount > 0 && (
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -378,7 +395,7 @@ export function SkillsManagerPage() {
|
||||
>
|
||||
{showDisabledSection ? <ChevronDown className="w-4 h-4 mr-2" /> : <ChevronRight className="w-4 h-4 mr-2" />}
|
||||
<EyeOff className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'skills.disabledSkills.title' })} ({disabledCount})
|
||||
{formatMessage({ id: 'skills.disabledSkills.title' })} ({currentLocationDisabledCount})
|
||||
</Button>
|
||||
|
||||
{showDisabledSection && (
|
||||
@@ -387,7 +404,7 @@ export function SkillsManagerPage() {
|
||||
isLoading={false}
|
||||
onToggle={handleToggleWithConfirm}
|
||||
onClick={handleSkillClick}
|
||||
isToggling={isToggling}
|
||||
isToggling={isToggling || !!confirmDisable}
|
||||
compact={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user