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

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

View File

@@ -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>
); );

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>
); );
} }

View File

@@ -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>

View File

@@ -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);

View File

@@ -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'] });

View File

@@ -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,
}; };

View File

@@ -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"

View File

@@ -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",

View File

@@ -218,6 +218,8 @@
"all": "全部", "all": "全部",
"yes": "是", "yes": "是",
"no": "否", "no": "否",
"loading": "加载中...",
"error": "错误",
"navigation": { "navigation": {
"header": { "header": {
"brand": "CCW 仪表板" "brand": "CCW 仪表板"

View File

@@ -10,7 +10,14 @@
"copyError": "复制失败", "copyError": "复制失败",
"refresh": "刷新", "refresh": "刷新",
"expand": "展开", "expand": "展开",
"collapse": "收起" "collapse": "收起",
"favoriteAdded": "已添加到收藏",
"favoriteRemoved": "已从收藏移除",
"favoriteError": "更新收藏状态失败",
"archiveSuccess": "记忆已归档",
"archiveError": "归档记忆失败",
"unarchiveSuccess": "记忆已恢复",
"unarchiveError": "恢复记忆失败"
}, },
"tabs": { "tabs": {
"memories": "记忆", "memories": "记忆",

View File

@@ -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()

View File

@@ -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>

View File

@@ -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) => {

View File

@@ -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}
/> />
)} )}