mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-11 02:33:51 +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:
@@ -7,6 +7,7 @@ import { QueryClientProvider } from '@tanstack/react-query';
|
|||||||
import { RouterProvider } from 'react-router-dom';
|
import { RouterProvider } from 'react-router-dom';
|
||||||
import { IntlProvider } from 'react-intl';
|
import { IntlProvider } from 'react-intl';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
import { Toaster } from 'sonner';
|
||||||
import { router } from './router';
|
import { router } from './router';
|
||||||
import queryClient from './lib/query-client';
|
import queryClient from './lib/query-client';
|
||||||
import type { Locale } from './lib/i18n';
|
import type { Locale } from './lib/i18n';
|
||||||
@@ -29,6 +30,7 @@ function App({ locale, messages }: AppProps) {
|
|||||||
<QueryInvalidator />
|
<QueryInvalidator />
|
||||||
<CliExecutionSync />
|
<CliExecutionSync />
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
|
<Toaster richColors position="top-right" />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Download, Loader2, X } from 'lucide-react';
|
import { Download, Loader2, Search, Globe, Sparkles, Settings } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Label } from '@/components/ui/Label';
|
import { Label } from '@/components/ui/Label';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
import {
|
import {
|
||||||
addGlobalMcpServer,
|
addGlobalMcpServer,
|
||||||
copyMcpServerToProject,
|
copyMcpServerToProject,
|
||||||
@@ -27,6 +26,14 @@ import { mcpServersKeys } from '@/hooks';
|
|||||||
import { useNotifications } from '@/hooks/useNotifications';
|
import { useNotifications } from '@/hooks/useNotifications';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Icon map for MCP definitions
|
||||||
|
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||||
|
'search-code': Search,
|
||||||
|
'chrome': Globe,
|
||||||
|
'globe-2': Sparkles,
|
||||||
|
'code-2': Settings,
|
||||||
|
};
|
||||||
|
|
||||||
// ========== Types ==========
|
// ========== Types ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -207,6 +214,7 @@ export function RecommendedMcpWizard({
|
|||||||
if (!mcpDefinition) return null;
|
if (!mcpDefinition) return null;
|
||||||
|
|
||||||
const hasFields = mcpDefinition.fields.length > 0;
|
const hasFields = mcpDefinition.fields.length > 0;
|
||||||
|
const Icon = ICON_MAP[mcpDefinition.icon] || Settings;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
@@ -215,7 +223,7 @@ export function RecommendedMcpWizard({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center">
|
<div className="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||||
<i className={cn('lucide w-5 h-5 text-primary', `lucide-${mcpDefinition.icon}`)} />
|
<Icon className="w-5 h-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// ========================================
|
// ========================================
|
||||||
// Redesigned CLI streaming monitor with smart parsing and message-based layout
|
// Redesigned CLI streaming monitor with smart parsing and message-based layout
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import {
|
import {
|
||||||
Terminal,
|
Terminal,
|
||||||
@@ -220,9 +220,14 @@ export function CliStreamMonitorNew({ isOpen, onClose }: CliStreamMonitorNewProp
|
|||||||
// WebSocket last message
|
// WebSocket last message
|
||||||
const lastMessage = useNotificationStore(selectWsLastMessage);
|
const lastMessage = useNotificationStore(selectWsLastMessage);
|
||||||
|
|
||||||
|
// Track last processed WebSocket message to prevent duplicate processing
|
||||||
|
const lastProcessedMsgRef = useRef<unknown>(null);
|
||||||
|
|
||||||
// Handle WebSocket messages (same as original)
|
// Handle WebSocket messages (same as original)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastMessage) return;
|
// Skip if no message or same message already processed (prevents React strict mode double-execution)
|
||||||
|
if (!lastMessage || lastMessage === lastProcessedMsgRef.current) return;
|
||||||
|
lastProcessedMsgRef.current = lastMessage;
|
||||||
|
|
||||||
const { type, payload } = lastMessage;
|
const { type, payload } = lastMessage;
|
||||||
|
|
||||||
|
|||||||
@@ -58,13 +58,21 @@ export function ExecutionTab({ execution, isActive, onClick, onClose }: Executio
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Close button - show on hover */}
|
{/* Close button - show on hover */}
|
||||||
<button
|
<span
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="ml-0.5 p-0.5 rounded hover:bg-rose-500/20 transition-opacity opacity-0 group-hover:opacity-100"
|
className="ml-0.5 p-0.5 rounded hover:bg-rose-500/20 transition-opacity opacity-0 group-hover:opacity-100 cursor-pointer"
|
||||||
aria-label="Close execution tab"
|
aria-label="Close execution tab"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose(e as any);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<X className="h-2.5 w-2.5 text-rose-600 dark:text-rose-400" />
|
<X className="h-2.5 w-2.5 text-rose-600 dark:text-rose-400" />
|
||||||
</button>
|
</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
ArrowDownToLine,
|
ArrowDownToLine,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
ExternalLink,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
@@ -196,6 +198,7 @@ export interface CliStreamMonitorProps {
|
|||||||
|
|
||||||
export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
|
export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
|
const navigate = useNavigate();
|
||||||
const logsEndRef = useRef<HTMLDivElement>(null);
|
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||||
const logsContainerRef = useRef<HTMLDivElement>(null);
|
const logsContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -206,6 +209,9 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
|
|||||||
// Track last output length to detect new output
|
// Track last output length to detect new output
|
||||||
const lastOutputLengthRef = useRef<Record<string, number>>({});
|
const lastOutputLengthRef = useRef<Record<string, number>>({});
|
||||||
|
|
||||||
|
// Track last processed WebSocket message to prevent duplicate processing
|
||||||
|
const lastProcessedMsgRef = useRef<unknown>(null);
|
||||||
|
|
||||||
// Store state
|
// Store state
|
||||||
const executions = useCliStreamStore((state) => state.executions);
|
const executions = useCliStreamStore((state) => state.executions);
|
||||||
const currentExecutionId = useCliStreamStore((state) => state.currentExecutionId);
|
const currentExecutionId = useCliStreamStore((state) => state.currentExecutionId);
|
||||||
@@ -222,7 +228,9 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
|
|||||||
|
|
||||||
// Handle WebSocket messages for CLI stream
|
// Handle WebSocket messages for CLI stream
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastMessage) return;
|
// Skip if no message or same message already processed (prevents React strict mode double-execution)
|
||||||
|
if (!lastMessage || lastMessage === lastProcessedMsgRef.current) return;
|
||||||
|
lastProcessedMsgRef.current = lastMessage;
|
||||||
|
|
||||||
const { type, payload } = lastMessage;
|
const { type, payload } = lastMessage;
|
||||||
|
|
||||||
@@ -377,6 +385,15 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
|
|||||||
setCurrentExecution(null);
|
setCurrentExecution(null);
|
||||||
}, [markExecutionClosedByUser, removeExecution, executions, setCurrentExecution]);
|
}, [markExecutionClosedByUser, removeExecution, executions, setCurrentExecution]);
|
||||||
|
|
||||||
|
// Open in full page viewer
|
||||||
|
const handlePopOut = useCallback(() => {
|
||||||
|
const url = currentExecutionId
|
||||||
|
? `/cli-viewer?executionId=${currentExecutionId}`
|
||||||
|
: '/cli-viewer';
|
||||||
|
navigate(url);
|
||||||
|
onClose();
|
||||||
|
}, [currentExecutionId, navigate, onClose]);
|
||||||
|
|
||||||
// ESC key to close
|
// ESC key to close
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEsc = (e: KeyboardEvent) => {
|
const handleEsc = (e: KeyboardEvent) => {
|
||||||
@@ -507,6 +524,14 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
|
|||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handlePopOut}
|
||||||
|
title="Open in full page viewer"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -636,17 +661,6 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
|
|||||||
<div ref={logsEndRef} />
|
<div ref={logsEndRef} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isUserScrolling && filteredOutput.length > 0 && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
className="absolute bottom-4 right-4"
|
|
||||||
onClick={scrollToBottom}
|
|
||||||
>
|
|
||||||
<ArrowDownToLine className="h-4 w-4 mr-1" />
|
|
||||||
Scroll to bottom
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,9 +54,10 @@ export function NavGroup({
|
|||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const ItemIcon = item.icon;
|
const ItemIcon = item.icon;
|
||||||
const [basePath] = item.path.split('?');
|
const [basePath] = item.path.split('?');
|
||||||
|
// More precise matching: exact match or basePath followed by '/' to avoid parent/child conflicts
|
||||||
const isActive =
|
const isActive =
|
||||||
location.pathname === basePath ||
|
location.pathname === basePath ||
|
||||||
(basePath !== '/' && location.pathname.startsWith(basePath));
|
(basePath !== '/' && location.pathname.startsWith(basePath + '/'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -93,9 +94,10 @@ export function NavGroup({
|
|||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const ItemIcon = item.icon;
|
const ItemIcon = item.icon;
|
||||||
const [basePath, searchParams] = item.path.split('?');
|
const [basePath, searchParams] = item.path.split('?');
|
||||||
|
// More precise matching: exact match or basePath followed by '/' to avoid parent/child conflicts
|
||||||
const isActive =
|
const isActive =
|
||||||
location.pathname === basePath ||
|
location.pathname === basePath ||
|
||||||
(basePath !== '/' && location.pathname.startsWith(basePath));
|
(basePath !== '/' && location.pathname.startsWith(basePath + '/'));
|
||||||
const isQueryParamActive =
|
const isQueryParamActive =
|
||||||
searchParams && location.search.includes(searchParams);
|
searchParams && location.search.includes(searchParams);
|
||||||
|
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export function useMemory(options: UseMemoryOptions = {}): UseMemoryReturn {
|
|||||||
// ========== Mutations ==========
|
// ========== Mutations ==========
|
||||||
|
|
||||||
export interface UseCreateMemoryReturn {
|
export interface UseCreateMemoryReturn {
|
||||||
createMemory: (input: { content: string; tags?: string[] }) => Promise<CoreMemory>;
|
createMemory: (input: { content: string; tags?: string[]; metadata?: Record<string, any> }) => Promise<CoreMemory>;
|
||||||
isCreating: boolean;
|
isCreating: boolean;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
}
|
}
|
||||||
@@ -160,7 +160,8 @@ export function useCreateMemory(): UseCreateMemoryReturn {
|
|||||||
const projectPath = useWorkflowStore(selectProjectPath);
|
const projectPath = useWorkflowStore(selectProjectPath);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (input: { content: string; tags?: string[] }) => createMemory(input, projectPath),
|
mutationFn: (input: { content: string; tags?: string[]; metadata?: Record<string, any> }) =>
|
||||||
|
createMemory(input, projectPath),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Invalidate memory cache to trigger refetch
|
// Invalidate memory cache to trigger refetch
|
||||||
queryClient.invalidateQueries({ queryKey: projectPath ? workspaceQueryKeys.memory(projectPath) : ['memory'] });
|
queryClient.invalidateQueries({ queryKey: projectPath ? workspaceQueryKeys.memory(projectPath) : ['memory'] });
|
||||||
|
|||||||
@@ -993,18 +993,19 @@ export interface SkillsResponse {
|
|||||||
* @param projectPath - Optional project path to filter data by workspace
|
* @param projectPath - Optional project path to filter data by workspace
|
||||||
*/
|
*/
|
||||||
export async function fetchSkills(projectPath?: string): Promise<SkillsResponse> {
|
export async function fetchSkills(projectPath?: string): Promise<SkillsResponse> {
|
||||||
// Helper to add location to skills
|
// Helper to add location and enabled status to skills
|
||||||
const addLocation = (skills: Skill[], location: 'project' | 'user'): Skill[] =>
|
// Backend only returns enabled skills (with SKILL.md), so we set enabled: true
|
||||||
skills.map(skill => ({ ...skill, location }));
|
const addMetadata = (skills: Skill[], location: 'project' | 'user'): Skill[] =>
|
||||||
|
skills.map(skill => ({ ...skill, location, enabled: true }));
|
||||||
|
|
||||||
// Try with project path first, fall back to global on 403/404
|
// Try with project path first, fall back to global on 403/404
|
||||||
if (projectPath) {
|
if (projectPath) {
|
||||||
try {
|
try {
|
||||||
const url = `/api/skills?path=${encodeURIComponent(projectPath)}`;
|
const url = `/api/skills?path=${encodeURIComponent(projectPath)}`;
|
||||||
const data = await fetchApi<{ skills?: Skill[]; projectSkills?: Skill[]; userSkills?: Skill[] }>(url);
|
const data = await fetchApi<{ skills?: Skill[]; projectSkills?: Skill[]; userSkills?: Skill[] }>(url);
|
||||||
const projectSkillsWithLocation = addLocation(data.projectSkills ?? [], 'project');
|
const projectSkillsWithMetadata = addMetadata(data.projectSkills ?? [], 'project');
|
||||||
const userSkillsWithLocation = addLocation(data.userSkills ?? [], 'user');
|
const userSkillsWithMetadata = addMetadata(data.userSkills ?? [], 'user');
|
||||||
const allSkills = [...projectSkillsWithLocation, ...userSkillsWithLocation];
|
const allSkills = [...projectSkillsWithMetadata, ...userSkillsWithMetadata];
|
||||||
return {
|
return {
|
||||||
skills: data.skills ?? allSkills,
|
skills: data.skills ?? allSkills,
|
||||||
};
|
};
|
||||||
@@ -1020,9 +1021,9 @@ export async function fetchSkills(projectPath?: string): Promise<SkillsResponse>
|
|||||||
}
|
}
|
||||||
// Fallback: fetch global skills
|
// Fallback: fetch global skills
|
||||||
const data = await fetchApi<{ skills?: Skill[]; projectSkills?: Skill[]; userSkills?: Skill[] }>('/api/skills');
|
const data = await fetchApi<{ skills?: Skill[]; projectSkills?: Skill[]; userSkills?: Skill[] }>('/api/skills');
|
||||||
const projectSkillsWithLocation = addLocation(data.projectSkills ?? [], 'project');
|
const projectSkillsWithMetadata = addMetadata(data.projectSkills ?? [], 'project');
|
||||||
const userSkillsWithLocation = addLocation(data.userSkills ?? [], 'user');
|
const userSkillsWithMetadata = addMetadata(data.userSkills ?? [], 'user');
|
||||||
const allSkills = [...projectSkillsWithLocation, ...userSkillsWithLocation];
|
const allSkills = [...projectSkillsWithMetadata, ...userSkillsWithMetadata];
|
||||||
return {
|
return {
|
||||||
skills: data.skills ?? allSkills,
|
skills: data.skills ?? allSkills,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -224,6 +224,8 @@
|
|||||||
"all": "All",
|
"all": "All",
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
"no": "No",
|
"no": "No",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"error": "Error",
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"header": {
|
"header": {
|
||||||
"brand": "CCW Dashboard"
|
"brand": "CCW Dashboard"
|
||||||
|
|||||||
@@ -10,7 +10,14 @@
|
|||||||
"copyError": "Failed to copy",
|
"copyError": "Failed to copy",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"expand": "Expand",
|
"expand": "Expand",
|
||||||
"collapse": "Collapse"
|
"collapse": "Collapse",
|
||||||
|
"favoriteAdded": "Added to favorites",
|
||||||
|
"favoriteRemoved": "Removed from favorites",
|
||||||
|
"favoriteError": "Failed to update favorite status",
|
||||||
|
"archiveSuccess": "Memory archived",
|
||||||
|
"archiveError": "Failed to archive memory",
|
||||||
|
"unarchiveSuccess": "Memory restored",
|
||||||
|
"unarchiveError": "Failed to restore memory"
|
||||||
},
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"memories": "Memories",
|
"memories": "Memories",
|
||||||
|
|||||||
@@ -218,6 +218,8 @@
|
|||||||
"all": "全部",
|
"all": "全部",
|
||||||
"yes": "是",
|
"yes": "是",
|
||||||
"no": "否",
|
"no": "否",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"error": "错误",
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"header": {
|
"header": {
|
||||||
"brand": "CCW 仪表板"
|
"brand": "CCW 仪表板"
|
||||||
|
|||||||
@@ -10,7 +10,14 @@
|
|||||||
"copyError": "复制失败",
|
"copyError": "复制失败",
|
||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
"expand": "展开",
|
"expand": "展开",
|
||||||
"collapse": "收起"
|
"collapse": "收起",
|
||||||
|
"favoriteAdded": "已添加到收藏",
|
||||||
|
"favoriteRemoved": "已从收藏移除",
|
||||||
|
"favoriteError": "更新收藏状态失败",
|
||||||
|
"archiveSuccess": "记忆已归档",
|
||||||
|
"archiveError": "归档记忆失败",
|
||||||
|
"unarchiveSuccess": "记忆已恢复",
|
||||||
|
"unarchiveError": "恢复记忆失败"
|
||||||
},
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"memories": "记忆",
|
"memories": "记忆",
|
||||||
|
|||||||
@@ -7,10 +7,30 @@ import 'react-resizable/css/styles.css'
|
|||||||
import { initMessages, getInitialLocale, getMessages, type Locale } from './lib/i18n'
|
import { initMessages, getInitialLocale, getMessages, type Locale } from './lib/i18n'
|
||||||
import { logWebVitals } from './lib/webVitals'
|
import { logWebVitals } from './lib/webVitals'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize CSRF token by fetching from backend
|
||||||
|
* This ensures the CSRF cookie is set before any mutating API calls
|
||||||
|
*/
|
||||||
|
async function initCsrfToken() {
|
||||||
|
try {
|
||||||
|
// Fetch CSRF token from backend - this sets the XSRF-TOKEN cookie
|
||||||
|
await fetch('/api/csrf-token', {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
// Log error but don't block app initialization
|
||||||
|
console.error('Failed to initialize CSRF token:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function bootstrapApplication() {
|
async function bootstrapApplication() {
|
||||||
const rootElement = document.getElementById('root')
|
const rootElement = document.getElementById('root')
|
||||||
if (!rootElement) throw new Error('Failed to find the root element')
|
if (!rootElement) throw new Error('Failed to find the root element')
|
||||||
|
|
||||||
|
// Initialize CSRF token before any API calls
|
||||||
|
await initCsrfToken()
|
||||||
|
|
||||||
// Initialize translation messages
|
// Initialize translation messages
|
||||||
await initMessages()
|
await initMessages()
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
// ========================================
|
// ========================================
|
||||||
// Multi-pane CLI output viewer with configurable layouts
|
// Multi-pane CLI output viewer with configurable layouts
|
||||||
// Integrates with viewerStore for state management
|
// 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 { useSearchParams } from 'react-router-dom';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +35,9 @@ import {
|
|||||||
useFocusedPaneId,
|
useFocusedPaneId,
|
||||||
type AllotmentLayout,
|
type AllotmentLayout,
|
||||||
} from '@/stores/viewerStore';
|
} from '@/stores/viewerStore';
|
||||||
|
import { useCliStreamStore, type CliOutputLine } from '@/stores/cliStreamStore';
|
||||||
|
import { useNotificationStore, selectWsLastMessage } from '@/stores';
|
||||||
|
import { useActiveCliExecutions, useInvalidateActiveCliExecutions } from '@/hooks/useActiveCliExecutions';
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// Types
|
// Types
|
||||||
@@ -47,6 +51,37 @@ interface LayoutOption {
|
|||||||
labelKey: string;
|
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
|
// Constants
|
||||||
// ========================================
|
// ========================================
|
||||||
@@ -64,6 +99,18 @@ const DEFAULT_LAYOUT: LayoutType = 'split-h';
|
|||||||
// Helper Functions
|
// 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
|
* Detect layout type from AllotmentLayout structure
|
||||||
*/
|
*/
|
||||||
@@ -131,6 +178,19 @@ export function CliViewerPage() {
|
|||||||
const focusedPaneId = useFocusedPaneId();
|
const focusedPaneId = useFocusedPaneId();
|
||||||
const { initializeDefaultLayout, addTab, reset } = useViewerStore();
|
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
|
// Detect current layout type from store
|
||||||
const currentLayoutType = useMemo(() => detectLayoutType(layout), [layout]);
|
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);
|
return Object.values(panes).reduce((count, pane) => count + pane.tabs.length, 0);
|
||||||
}, [panes]);
|
}, [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
|
// Initialize layout if empty
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const paneCount = countPanes(layout);
|
const paneCount = countPanes(layout);
|
||||||
@@ -192,14 +363,23 @@ export function CliViewerPage() {
|
|||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Terminal className="w-5 h-5 text-primary flex-shrink-0" />
|
<Terminal className="w-5 h-5 text-primary flex-shrink-0" />
|
||||||
<div className="flex flex-col min-w-0">
|
<div className="flex flex-col min-w-0">
|
||||||
<span className="text-sm font-medium text-foreground">
|
<div className="flex items-center gap-2">
|
||||||
{formatMessage({ id: 'cliViewer.page.title' })}
|
<span className="text-sm font-medium text-foreground">
|
||||||
</span>
|
{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">
|
<span className="text-xs text-muted-foreground">
|
||||||
{formatMessage(
|
{formatMessage(
|
||||||
{ id: 'cliViewer.page.subtitle' },
|
{ id: 'cliViewer.page.subtitle' },
|
||||||
{ count: activeSessionCount }
|
{ count: activeSessionCount }
|
||||||
)}
|
)}
|
||||||
|
{executionCount > 0 && ` · ${executionCount} executions`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -240,18 +240,31 @@ function NewMemoryDialog({
|
|||||||
|
|
||||||
// Initialize from editing memory metadata
|
// Initialize from editing memory metadata
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingMemory && editingMemory.metadata) {
|
if (editingMemory) {
|
||||||
try {
|
// Sync content and tags
|
||||||
const metadata = typeof editingMemory.metadata === 'string'
|
setContent(editingMemory.content || '');
|
||||||
? JSON.parse(editingMemory.metadata)
|
setTagsInput(editingMemory.tags?.join(', ') || '');
|
||||||
: editingMemory.metadata;
|
|
||||||
setIsFavorite(metadata.favorite === true);
|
// Sync metadata
|
||||||
setPriority(metadata.priority || 'medium');
|
if (editingMemory.metadata) {
|
||||||
} catch {
|
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);
|
setIsFavorite(false);
|
||||||
setPriority('medium');
|
setPriority('medium');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// New mode: reset all state
|
||||||
|
setContent('');
|
||||||
|
setTagsInput('');
|
||||||
setIsFavorite(false);
|
setIsFavorite(false);
|
||||||
setPriority('medium');
|
setPriority('medium');
|
||||||
}
|
}
|
||||||
@@ -410,7 +423,7 @@ export function MemoryPage() {
|
|||||||
await updateMemory(editingMemory.id, data);
|
await updateMemory(editingMemory.id, data);
|
||||||
setEditingMemory(null);
|
setEditingMemory(null);
|
||||||
} else {
|
} else {
|
||||||
await createMemory(data as any); // TODO: update createMemory type to accept metadata
|
await createMemory(data);
|
||||||
}
|
}
|
||||||
setIsNewMemoryOpen(false);
|
setIsNewMemoryOpen(false);
|
||||||
};
|
};
|
||||||
@@ -427,19 +440,44 @@ export function MemoryPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleFavorite = async (memory: CoreMemory) => {
|
const handleToggleFavorite = async (memory: CoreMemory) => {
|
||||||
const currentMetadata = memory.metadata ? (typeof memory.metadata === 'string' ? JSON.parse(memory.metadata) : memory.metadata) : {};
|
try {
|
||||||
const newFavorite = !(currentMetadata.favorite === true);
|
const currentMetadata = memory.metadata
|
||||||
await updateMemory(memory.id, {
|
? (typeof memory.metadata === 'string' ? JSON.parse(memory.metadata) : memory.metadata)
|
||||||
metadata: JSON.stringify({ ...currentMetadata, favorite: newFavorite }),
|
: {};
|
||||||
} as any); // TODO: update updateMemory to accept metadata field
|
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) => {
|
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) => {
|
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) => {
|
const copyToClipboard = async (content: string) => {
|
||||||
|
|||||||
@@ -122,8 +122,6 @@ export function SkillsManagerPage() {
|
|||||||
const {
|
const {
|
||||||
skills,
|
skills,
|
||||||
categories,
|
categories,
|
||||||
totalCount,
|
|
||||||
enabledCount,
|
|
||||||
projectSkills,
|
projectSkills,
|
||||||
userSkills,
|
userSkills,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -141,9 +139,6 @@ export function SkillsManagerPage() {
|
|||||||
|
|
||||||
const { toggleSkill, isToggling } = useSkillMutations();
|
const { toggleSkill, isToggling } = useSkillMutations();
|
||||||
|
|
||||||
// Calculate disabled count
|
|
||||||
const disabledCount = totalCount - enabledCount;
|
|
||||||
|
|
||||||
// Filter skills based on enabled filter
|
// Filter skills based on enabled filter
|
||||||
const filteredSkills = useMemo(() => {
|
const filteredSkills = useMemo(() => {
|
||||||
if (enabledFilter === 'disabled') {
|
if (enabledFilter === 'disabled') {
|
||||||
@@ -152,10 +147,32 @@ export function SkillsManagerPage() {
|
|||||||
return skills;
|
return skills;
|
||||||
}, [skills, enabledFilter]);
|
}, [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) => {
|
const handleToggle = async (skill: Skill, enabled: boolean) => {
|
||||||
// Use the skill's location property
|
// Use the skill's location property
|
||||||
const location = skill.location || 'project';
|
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) => {
|
const handleToggleWithConfirm = (skill: Skill, enabled: boolean) => {
|
||||||
@@ -244,21 +261,21 @@ export function SkillsManagerPage() {
|
|||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Sparkles className="w-5 h-5 text-primary" />
|
<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>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'common.stats.totalSkills' })}</p>
|
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'common.stats.totalSkills' })}</p>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Power className="w-5 h-5 text-success" />
|
<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>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'skills.state.enabled' })}</p>
|
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'skills.state.enabled' })}</p>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<PowerOff className="w-5 h-5 text-muted-foreground" />
|
<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>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'skills.state.disabled' })}</p>
|
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'skills.state.disabled' })}</p>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -327,7 +344,7 @@ export function SkillsManagerPage() {
|
|||||||
className={enabledFilter === 'all' ? 'bg-primary text-primary-foreground' : ''}
|
className={enabledFilter === 'all' ? 'bg-primary text-primary-foreground' : ''}
|
||||||
>
|
>
|
||||||
<Sparkles className="w-4 h-4 mr-1" />
|
<Sparkles className="w-4 h-4 mr-1" />
|
||||||
{formatMessage({ id: 'skills.filters.all' })} ({totalCount})
|
{formatMessage({ id: 'skills.filters.all' })} ({currentLocationTotalCount})
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -336,7 +353,7 @@ export function SkillsManagerPage() {
|
|||||||
className={enabledFilter === 'enabled' ? 'bg-primary text-primary-foreground' : ''}
|
className={enabledFilter === 'enabled' ? 'bg-primary text-primary-foreground' : ''}
|
||||||
>
|
>
|
||||||
<Power className="w-4 h-4 mr-1" />
|
<Power className="w-4 h-4 mr-1" />
|
||||||
{formatMessage({ id: 'skills.state.enabled' })} ({enabledCount})
|
{formatMessage({ id: 'skills.state.enabled' })} ({currentLocationEnabledCount})
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -345,7 +362,7 @@ export function SkillsManagerPage() {
|
|||||||
className={enabledFilter === 'disabled' ? 'bg-primary text-primary-foreground' : ''}
|
className={enabledFilter === 'disabled' ? 'bg-primary text-primary-foreground' : ''}
|
||||||
>
|
>
|
||||||
<PowerOff className="w-4 h-4 mr-1" />
|
<PowerOff className="w-4 h-4 mr-1" />
|
||||||
{formatMessage({ id: 'skills.state.disabled' })} ({disabledCount})
|
{formatMessage({ id: 'skills.state.disabled' })} ({currentLocationDisabledCount})
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button
|
<Button
|
||||||
@@ -364,12 +381,12 @@ export function SkillsManagerPage() {
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onToggle={handleToggleWithConfirm}
|
onToggle={handleToggleWithConfirm}
|
||||||
onClick={handleSkillClick}
|
onClick={handleSkillClick}
|
||||||
isToggling={isToggling}
|
isToggling={isToggling || !!confirmDisable}
|
||||||
compact={viewMode === 'compact'}
|
compact={viewMode === 'compact'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Disabled Skills Section */}
|
{/* Disabled Skills Section */}
|
||||||
{enabledFilter === 'all' && disabledCount > 0 && (
|
{enabledFilter === 'all' && currentLocationDisabledCount > 0 && (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
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" />}
|
{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" />
|
<EyeOff className="w-4 h-4 mr-2" />
|
||||||
{formatMessage({ id: 'skills.disabledSkills.title' })} ({disabledCount})
|
{formatMessage({ id: 'skills.disabledSkills.title' })} ({currentLocationDisabledCount})
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{showDisabledSection && (
|
{showDisabledSection && (
|
||||||
@@ -387,7 +404,7 @@ export function SkillsManagerPage() {
|
|||||||
isLoading={false}
|
isLoading={false}
|
||||||
onToggle={handleToggleWithConfirm}
|
onToggle={handleToggleWithConfirm}
|
||||||
onClick={handleSkillClick}
|
onClick={handleSkillClick}
|
||||||
isToggling={isToggling}
|
isToggling={isToggling || !!confirmDisable}
|
||||||
compact={true}
|
compact={true}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user