From 2bfce150ec5d3bef6b5f74481cb11bc1c174af1b Mon Sep 17 00:00:00 2001 From: catlog22 Date: Wed, 4 Feb 2026 16:38:18 +0800 Subject: [PATCH] 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 --- ccw/frontend/src/App.tsx | 2 + .../components/mcp/RecommendedMcpWizard.tsx | 14 +- .../CliStreamMonitor/CliStreamMonitorNew.tsx | 9 +- .../components/ExecutionTab.tsx | 14 +- .../shared/CliStreamMonitorLegacy.tsx | 38 ++-- .../src/components/shared/NavGroup.tsx | 6 +- ccw/frontend/src/hooks/useMemory.ts | 5 +- ccw/frontend/src/lib/api.ts | 19 +- ccw/frontend/src/locales/en/common.json | 2 + ccw/frontend/src/locales/en/memory.json | 9 +- ccw/frontend/src/locales/zh/common.json | 2 + ccw/frontend/src/locales/zh/memory.json | 9 +- ccw/frontend/src/main.tsx | 20 ++ ccw/frontend/src/pages/CliViewerPage.tsx | 188 +++++++++++++++++- ccw/frontend/src/pages/MemoryPage.tsx | 70 +++++-- ccw/frontend/src/pages/SkillsManagerPage.tsx | 49 +++-- 16 files changed, 385 insertions(+), 71 deletions(-) diff --git a/ccw/frontend/src/App.tsx b/ccw/frontend/src/App.tsx index 00ad5e40..6e8ab8e4 100644 --- a/ccw/frontend/src/App.tsx +++ b/ccw/frontend/src/App.tsx @@ -7,6 +7,7 @@ import { QueryClientProvider } from '@tanstack/react-query'; import { RouterProvider } from 'react-router-dom'; import { IntlProvider } from 'react-intl'; import { useEffect } from 'react'; +import { Toaster } from 'sonner'; import { router } from './router'; import queryClient from './lib/query-client'; import type { Locale } from './lib/i18n'; @@ -29,6 +30,7 @@ function App({ locale, messages }: AppProps) { + ); diff --git a/ccw/frontend/src/components/mcp/RecommendedMcpWizard.tsx b/ccw/frontend/src/components/mcp/RecommendedMcpWizard.tsx index 6e09e30a..05e957f0 100644 --- a/ccw/frontend/src/components/mcp/RecommendedMcpWizard.tsx +++ b/ccw/frontend/src/components/mcp/RecommendedMcpWizard.tsx @@ -7,7 +7,7 @@ import { useState } from 'react'; import { useIntl } from 'react-intl'; 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 { Dialog, DialogContent, @@ -18,7 +18,6 @@ import { import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { Label } from '@/components/ui/Label'; -import { Badge } from '@/components/ui/Badge'; import { addGlobalMcpServer, copyMcpServerToProject, @@ -27,6 +26,14 @@ import { mcpServersKeys } from '@/hooks'; import { useNotifications } from '@/hooks/useNotifications'; import { cn } from '@/lib/utils'; +// Icon map for MCP definitions +const ICON_MAP: Record> = { + 'search-code': Search, + 'chrome': Globe, + 'globe-2': Sparkles, + 'code-2': Settings, +}; + // ========== Types ========== /** @@ -207,6 +214,7 @@ export function RecommendedMcpWizard({ if (!mcpDefinition) return null; const hasFields = mcpDefinition.fields.length > 0; + const Icon = ICON_MAP[mcpDefinition.icon] || Settings; return ( @@ -215,7 +223,7 @@ export function RecommendedMcpWizard({
- +
diff --git a/ccw/frontend/src/components/shared/CliStreamMonitor/CliStreamMonitorNew.tsx b/ccw/frontend/src/components/shared/CliStreamMonitor/CliStreamMonitorNew.tsx index 4b4da222..e87b1152 100644 --- a/ccw/frontend/src/components/shared/CliStreamMonitor/CliStreamMonitorNew.tsx +++ b/ccw/frontend/src/components/shared/CliStreamMonitor/CliStreamMonitorNew.tsx @@ -3,7 +3,7 @@ // ======================================== // 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 { Terminal, @@ -220,9 +220,14 @@ export function CliStreamMonitorNew({ isOpen, onClose }: CliStreamMonitorNewProp // WebSocket last message const lastMessage = useNotificationStore(selectWsLastMessage); + // Track last processed WebSocket message to prevent duplicate processing + const lastProcessedMsgRef = useRef(null); + // Handle WebSocket messages (same as original) 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; diff --git a/ccw/frontend/src/components/shared/CliStreamMonitor/components/ExecutionTab.tsx b/ccw/frontend/src/components/shared/CliStreamMonitor/components/ExecutionTab.tsx index 52354622..862b2549 100644 --- a/ccw/frontend/src/components/shared/CliStreamMonitor/components/ExecutionTab.tsx +++ b/ccw/frontend/src/components/shared/CliStreamMonitor/components/ExecutionTab.tsx @@ -58,13 +58,21 @@ export function ExecutionTab({ execution, isActive, onClick, onClose }: Executio {/* Close button - show on hover */} - + ); } diff --git a/ccw/frontend/src/components/shared/CliStreamMonitorLegacy.tsx b/ccw/frontend/src/components/shared/CliStreamMonitorLegacy.tsx index 2c9cba8c..d4468146 100644 --- a/ccw/frontend/src/components/shared/CliStreamMonitorLegacy.tsx +++ b/ccw/frontend/src/components/shared/CliStreamMonitorLegacy.tsx @@ -14,7 +14,9 @@ import { Search, ArrowDownToLine, Trash2, + ExternalLink, } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; @@ -196,6 +198,7 @@ export interface CliStreamMonitorProps { export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) { const { formatMessage } = useIntl(); + const navigate = useNavigate(); const logsEndRef = useRef(null); const logsContainerRef = useRef(null); const [searchQuery, setSearchQuery] = useState(''); @@ -206,6 +209,9 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) { // Track last output length to detect new output const lastOutputLengthRef = useRef>({}); + // Track last processed WebSocket message to prevent duplicate processing + const lastProcessedMsgRef = useRef(null); + // Store state const executions = useCliStreamStore((state) => state.executions); const currentExecutionId = useCliStreamStore((state) => state.currentExecutionId); @@ -222,7 +228,9 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) { // Handle WebSocket messages for CLI stream 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; @@ -377,6 +385,15 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) { setCurrentExecution(null); }, [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 useEffect(() => { const handleEsc = (e: KeyboardEvent) => { @@ -507,6 +524,14 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) { )} +
)} - {isUserScrolling && filteredOutput.length > 0 && ( - - )}
)} diff --git a/ccw/frontend/src/components/shared/NavGroup.tsx b/ccw/frontend/src/components/shared/NavGroup.tsx index 5567bd7c..e174ec80 100644 --- a/ccw/frontend/src/components/shared/NavGroup.tsx +++ b/ccw/frontend/src/components/shared/NavGroup.tsx @@ -54,9 +54,10 @@ export function NavGroup({ {items.map((item) => { const ItemIcon = item.icon; const [basePath] = item.path.split('?'); + // More precise matching: exact match or basePath followed by '/' to avoid parent/child conflicts const isActive = location.pathname === basePath || - (basePath !== '/' && location.pathname.startsWith(basePath)); + (basePath !== '/' && location.pathname.startsWith(basePath + '/')); return ( { const ItemIcon = item.icon; const [basePath, searchParams] = item.path.split('?'); + // More precise matching: exact match or basePath followed by '/' to avoid parent/child conflicts const isActive = location.pathname === basePath || - (basePath !== '/' && location.pathname.startsWith(basePath)); + (basePath !== '/' && location.pathname.startsWith(basePath + '/')); const isQueryParamActive = searchParams && location.search.includes(searchParams); diff --git a/ccw/frontend/src/hooks/useMemory.ts b/ccw/frontend/src/hooks/useMemory.ts index 498ff79a..3c4a7a07 100644 --- a/ccw/frontend/src/hooks/useMemory.ts +++ b/ccw/frontend/src/hooks/useMemory.ts @@ -150,7 +150,7 @@ export function useMemory(options: UseMemoryOptions = {}): UseMemoryReturn { // ========== Mutations ========== export interface UseCreateMemoryReturn { - createMemory: (input: { content: string; tags?: string[] }) => Promise; + createMemory: (input: { content: string; tags?: string[]; metadata?: Record }) => Promise; isCreating: boolean; error: Error | null; } @@ -160,7 +160,8 @@ export function useCreateMemory(): UseCreateMemoryReturn { const projectPath = useWorkflowStore(selectProjectPath); const mutation = useMutation({ - mutationFn: (input: { content: string; tags?: string[] }) => createMemory(input, projectPath), + mutationFn: (input: { content: string; tags?: string[]; metadata?: Record }) => + createMemory(input, projectPath), onSuccess: () => { // Invalidate memory cache to trigger refetch queryClient.invalidateQueries({ queryKey: projectPath ? workspaceQueryKeys.memory(projectPath) : ['memory'] }); diff --git a/ccw/frontend/src/lib/api.ts b/ccw/frontend/src/lib/api.ts index e8da84b8..77176571 100644 --- a/ccw/frontend/src/lib/api.ts +++ b/ccw/frontend/src/lib/api.ts @@ -993,18 +993,19 @@ export interface SkillsResponse { * @param projectPath - Optional project path to filter data by workspace */ export async function fetchSkills(projectPath?: string): Promise { - // Helper to add location to skills - const addLocation = (skills: Skill[], location: 'project' | 'user'): Skill[] => - skills.map(skill => ({ ...skill, location })); + // Helper to add location and enabled status to skills + // Backend only returns enabled skills (with SKILL.md), so we set enabled: true + 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 if (projectPath) { try { const url = `/api/skills?path=${encodeURIComponent(projectPath)}`; const data = await fetchApi<{ skills?: Skill[]; projectSkills?: Skill[]; userSkills?: Skill[] }>(url); - const projectSkillsWithLocation = addLocation(data.projectSkills ?? [], 'project'); - const userSkillsWithLocation = addLocation(data.userSkills ?? [], 'user'); - const allSkills = [...projectSkillsWithLocation, ...userSkillsWithLocation]; + const projectSkillsWithMetadata = addMetadata(data.projectSkills ?? [], 'project'); + const userSkillsWithMetadata = addMetadata(data.userSkills ?? [], 'user'); + const allSkills = [...projectSkillsWithMetadata, ...userSkillsWithMetadata]; return { skills: data.skills ?? allSkills, }; @@ -1020,9 +1021,9 @@ export async function fetchSkills(projectPath?: string): Promise } // Fallback: fetch global skills const data = await fetchApi<{ skills?: Skill[]; projectSkills?: Skill[]; userSkills?: Skill[] }>('/api/skills'); - const projectSkillsWithLocation = addLocation(data.projectSkills ?? [], 'project'); - const userSkillsWithLocation = addLocation(data.userSkills ?? [], 'user'); - const allSkills = [...projectSkillsWithLocation, ...userSkillsWithLocation]; + const projectSkillsWithMetadata = addMetadata(data.projectSkills ?? [], 'project'); + const userSkillsWithMetadata = addMetadata(data.userSkills ?? [], 'user'); + const allSkills = [...projectSkillsWithMetadata, ...userSkillsWithMetadata]; return { skills: data.skills ?? allSkills, }; diff --git a/ccw/frontend/src/locales/en/common.json b/ccw/frontend/src/locales/en/common.json index 382c5ded..245c7802 100644 --- a/ccw/frontend/src/locales/en/common.json +++ b/ccw/frontend/src/locales/en/common.json @@ -224,6 +224,8 @@ "all": "All", "yes": "Yes", "no": "No", + "loading": "Loading...", + "error": "Error", "navigation": { "header": { "brand": "CCW Dashboard" diff --git a/ccw/frontend/src/locales/en/memory.json b/ccw/frontend/src/locales/en/memory.json index b4decbef..6a39a05d 100644 --- a/ccw/frontend/src/locales/en/memory.json +++ b/ccw/frontend/src/locales/en/memory.json @@ -10,7 +10,14 @@ "copyError": "Failed to copy", "refresh": "Refresh", "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": { "memories": "Memories", diff --git a/ccw/frontend/src/locales/zh/common.json b/ccw/frontend/src/locales/zh/common.json index 51641162..7ac11e92 100644 --- a/ccw/frontend/src/locales/zh/common.json +++ b/ccw/frontend/src/locales/zh/common.json @@ -218,6 +218,8 @@ "all": "全部", "yes": "是", "no": "否", + "loading": "加载中...", + "error": "错误", "navigation": { "header": { "brand": "CCW 仪表板" diff --git a/ccw/frontend/src/locales/zh/memory.json b/ccw/frontend/src/locales/zh/memory.json index 57cee731..238aa421 100644 --- a/ccw/frontend/src/locales/zh/memory.json +++ b/ccw/frontend/src/locales/zh/memory.json @@ -10,7 +10,14 @@ "copyError": "复制失败", "refresh": "刷新", "expand": "展开", - "collapse": "收起" + "collapse": "收起", + "favoriteAdded": "已添加到收藏", + "favoriteRemoved": "已从收藏移除", + "favoriteError": "更新收藏状态失败", + "archiveSuccess": "记忆已归档", + "archiveError": "归档记忆失败", + "unarchiveSuccess": "记忆已恢复", + "unarchiveError": "恢复记忆失败" }, "tabs": { "memories": "记忆", diff --git a/ccw/frontend/src/main.tsx b/ccw/frontend/src/main.tsx index 8afd640f..1bd3d6d6 100644 --- a/ccw/frontend/src/main.tsx +++ b/ccw/frontend/src/main.tsx @@ -7,10 +7,30 @@ import 'react-resizable/css/styles.css' import { initMessages, getInitialLocale, getMessages, type Locale } from './lib/i18n' 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() { const rootElement = document.getElementById('root') if (!rootElement) throw new Error('Failed to find the root element') + // Initialize CSRF token before any API calls + await initCsrfToken() + // Initialize translation messages await initMessages() diff --git a/ccw/frontend/src/pages/CliViewerPage.tsx b/ccw/frontend/src/pages/CliViewerPage.tsx index 41ede20c..d7537356 100644 --- a/ccw/frontend/src/pages/CliViewerPage.tsx +++ b/ccw/frontend/src/pages/CliViewerPage.tsx @@ -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(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>(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() {
- - {formatMessage({ id: 'cliViewer.page.title' })} - +
+ + {formatMessage({ id: 'cliViewer.page.title' })} + + {runningCount > 0 && ( + + + {runningCount} active + + )} +
{formatMessage( { id: 'cliViewer.page.subtitle' }, { count: activeSessionCount } )} + {executionCount > 0 && ` · ${executionCount} executions`}
diff --git a/ccw/frontend/src/pages/MemoryPage.tsx b/ccw/frontend/src/pages/MemoryPage.tsx index 9f3eddbb..a591a77e 100644 --- a/ccw/frontend/src/pages/MemoryPage.tsx +++ b/ccw/frontend/src/pages/MemoryPage.tsx @@ -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) => { diff --git a/ccw/frontend/src/pages/SkillsManagerPage.tsx b/ccw/frontend/src/pages/SkillsManagerPage.tsx index e24e6685..31cdc86d 100644 --- a/ccw/frontend/src/pages/SkillsManagerPage.tsx +++ b/ccw/frontend/src/pages/SkillsManagerPage.tsx @@ -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() {
- {totalCount} + {currentLocationTotalCount}

{formatMessage({ id: 'common.stats.totalSkills' })}

- {enabledCount} + {currentLocationEnabledCount}

{formatMessage({ id: 'skills.state.enabled' })}

- {totalCount - enabledCount} + {currentLocationDisabledCount}

{formatMessage({ id: 'skills.state.disabled' })}

@@ -327,7 +344,7 @@ export function SkillsManagerPage() { className={enabledFilter === 'all' ? 'bg-primary text-primary-foreground' : ''} > - {formatMessage({ id: 'skills.filters.all' })} ({totalCount}) + {formatMessage({ id: 'skills.filters.all' })} ({currentLocationTotalCount})
{showDisabledSection && ( @@ -387,7 +404,7 @@ export function SkillsManagerPage() { isLoading={false} onToggle={handleToggleWithConfirm} onClick={handleSkillClick} - isToggling={isToggling} + isToggling={isToggling || !!confirmDisable} compact={true} /> )}