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:
catlog22
2026-02-27 13:27:27 +08:00
parent 99a3561f71
commit dd72e95e4d
57 changed files with 11018 additions and 1915 deletions

View File

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

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

View File

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

View File

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

View File

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