feat: add Discuss and Explore subagents for dynamic critique and code exploration

- Implement Discuss Subagent for multi-perspective critique with dynamic perspectives.
- Create Explore Subagent for shared codebase exploration with centralized caching.
- Add tests for CcwToolsMcpCard component to ensure enabled tools are preserved on config save.
- Introduce SessionPreviewPanel component for previewing and selecting sessions for Memory V2 extraction.
- Develop CommandCreateDialog component for creating/importing commands with import and CLI generate modes.
This commit is contained in:
catlog22
2026-02-27 17:25:52 +08:00
parent 3db74cc7b0
commit 3b92bfae8c
45 changed files with 6508 additions and 128 deletions

View File

@@ -19,6 +19,7 @@ import {
GitBranch,
Send,
FileBarChart,
Settings,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
@@ -31,7 +32,7 @@ import type { HookTriggerType } from './HookCard';
/**
* Template category type
*/
export type TemplateCategory = 'notification' | 'indexing' | 'automation';
export type TemplateCategory = 'notification' | 'indexing' | 'automation' | 'utility';
/**
* Hook template definition
@@ -226,6 +227,34 @@ export const HOOK_TEMPLATES: readonly HookTemplate[] = [
'-e',
'const cp=require("child_process");const payload=JSON.stringify({type:"MEMORY_V2_STATUS_UPDATED",project:process.env.CLAUDE_PROJECT_DIR||process.cwd(),timestamp:Date.now()});cp.spawnSync("curl",["-s","-X","POST","-H","Content-Type: application/json","-d",payload,"http://localhost:3456/api/hook"],{stdio:"inherit",shell:true})'
]
},
// --- Memory Operations ---
{
id: 'memory-auto-compress',
name: 'Auto Memory Compress',
description: 'Automatically compress memory when entries exceed threshold',
category: 'automation',
trigger: 'Stop',
command: 'ccw',
args: ['memory', 'consolidate', '--threshold', '50']
},
{
id: 'memory-preview-extract',
name: 'Memory Preview & Extract',
description: 'Preview extraction queue and extract eligible sessions',
category: 'automation',
trigger: 'SessionStart',
command: 'ccw',
args: ['memory', 'preview', '--include-native']
},
{
id: 'memory-status-check',
name: 'Memory Status Check',
description: 'Check memory extraction and consolidation status',
category: 'utility',
trigger: 'SessionStart',
command: 'ccw',
args: ['memory', 'status']
}
] as const;
@@ -234,7 +263,8 @@ export const HOOK_TEMPLATES: readonly HookTemplate[] = [
const CATEGORY_ICONS: Record<TemplateCategory, { icon: typeof Bell; color: string; bg: string }> = {
notification: { icon: Bell, color: 'text-blue-500', bg: 'bg-blue-500/10' },
indexing: { icon: Database, color: 'text-purple-500', bg: 'bg-purple-500/10' },
automation: { icon: Wrench, color: 'text-orange-500', bg: 'bg-orange-500/10' }
automation: { icon: Wrench, color: 'text-orange-500', bg: 'bg-orange-500/10' },
utility: { icon: Settings, color: 'text-gray-500', bg: 'bg-gray-500/10' }
};
// ========== Template Icons ==========
@@ -258,7 +288,8 @@ function getCategoryName(category: TemplateCategory, formatMessage: ReturnType<t
const names: Record<TemplateCategory, string> = {
notification: formatMessage({ id: 'cliHooks.templates.categories.notification' }),
indexing: formatMessage({ id: 'cliHooks.templates.categories.indexing' }),
automation: formatMessage({ id: 'cliHooks.templates.categories.automation' })
automation: formatMessage({ id: 'cliHooks.templates.categories.automation' }),
utility: formatMessage({ id: 'cliHooks.templates.categories.utility' })
};
return names[category];
}
@@ -352,7 +383,9 @@ export function HookQuickTemplates({
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-foreground leading-tight">
{formatMessage({ id: `cliHooks.templates.templates.${template.id}.name` })}
{formatMessage(
{ id: `cliHooks.templates.templates.${template.id}.name`, defaultMessage: template.name }
)}
</h4>
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
@@ -394,7 +427,9 @@ export function HookQuickTemplates({
{/* Description */}
<p className="text-xs text-muted-foreground leading-relaxed flex-1 pl-11">
{formatMessage({ id: `cliHooks.templates.templates.${template.id}.description` })}
{formatMessage(
{ id: `cliHooks.templates.templates.${template.id}.description`, defaultMessage: template.description }
)}
</p>
</Card>
);

View File

@@ -0,0 +1,102 @@
// ========================================
// CcwToolsMcpCard Component Tests
// ========================================
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@/test/i18n';
import userEvent from '@testing-library/user-event';
import { CcwToolsMcpCard } from './CcwToolsMcpCard';
import { updateCcwConfig, updateCcwConfigForCodex } from '@/lib/api';
vi.mock('@/lib/api', () => ({
installCcwMcp: vi.fn(),
uninstallCcwMcp: vi.fn(),
updateCcwConfig: vi.fn(),
installCcwMcpToCodex: vi.fn(),
uninstallCcwMcpFromCodex: vi.fn(),
updateCcwConfigForCodex: vi.fn(),
}));
vi.mock('@/hooks/useNotifications', () => ({
useNotifications: () => ({
success: vi.fn(),
error: vi.fn(),
}),
}));
describe('CcwToolsMcpCard', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('preserves enabledTools when saving config (Codex)', async () => {
const updateCodexMock = vi.mocked(updateCcwConfigForCodex);
updateCodexMock.mockResolvedValue({
isInstalled: true,
enabledTools: [],
installedScopes: ['global'],
});
render(
<CcwToolsMcpCard
target="codex"
isInstalled={true}
enabledTools={['write_file', 'read_many_files']}
onToggleTool={vi.fn()}
onUpdateConfig={vi.fn()}
onInstall={vi.fn()}
/>,
{ locale: 'en' }
);
const user = userEvent.setup();
await user.click(screen.getByText(/CCW MCP Server|mcp\.ccw\.title/i));
await user.click(
screen.getByRole('button', { name: /Save Configuration|mcp\.ccw\.actions\.saveConfig/i })
);
await waitFor(() => {
expect(updateCodexMock).toHaveBeenCalledWith(
expect.objectContaining({
enabledTools: ['write_file', 'read_many_files'],
})
);
});
});
it('preserves enabledTools when saving config (Claude)', async () => {
const updateClaudeMock = vi.mocked(updateCcwConfig);
updateClaudeMock.mockResolvedValue({
isInstalled: true,
enabledTools: [],
installedScopes: ['global'],
});
render(
<CcwToolsMcpCard
isInstalled={true}
enabledTools={['write_file', 'smart_search']}
onToggleTool={vi.fn()}
onUpdateConfig={vi.fn()}
onInstall={vi.fn()}
/>,
{ locale: 'en' }
);
const user = userEvent.setup();
await user.click(screen.getByText(/CCW MCP Server|mcp\.ccw\.title/i));
await user.click(
screen.getByRole('button', { name: /Save Configuration|mcp\.ccw\.actions\.saveConfig/i })
);
await waitFor(() => {
expect(updateClaudeMock).toHaveBeenCalledWith(
expect.objectContaining({
enabledTools: ['write_file', 'smart_search'],
})
);
});
});
});

View File

@@ -37,7 +37,7 @@ import {
uninstallCcwMcpFromCodex,
updateCcwConfigForCodex,
} from '@/lib/api';
import { mcpServersKeys } from '@/hooks';
import { mcpServersKeys, useNotifications } from '@/hooks';
import { useQueryClient } from '@tanstack/react-query';
import { cn } from '@/lib/utils';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
@@ -128,6 +128,7 @@ export function CcwToolsMcpCard({
}: CcwToolsMcpCardProps) {
const { formatMessage } = useIntl();
const queryClient = useQueryClient();
const { success: notifySuccess, error: notifyError } = useNotifications();
const currentProjectPath = useWorkflowStore(selectProjectPath);
// Local state for config inputs
@@ -179,9 +180,19 @@ export function CcwToolsMcpCard({
onSuccess: () => {
if (isCodex) {
queryClient.invalidateQueries({ queryKey: ['codexMcpServers'] });
queryClient.invalidateQueries({ queryKey: ['ccwMcpConfigCodex'] });
} else {
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
queryClient.invalidateQueries({ queryKey: ['ccwMcpConfig'] });
}
notifySuccess(formatMessage({ id: 'mcp.ccw.feedback.saveSuccess' }));
},
onError: (error) => {
console.error('Failed to update CCW config:', error);
notifyError(
formatMessage({ id: 'mcp.ccw.feedback.saveError' }),
error instanceof Error ? error.message : String(error)
);
},
});
@@ -201,6 +212,9 @@ export function CcwToolsMcpCard({
const handleConfigSave = () => {
updateConfigMutation.mutate({
// Preserve current tool selection; otherwise updateCcwConfig* falls back to defaults
// and can unintentionally overwrite user-chosen enabled tools.
enabledTools,
projectRoot: projectRootInput || undefined,
allowedDirs: allowedDirsInput || undefined,
enableSandbox: enableSandboxInput,

View File

@@ -0,0 +1,332 @@
// ========================================
// SessionPreviewPanel Component
// ========================================
// Preview and select sessions for Memory V2 extraction
import { useState, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { formatDistanceToNow } from 'date-fns';
import { Search, Eye, Loader2, CheckCircle2, XCircle, Clock } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Input } from '@/components/ui/Input';
import { Checkbox } from '@/components/ui/Checkbox';
import {
usePreviewSessions,
useTriggerSelectiveExtraction,
} from '@/hooks/useMemoryV2';
import { cn } from '@/lib/utils';
interface SessionPreviewPanelProps {
onClose?: () => void;
onExtractComplete?: () => void;
}
// Helper function to format bytes
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
// Helper function to format timestamp
function formatTimestamp(timestamp: number): string {
try {
const date = new Date(timestamp);
return formatDistanceToNow(date, { addSuffix: true });
} catch {
return '-';
}
}
export function SessionPreviewPanel({ onClose, onExtractComplete }: SessionPreviewPanelProps) {
const intl = useIntl();
const [searchQuery, setSearchQuery] = useState('');
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [includeNative, setIncludeNative] = useState(false);
const { data, isLoading, refetch } = usePreviewSessions(includeNative);
const triggerExtraction = useTriggerSelectiveExtraction();
// Filter sessions based on search query
const filteredSessions = useMemo(() => {
if (!data?.sessions) return [];
if (!searchQuery.trim()) return data.sessions;
const query = searchQuery.toLowerCase();
return data.sessions.filter(
(session) =>
session.sessionId.toLowerCase().includes(query) ||
session.tool.toLowerCase().includes(query) ||
session.source.toLowerCase().includes(query)
);
}, [data?.sessions, searchQuery]);
// Get ready sessions (eligible and not extracted)
const readySessions = useMemo(() => {
return filteredSessions.filter((s) => s.eligible && !s.extracted);
}, [filteredSessions]);
// Toggle session selection
const toggleSelection = (sessionId: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(sessionId)) {
next.delete(sessionId);
} else {
next.add(sessionId);
}
return next;
});
};
// Select all ready sessions
const selectAll = () => {
setSelectedIds(new Set(readySessions.map((s) => s.sessionId)));
};
// Clear selection
const selectNone = () => {
setSelectedIds(new Set());
};
// Trigger extraction for selected sessions
const handleExtract = async () => {
if (selectedIds.size === 0) return;
triggerExtraction.mutate(
{
sessionIds: Array.from(selectedIds),
includeNative,
},
{
onSuccess: () => {
setSelectedIds(new Set());
onExtractComplete?.();
},
}
);
};
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Eye className="w-5 h-5" />
{intl.formatMessage({ id: 'memory.v2.preview.title', defaultMessage: 'Extraction Queue Preview' })}
</h2>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={includeNative}
onCheckedChange={(checked) => setIncludeNative(checked === true)}
/>
{intl.formatMessage({ id: 'memory.v2.preview.includeNative', defaultMessage: 'Include Native Sessions' })}
</label>
<Button variant="outline" size="sm" onClick={() => refetch()}>
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
'Refresh'
)}
</Button>
</div>
</div>
{/* Summary Bar */}
{data?.summary && (
<div className="grid grid-cols-4 gap-2 mb-4">
<div className="text-center p-2 bg-muted rounded">
<div className="text-lg font-bold">{data.summary.total}</div>
<div className="text-xs text-muted-foreground">
{intl.formatMessage({ id: 'memory.v2.preview.total', defaultMessage: 'Total' })}
</div>
</div>
<div className="text-center p-2 bg-muted rounded">
<div className="text-lg font-bold text-blue-600">{data.summary.eligible}</div>
<div className="text-xs text-muted-foreground">
{intl.formatMessage({ id: 'memory.v2.preview.eligible', defaultMessage: 'Eligible' })}
</div>
</div>
<div className="text-center p-2 bg-muted rounded">
<div className="text-lg font-bold text-green-600">{data.summary.alreadyExtracted}</div>
<div className="text-xs text-muted-foreground">
{intl.formatMessage({ id: 'memory.v2.preview.extracted', defaultMessage: 'Already Extracted' })}
</div>
</div>
<div className="text-center p-2 bg-muted rounded">
<div className="text-lg font-bold text-amber-600">{data.summary.readyForExtraction}</div>
<div className="text-xs text-muted-foreground">
{intl.formatMessage({ id: 'memory.v2.preview.ready', defaultMessage: 'Ready' })}
</div>
</div>
</div>
)}
{/* Search and Actions */}
<div className="flex items-center gap-2 mb-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder={intl.formatMessage({
id: 'memory.v2.preview.selectSessions',
defaultMessage: 'Search sessions...',
})}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Button variant="outline" size="sm" onClick={selectAll}>
{intl.formatMessage({ id: 'memory.v2.preview.selectAll', defaultMessage: 'Select All' })}
</Button>
<Button variant="outline" size="sm" onClick={selectNone}>
{intl.formatMessage({ id: 'memory.v2.preview.selectNone', defaultMessage: 'Select None' })}
</Button>
</div>
{/* Session Table */}
<div className="flex-1 overflow-auto border rounded-lg">
{isLoading ? (
<div className="flex items-center justify-center h-48">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : filteredSessions.length === 0 ? (
<div className="flex items-center justify-center h-48 text-muted-foreground">
{intl.formatMessage({ id: 'memory.v2.preview.noSessions', defaultMessage: 'No sessions found' })}
</div>
) : (
<table className="w-full text-sm">
<thead className="bg-muted sticky top-0">
<tr>
<th className="w-10 p-2"></th>
<th className="text-left p-2">Source</th>
<th className="text-left p-2">Session ID</th>
<th className="text-left p-2">Tool</th>
<th className="text-left p-2">Timestamp</th>
<th className="text-right p-2">Size</th>
<th className="text-right p-2">Turns</th>
<th className="text-center p-2">Status</th>
</tr>
</thead>
<tbody>
{filteredSessions.map((session) => {
const isReady = session.eligible && !session.extracted;
const isSelected = selectedIds.has(session.sessionId);
const isDisabled = !isReady;
return (
<tr
key={session.sessionId}
className={cn(
'border-b hover:bg-muted/50 transition-colors',
isDisabled && 'opacity-60',
isSelected && 'bg-blue-50 dark:bg-blue-950/20'
)}
>
<td className="p-2">
<Checkbox
checked={isSelected}
disabled={isDisabled}
onCheckedChange={() => toggleSelection(session.sessionId)}
/>
</td>
<td className="p-2">
<Badge
variant="outline"
className={cn(
session.source === 'ccw'
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-300'
)}
>
{session.source === 'ccw'
? intl.formatMessage({ id: 'memory.v2.preview.sourceCcw', defaultMessage: 'CCW' })
: intl.formatMessage({ id: 'memory.v2.preview.sourceNative', defaultMessage: 'Native' })}
</Badge>
</td>
<td className="p-2 font-mono text-xs truncate max-w-[150px]" title={session.sessionId}>
{session.sessionId}
</td>
<td className="p-2 truncate max-w-[100px]" title={session.tool}>
{session.tool || '-'}
</td>
<td className="p-2 text-muted-foreground">
{formatTimestamp(session.timestamp)}
</td>
<td className="p-2 text-right font-mono text-xs">
{formatBytes(session.bytes)}
</td>
<td className="p-2 text-right">
{session.turns}
</td>
<td className="p-2 text-center">
{session.extracted ? (
<Badge className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
<CheckCircle2 className="w-3 h-3 mr-1" />
{intl.formatMessage({ id: 'memory.v2.preview.extracted', defaultMessage: 'Extracted' })}
</Badge>
) : session.eligible ? (
<Badge className="bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
<Clock className="w-3 h-3 mr-1" />
{intl.formatMessage({ id: 'memory.v2.preview.ready', defaultMessage: 'Ready' })}
</Badge>
) : (
<Badge className="bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300">
<XCircle className="w-3 h-3 mr-1" />
{intl.formatMessage({ id: 'memory.v2.preview.ineligible', defaultMessage: 'Ineligible' })}
</Badge>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{/* Footer Actions */}
<div className="flex items-center justify-between mt-4 pt-4 border-t">
<div className="text-sm text-muted-foreground">
{selectedIds.size > 0 ? (
intl.formatMessage(
{ id: 'memory.v2.preview.selected', defaultMessage: '{count} sessions selected' },
{ count: selectedIds.size }
)
) : (
intl.formatMessage({ id: 'memory.v2.preview.selectHint', defaultMessage: 'Select sessions to extract' })
)}
</div>
<div className="flex items-center gap-2">
{onClose && (
<Button variant="outline" onClick={onClose}>
{intl.formatMessage({ id: 'common.close', defaultMessage: 'Close' })}
</Button>
)}
<Button
onClick={handleExtract}
disabled={selectedIds.size === 0 || triggerExtraction.isPending}
>
{triggerExtraction.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
{intl.formatMessage({ id: 'memory.v2.extraction.extracting', defaultMessage: 'Extracting...' })}
</>
) : (
intl.formatMessage(
{ id: 'memory.v2.preview.extractSelected', defaultMessage: 'Extract Selected ({count})' },
{ count: selectedIds.size }
)
)}
</Button>
</div>
</div>
</div>
);
}
export default SessionPreviewPanel;

View File

@@ -28,6 +28,7 @@ import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
import { SessionPreviewPanel } from '@/components/memory/SessionPreviewPanel';
import 'highlight.js/styles/github-dark.css';
import {
useExtractionStatus,
@@ -84,6 +85,7 @@ function ExtractionCard() {
const { data: status, isLoading, refetch } = useExtractionStatus();
const trigger = useTriggerExtraction();
const [maxSessions, setMaxSessions] = useState(10);
const [showPreview, setShowPreview] = useState(false);
const handleTrigger = () => {
trigger.mutate(maxSessions);
@@ -94,83 +96,107 @@ function ExtractionCard() {
const lastRunText = formatRelativeTime(status?.lastRun);
return (
<Card className="p-4">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="font-medium flex items-center gap-2">
<Zap className="w-5 h-5 text-yellow-500" />
Phase 1: {intl.formatMessage({ id: 'memory.v2.extraction.title', defaultMessage: 'Extraction' })}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{intl.formatMessage({ id: 'memory.v2.extraction.description', defaultMessage: 'Extract structured memories from CLI sessions' })}
</p>
{lastRunText && (
<p className="text-xs text-muted-foreground mt-1">
{intl.formatMessage({ id: 'memory.v2.extraction.lastRun', defaultMessage: 'Last run' })}: {lastRunText}
<>
<Card className="p-4">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="font-medium flex items-center gap-2">
<Zap className="w-5 h-5 text-yellow-500" />
Phase 1: {intl.formatMessage({ id: 'memory.v2.extraction.title', defaultMessage: 'Extraction' })}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{intl.formatMessage({ id: 'memory.v2.extraction.description', defaultMessage: 'Extract structured memories from CLI sessions' })}
</p>
{lastRunText && (
<p className="text-xs text-muted-foreground mt-1">
{intl.formatMessage({ id: 'memory.v2.extraction.lastRun', defaultMessage: 'Last run' })}: {lastRunText}
</p>
)}
</div>
{status && (
<div className="text-right">
<div className="text-2xl font-bold">{status.total_stage1}</div>
<div className="text-xs text-muted-foreground">
{intl.formatMessage({ id: 'memory.v2.extraction.extracted', defaultMessage: 'Extracted' })}
</div>
</div>
)}
</div>
{status && (
<div className="text-right">
<div className="text-2xl font-bold">{status.total_stage1}</div>
<div className="text-xs text-muted-foreground">
{intl.formatMessage({ id: 'memory.v2.extraction.extracted', defaultMessage: 'Extracted' })}
<div className="flex items-center gap-2 mb-4">
<input
type="number"
value={maxSessions}
onChange={(e) => setMaxSessions(Math.max(1, parseInt(e.target.value) || 10))}
className="w-20 px-2 py-1 text-sm border rounded bg-background"
min={1}
max={64}
/>
<span className="text-sm text-muted-foreground">sessions max</span>
</div>
<div className="flex items-center gap-2">
<Button
onClick={handleTrigger}
disabled={trigger.isPending || hasRunningJob}
size="sm"
>
{trigger.isPending || hasRunningJob ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
{intl.formatMessage({ id: 'memory.v2.extraction.extracting', defaultMessage: 'Extracting...' })}
</>
) : (
<>
<Play className="w-4 h-4 mr-1" />
{intl.formatMessage({ id: 'memory.v2.extraction.trigger', defaultMessage: 'Trigger Extraction' })}
</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowPreview(true)}
title={intl.formatMessage({ id: 'memory.v2.preview.previewQueue', defaultMessage: 'Preview Queue' })}
>
<Eye className="w-4 h-4 mr-1" />
{intl.formatMessage({ id: 'memory.v2.preview.previewQueue', defaultMessage: 'Preview Queue' })}
</Button>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
</Button>
</div>
{status?.jobs && status.jobs.length > 0 && (
<div className="mt-4 pt-4 border-t">
<div className="text-xs text-muted-foreground mb-2">
{intl.formatMessage({ id: 'memory.v2.extraction.recentJobs', defaultMessage: 'Recent Jobs' })}
</div>
<div className="space-y-1 max-h-32 overflow-y-auto">
{status.jobs.slice(0, 5).map((job) => (
<div key={job.job_key} className="flex items-center justify-between text-sm">
<span className="font-mono text-xs truncate max-w-[150px]">{job.job_key}</span>
<StatusBadge status={job.status} />
</div>
))}
</div>
</div>
)}
</div>
</Card>
<div className="flex items-center gap-2 mb-4">
<input
type="number"
value={maxSessions}
onChange={(e) => setMaxSessions(Math.max(1, parseInt(e.target.value) || 10))}
className="w-20 px-2 py-1 text-sm border rounded bg-background"
min={1}
max={64}
/>
<span className="text-sm text-muted-foreground">sessions max</span>
</div>
<div className="flex items-center gap-2">
<Button
onClick={handleTrigger}
disabled={trigger.isPending || hasRunningJob}
size="sm"
>
{trigger.isPending || hasRunningJob ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
{intl.formatMessage({ id: 'memory.v2.extraction.extracting', defaultMessage: 'Extracting...' })}
</>
) : (
<>
<Play className="w-4 h-4 mr-1" />
{intl.formatMessage({ id: 'memory.v2.extraction.trigger', defaultMessage: 'Trigger Extraction' })}
</>
)}
</Button>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
</Button>
</div>
{status?.jobs && status.jobs.length > 0 && (
<div className="mt-4 pt-4 border-t">
<div className="text-xs text-muted-foreground mb-2">
{intl.formatMessage({ id: 'memory.v2.extraction.recentJobs', defaultMessage: 'Recent Jobs' })}
</div>
<div className="space-y-1 max-h-32 overflow-y-auto">
{status.jobs.slice(0, 5).map((job) => (
<div key={job.job_key} className="flex items-center justify-between text-sm">
<span className="font-mono text-xs truncate max-w-[150px]">{job.job_key}</span>
<StatusBadge status={job.status} />
</div>
))}
</div>
</div>
)}
</Card>
{/* Preview Queue Dialog */}
<Dialog open={showPreview} onOpenChange={setShowPreview}>
<DialogContent className="max-w-4xl max-h-[80vh]">
<SessionPreviewPanel
onClose={() => setShowPreview(false)}
onExtractComplete={() => {
setShowPreview(false);
refetch();
}}
/>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,408 @@
// ========================================
// Command Create Dialog Component
// ========================================
// Modal dialog for creating/importing commands with two modes:
// - Import: import existing command file
// - CLI Generate: AI-generated command from description
import { useState, useCallback } from 'react';
import { useIntl } from 'react-intl';
import {
Folder,
User,
FileCode,
Sparkles,
CheckCircle,
XCircle,
Loader2,
Info,
} from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from '@/components/ui/Dialog';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Textarea } from '@/components/ui/Textarea';
import { Label } from '@/components/ui/Label';
import { validateCommandImport, createCommand } from '@/lib/api';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import { cn } from '@/lib/utils';
export interface CommandCreateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated: () => void;
cliType?: 'claude' | 'codex';
}
type CreateMode = 'import' | 'cli-generate';
type CommandLocation = 'project' | 'user';
interface ValidationResult {
valid: boolean;
errors?: string[];
commandInfo?: { name: string; description: string; usage?: string };
}
export function CommandCreateDialog({ open, onOpenChange, onCreated, cliType = 'claude' }: CommandCreateDialogProps) {
const { formatMessage } = useIntl();
const projectPath = useWorkflowStore(selectProjectPath);
const [mode, setMode] = useState<CreateMode>('import');
const [location, setLocation] = useState<CommandLocation>('project');
// Import mode state
const [sourcePath, setSourcePath] = useState('');
const [customName, setCustomName] = useState('');
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null);
const [isValidating, setIsValidating] = useState(false);
// CLI Generate mode state
const [commandName, setCommandName] = useState('');
const [description, setDescription] = useState('');
const [isCreating, setIsCreating] = useState(false);
const resetState = useCallback(() => {
setMode('import');
setLocation('project');
setSourcePath('');
setCustomName('');
setValidationResult(null);
setIsValidating(false);
setCommandName('');
setDescription('');
setIsCreating(false);
}, []);
const handleOpenChange = useCallback((open: boolean) => {
if (!open) {
resetState();
}
onOpenChange(open);
}, [onOpenChange, resetState]);
const handleValidate = useCallback(async () => {
if (!sourcePath.trim()) return;
setIsValidating(true);
setValidationResult(null);
try {
const result = await validateCommandImport(sourcePath.trim());
setValidationResult(result);
} catch (err) {
setValidationResult({
valid: false,
errors: [err instanceof Error ? err.message : String(err)],
});
} finally {
setIsValidating(false);
}
}, [sourcePath]);
const handleCreate = useCallback(async () => {
if (mode === 'import') {
if (!sourcePath.trim()) return;
if (!validationResult?.valid) return;
} else {
if (!commandName.trim()) return;
if (!description.trim()) return;
}
setIsCreating(true);
try {
await createCommand({
mode,
location,
sourcePath: mode === 'import' ? sourcePath.trim() : undefined,
commandName: mode === 'import' ? (customName.trim() || undefined) : commandName.trim(),
description: mode === 'cli-generate' ? description.trim() : undefined,
generationType: mode === 'cli-generate' ? 'description' : undefined,
projectPath,
cliType,
});
handleOpenChange(false);
onCreated();
} catch (err) {
console.error('Failed to create command:', err);
if (mode === 'import') {
setValidationResult({
valid: false,
errors: [err instanceof Error ? err.message : formatMessage({ id: 'commands.create.createError' })],
});
}
} finally {
setIsCreating(false);
}
}, [mode, location, sourcePath, customName, commandName, description, validationResult, projectPath, handleOpenChange, onCreated, formatMessage]);
const canCreate = mode === 'import'
? sourcePath.trim() && validationResult?.valid && !isCreating
: commandName.trim() && description.trim() && !isCreating;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{formatMessage({ id: 'commands.create.title' })}</DialogTitle>
<DialogDescription>
{formatMessage({ id: 'commands.description' })}
</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-2">
{/* Location Selection */}
<div className="space-y-2">
<Label>{formatMessage({ id: 'commands.create.location' })}</Label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
className={cn(
'px-4 py-3 text-left border-2 rounded-lg transition-all',
location === 'project'
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50'
)}
onClick={() => setLocation('project')}
>
<div className="flex items-center gap-2">
<Folder className="w-5 h-5" />
<div>
<div className="font-medium text-sm">{formatMessage({ id: 'commands.create.locationProject' })}</div>
<div className="text-xs text-muted-foreground">{`.${cliType}/commands/`}</div>
</div>
</div>
</button>
<button
type="button"
className={cn(
'px-4 py-3 text-left border-2 rounded-lg transition-all',
location === 'user'
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50'
)}
onClick={() => setLocation('user')}
>
<div className="flex items-center gap-2">
<User className="w-5 h-5" />
<div>
<div className="font-medium text-sm">{formatMessage({ id: 'commands.create.locationUser' })}</div>
<div className="text-xs text-muted-foreground">{`~/.${cliType}/commands/`}</div>
</div>
</div>
</button>
</div>
</div>
{/* Mode Selection */}
<div className="space-y-2">
<Label>{formatMessage({ id: 'commands.create.mode' })}</Label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
className={cn(
'px-4 py-3 text-left border-2 rounded-lg transition-all',
mode === 'import'
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50'
)}
onClick={() => setMode('import')}
>
<div className="flex items-center gap-2">
<FileCode className="w-5 h-5" />
<div>
<div className="font-medium text-sm">{formatMessage({ id: 'commands.create.modeImport' })}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'commands.create.modeImportHint' })}</div>
</div>
</div>
</button>
<button
type="button"
className={cn(
'px-4 py-3 text-left border-2 rounded-lg transition-all',
mode === 'cli-generate'
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50'
)}
onClick={() => setMode('cli-generate')}
>
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5" />
<div>
<div className="font-medium text-sm">{formatMessage({ id: 'commands.create.modeGenerate' })}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'commands.create.modeGenerateHint' })}</div>
</div>
</div>
</button>
</div>
</div>
{/* Import Mode Content */}
{mode === 'import' && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="sourcePath">{formatMessage({ id: 'commands.create.sourcePath' })}</Label>
<Input
id="sourcePath"
value={sourcePath}
onChange={(e) => {
setSourcePath(e.target.value);
setValidationResult(null);
}}
placeholder={formatMessage({ id: 'commands.create.sourcePathPlaceholder' })}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'commands.create.sourcePathHint' })}</p>
</div>
<div className="space-y-2">
<Label htmlFor="customName">
{formatMessage({ id: 'commands.create.customName' })}
<span className="text-muted-foreground ml-1">({formatMessage({ id: 'commands.create.customNameHint' })})</span>
</Label>
<Input
id="customName"
value={customName}
onChange={(e) => setCustomName(e.target.value)}
placeholder={formatMessage({ id: 'commands.create.customNamePlaceholder' })}
/>
</div>
{/* Validation Result */}
{isValidating && (
<div className="flex items-center gap-2 p-3 bg-muted/50 rounded-lg">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm text-muted-foreground">{formatMessage({ id: 'commands.create.validating' })}</span>
</div>
)}
{validationResult && !isValidating && (
validationResult.valid ? (
<div className="p-4 bg-green-500/10 border border-green-500/20 rounded-lg">
<div className="flex items-center gap-2 text-green-600 mb-2">
<CheckCircle className="w-5 h-5" />
<span className="font-medium">{formatMessage({ id: 'commands.create.validCommand' })}</span>
</div>
{validationResult.commandInfo && (
<div className="space-y-1 text-sm">
<div>
<span className="text-muted-foreground">{formatMessage({ id: 'commands.card.name' })}: </span>
<span>{validationResult.commandInfo.name}</span>
</div>
{validationResult.commandInfo.description && (
<div>
<span className="text-muted-foreground">{formatMessage({ id: 'commands.card.description' })}: </span>
<span>{validationResult.commandInfo.description}</span>
</div>
)}
{validationResult.commandInfo.usage && (
<div>
<span className="text-muted-foreground">{formatMessage({ id: 'commands.card.usage' })}: </span>
<span>{validationResult.commandInfo.usage}</span>
</div>
)}
</div>
)}
</div>
) : (
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-lg">
<div className="flex items-center gap-2 text-destructive mb-2">
<XCircle className="w-5 h-5" />
<span className="font-medium">{formatMessage({ id: 'commands.create.invalidCommand' })}</span>
</div>
{validationResult.errors && (
<ul className="space-y-1 text-sm">
{validationResult.errors.map((error, i) => (
<li key={i} className="text-destructive">{error}</li>
))}
</ul>
)}
</div>
)
)}
</div>
)}
{/* CLI Generate Mode Content */}
{mode === 'cli-generate' && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="commandName">
{formatMessage({ id: 'commands.create.commandName' })} <span className="text-destructive">*</span>
</Label>
<Input
id="commandName"
value={commandName}
onChange={(e) => setCommandName(e.target.value)}
placeholder={formatMessage({ id: 'commands.create.commandNamePlaceholder' })}
/>
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'commands.create.commandNameHint' })}</p>
</div>
<div className="space-y-2">
<Label htmlFor="description">
{formatMessage({ id: 'commands.create.descriptionLabel' })} <span className="text-destructive">*</span>
</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={formatMessage({ id: 'commands.create.descriptionPlaceholder' })}
rows={6}
/>
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'commands.create.descriptionHint' })}</p>
</div>
<div className="p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
<div className="flex items-start gap-2">
<Info className="w-4 h-4 text-blue-600 mt-0.5" />
<div className="text-sm text-blue-600">
<p className="font-medium">{formatMessage({ id: 'commands.create.generateInfo' })}</p>
<p className="text-xs mt-1">{formatMessage({ id: 'commands.create.generateTimeHint' })}</p>
</div>
</div>
</div>
</div>
)}
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isCreating}>
{formatMessage({ id: 'commands.actions.cancel' })}
</Button>
{mode === 'import' && (
<Button
variant="outline"
onClick={handleValidate}
disabled={!sourcePath.trim() || isValidating || isCreating}
>
{isValidating && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{formatMessage({ id: 'commands.create.validate' })}
</Button>
)}
<Button
onClick={handleCreate}
disabled={!canCreate}
>
{isCreating && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{isCreating
? formatMessage({ id: 'commands.create.creating' })
: mode === 'import'
? formatMessage({ id: 'commands.create.import' })
: formatMessage({ id: 'commands.create.generate' })
}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default CommandCreateDialog;