mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-06 16:31:12 +08:00
feat: add templates for epics, product brief, and requirements PRD
- Created a new directory structure for epics and stories with templates for individual epics and an index file. - Added a product brief template for generating product brief documents in Phase 2. - Introduced a requirements PRD template for generating a Product Requirements Document as a directory of individual requirement files in Phase 3. feat: implement V2PipelineTab component for Memory V2 management - Developed the V2PipelineTab component to manage extraction and consolidation processes. - Included ExtractionCard and ConsolidationCard components to handle respective functionalities. - Added JobsList component to display job statuses and allow filtering by job kind. feat: create hooks for Memory V2 pipeline - Implemented custom hooks for managing extraction and consolidation statuses, as well as job listings. - Added mutation hooks to trigger extraction and consolidation processes with automatic query invalidation on success.
This commit is contained in:
@@ -191,6 +191,41 @@ export const HOOK_TEMPLATES: readonly HookTemplate[] = [
|
||||
trigger: 'SessionStart',
|
||||
command: 'ccw',
|
||||
args: ['hook', 'project-state', '--stdin']
|
||||
},
|
||||
// --- Memory V2 ---
|
||||
{
|
||||
id: 'memory-v2-extract',
|
||||
name: 'Memory V2 Extract',
|
||||
description: 'Trigger Phase 1 extraction when session ends (after idle period)',
|
||||
category: 'indexing',
|
||||
trigger: 'Stop',
|
||||
command: 'ccw',
|
||||
args: ['core-memory', 'extract', '--max-sessions', '10']
|
||||
},
|
||||
{
|
||||
id: 'memory-v2-auto-consolidate',
|
||||
name: 'Memory V2 Auto Consolidate',
|
||||
description: 'Trigger Phase 2 consolidation after extraction jobs complete',
|
||||
category: 'indexing',
|
||||
trigger: 'Stop',
|
||||
command: 'node',
|
||||
args: [
|
||||
'-e',
|
||||
'const cp=require("child_process");const r=cp.spawnSync("ccw",["core-memory","extract","--json"],{encoding:"utf8",shell:true});try{const d=JSON.parse(r.stdout);if(d&&d.total_stage1>=5){cp.spawnSync("ccw",["core-memory","consolidate"],{stdio:"inherit",shell:true})}}catch(e){}'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'memory-sync-dashboard',
|
||||
name: 'Memory Sync Dashboard',
|
||||
description: 'Sync memory V2 status to dashboard on changes',
|
||||
category: 'notification',
|
||||
trigger: 'PostToolUse',
|
||||
matcher: 'core_memory',
|
||||
command: 'node',
|
||||
args: [
|
||||
'-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})'
|
||||
]
|
||||
}
|
||||
] as const;
|
||||
|
||||
|
||||
351
ccw/frontend/src/components/memory/V2PipelineTab.tsx
Normal file
351
ccw/frontend/src/components/memory/V2PipelineTab.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
// ========================================
|
||||
// V2PipelineTab Component
|
||||
// ========================================
|
||||
// Memory V2 Pipeline management UI
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Zap,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Play,
|
||||
Eye,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
FileText,
|
||||
Database,
|
||||
Activity,
|
||||
} from 'lucide-react';
|
||||
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 {
|
||||
useExtractionStatus,
|
||||
useConsolidationStatus,
|
||||
useV2Jobs,
|
||||
useTriggerExtraction,
|
||||
useTriggerConsolidation,
|
||||
} from '@/hooks/useMemoryV2';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Status Badge ==========
|
||||
|
||||
const STATUS_CONFIG: Record<string, { color: string; icon: React.ReactNode; label: string }> = {
|
||||
idle: { color: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300', icon: <Clock className="w-3 h-3" />, label: 'Idle' },
|
||||
running: { color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', icon: <Loader2 className="w-3 h-3 animate-spin" />, label: 'Running' },
|
||||
completed: { color: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300', icon: <CheckCircle className="w-3 h-3" />, label: 'Completed' },
|
||||
done: { color: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300', icon: <CheckCircle className="w-3 h-3" />, label: 'Done' },
|
||||
error: { color: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300', icon: <AlertCircle className="w-3 h-3" />, label: 'Error' },
|
||||
pending: { color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300', icon: <Clock className="w-3 h-3" />, label: 'Pending' },
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const config = STATUS_CONFIG[status] || STATUS_CONFIG.idle;
|
||||
return (
|
||||
<Badge className={cn('flex items-center gap-1', config.color)}>
|
||||
{config.icon}
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Extraction Card ==========
|
||||
|
||||
function ExtractionCard() {
|
||||
const intl = useIntl();
|
||||
const { data: status, isLoading, refetch } = useExtractionStatus();
|
||||
const trigger = useTriggerExtraction();
|
||||
const [maxSessions, setMaxSessions] = useState(10);
|
||||
|
||||
const handleTrigger = () => {
|
||||
trigger.mutate(maxSessions);
|
||||
};
|
||||
|
||||
// Check if any job is running
|
||||
const hasRunningJob = status?.jobs?.some(j => j.status === 'running');
|
||||
|
||||
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>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Consolidation Card ==========
|
||||
|
||||
function ConsolidationCard() {
|
||||
const intl = useIntl();
|
||||
const { data: status, isLoading, refetch } = useConsolidationStatus();
|
||||
const trigger = useTriggerConsolidation();
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
const handleTrigger = () => {
|
||||
trigger.mutate();
|
||||
};
|
||||
|
||||
const isRunning = status?.status === 'running';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="font-medium flex items-center gap-2">
|
||||
<Database className="w-5 h-5 text-blue-500" />
|
||||
Phase 2: {intl.formatMessage({ id: 'memory.v2.consolidation.title', defaultMessage: 'Consolidation' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{intl.formatMessage({ id: 'memory.v2.consolidation.description', defaultMessage: 'Merge extracted results into MEMORY.md' })}
|
||||
</p>
|
||||
</div>
|
||||
{status && <StatusBadge status={status.status} />}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="text-center p-2 bg-muted rounded">
|
||||
<div className="text-lg font-bold">
|
||||
{status?.memoryMdAvailable ? '✅' : '❌'}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">MEMORY.md</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-muted rounded">
|
||||
<div className="text-lg font-bold">{status?.inputCount ?? '-'}</div>
|
||||
<div className="text-xs text-muted-foreground">Inputs</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status?.lastError && (
|
||||
<div className="mb-4 p-2 bg-red-100 dark:bg-red-900/30 rounded text-xs text-red-800 dark:text-red-300">
|
||||
{status.lastError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleTrigger}
|
||||
disabled={trigger.isPending || isRunning}
|
||||
size="sm"
|
||||
>
|
||||
{trigger.isPending || isRunning ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||
{intl.formatMessage({ id: 'memory.v2.consolidation.consolidating', defaultMessage: 'Consolidating...' })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4 mr-1" />
|
||||
{intl.formatMessage({ id: 'memory.v2.consolidation.trigger', defaultMessage: 'Trigger Consolidation' })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{status?.memoryMdAvailable && (
|
||||
<Button variant="outline" size="sm" onClick={() => setShowPreview(true)}>
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
{intl.formatMessage({ id: 'memory.v2.consolidation.preview', defaultMessage: 'Preview' })}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* MEMORY.md Preview Dialog */}
|
||||
<Dialog open={showPreview} onOpenChange={setShowPreview}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
MEMORY.md
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="overflow-auto flex-1">
|
||||
<pre className="text-sm whitespace-pre-wrap p-4 bg-muted rounded font-mono">
|
||||
{status?.memoryMdPreview || 'No content available'}
|
||||
</pre>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Jobs List ==========
|
||||
|
||||
function JobsList() {
|
||||
const intl = useIntl();
|
||||
const [kindFilter, setKindFilter] = useState<string>('');
|
||||
const { data, isLoading, refetch } = useV2Jobs(kindFilter ? { kind: kindFilter } : undefined);
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-medium flex items-center gap-2">
|
||||
<Activity className="w-5 h-5 text-purple-500" />
|
||||
{intl.formatMessage({ id: 'memory.v2.jobs.title', defaultMessage: 'Jobs' })}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={kindFilter}
|
||||
onChange={(e) => setKindFilter(e.target.value)}
|
||||
className="px-2 py-1 text-sm border rounded bg-background"
|
||||
>
|
||||
<option value="">All Kinds</option>
|
||||
<option value="phase1_extraction">Extraction</option>
|
||||
<option value="memory_consolidate_global">Consolidation</option>
|
||||
</select>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data?.jobs && data.jobs.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left p-2">
|
||||
{intl.formatMessage({ id: 'memory.v2.jobs.kind', defaultMessage: 'Kind' })}
|
||||
</th>
|
||||
<th className="text-left p-2">
|
||||
{intl.formatMessage({ id: 'memory.v2.jobs.key', defaultMessage: 'Key' })}
|
||||
</th>
|
||||
<th className="text-left p-2">
|
||||
{intl.formatMessage({ id: 'memory.v2.jobs.status', defaultMessage: 'Status' })}
|
||||
</th>
|
||||
<th className="text-left p-2">
|
||||
{intl.formatMessage({ id: 'memory.v2.jobs.error', defaultMessage: 'Error' })}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.jobs.map((job) => (
|
||||
<tr key={`${job.kind}-${job.job_key}`} className="border-b">
|
||||
<td className="p-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{job.kind === 'phase1_extraction' ? 'Extraction' :
|
||||
job.kind === 'memory_consolidate_global' ? 'Consolidation' : job.kind}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-2 font-mono text-xs truncate max-w-[150px]">{job.job_key}</td>
|
||||
<td className="p-2"><StatusBadge status={job.status} /></td>
|
||||
<td className="p-2 text-red-500 text-xs truncate max-w-[200px]">{job.last_error || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
{intl.formatMessage({ id: 'memory.v2.jobs.noJobs', defaultMessage: 'No jobs found' })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 按状态统计 */}
|
||||
{data?.byStatus && Object.keys(data.byStatus).length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t flex items-center gap-4 text-sm flex-wrap">
|
||||
{Object.entries(data.byStatus).map(([status, count]) => (
|
||||
<span key={status} className="flex items-center gap-1">
|
||||
<StatusBadge status={status} />
|
||||
<span className="font-bold">{count}</span>
|
||||
</span>
|
||||
))}
|
||||
<span className="text-muted-foreground ml-auto">
|
||||
Total: {data.total}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
export function V2PipelineTab() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<ExtractionCard />
|
||||
<ConsolidationCard />
|
||||
</div>
|
||||
<JobsList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default V2PipelineTab;
|
||||
@@ -187,10 +187,7 @@ export function GlobalSettingsTab() {
|
||||
});
|
||||
|
||||
// Fetch project-tech stats
|
||||
const {
|
||||
data: projectTechStats,
|
||||
isLoading: isLoadingProjectTech,
|
||||
} = useQuery({
|
||||
const { data: projectTechStats } = useQuery({
|
||||
queryKey: settingsKeys.projectTech(),
|
||||
queryFn: fetchProjectTechStats,
|
||||
staleTime: 60000, // 1 minute
|
||||
@@ -490,7 +487,7 @@ export function GlobalSettingsTab() {
|
||||
)}
|
||||
onClick={() => localDevProgress.enabled && handleCategoryToggle(cat)}
|
||||
>
|
||||
{cat} ({projectTechStats?.categories[cat] || 0})
|
||||
{formatMessage({ id: `specs.devCategory.${cat}`, defaultMessage: cat })} ({projectTechStats?.categories[cat] || 0})
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -48,7 +48,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useInstallRecommendedHooks } from '@/hooks/useSystemSettings';
|
||||
import type { InjectionPreviewFile, InjectionPreviewResponse } from '@/lib/api';
|
||||
import { getInjectionPreview } from '@/lib/api';
|
||||
import { getInjectionPreview, COMMAND_PREVIEWS, type CommandPreviewConfig } from '@/lib/api';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
@@ -209,6 +209,12 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
|
||||
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
|
||||
const [previewFile, setPreviewFile] = useState<InjectionPreviewFile | null>(null);
|
||||
|
||||
// State for command preview
|
||||
const [selectedCommand, setSelectedCommand] = useState<CommandPreviewConfig>(COMMAND_PREVIEWS[0]);
|
||||
const [commandPreviewData, setCommandPreviewData] = useState<InjectionPreviewResponse | null>(null);
|
||||
const [commandPreviewLoading, setCommandPreviewLoading] = useState(false);
|
||||
const [commandPreviewDialogOpen, setCommandPreviewDialogOpen] = useState(false);
|
||||
|
||||
// Fetch stats
|
||||
const loadStats = useCallback(async () => {
|
||||
setStatsLoading(true);
|
||||
@@ -264,6 +270,21 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
|
||||
}
|
||||
}, [previewMode]);
|
||||
|
||||
// Load command preview content
|
||||
const loadCommandPreview = useCallback(async (command: CommandPreviewConfig) => {
|
||||
setCommandPreviewLoading(true);
|
||||
try {
|
||||
const data = await getInjectionPreview(command.mode, true, undefined, command.category);
|
||||
setCommandPreviewData(data);
|
||||
setSelectedCommand(command);
|
||||
setCommandPreviewDialogOpen(true);
|
||||
} catch (err) {
|
||||
console.error('Failed to load command preview:', err);
|
||||
} finally {
|
||||
setCommandPreviewLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
@@ -406,15 +427,20 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
|
||||
return stats;
|
||||
}, [previewData]);
|
||||
|
||||
// Calculate progress and status
|
||||
// Calculate progress and status - use API's maxLength for consistency
|
||||
const currentLength = stats?.injectionLength?.withKeywords || 0;
|
||||
const maxLength = settings.maxLength;
|
||||
const apiMaxLength = stats?.injectionLength?.maxLength || settings.maxLength;
|
||||
const maxLength = apiMaxLength; // Use API's maxLength for consistency
|
||||
const warnThreshold = settings.warnThreshold;
|
||||
const percentage = calculatePercentage(currentLength, maxLength);
|
||||
const isOverLimit = currentLength > maxLength;
|
||||
const isOverWarning = currentLength > warnThreshold;
|
||||
const remainingSpace = Math.max(0, maxLength - currentLength);
|
||||
|
||||
// Calculate approximate line count (assuming ~80 chars per line)
|
||||
const estimatedLineCount = Math.ceil(currentLength / 80);
|
||||
const maxLineCount = Math.ceil(maxLength / 80);
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-6', className)}>
|
||||
{/* Recommended Hooks Section */}
|
||||
@@ -556,36 +582,40 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
|
||||
>
|
||||
{formatNumber(currentLength)} / {formatNumber(maxLength)}{' '}
|
||||
{formatMessage({ id: 'specs.injection.characters', defaultMessage: 'characters' })}
|
||||
<span className="text-muted-foreground ml-2">
|
||||
(~{formatNumber(estimatedLineCount)} / {formatNumber(maxLineCount)} {formatMessage({ id: 'specs.injection.lines', defaultMessage: 'lines' })})
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="space-y-2">
|
||||
<Progress
|
||||
value={percentage}
|
||||
className={cn(
|
||||
'h-3',
|
||||
isOverLimit && 'bg-destructive/20',
|
||||
!isOverLimit && isOverWarning && 'bg-yellow-100 dark:bg-yellow-900/30'
|
||||
)}
|
||||
indicatorClassName={cn(
|
||||
isOverLimit && 'bg-destructive',
|
||||
!isOverLimit && isOverWarning && 'bg-yellow-500'
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Warning threshold marker */}
|
||||
<div
|
||||
className="relative h-0"
|
||||
style={{
|
||||
left: `${Math.min(100, (warnThreshold / maxLength) * 100)}%`,
|
||||
}}
|
||||
>
|
||||
<div className="absolute -top-5 transform -translate-x-1/2 flex flex-col items-center">
|
||||
<AlertTriangle className="h-3 w-3 text-yellow-500" />
|
||||
<div className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{formatMessage({ id: 'specs.injection.warnThreshold', defaultMessage: 'Warn' })}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
|
||||
<span>{percentage}%</span>
|
||||
<span>{formatMessage({ id: 'specs.injection.maxLimit', defaultMessage: 'Max' })}: {formatNumber(maxLength)}</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Progress
|
||||
value={percentage}
|
||||
className={cn(
|
||||
'h-3',
|
||||
isOverLimit && 'bg-destructive/20',
|
||||
!isOverLimit && isOverWarning && 'bg-yellow-100 dark:bg-yellow-900/30'
|
||||
)}
|
||||
indicatorClassName={cn(
|
||||
isOverLimit && 'bg-destructive',
|
||||
!isOverLimit && isOverWarning && 'bg-yellow-500'
|
||||
)}
|
||||
/>
|
||||
{/* Warning threshold marker */}
|
||||
<div
|
||||
className="absolute top-0 h-3 flex flex-col items-center"
|
||||
style={{
|
||||
left: `${Math.min(100, (warnThreshold / maxLength) * 100)}%`,
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle className="h-3 w-3 text-yellow-500 -mt-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -791,6 +821,59 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Command Preview Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Eye className="h-5 w-5" />
|
||||
{formatMessage({ id: 'specs.injection.commandPreview', defaultMessage: 'Command Injection Preview' })}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{formatMessage({
|
||||
id: 'specs.injection.commandPreviewDesc',
|
||||
defaultMessage: 'Preview the content that would be injected by different CLI commands',
|
||||
})}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
{COMMAND_PREVIEWS.map((cmd) => (
|
||||
<Button
|
||||
key={cmd.command}
|
||||
variant="outline"
|
||||
className="h-auto flex-col items-start py-3 px-4"
|
||||
onClick={() => loadCommandPreview(cmd)}
|
||||
disabled={commandPreviewLoading}
|
||||
>
|
||||
<div className="font-medium text-sm">
|
||||
{formatMessage({
|
||||
id: `specs.commandPreview.${cmd.labelKey}.label`,
|
||||
defaultMessage: cmd.labelKey
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{formatMessage({
|
||||
id: `specs.commandPreview.${cmd.descriptionKey}.description`,
|
||||
defaultMessage: cmd.descriptionKey
|
||||
})}
|
||||
</div>
|
||||
<code className="text-xs bg-muted px-1.5 py-0.5 rounded mt-2 w-full text-center">
|
||||
{cmd.command.replace('ccw spec load', '').trim() || 'default'}
|
||||
</code>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{commandPreviewLoading && (
|
||||
<div className="flex items-center justify-center py-4 mt-4 border rounded-lg">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground mr-2" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'specs.injection.loadingPreview', defaultMessage: 'Loading preview...' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Settings Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -814,39 +897,83 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
|
||||
{/* Max Injection Length */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxLength">
|
||||
{formatMessage({ id: 'specs.injection.maxLength', defaultMessage: 'Max Injection Length (characters)' })}
|
||||
{formatMessage({ id: 'specs.injection.maxLength', defaultMessage: 'Max Injection Length' })}
|
||||
</Label>
|
||||
<Input
|
||||
id="maxLength"
|
||||
type="number"
|
||||
min={1000}
|
||||
max={50000}
|
||||
step={500}
|
||||
value={formData.maxLength}
|
||||
onChange={(e) => handleFieldChange('maxLength', Number(e.target.value))}
|
||||
/>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
id="maxLength"
|
||||
type="number"
|
||||
min={1000}
|
||||
max={50000}
|
||||
step={500}
|
||||
value={formData.maxLength}
|
||||
onChange={(e) => handleFieldChange('maxLength', Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
(~{Math.ceil(formData.maxLength / 80)} {formatMessage({ id: 'specs.injection.lines', defaultMessage: 'lines' })})
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({
|
||||
id: 'specs.injection.maxLengthHelp',
|
||||
defaultMessage: 'Recommended: 4000-10000. Too large may consume too much context; too small may truncate important specs.',
|
||||
defaultMessage: 'Recommended: 4000-10000 characters (50-125 lines). Too large may consume too much context.',
|
||||
})}
|
||||
</p>
|
||||
{/* Quick presets */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'specs.injection.quickPresets', defaultMessage: 'Quick presets:' })}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={() => handleFieldChange('maxLength', 4000)}
|
||||
>
|
||||
4000 (50 lines)
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={() => handleFieldChange('maxLength', 8000)}
|
||||
>
|
||||
8000 (100 lines)
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={() => handleFieldChange('maxLength', 12000)}
|
||||
>
|
||||
12000 (150 lines)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warning Threshold */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="warnThreshold">
|
||||
{formatMessage({ id: 'specs.injection.warnThresholdLabel', defaultMessage: 'Warning Threshold (characters)' })}
|
||||
{formatMessage({ id: 'specs.injection.warnThresholdLabel', defaultMessage: 'Warning Threshold' })}
|
||||
</Label>
|
||||
<Input
|
||||
id="warnThreshold"
|
||||
type="number"
|
||||
min={500}
|
||||
max={formData.maxLength - 1}
|
||||
step={500}
|
||||
value={formData.warnThreshold}
|
||||
onChange={(e) => handleFieldChange('warnThreshold', Number(e.target.value))}
|
||||
/>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
id="warnThreshold"
|
||||
type="number"
|
||||
min={500}
|
||||
max={formData.maxLength - 1}
|
||||
step={500}
|
||||
value={formData.warnThreshold}
|
||||
onChange={(e) => handleFieldChange('warnThreshold', Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
(~{Math.ceil(formData.warnThreshold / 80)} {formatMessage({ id: 'specs.injection.lines', defaultMessage: 'lines' })})
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({
|
||||
id: 'specs.injection.warnThresholdHelp',
|
||||
@@ -916,6 +1043,84 @@ export function InjectionControlTab({ className }: InjectionControlTabProps) {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Command Preview Dialog */}
|
||||
<Dialog open={commandPreviewDialogOpen} onOpenChange={setCommandPreviewDialogOpen}>
|
||||
<DialogContent className="max-w-4xl max-h-[85vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Eye className="h-5 w-5" />
|
||||
{formatMessage({ id: 'specs.injection.previewTitle', defaultMessage: 'Injection Preview' })}
|
||||
</DialogTitle>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<code className="bg-muted px-2 py-1 rounded text-xs">{selectedCommand.command}</code>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{formatMessage({
|
||||
id: `specs.commandPreview.${selectedCommand.descriptionKey}.description`,
|
||||
defaultMessage: selectedCommand.descriptionKey
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{/* Stats */}
|
||||
{commandPreviewData && (
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<Badge variant="secondary">
|
||||
{commandPreviewData.stats.count} {formatMessage({ id: 'specs.injection.files', defaultMessage: 'files' })}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{formatNumber(commandPreviewData.stats.totalLength)} {formatMessage({ id: 'specs.injection.characters', defaultMessage: 'characters' })}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
~{Math.ceil(commandPreviewData.stats.totalLength / 80)} {formatMessage({ id: 'specs.injection.lines', defaultMessage: 'lines' })}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{/* Preview Content */}
|
||||
<div className="flex-1 overflow-auto max-h-[60vh] border rounded-lg">
|
||||
{commandPreviewData?.files.length ? (
|
||||
<div className="space-y-4 p-4">
|
||||
{commandPreviewData.files.map((file, idx) => (
|
||||
<div key={file.file} className="space-y-2">
|
||||
{/* File Header */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{idx + 1}
|
||||
</Badge>
|
||||
<span className="font-medium">{file.title}</span>
|
||||
{file.category && (
|
||||
<Badge variant="outline" className={cn(
|
||||
'text-xs',
|
||||
CATEGORY_CONFIG[file.category as SpecCategory]?.bgColor,
|
||||
CATEGORY_CONFIG[file.category as SpecCategory]?.color
|
||||
)}>
|
||||
{file.category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatNumber(file.contentLength)} {formatMessage({ id: 'specs.injection.characters', defaultMessage: 'characters' })}
|
||||
</span>
|
||||
</div>
|
||||
{/* File Content */}
|
||||
<pre className="text-xs whitespace-pre-wrap p-3 bg-muted rounded border-l-2 border-primary/30">
|
||||
{file.content || formatMessage({ id: 'specs.content.noContent', defaultMessage: 'No content available' })}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
{formatMessage({ id: 'specs.injection.noFiles', defaultMessage: 'No files match this command' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,21 +7,26 @@ const Progress = React.forwardRef<
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
|
||||
indicatorClassName?: string;
|
||||
}
|
||||
>(({ className, value, indicatorClassName, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className={cn("h-full w-full flex-1 bg-primary transition-all", indicatorClassName)}
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
));
|
||||
>(({ className, value, indicatorClassName, ...props }, ref) => {
|
||||
// Ensure value is a valid number between 0-100
|
||||
const safeValue = Math.max(0, Math.min(100, Number(value) || 0));
|
||||
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className={cn("h-full w-full flex-1 bg-primary transition-all", indicatorClassName)}
|
||||
style={{ transform: `translateX(-${100 - safeValue}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
});
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress };
|
||||
|
||||
Reference in New Issue
Block a user