feat: add SpecDialog component for editing spec frontmatter

- Implement SpecDialog for managing spec details including title, read mode, priority, and keywords.
- Add validation and keyword management functionality.
- Integrate SpecDialog into SpecsSettingsPage for editing specs.

feat: create index file for specs components

- Export SpecCard, SpecDialog, and related types from a new index file for better organization.

feat: implement SpecsSettingsPage for managing specs and hooks

- Create main settings page with tabs for Project Specs, Personal Specs, Hooks, Injection, and Settings.
- Integrate SpecDialog and HookDialog for editing specs and hooks.
- Add search functionality and mock data for specs and hooks.

feat: add spec management API routes

- Implement API endpoints for listing specs, getting spec details, updating frontmatter, rebuilding indices, and initializing the spec system.
- Handle errors and responses appropriately for each endpoint.
This commit is contained in:
catlog22
2026-02-26 22:03:13 +08:00
parent 430d817e43
commit 6155fcc7b8
115 changed files with 4883 additions and 21127 deletions

View File

@@ -25,11 +25,12 @@ type ToolName = 'claude' | 'codex' | 'gemini';
type ResumeStrategy = 'nativeResume' | 'promptConcat';
const BOARD_COLUMNS: Array<{ id: IssueBoardStatus; titleKey: string }> = [
{ id: 'open', titleKey: 'issues.status.open' },
{ id: 'in_progress', titleKey: 'issues.status.inProgress' },
{ id: 'resolved', titleKey: 'issues.status.resolved' },
{ id: 'registered', titleKey: 'issues.status.registered' },
{ id: 'planning', titleKey: 'issues.status.planning' },
{ id: 'planned', titleKey: 'issues.status.planned' },
{ id: 'executing', titleKey: 'issues.status.executing' },
{ id: 'completed', titleKey: 'issues.status.completed' },
{ id: 'closed', titleKey: 'issues.status.closed' },
{ id: 'failed', titleKey: 'issues.status.failed' },
];
type BoardOrder = Partial<Record<IssueBoardStatus, string[]>>;
@@ -294,8 +295,8 @@ export function IssueBoardPanel() {
try {
await updateIssue(issueId, { status: destStatus });
// Auto action: drag to in_progress opens the drawer on terminal tab.
if (destStatus === 'in_progress' && sourceStatus !== 'in_progress') {
// Auto action: drag to executing opens the drawer on terminal tab.
if (destStatus === 'executing' && sourceStatus !== 'executing') {
setDrawerInitialTab('terminal');
setSelectedIssue({ ...issue, status: destStatus });

View File

@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils';
// Keep in sync with IssueHubHeader/IssueHubPage
export type IssueTab = 'issues' | 'board' | 'queue' | 'discovery';
export type IssueTab = 'issues' | 'queue' | 'discovery';
interface IssueHubTabsProps {
currentTab: IssueTab;
@@ -20,7 +20,6 @@ export function IssueHubTabs({ currentTab, onTabChange }: IssueHubTabsProps) {
const tabs: Array<{ value: IssueTab; label: string }> = [
{ value: 'issues', label: formatMessage({ id: 'issues.hub.tabs.issues' }) },
{ value: 'board', label: formatMessage({ id: 'issues.hub.tabs.board' }) },
{ value: 'queue', label: formatMessage({ id: 'issues.hub.tabs.queue' }) },
{ value: 'discovery', label: formatMessage({ id: 'issues.hub.tabs.discovery' }) },
];

View File

@@ -1,9 +1,9 @@
// ========================================
// Issues Panel
// ========================================
// Issue list panel for IssueHub
// Unified issue list panel with list/board view toggle
import { useState, useMemo } from 'react';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useIntl } from 'react-intl';
import {
Search,
@@ -11,7 +11,10 @@ import {
Clock,
AlertTriangle,
AlertCircle,
LayoutGrid,
List,
} from 'lucide-react';
import type { DropResult } from '@hello-pangea/dnd';
import { Card } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
@@ -19,11 +22,24 @@ import { Button } from '@/components/ui/Button';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select';
import { IssueCard } from '@/components/shared/IssueCard';
import { IssueDrawer } from '@/components/issue/hub/IssueDrawer';
import { KanbanBoard, type KanbanColumn, type KanbanItem } from '@/components/shared/KanbanBoard';
import { useIssues, useIssueMutations } from '@/hooks';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import { cn } from '@/lib/utils';
import type { Issue } from '@/lib/api';
type StatusFilter = 'all' | Issue['status'];
type PriorityFilter = 'all' | Issue['priority'];
type ViewMode = 'list' | 'board';
// Board columns matching backend status with Chinese defaults
const BOARD_COLUMNS: Array<{ id: Issue['status']; titleKey: string; defaultLabel: string }> = [
{ id: 'registered', titleKey: 'issues.status.registered', defaultLabel: '新注册' },
{ id: 'planning', titleKey: 'issues.status.planning', defaultLabel: '规划中' },
{ id: 'planned', titleKey: 'issues.status.planned', defaultLabel: '已规划' },
{ id: 'executing', titleKey: 'issues.status.executing', defaultLabel: '执行中' },
{ id: 'completed', titleKey: 'issues.status.completed', defaultLabel: '已完成' },
];
interface IssuesPanelProps {
onCreateIssue?: () => void;
@@ -77,14 +93,114 @@ function IssueList({ issues, isLoading, onIssueClick, onIssueEdit, onIssueDelete
);
}
// Local storage key for board order
function storageKey(projectPath: string | null | undefined): string {
const base = projectPath ? encodeURIComponent(projectPath) : 'global';
return `ccw.issues.board.order:${base}`;
}
type BoardOrder = Partial<Record<Issue['status'], string[]>>;
function safeParseOrder(value: string | null): BoardOrder {
if (!value) return {};
try {
const parsed = JSON.parse(value) as unknown;
if (!parsed || typeof parsed !== 'object') return {};
return parsed as BoardOrder;
} catch {
return {};
}
}
function buildColumns(
issues: Issue[],
order: BoardOrder,
formatTitle: (statusId: Issue['status']) => string
): KanbanColumn<Issue & KanbanItem>[] {
const byId = new Map(issues.map((i) => [i.id, i]));
const columns: KanbanColumn<Issue & KanbanItem>[] = [];
for (const col of BOARD_COLUMNS) {
const desired = (order[col.id] ?? []).map((id) => byId.get(id)).filter(Boolean) as Issue[];
const desiredIds = new Set(desired.map((i) => i.id));
const remaining = issues
.filter((i) => i.status === col.id && !desiredIds.has(i.id))
.sort((a, b) => {
const at = a.updatedAt || a.createdAt || '';
const bt = b.updatedAt || b.createdAt || '';
return bt.localeCompare(at);
});
const items = [...desired, ...remaining].map((issue) => ({
...issue,
id: issue.id,
title: issue.title,
status: issue.status,
}));
columns.push({
id: col.id,
title: formatTitle(col.id),
items,
icon: <LayoutGrid className="w-4 h-4" />,
});
}
return columns;
}
function syncOrderWithIssues(prev: BoardOrder, issues: Issue[]): BoardOrder {
const statusById = new Map(issues.map((i) => [i.id, i.status]));
const next: BoardOrder = {};
for (const { id: status } of BOARD_COLUMNS) {
const existing = prev[status] ?? [];
const filtered = existing.filter((id) => statusById.get(id) === status);
const present = new Set(filtered);
const missing = issues
.filter((i) => i.status === status && !present.has(i.id))
.map((i) => i.id);
next[status] = [...filtered, ...missing];
}
return next;
}
function reorderIds(list: string[], from: number, to: number): string[] {
const next = [...list];
const [moved] = next.splice(from, 1);
if (moved === undefined) return list;
next.splice(to, 0, moved);
return next;
}
export function IssuesPanel({ onCreateIssue: _onCreateIssue }: IssuesPanelProps) {
const { formatMessage } = useIntl();
const projectPath = useWorkflowStore(selectProjectPath);
// View mode state
const [viewMode, setViewMode] = useState<ViewMode>('list');
// Filter state
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [priorityFilter, setPriorityFilter] = useState<PriorityFilter>('all');
const [selectedIssue, setSelectedIssue] = useState<Issue | null>(null);
const { issues, issuesByStatus, openCount, criticalCount, isLoading } = useIssues({
// Board order state
const [order, setOrder] = useState<BoardOrder>({});
// Load board order when project changes
useEffect(() => {
const key = storageKey(projectPath);
const loaded = safeParseOrder(localStorage.getItem(key));
setOrder(loaded);
}, [projectPath]);
const { issues, criticalCount, isLoading } = useIssues({
filter: {
search: searchQuery || undefined,
status: statusFilter !== 'all' ? [statusFilter] : undefined,
@@ -94,14 +210,50 @@ export function IssuesPanel({ onCreateIssue: _onCreateIssue }: IssuesPanelProps)
const { updateIssue, deleteIssue } = useIssueMutations();
// Keep order consistent with current issues
useEffect(() => {
setOrder((prev) => syncOrderWithIssues(prev, issues));
}, [issues]);
// Persist order
useEffect(() => {
const key = storageKey(projectPath);
try {
localStorage.setItem(key, JSON.stringify(order));
} catch {
// ignore quota errors
}
}, [order, projectPath]);
// Status counts using backend statuses
const statusCounts = useMemo(() => ({
all: issues.length,
open: issuesByStatus.open?.length || 0,
in_progress: issuesByStatus.in_progress?.length || 0,
resolved: issuesByStatus.resolved?.length || 0,
closed: issuesByStatus.closed?.length || 0,
completed: issuesByStatus.completed?.length || 0,
}), [issues, issuesByStatus]);
registered: issues.filter(i => i.status === 'registered').length,
planning: issues.filter(i => i.status === 'planning').length,
planned: issues.filter(i => i.status === 'planned').length,
executing: issues.filter(i => i.status === 'executing').length,
completed: issues.filter(i => i.status === 'completed').length,
failed: issues.filter(i => i.status === 'failed').length,
}), [issues]);
// Build kanban columns
const columns = useMemo(
() =>
buildColumns(issues, order, (statusId) => {
const col = BOARD_COLUMNS.find((c) => c.id === statusId);
if (!col) return statusId;
return formatMessage({ id: col.titleKey, defaultMessage: col.defaultLabel });
}),
[issues, order, formatMessage]
);
const idsByStatus = useMemo(() => {
const map: Record<string, string[]> = {};
for (const col of columns) {
map[col.id] = col.items.map((i) => i.id);
}
return map;
}, [columns]);
const handleEditIssue = (_issue: Issue) => {};
@@ -123,22 +275,66 @@ export function IssuesPanel({ onCreateIssue: _onCreateIssue }: IssuesPanelProps)
setSelectedIssue(null);
};
// Board drag handler
const handleDragEnd = useCallback(
async (result: DropResult, sourceColumn: string, destColumn: string) => {
const issueId = result.draggableId;
const issue = issues.find((i) => i.id === issueId);
if (!issue) return;
const sourceStatus = sourceColumn as Issue['status'];
const destStatus = destColumn as Issue['status'];
const sourceIds = idsByStatus[sourceStatus] ?? [];
const destIds = idsByStatus[destStatus] ?? [];
// Update local order first (optimistic)
setOrder((prev) => {
const next = { ...prev };
if (sourceStatus === destStatus) {
next[sourceStatus] = reorderIds(sourceIds, result.source.index, result.destination!.index);
return next;
}
const nextSource = [...sourceIds];
nextSource.splice(result.source.index, 1);
const nextDest = [...destIds];
nextDest.splice(result.destination!.index, 0, issueId);
next[sourceStatus] = nextSource;
next[destStatus] = nextDest;
return next;
});
// Status update
if (sourceStatus !== destStatus) {
try {
await updateIssue(issueId, { status: destStatus });
} catch (e) {
console.error('Failed to update issue status:', e);
}
}
},
[issues, idsByStatus, updateIssue]
);
return (
<div className="space-y-4">
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="p-4">
<div className="flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-info" />
<span className="text-2xl font-bold">{openCount}</span>
<span className="text-2xl font-bold">{statusCounts.registered}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'common.status.openIssues' })}</p>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'issues.status.registered', defaultMessage: '新注册' })}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2">
<Clock className="w-5 h-5 text-warning" />
<span className="text-2xl font-bold">{issuesByStatus.in_progress?.length || 0}</span>
<span className="text-2xl font-bold">{statusCounts.executing}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'issues.status.inProgress' })}</p>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'issues.status.executing', defaultMessage: '执行中' })}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2">
@@ -150,12 +346,13 @@ export function IssuesPanel({ onCreateIssue: _onCreateIssue }: IssuesPanelProps)
<Card className="p-4">
<div className="flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-success" />
<span className="text-2xl font-bold">{issuesByStatus.resolved?.length || 0}</span>
<span className="text-2xl font-bold">{statusCounts.completed}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'issues.status.resolved' })}</p>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'issues.status.completed', defaultMessage: '已完成' })}</p>
</Card>
</div>
{/* Toolbar */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
@@ -166,6 +363,29 @@ export function IssuesPanel({ onCreateIssue: _onCreateIssue }: IssuesPanelProps)
className="pl-9"
/>
</div>
{/* View Mode Toggle */}
<div className="flex border border-border rounded-md overflow-hidden">
<Button
variant={viewMode === 'list' ? 'default' : 'ghost'}
size="sm"
className="rounded-none"
onClick={() => setViewMode('list')}
>
<List className="w-4 h-4 mr-1" />
{formatMessage({ id: 'common.view.list', defaultMessage: '列表' })}
</Button>
<Button
variant={viewMode === 'board' ? 'default' : 'ghost'}
size="sm"
className="rounded-none"
onClick={() => setViewMode('board')}
>
<LayoutGrid className="w-4 h-4 mr-1" />
{formatMessage({ id: 'common.view.board', defaultMessage: '看板' })}
</Button>
</div>
<div className="flex gap-2">
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
<SelectTrigger className="w-[140px]">
@@ -173,10 +393,12 @@ export function IssuesPanel({ onCreateIssue: _onCreateIssue }: IssuesPanelProps)
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{formatMessage({ id: 'issues.filters.all' })}</SelectItem>
<SelectItem value="open">{formatMessage({ id: 'issues.status.open' })}</SelectItem>
<SelectItem value="in_progress">{formatMessage({ id: 'issues.status.inProgress' })}</SelectItem>
<SelectItem value="resolved">{formatMessage({ id: 'issues.status.resolved' })}</SelectItem>
<SelectItem value="closed">{formatMessage({ id: 'issues.status.closed' })}</SelectItem>
<SelectItem value="registered">{formatMessage({ id: 'issues.status.registered', defaultMessage: '新注册' })}</SelectItem>
<SelectItem value="planning">{formatMessage({ id: 'issues.status.planning', defaultMessage: '规划中' })}</SelectItem>
<SelectItem value="planned">{formatMessage({ id: 'issues.status.planned', defaultMessage: '已规划' })}</SelectItem>
<SelectItem value="executing">{formatMessage({ id: 'issues.status.executing', defaultMessage: '执行中' })}</SelectItem>
<SelectItem value="completed">{formatMessage({ id: 'issues.status.completed', defaultMessage: '已完成' })}</SelectItem>
<SelectItem value="failed">{formatMessage({ id: 'issues.status.failed', defaultMessage: '失败' })}</SelectItem>
</SelectContent>
</Select>
<Select value={priorityFilter} onValueChange={(v) => setPriorityFilter(v as PriorityFilter)}>
@@ -194,32 +416,59 @@ export function IssuesPanel({ onCreateIssue: _onCreateIssue }: IssuesPanelProps)
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant={statusFilter === 'all' ? 'default' : 'outline'} size="sm" onClick={() => setStatusFilter('all')}>
{formatMessage({ id: 'issues.filters.all' })} ({statusCounts.all})
</Button>
<Button variant={statusFilter === 'open' ? 'default' : 'outline'} size="sm" onClick={() => setStatusFilter('open')}>
<Badge variant="info" className="mr-2">{statusCounts.open}</Badge>
{formatMessage({ id: 'issues.status.open' })}
</Button>
<Button variant={statusFilter === 'in_progress' ? 'default' : 'outline'} size="sm" onClick={() => setStatusFilter('in_progress')}>
<Badge variant="warning" className="mr-2">{statusCounts.in_progress}</Badge>
{formatMessage({ id: 'issues.status.inProgress' })}
</Button>
<Button variant={priorityFilter === 'critical' ? 'destructive' : 'outline'} size="sm" onClick={() => { setPriorityFilter(priorityFilter === 'critical' ? 'all' : 'critical'); setStatusFilter('all'); }}>
<Badge variant="destructive" className="mr-2">{criticalCount}</Badge>
{formatMessage({ id: 'issues.priority.critical' })}
</Button>
</div>
{/* Status Filter Pills - only in list mode */}
{viewMode === 'list' && (
<div className="flex flex-wrap gap-2">
<Button variant={statusFilter === 'all' ? 'default' : 'outline'} size="sm" onClick={() => setStatusFilter('all')}>
{formatMessage({ id: 'issues.filters.all' })} ({statusCounts.all})
</Button>
<Button variant={statusFilter === 'registered' ? 'default' : 'outline'} size="sm" onClick={() => setStatusFilter('registered')}>
<Badge variant="info" className="mr-2">{statusCounts.registered}</Badge>
{formatMessage({ id: 'issues.status.registered', defaultMessage: '新注册' })}
</Button>
<Button variant={statusFilter === 'executing' ? 'default' : 'outline'} size="sm" onClick={() => setStatusFilter('executing')}>
<Badge variant="warning" className="mr-2">{statusCounts.executing}</Badge>
{formatMessage({ id: 'issues.status.executing', defaultMessage: '执行中' })}
</Button>
<Button variant={priorityFilter === 'critical' ? 'destructive' : 'outline'} size="sm" onClick={() => { setPriorityFilter(priorityFilter === 'critical' ? 'all' : 'critical'); setStatusFilter('all'); }}>
<Badge variant="destructive" className="mr-2">{criticalCount}</Badge>
{formatMessage({ id: 'issues.priority.critical' })}
</Button>
</div>
)}
<IssueList
issues={issues}
isLoading={isLoading}
onIssueClick={handleIssueClick}
onIssueEdit={handleEditIssue}
onIssueDelete={handleDeleteIssue}
onStatusChange={handleStatusChange}
/>
{/* Content Area */}
{viewMode === 'list' ? (
<IssueList
issues={issues}
isLoading={isLoading}
onIssueClick={handleIssueClick}
onIssueEdit={handleEditIssue}
onIssueDelete={handleDeleteIssue}
onStatusChange={handleStatusChange}
/>
) : (
<KanbanBoard<Issue & KanbanItem>
columns={columns}
onDragEnd={handleDragEnd}
onItemClick={(item) => handleIssueClick(item as unknown as Issue)}
isLoading={isLoading}
emptyColumnMessage={formatMessage({ id: 'issues.emptyState.message' })}
className={cn('gap-4', 'grid')}
renderItem={(item, provided) => (
<IssueCard
issue={item as unknown as Issue}
compact
showActions={false}
onClick={(i) => handleIssueClick(i)}
innerRef={provided.innerRef}
draggableProps={provided.draggableProps}
dragHandleProps={provided.dragHandleProps}
className="w-full"
/>
)}
/>
)}
{/* Issue Detail Drawer */}
<IssueDrawer

View File

@@ -26,6 +26,7 @@ import {
Cog,
Users,
FileSearch,
ScrollText,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
@@ -101,6 +102,7 @@ const navGroupDefinitions: NavGroupDef[] = [
items: [
{ path: '/hooks', labelKey: 'navigation.main.hooks', icon: GitFork },
{ path: '/settings/mcp', labelKey: 'navigation.main.mcp', icon: Server },
{ path: '/settings/specs', labelKey: 'navigation.main.specs', icon: ScrollText },
],
},
{

View File

@@ -16,6 +16,7 @@ import {
CheckCircle,
Clock,
XCircle,
Loader2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Card } from '@/components/ui/Card';
@@ -61,20 +62,26 @@ const priorityLabelKeys: Record<Issue['priority'], string> = {
// Status icon and color configuration (without labels for i18n)
const statusVariantConfig: Record<Issue['status'], { icon: React.ElementType; color: string }> = {
open: { icon: AlertCircle, color: 'info' },
in_progress: { icon: Clock, color: 'warning' },
resolved: { icon: CheckCircle, color: 'success' },
closed: { icon: XCircle, color: 'muted' },
registered: { icon: AlertCircle, color: 'info' },
planning: { icon: Clock, color: 'warning' },
planned: { icon: Clock, color: 'warning' },
queued: { icon: Clock, color: 'warning' },
executing: { icon: Loader2, color: 'warning' },
completed: { icon: CheckCircle, color: 'success' },
failed: { icon: XCircle, color: 'destructive' },
paused: { icon: Clock, color: 'muted' },
};
// Status label keys for i18n
const statusLabelKeys: Record<Issue['status'], string> = {
open: 'issues.status.open',
in_progress: 'issues.status.inProgress',
resolved: 'issues.status.resolved',
closed: 'issues.status.closed',
registered: 'issues.status.registered',
planning: 'issues.status.planning',
planned: 'issues.status.planned',
queued: 'issues.status.queued',
executing: 'issues.status.executing',
completed: 'issues.status.completed',
failed: 'issues.status.failed',
paused: 'issues.status.paused',
};
// ========== Priority Badge ==========
@@ -228,11 +235,11 @@ export function IssueCard({
<Edit className="w-4 h-4 mr-2" />
{formatMessage({ id: 'issues.actions.edit' })}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onStatusChange?.(issue, 'in_progress')}>
<DropdownMenuItem onClick={() => onStatusChange?.(issue, 'executing')}>
<Clock className="w-4 h-4 mr-2" />
{formatMessage({ id: 'issues.actions.startProgress' })}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onStatusChange?.(issue, 'resolved')}>
<DropdownMenuItem onClick={() => onStatusChange?.(issue, 'completed')}>
<CheckCircle className="w-4 h-4 mr-2" />
{formatMessage({ id: 'issues.actions.markResolved' })}
</DropdownMenuItem>

View File

@@ -0,0 +1,341 @@
// ========================================
// GlobalSettingsTab Component
// ========================================
// Global settings for personal spec defaults and spec statistics
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Settings, RefreshCw } from 'lucide-react';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Label } from '@/components/ui/Label';
import { Switch } from '@/components/ui/Switch';
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui/Select';
import { cn } from '@/lib/utils';
// ========== Types ==========
interface PersonalSpecDefaults {
defaultReadMode: 'required' | 'optional';
autoEnable: boolean;
}
interface SystemSettings {
injectionControl: {
maxLength: number;
warnThreshold: number;
truncateOnExceed: boolean;
};
personalSpecDefaults: PersonalSpecDefaults;
}
interface SpecDimensionStats {
count: number;
requiredCount: number;
}
interface SpecStats {
dimensions: {
specs: SpecDimensionStats;
roadmap: SpecDimensionStats;
changelog: SpecDimensionStats;
personal: SpecDimensionStats;
};
injectionLength?: {
requiredOnly: number;
withKeywords: number;
maxLength: number;
percentage: number;
};
}
// ========== API Functions ==========
const API_BASE = '/api';
async function fetchSystemSettings(): Promise<SystemSettings> {
const response = await fetch(`${API_BASE}/system/settings`, {
credentials: 'same-origin',
});
if (!response.ok) {
throw new Error(`Failed to fetch settings: ${response.statusText}`);
}
const data = await response.json();
return data;
}
async function updateSystemSettings(
settings: Partial<SystemSettings>
): Promise<{ success: boolean; settings: SystemSettings }> {
const response = await fetch(`${API_BASE}/system/settings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify(settings),
});
if (!response.ok) {
throw new Error(`Failed to update settings: ${response.statusText}`);
}
return response.json();
}
async function fetchSpecStats(): Promise<SpecStats> {
const response = await fetch(`${API_BASE}/specs/stats`, {
credentials: 'same-origin',
});
if (!response.ok) {
throw new Error(`Failed to fetch spec stats: ${response.statusText}`);
}
return response.json();
}
// ========== Query Keys ==========
const settingsKeys = {
all: ['system-settings'] as const,
settings: () => [...settingsKeys.all, 'settings'] as const,
stats: () => [...settingsKeys.all, 'stats'] as const,
};
// ========== Component ==========
export function GlobalSettingsTab() {
const queryClient = useQueryClient();
// Local state for immediate UI feedback
const [localDefaults, setLocalDefaults] = useState<PersonalSpecDefaults>({
defaultReadMode: 'optional',
autoEnable: true,
});
// Fetch system settings
const {
data: settings,
isLoading: isLoadingSettings,
error: settingsError,
} = useQuery({
queryKey: settingsKeys.settings(),
queryFn: fetchSystemSettings,
staleTime: 60000, // 1 minute
});
// Fetch spec stats
const {
data: stats,
isLoading: isLoadingStats,
error: statsError,
refetch: refetchStats,
} = useQuery({
queryKey: settingsKeys.stats(),
queryFn: fetchSpecStats,
staleTime: 30000, // 30 seconds
});
// Update settings mutation
const updateMutation = useMutation({
mutationFn: updateSystemSettings,
onSuccess: (data) => {
queryClient.setQueryData(settingsKeys.settings(), data.settings);
toast.success('Settings saved successfully');
},
onError: (error) => {
toast.error(`Failed to save settings: ${error.message}`);
},
});
// Sync local state with server state
useEffect(() => {
if (settings?.personalSpecDefaults) {
setLocalDefaults(settings.personalSpecDefaults);
}
}, [settings]);
// Handlers
const handleReadModeChange = (value: 'required' | 'optional') => {
const newDefaults = { ...localDefaults, defaultReadMode: value };
setLocalDefaults(newDefaults);
updateMutation.mutate({ personalSpecDefaults: newDefaults });
};
const handleAutoEnableChange = (checked: boolean) => {
const newDefaults = { ...localDefaults, autoEnable: checked };
setLocalDefaults(newDefaults);
updateMutation.mutate({ personalSpecDefaults: newDefaults });
};
// Calculate totals
const dimensions = stats?.dimensions || {};
const dimensionEntries = Object.entries(dimensions) as [
keyof typeof dimensions,
SpecDimensionStats
][];
const totalCount = dimensionEntries.reduce(
(sum, [, data]) => sum + data.count,
0
);
const totalRequired = dimensionEntries.reduce(
(sum, [, data]) => sum + data.requiredCount,
0
);
const isLoading = isLoadingSettings || isLoadingStats;
const hasError = settingsError || statsError;
// Dimension display config
const dimensionLabels: Record<string, string> = {
specs: 'Specs',
roadmap: 'Roadmap',
changelog: 'Changelog',
personal: 'Personal',
};
return (
<div className="space-y-6">
{/* Personal Spec Defaults Card */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-muted-foreground" />
<CardTitle>Personal Spec Defaults</CardTitle>
</div>
<CardDescription>
These settings will be applied when creating new personal specs
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Default Read Mode */}
<div className="space-y-2">
<Label htmlFor="default-read-mode">Default Read Mode</Label>
<Select
value={localDefaults.defaultReadMode}
onValueChange={(value) =>
handleReadModeChange(value as 'required' | 'optional')
}
>
<SelectTrigger id="default-read-mode" className="w-full">
<SelectValue placeholder="Select read mode" />
</SelectTrigger>
<SelectContent>
<SelectItem value="required">
Required (Always inject)
</SelectItem>
<SelectItem value="optional">
Optional (Inject on keyword match)
</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
The default read mode for newly created personal specs
</p>
</div>
{/* Auto Enable */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="auto-enable">Auto Enable New Specs</Label>
<p className="text-sm text-muted-foreground">
Automatically enable newly created personal specs
</p>
</div>
<Switch
id="auto-enable"
checked={localDefaults.autoEnable}
onCheckedChange={handleAutoEnableChange}
disabled={updateMutation.isPending}
/>
</div>
</CardContent>
</Card>
{/* Spec Statistics Card */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Spec Statistics</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => refetchStats()}
disabled={isLoadingStats}
>
<RefreshCw
className={cn(
'h-4 w-4',
isLoadingStats && 'animate-spin'
)}
/>
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="grid grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="text-center p-4 rounded-lg bg-muted animate-pulse"
>
<div className="h-8 w-12 mx-auto bg-muted-foreground/20 rounded mb-2" />
<div className="h-4 w-16 mx-auto bg-muted-foreground/20 rounded" />
</div>
))}
</div>
) : hasError ? (
<div className="text-center py-8 text-muted-foreground">
Failed to load statistics
</div>
) : (
<>
<div className="grid grid-cols-4 gap-4">
{dimensionEntries.map(([dim, data]) => (
<div
key={dim}
className="text-center p-4 rounded-lg bg-muted/50 hover:bg-muted transition-colors"
>
<div className="text-2xl font-bold text-foreground">
{data.count}
</div>
<div className="text-sm text-muted-foreground capitalize">
{dimensionLabels[dim] || dim}
</div>
<div className="text-xs text-muted-foreground mt-1">
{data.requiredCount} required
</div>
</div>
))}
</div>
{/* Summary */}
<div className="mt-4 pt-4 border-t border-border">
<div className="flex justify-between text-sm text-muted-foreground">
<span>Total: {totalCount} spec files</span>
<span>
{totalRequired} required | {totalCount - totalRequired}{' '}
optional
</span>
</div>
</div>
</>
)}
</CardContent>
</Card>
</div>
);
}
export default GlobalSettingsTab;

View File

@@ -0,0 +1,318 @@
// ========================================
// HookCard Component
// ========================================
// Hook card with event badge, scope badge and action menu
import * as React from 'react';
import { useIntl } from 'react-intl';
import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Switch } from '@/components/ui/Switch';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from '@/components/ui/Dropdown';
import {
Zap,
MoreVertical,
Edit,
Trash2,
Globe,
Folder,
Play,
Clock,
Terminal,
} from 'lucide-react';
/**
* Hook event types
*/
export type HookEvent = 'SessionStart' | 'UserPromptSubmit' | 'SessionEnd';
/**
* Hook scope types
*/
export type HookScope = 'global' | 'project';
/**
* Fail mode for hooks
*/
export type HookFailMode = 'continue' | 'block' | 'warn';
/**
* Hook configuration interface
*/
export interface HookConfig {
/** Unique hook identifier */
id: string;
/** Hook name */
name: string;
/** Trigger event */
event: HookEvent;
/** Command to execute */
command: string;
/** Description */
description?: string;
/** Scope (global or project) */
scope: HookScope;
/** Whether hook is enabled */
enabled: boolean;
/** Whether hook is installed */
installed?: boolean;
/** Whether this is a recommended hook */
isRecommended?: boolean;
/** Timeout in milliseconds */
timeout?: number;
/** Fail mode */
failMode?: HookFailMode;
}
export interface HookCardProps {
/** Hook data */
hook: HookConfig;
/** Called when edit action is triggered */
onEdit?: (hook: HookConfig) => void;
/** Called when uninstall action is triggered */
onUninstall?: (hookId: string) => void;
/** Called when toggle enabled is triggered */
onToggle?: (hookId: string, enabled: boolean) => void;
/** Optional className */
className?: string;
/** Show actions dropdown */
showActions?: boolean;
/** Disabled state for actions */
actionsDisabled?: boolean;
/** Whether this is a recommended hook card */
isRecommendedCard?: boolean;
/** Called when install action is triggered (recommended hooks only) */
onInstall?: (hookId: string) => void;
}
// Event type configuration
const eventConfig: Record<
HookEvent,
{ variant: 'default' | 'secondary' | 'destructive' | 'success' | 'warning' | 'info'; icon: React.ReactNode }
> = {
SessionStart: { variant: 'success' as const, icon: <Play className="h-3 w-3" /> },
UserPromptSubmit: { variant: 'info' as const, icon: <Terminal className="h-3 w-3" /> },
SessionEnd: { variant: 'secondary' as const, icon: <Clock className="h-3 w-3" /> },
};
// Event label keys for i18n
const eventLabelKeys: Record<HookEvent, string> = {
SessionStart: 'hooks.events.sessionStart',
UserPromptSubmit: 'hooks.events.userPromptSubmit',
SessionEnd: 'hooks.events.sessionEnd',
};
// Scope label keys for i18n
const scopeLabelKeys: Record<HookScope, string> = {
global: 'hooks.scope.global',
project: 'hooks.scope.project',
};
/**
* HookCard component for displaying hook information
*/
export function HookCard({
hook,
onEdit,
onUninstall,
onToggle,
className,
showActions = true,
actionsDisabled = false,
isRecommendedCard = false,
onInstall,
}: HookCardProps) {
const { formatMessage } = useIntl();
const { variant: eventVariant, icon: eventIcon } = eventConfig[hook.event] || {
variant: 'default' as const,
icon: <Zap className="h-3 w-3" />,
};
const eventLabel = formatMessage({ id: eventLabelKeys[hook.event] || 'hooks.events.unknown' });
const scopeIcon = hook.scope === 'global' ? <Globe className="h-3 w-3" /> : <Folder className="h-3 w-3" />;
const scopeLabel = formatMessage({ id: scopeLabelKeys[hook.scope] });
const handleToggle = (enabled: boolean) => {
onToggle?.(hook.id, enabled);
};
const handleAction = (e: React.MouseEvent, action: 'edit' | 'uninstall' | 'install') => {
e.stopPropagation();
switch (action) {
case 'edit':
onEdit?.(hook);
break;
case 'uninstall':
onUninstall?.(hook.id);
break;
case 'install':
onInstall?.(hook.id);
break;
}
};
// For recommended hooks that are not installed
if (isRecommendedCard && !hook.installed) {
return (
<Card
className={cn(
'group transition-all duration-200 hover:shadow-md hover:border-primary/30 hover-glow',
className
)}
>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-card-foreground truncate">
{hook.name}
</h3>
<Badge variant="outline" className="gap-1">
{scopeIcon}
{scopeLabel}
</Badge>
</div>
{hook.description && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-1">
{hook.description}
</p>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={(e) => handleAction(e, 'install')}
disabled={actionsDisabled}
className="ml-4"
>
{formatMessage({ id: 'hooks.actions.install' })}
</Button>
</div>
</CardContent>
</Card>
);
}
return (
<Card
className={cn(
'group transition-all duration-200 hover:shadow-md hover:border-primary/30 hover-glow',
!hook.enabled && 'opacity-60',
className
)}
>
<CardContent className="p-4">
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-card-foreground truncate">
{hook.name}
</h3>
<Badge variant="outline" className="gap-1" title={scopeLabel}>
{scopeIcon}
</Badge>
</div>
{hook.description && (
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-1">
{hook.description}
</p>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Badge variant={eventVariant} className="gap-1">
{eventIcon}
{eventLabel}
</Badge>
<Switch
checked={hook.enabled}
onCheckedChange={handleToggle}
disabled={actionsDisabled}
className="data-[state=checked]:bg-primary"
/>
{showActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
disabled={actionsDisabled}
>
<MoreVertical className="h-4 w-4" />
<span className="sr-only">{formatMessage({ id: 'common.aria.actions' })}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => handleAction(e, 'edit')}>
<Edit className="mr-2 h-4 w-4" />
{formatMessage({ id: 'hooks.actions.edit' })}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => handleAction(e, 'uninstall')}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{formatMessage({ id: 'hooks.actions.uninstall' })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{/* Command info */}
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1 font-mono">
<Terminal className="h-3.5 w-3.5" />
{hook.command}
</span>
{hook.timeout && (
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{hook.timeout}ms
</span>
)}
</div>
</CardContent>
</Card>
);
}
/**
* Skeleton loader for HookCard
*/
export function HookCardSkeleton({ className }: { className?: string }) {
return (
<Card className={cn('animate-pulse', className)}>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="h-5 w-32 rounded bg-muted" />
<div className="mt-1 h-3 w-48 rounded bg-muted" />
</div>
<div className="flex items-center gap-2">
<div className="h-5 w-20 rounded bg-muted" />
<div className="h-5 w-8 rounded-full bg-muted" />
<div className="h-8 w-8 rounded bg-muted" />
</div>
</div>
<div className="mt-3 flex gap-4">
<div className="h-3 w-32 rounded bg-muted" />
<div className="h-3 w-16 rounded bg-muted" />
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,354 @@
// ========================================
// HookDialog Component
// ========================================
// Dialog for editing hook configuration
import * as React from 'react';
import { useIntl } from 'react-intl';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/Dialog';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label';
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui/Select';
import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup';
import { Textarea } from '@/components/ui/Textarea';
import { Globe, Folder, HelpCircle } from 'lucide-react';
import {
HookConfig,
HookEvent,
HookScope,
HookFailMode,
} from './HookCard';
export interface HookDialogProps {
/** Whether dialog is open */
open: boolean;
/** Called when dialog open state changes */
onOpenChange: (open: boolean) => void;
/** Hook data to edit (undefined for new hook) */
hook?: HookConfig;
/** Called when save is triggered */
onSave: (hook: Omit<HookConfig, 'id'> & { id?: string }) => void;
/** Optional className */
className?: string;
/** Loading state */
isLoading?: boolean;
}
/**
* Default hook configuration
*/
const defaultHookConfig: Omit<HookConfig, 'id'> = {
name: '',
event: 'SessionStart',
command: '',
description: '',
scope: 'global',
enabled: true,
installed: true,
timeout: 30000,
failMode: 'continue',
};
/**
* HookDialog component for editing hook configuration
*/
export function HookDialog({
open,
onOpenChange,
hook,
onSave,
className,
isLoading = false,
}: HookDialogProps) {
const { formatMessage } = useIntl();
const isEditing = !!hook;
// Form state
const [formData, setFormData] = React.useState<Omit<HookConfig, 'id'>>(defaultHookConfig);
const [errors, setErrors] = React.useState<Record<string, string>>({});
// Reset form when hook changes or dialog opens
React.useEffect(() => {
if (open) {
setFormData(hook ? { ...hook } : defaultHookConfig);
setErrors({});
}
}, [open, hook]);
// Update form field
const updateField = <K extends keyof typeof formData>(
field: K,
value: (typeof formData)[K]
) => {
setFormData((prev) => ({ ...prev, [field]: value }));
// Clear error when field is updated
if (errors[field]) {
setErrors((prev) => {
const next = { ...prev };
delete next[field];
return next;
});
}
};
// Validate form
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.name.trim()) {
newErrors.name = formatMessage({ id: 'hooks.validation.nameRequired' });
}
if (!formData.command.trim()) {
newErrors.command = formatMessage({ id: 'hooks.validation.commandRequired' });
}
if (formData.timeout && formData.timeout < 1000) {
newErrors.timeout = formatMessage({ id: 'hooks.validation.timeoutMin' });
}
if (formData.timeout && formData.timeout > 300000) {
newErrors.timeout = formatMessage({ id: 'hooks.validation.timeoutMax' });
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Handle save
const handleSave = () => {
if (!validateForm()) {
return;
}
onSave({
...(hook ? { id: hook.id } : {}),
...formData,
});
};
// Handle cancel
const handleCancel = () => {
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={cn('sm:max-w-[500px]', className)}>
<DialogHeader>
<DialogTitle>
{isEditing
? formatMessage({ id: 'hooks.dialog.editTitle' })
: formatMessage({ id: 'hooks.dialog.createTitle' })}
</DialogTitle>
<DialogDescription>
{formatMessage({ id: 'hooks.dialog.description' })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Name field */}
<div className="space-y-2">
<Label htmlFor="name" className="required">
{formatMessage({ id: 'hooks.fields.name' })}
</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => updateField('name', e.target.value)}
placeholder={formatMessage({ id: 'hooks.placeholders.name' })}
className={errors.name ? 'border-destructive' : ''}
disabled={isLoading}
/>
{errors.name && (
<p className="text-xs text-destructive">{errors.name}</p>
)}
</div>
{/* Event type field */}
<div className="space-y-2">
<Label htmlFor="event" className="required">
{formatMessage({ id: 'hooks.fields.event' })}
</Label>
<Select
value={formData.event}
onValueChange={(value) => updateField('event', value as HookEvent)}
disabled={isLoading}
>
<SelectTrigger id="event">
<SelectValue placeholder={formatMessage({ id: 'hooks.placeholders.event' })} />
</SelectTrigger>
<SelectContent>
<SelectItem value="SessionStart">
{formatMessage({ id: 'hooks.events.sessionStart' })}
</SelectItem>
<SelectItem value="UserPromptSubmit">
{formatMessage({ id: 'hooks.events.userPromptSubmit' })}
</SelectItem>
<SelectItem value="SessionEnd">
{formatMessage({ id: 'hooks.events.sessionEnd' })}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'hints.hookEvents' })}
</p>
</div>
{/* Scope field */}
<div className="space-y-2">
<Label className="required">
{formatMessage({ id: 'hooks.fields.scope' })}
</Label>
<RadioGroup
value={formData.scope}
onValueChange={(value) => updateField('scope', value as HookScope)}
className="flex gap-4"
disabled={isLoading}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="global" id="scope-global" />
<Label htmlFor="scope-global" className="flex items-center gap-1.5 cursor-pointer">
<Globe className="h-4 w-4" />
{formatMessage({ id: 'hooks.scope.global' })}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="project" id="scope-project" />
<Label htmlFor="scope-project" className="flex items-center gap-1.5 cursor-pointer">
<Folder className="h-4 w-4" />
{formatMessage({ id: 'hooks.scope.project' })}
</Label>
</div>
</RadioGroup>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'hints.hookScope' })}
</p>
</div>
{/* Command field */}
<div className="space-y-2">
<Label htmlFor="command" className="required">
{formatMessage({ id: 'hooks.fields.command' })}
</Label>
<Input
id="command"
value={formData.command}
onChange={(e) => updateField('command', e.target.value)}
placeholder={formatMessage({ id: 'hooks.placeholders.command' })}
className={cn('font-mono', errors.command ? 'border-destructive' : '')}
disabled={isLoading}
/>
{errors.command && (
<p className="text-xs text-destructive">{errors.command}</p>
)}
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'hints.hookCommand' })}
</p>
</div>
{/* Description field */}
<div className="space-y-2">
<Label htmlFor="description">
{formatMessage({ id: 'hooks.fields.description' })}
</Label>
<Textarea
id="description"
value={formData.description || ''}
onChange={(e) => updateField('description', e.target.value)}
placeholder={formatMessage({ id: 'hooks.placeholders.description' })}
rows={2}
disabled={isLoading}
/>
</div>
{/* Timeout field */}
<div className="space-y-2">
<Label htmlFor="timeout" className="flex items-center gap-1">
{formatMessage({ id: 'hooks.fields.timeout' })}
<span className="text-xs text-muted-foreground">
({formatMessage({ id: 'hooks.fields.timeoutUnit' })})
</span>
</Label>
<Input
id="timeout"
type="number"
value={formData.timeout || ''}
onChange={(e) => updateField('timeout', parseInt(e.target.value, 10) || undefined)}
placeholder="30000"
className={errors.timeout ? 'border-destructive' : ''}
disabled={isLoading}
min={1000}
max={300000}
/>
{errors.timeout && (
<p className="text-xs text-destructive">{errors.timeout}</p>
)}
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'hints.hookTimeout' })}
</p>
</div>
{/* Fail mode field */}
<div className="space-y-2">
<Label htmlFor="failMode" className="flex items-center gap-1">
{formatMessage({ id: 'hooks.fields.failMode' })}
<HelpCircle className="h-3.5 w-3.5 text-muted-foreground" />
</Label>
<Select
value={formData.failMode || 'continue'}
onValueChange={(value) => updateField('failMode', value as HookFailMode)}
disabled={isLoading}
>
<SelectTrigger id="failMode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="continue">
{formatMessage({ id: 'hooks.failModes.continue' })}
</SelectItem>
<SelectItem value="warn">
{formatMessage({ id: 'hooks.failModes.warn' })}
</SelectItem>
<SelectItem value="block">
{formatMessage({ id: 'hooks.failModes.block' })}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'hints.hookFailMode' })}
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancel} disabled={isLoading}>
{formatMessage({ id: 'common.cancel' })}
</Button>
<Button onClick={handleSave} disabled={isLoading}>
{isLoading
? formatMessage({ id: 'common.saving' })
: formatMessage({ id: 'common.save' })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { type HookConfig, type HookEvent, type HookScope, type HookFailMode } from './HookCard';

View File

@@ -0,0 +1,474 @@
// ========================================
// InjectionControlTab Component
// ========================================
// Tab for managing spec injection control settings
import { useState, useEffect, useCallback } from 'react';
import { useIntl } from 'react-intl';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label';
import { Button } from '@/components/ui/Button';
import { Switch } from '@/components/ui/Switch';
import { Progress } from '@/components/ui/Progress';
import {
AlertCircle,
Info,
Loader2,
RefreshCw,
AlertTriangle,
} from 'lucide-react';
// ========== Types ==========
export interface InjectionStats {
requiredOnly: number;
withKeywords: number;
maxLength: number;
percentage: number;
}
export interface SpecStatsResponse {
dimensions: {
specs: { count: number; requiredCount: number };
roadmap: { count: number; requiredCount: number };
changelog: { count: number; requiredCount: number };
personal: { count: number; requiredCount: number };
};
injectionLength: InjectionStats;
}
export interface InjectionSettings {
maxLength: number;
warnThreshold: number;
truncateOnExceed: boolean;
}
export interface SystemSettingsResponse {
injectionControl: InjectionSettings;
personalSpecDefaults: {
defaultReadMode: 'required' | 'optional';
autoEnable: boolean;
};
}
export interface InjectionControlTabProps {
className?: string;
}
// ========== API Functions ==========
async function fetchSpecStats(): Promise<SpecStatsResponse> {
const response = await fetch('/api/specs/stats');
if (!response.ok) {
throw new Error('Failed to fetch spec stats');
}
return response.json();
}
async function fetchSystemSettings(): Promise<SystemSettingsResponse> {
const response = await fetch('/api/system/settings');
if (!response.ok) {
throw new Error('Failed to fetch system settings');
}
return response.json();
}
async function updateInjectionSettings(
settings: Partial<InjectionSettings>
): Promise<SystemSettingsResponse> {
const currentSettings = await fetchSystemSettings();
const response = await fetch('/api/system/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
injectionControl: {
...currentSettings.injectionControl,
...settings,
},
}),
});
if (!response.ok) {
throw new Error('Failed to update settings');
}
return response.json();
}
// ========== Helper Functions ==========
function formatNumber(num: number): string {
return num.toLocaleString();
}
function calculatePercentage(current: number, max: number): number {
if (max <= 0) return 0;
return Math.min(100, (current / max) * 100);
}
// ========== Component ==========
export function InjectionControlTab({ className }: InjectionControlTabProps) {
const { formatMessage } = useIntl();
// State for stats
const [stats, setStats] = useState<SpecStatsResponse | null>(null);
const [statsLoading, setStatsLoading] = useState(true);
const [statsError, setStatsError] = useState<Error | null>(null);
// State for settings
const [settings, setSettings] = useState<InjectionSettings>({
maxLength: 8000,
warnThreshold: 6000,
truncateOnExceed: true,
});
const [settingsLoading, setSettingsLoading] = useState(true);
// Form state (for local editing before save)
const [formData, setFormData] = useState<InjectionSettings>(settings);
const [hasChanges, setHasChanges] = useState(false);
const [isSaving, setIsSaving] = useState(false);
// Fetch stats
const loadStats = useCallback(async () => {
setStatsLoading(true);
setStatsError(null);
try {
const data = await fetchSpecStats();
setStats(data);
} catch (err) {
setStatsError(err instanceof Error ? err : new Error('Unknown error'));
} finally {
setStatsLoading(false);
}
}, []);
// Fetch settings
const loadSettings = useCallback(async () => {
setSettingsLoading(true);
try {
const data = await fetchSystemSettings();
setSettings(data.injectionControl);
setFormData(data.injectionControl);
} catch (err) {
console.error('Failed to load settings:', err);
} finally {
setSettingsLoading(false);
}
}, []);
// Initial load
useEffect(() => {
loadStats();
loadSettings();
}, [loadStats, loadSettings]);
// Check for changes
useEffect(() => {
const changed =
formData.maxLength !== settings.maxLength ||
formData.warnThreshold !== settings.warnThreshold ||
formData.truncateOnExceed !== settings.truncateOnExceed;
setHasChanges(changed);
}, [formData, settings]);
// Handle form field changes
const handleFieldChange = <K extends keyof InjectionSettings>(
field: K,
value: InjectionSettings[K]
) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
// Save settings
const handleSave = async () => {
setIsSaving(true);
try {
const result = await updateInjectionSettings(formData);
setSettings(result.injectionControl);
setFormData(result.injectionControl);
setHasChanges(false);
toast.success(
formatMessage({ id: 'specs.injection.saveSuccess' }, { default: 'Settings saved successfully' })
);
} catch (err) {
toast.error(
formatMessage({ id: 'specs.injection.saveError' }, { default: 'Failed to save settings' })
);
console.error('Failed to save settings:', err);
} finally {
setIsSaving(false);
}
};
// Reset form
const handleReset = () => {
setFormData(settings);
setHasChanges(false);
};
// Calculate progress and status
const currentLength = stats?.injectionLength?.withKeywords || 0;
const maxLength = settings.maxLength;
const warnThreshold = settings.warnThreshold;
const percentage = calculatePercentage(currentLength, maxLength);
const isOverLimit = currentLength > maxLength;
const isOverWarning = currentLength > warnThreshold;
const remainingSpace = Math.max(0, maxLength - currentLength);
return (
<div className={cn('space-y-6', className)}>
{/* Current Injection Status Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{formatMessage(
{ id: 'specs.injection.statusTitle', defaultMessage: 'Current Injection Status' }
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={loadStats}
disabled={statsLoading}
>
<RefreshCw className={cn('h-4 w-4', statsLoading && 'animate-spin')} />
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{statsLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : statsError ? (
<div className="text-sm text-destructive">
{formatMessage(
{ id: 'specs.injection.loadError', defaultMessage: 'Failed to load stats' }
)}
</div>
) : (
<>
{/* Current Length Display */}
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{formatMessage({ id: 'specs.injection.currentLength', defaultMessage: 'Current Length' })}
</span>
<span
className={cn(
'font-medium',
isOverLimit && 'text-destructive',
!isOverLimit && isOverWarning && 'text-yellow-600 dark:text-yellow-400'
)}
>
{formatNumber(currentLength)} / {formatNumber(maxLength)}{' '}
{formatMessage({ id: 'specs.injection.characters', defaultMessage: 'characters' })}
</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>
</div>
</div>
{/* Warning Alert when over limit */}
{isOverLimit && (
<div className="flex items-start gap-3 p-4 rounded-lg bg-destructive/10 border border-destructive/20">
<AlertCircle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
<div>
<div className="font-medium text-destructive">
{formatMessage({ id: 'specs.injection.overLimit', defaultMessage: 'Over Limit' })}
</div>
<div className="text-sm text-muted-foreground mt-1">
{formatMessage(
{
id: 'specs.injection.overLimitDescription',
defaultMessage: 'Current injection content exceeds maximum length of {max} characters. Excess content will be truncated.',
},
{ max: formatNumber(maxLength) }
)}
</div>
</div>
</div>
)}
{/* Statistics Info */}
<div className="p-4 rounded-lg bg-muted/50 space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Info className="h-4 w-4 text-muted-foreground" />
{formatMessage({ id: 'specs.injection.statsInfo', defaultMessage: 'Statistics' })}
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="text-muted-foreground">
{formatMessage({ id: 'specs.injection.requiredLength', defaultMessage: 'Required specs length:' })}
</div>
<div className="text-right">
{formatNumber(stats?.injectionLength?.requiredOnly || 0)} {formatMessage({ id: 'specs.injection.characters', defaultMessage: 'characters' })}
</div>
<div className="text-muted-foreground">
{formatMessage({ id: 'specs.injection.matchedLength', defaultMessage: 'Keyword-matched length:' })}
</div>
<div className="text-right">
{formatNumber(stats?.injectionLength?.withKeywords || 0)} {formatMessage({ id: 'specs.injection.characters', defaultMessage: 'characters' })}
</div>
<div className="text-muted-foreground">
{formatMessage({ id: 'specs.injection.remaining', defaultMessage: 'Remaining space:' })}
</div>
<div className={cn('text-right', remainingSpace === 0 && 'text-destructive')}>
{formatNumber(remainingSpace)} ({Math.round(100 - percentage)}%)
</div>
</div>
</div>
</>
)}
</CardContent>
</Card>
{/* Settings Card */}
<Card>
<CardHeader>
<CardTitle>
{formatMessage({ id: 'specs.injection.settingsTitle', defaultMessage: 'Injection Control Settings' })}
</CardTitle>
<CardDescription>
{formatMessage({
id: 'specs.injection.settingsDescription',
defaultMessage: 'Configure how spec content is injected into AI context.',
})}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{settingsLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : (
<>
{/* Max Injection Length */}
<div className="space-y-2">
<Label htmlFor="maxLength">
{formatMessage({ id: 'specs.injection.maxLength', defaultMessage: 'Max Injection Length (characters)' })}
</Label>
<Input
id="maxLength"
type="number"
min={1000}
max={50000}
step={500}
value={formData.maxLength}
onChange={(e) => handleFieldChange('maxLength', Number(e.target.value))}
/>
<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.',
})}
</p>
</div>
{/* Warning Threshold */}
<div className="space-y-2">
<Label htmlFor="warnThreshold">
{formatMessage({ id: 'specs.injection.warnThresholdLabel', defaultMessage: 'Warning Threshold (characters)' })}
</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))}
/>
<p className="text-sm text-muted-foreground">
{formatMessage({
id: 'specs.injection.warnThresholdHelp',
defaultMessage: 'A warning will be displayed when injection length exceeds this value.',
})}
</p>
</div>
{/* Truncate on Exceed */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="truncate">
{formatMessage({ id: 'specs.injection.truncateOnExceed', defaultMessage: 'Truncate on Exceed' })}
</Label>
<p className="text-sm text-muted-foreground">
{formatMessage({
id: 'specs.injection.truncateHelp',
defaultMessage: 'Automatically truncate content when it exceeds the maximum length.',
})}
</p>
</div>
<Switch
id="truncate"
checked={formData.truncateOnExceed}
onCheckedChange={(checked) => handleFieldChange('truncateOnExceed', checked)}
/>
</div>
{/* Action Buttons */}
<div className="flex items-center justify-end gap-3 pt-4 border-t">
<Button
variant="outline"
onClick={handleReset}
disabled={!hasChanges || isSaving}
>
{formatMessage({ id: 'common.actions.reset', defaultMessage: 'Reset' })}
</Button>
<Button onClick={handleSave} disabled={!hasChanges || isSaving}>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{formatMessage({ id: 'common.actions.saving', defaultMessage: 'Saving...' })}
</>
) : (
formatMessage({ id: 'common.actions.save', defaultMessage: 'Save' })
)}
</Button>
</div>
</>
)}
</CardContent>
</Card>
</div>
);
}
export default InjectionControlTab;

View File

@@ -0,0 +1,289 @@
// ========================================
// SpecCard Component
// ========================================
// Spec card with readMode badge, keywords, priority indicator and action menu
import * as React from 'react';
import { useIntl } from 'react-intl';
import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Switch } from '@/components/ui/Switch';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from '@/components/ui/DropdownMenu';
import {
MoreVertical,
Edit,
Trash2,
FileText,
Tag,
} from 'lucide-react';
// ========== Types ==========
/**
* Spec dimension type
*/
export type SpecDimension = 'specs' | 'roadmap' | 'changelog' | 'personal';
/**
* Spec read mode type
*/
export type SpecReadMode = 'required' | 'optional';
/**
* Spec priority type
*/
export type SpecPriority = 'critical' | 'high' | 'medium' | 'low';
/**
* Spec data structure
*/
export interface Spec {
/** Unique spec identifier */
id: string;
/** Spec title (display name) */
title: string;
/** Spec file path */
file: string;
/** Spec dimension/category */
dimension: SpecDimension;
/** Read mode: required (always inject) or optional (keyword match) */
readMode: SpecReadMode;
/** Priority level */
priority: SpecPriority;
/** Keywords for matching (optional specs) */
keywords: string[];
/** Whether spec is enabled */
enabled: boolean;
/** Optional description */
description?: string;
}
/**
* SpecCard component props
*/
export interface SpecCardProps {
/** Spec data */
spec: Spec;
/** Called when edit action is triggered */
onEdit?: (spec: Spec) => void;
/** Called when delete action is triggered */
onDelete?: (specId: string) => void;
/** Called when toggle enabled is triggered */
onToggle?: (specId: string, enabled: boolean) => void;
/** Optional className */
className?: string;
/** Show actions dropdown */
showActions?: boolean;
/** Disabled state for actions */
actionsDisabled?: boolean;
}
// ========== Configuration ==========
// Read mode badge configuration
const readModeConfig: Record<
SpecReadMode,
{ variant: 'default' | 'secondary'; labelKey: string }
> = {
required: { variant: 'default', labelKey: 'specs.readMode.required' },
optional: { variant: 'secondary', labelKey: 'specs.readMode.optional' },
};
// Priority badge configuration
const priorityConfig: Record<
SpecPriority,
{ variant: 'destructive' | 'warning' | 'info' | 'secondary'; labelKey: string }
> = {
critical: { variant: 'destructive', labelKey: 'specs.priority.critical' },
high: { variant: 'warning', labelKey: 'specs.priority.high' },
medium: { variant: 'info', labelKey: 'specs.priority.medium' },
low: { variant: 'secondary', labelKey: 'specs.priority.low' },
};
// ========== Component ==========
/**
* SpecCard component for displaying spec information
*/
export function SpecCard({
spec,
onEdit,
onDelete,
onToggle,
className,
showActions = true,
actionsDisabled = false,
}: SpecCardProps) {
const { formatMessage } = useIntl();
const readMode = readModeConfig[spec.readMode];
const priority = priorityConfig[spec.priority];
const handleToggle = (enabled: boolean) => {
onToggle?.(spec.id, enabled);
};
const handleAction = (e: React.MouseEvent, action: 'edit' | 'delete') => {
e.stopPropagation();
switch (action) {
case 'edit':
onEdit?.(spec);
break;
case 'delete':
onDelete?.(spec.id);
break;
}
};
return (
<Card
className={cn(
'group transition-all duration-200 hover:shadow-md hover:border-primary/30 hover-glow',
!spec.enabled && 'opacity-60',
className
)}
>
<CardContent className="p-4">
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
<h3 className="font-medium text-card-foreground truncate">
{spec.title}
</h3>
</div>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{spec.file}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{showActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
disabled={actionsDisabled}
>
<MoreVertical className="h-4 w-4" />
<span className="sr-only">{formatMessage({ id: 'common.aria.actions' })}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => handleAction(e, 'edit')}>
<Edit className="mr-2 h-4 w-4" />
{formatMessage({ id: 'specs.actions.edit' })}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => handleAction(e, 'delete')}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{formatMessage({ id: 'specs.actions.delete' })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{/* Badges */}
<div className="mt-3 flex flex-wrap items-center gap-2">
<Badge variant={readMode.variant} className="text-xs">
{formatMessage({ id: readMode.labelKey })}
</Badge>
<Badge variant={priority.variant} className="text-xs">
{formatMessage({ id: priority.labelKey })}
</Badge>
</div>
{/* Description */}
{spec.description && (
<p className="mt-3 text-sm text-muted-foreground line-clamp-2">
{spec.description}
</p>
)}
{/* Keywords */}
{spec.keywords.length > 0 && (
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
{spec.keywords.slice(0, 4).map((keyword) => (
<Badge key={keyword} variant="outline" className="text-xs px-1.5 py-0">
{keyword}
</Badge>
))}
{spec.keywords.length > 4 && (
<span className="text-xs text-muted-foreground">
+{spec.keywords.length - 4}
</span>
)}
</div>
)}
{/* Footer with toggle */}
<div className="mt-3 pt-3 border-t border-border flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{formatMessage({ id: spec.enabled ? 'specs.status.enabled' : 'specs.status.disabled' })}
</span>
<Switch
checked={spec.enabled}
onCheckedChange={handleToggle}
disabled={actionsDisabled}
className="data-[state=checked]:bg-primary"
/>
</div>
</CardContent>
</Card>
);
}
/**
* Skeleton loader for SpecCard
*/
export function SpecCardSkeleton({ className }: { className?: string }) {
return (
<Card className={cn('animate-pulse', className)}>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<div className="h-4 w-4 rounded bg-muted" />
<div className="h-5 w-32 rounded bg-muted" />
</div>
<div className="mt-1 h-3 w-24 rounded bg-muted" />
</div>
<div className="h-8 w-8 rounded bg-muted" />
</div>
<div className="mt-3 flex gap-2">
<div className="h-5 w-14 rounded-full bg-muted" />
<div className="h-5 w-12 rounded-full bg-muted" />
</div>
<div className="mt-3 h-4 w-full rounded bg-muted" />
<div className="mt-2 flex gap-1.5">
<div className="h-5 w-16 rounded bg-muted" />
<div className="h-5 w-14 rounded bg-muted" />
<div className="h-5 w-12 rounded bg-muted" />
</div>
<div className="mt-3 pt-3 border-t border-border flex items-center justify-between">
<div className="h-3 w-16 rounded bg-muted" />
<div className="h-5 w-9 rounded-full bg-muted" />
</div>
</CardContent>
</Card>
);
}
export default SpecCard;

View File

@@ -0,0 +1,319 @@
// ========================================
// SpecDialog Component
// ========================================
// Dialog for editing spec frontmatter (title, readMode, priority, keywords)
import * as React from 'react';
import { useIntl } from 'react-intl';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/Dialog';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label';
import { Badge } from '@/components/ui/Badge';
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui/Select';
import { Tag, X } from 'lucide-react';
import type { Spec, SpecReadMode, SpecPriority } from './SpecCard';
// ========== Types ==========
/**
* Spec form data for editing
*/
export interface SpecFormData {
title: string;
readMode: SpecReadMode;
priority: SpecPriority;
keywords: string[];
}
/**
* SpecDialog component props
*/
export interface SpecDialogProps {
/** Whether dialog is open */
open: boolean;
/** Called when dialog open state changes */
onOpenChange: (open: boolean) => void;
/** Spec being edited */
spec: Spec | null;
/** Called when save is clicked */
onSave: (specId: string, data: SpecFormData) => Promise<void> | void;
/** Optional loading state */
isLoading?: boolean;
}
// ========== Constants ==========
const READ_MODE_OPTIONS: { value: SpecReadMode; labelKey: string }[] = [
{ value: 'required', labelKey: 'specs.readMode.required' },
{ value: 'optional', labelKey: 'specs.readMode.optional' },
];
const PRIORITY_OPTIONS: { value: SpecPriority; labelKey: string }[] = [
{ value: 'critical', labelKey: 'specs.priority.critical' },
{ value: 'high', labelKey: 'specs.priority.high' },
{ value: 'medium', labelKey: 'specs.priority.medium' },
{ value: 'low', labelKey: 'specs.priority.low' },
];
// ========== Component ==========
/**
* SpecDialog component for editing spec frontmatter
*/
export function SpecDialog({
open,
onOpenChange,
spec,
onSave,
isLoading = false,
}: SpecDialogProps) {
const { formatMessage } = useIntl();
const [formData, setFormData] = React.useState<SpecFormData>({
title: '',
readMode: 'optional',
priority: 'medium',
keywords: [],
});
const [keywordInput, setKeywordInput] = React.useState('');
const [errors, setErrors] = React.useState<Partial<Record<keyof SpecFormData, string>>>({});
// Reset form when spec changes
React.useEffect(() => {
if (spec) {
setFormData({
title: spec.title,
readMode: spec.readMode,
priority: spec.priority,
keywords: [...spec.keywords],
});
setErrors({});
setKeywordInput('');
}
}, [spec]);
// Validate form
const validateForm = (): boolean => {
const newErrors: Partial<Record<keyof SpecFormData, string>> = {};
if (!formData.title.trim()) {
newErrors.title = formatMessage({ id: 'specs.validation.titleRequired' });
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Handle save
const handleSave = async () => {
if (!spec || !validateForm()) return;
await onSave(spec.id, formData);
};
// Handle keyword input
const handleKeywordKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addKeyword();
}
};
// Add keyword
const addKeyword = () => {
const keyword = keywordInput.trim().toLowerCase();
if (keyword && !formData.keywords.includes(keyword)) {
setFormData((prev) => ({
...prev,
keywords: [...prev.keywords, keyword],
}));
}
setKeywordInput('');
};
// Remove keyword
const removeKeyword = (keyword: string) => {
setFormData((prev) => ({
...prev,
keywords: prev.keywords.filter((k) => k !== keyword),
}));
};
if (!spec) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{formatMessage({ id: 'specs.dialog.editTitle' }, { title: spec.title })}
</DialogTitle>
<DialogDescription>
{formatMessage({ id: 'specs.dialog.editDescription' })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Title field */}
<div className="space-y-2">
<Label htmlFor="title">
{formatMessage({ id: 'specs.form.title' })}
</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))}
placeholder={formatMessage({ id: 'specs.form.titlePlaceholder' })}
error={!!errors.title}
disabled={isLoading}
/>
{errors.title && (
<p className="text-sm text-destructive">{errors.title}</p>
)}
</div>
{/* Read mode and Priority */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{formatMessage({ id: 'specs.form.readMode' })}</Label>
<Select
value={formData.readMode}
onValueChange={(value: SpecReadMode) =>
setFormData((prev) => ({ ...prev, readMode: value }))
}
disabled={isLoading}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{READ_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{formatMessage({ id: option.labelKey })}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{formatMessage({ id: 'specs.form.priority' })}</Label>
<Select
value={formData.priority}
onValueChange={(value: SpecPriority) =>
setFormData((prev) => ({ ...prev, priority: value }))
}
disabled={isLoading}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PRIORITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{formatMessage({ id: option.labelKey })}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Keywords */}
<div className="space-y-2">
<Label htmlFor="keywords">
{formatMessage({ id: 'specs.form.keywords' })}
</Label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Input
id="keywords"
value={keywordInput}
onChange={(e) => setKeywordInput(e.target.value)}
onKeyDown={handleKeywordKeyDown}
onBlur={addKeyword}
placeholder={formatMessage({ id: 'specs.form.keywordsPlaceholder' })}
disabled={isLoading}
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={addKeyword}
disabled={isLoading || !keywordInput.trim()}
>
{formatMessage({ id: 'specs.form.addKeyword' })}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'specs.form.keywordsHint' })}
</p>
{/* Keywords display */}
{formData.keywords.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
<Tag className="h-3.5 w-3.5 text-muted-foreground mt-0.5" />
{formData.keywords.map((keyword) => (
<Badge
key={keyword}
variant="secondary"
className="text-xs pl-2 pr-1 gap-1"
>
{keyword}
<button
type="button"
onClick={() => removeKeyword(keyword)}
className="ml-0.5 hover:text-destructive transition-colors"
disabled={isLoading}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div>
{/* File info */}
<div className="pt-2 border-t border-border">
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'specs.form.fileInfo' }, { file: spec.file })}
</p>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
{formatMessage({ id: 'common.cancel' })}
</Button>
<Button onClick={handleSave} disabled={isLoading}>
{isLoading
? formatMessage({ id: 'specs.form.saving' })
: formatMessage({ id: 'common.save' })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default SpecDialog;

View File

@@ -0,0 +1,62 @@
// ========================================
// Specs Components Index
// ========================================
// Export all specs-related components
export {
SpecCard,
SpecCardSkeleton,
} from './SpecCard';
export type {
Spec,
SpecDimension,
SpecReadMode,
SpecPriority,
SpecCardProps,
} from './SpecCard';
export {
SpecDialog,
} from './SpecDialog';
export type {
SpecFormData,
SpecDialogProps,
} from './SpecDialog';
export {
HookCard,
} from './HookCard';
export type {
HookConfig,
HookEvent,
HookScope,
HookFailMode,
HookCardProps,
} from './HookCard';
export {
HookDialog,
} from './HookDialog';
export type {
HookDialogProps,
} from './HookDialog';
export {
InjectionControlTab,
} from './InjectionControlTab';
export type {
InjectionStats,
InjectionSettings,
SpecStatsResponse,
SystemSettingsResponse,
InjectionControlTabProps,
} from './InjectionControlTab';
export {
GlobalSettingsTab,
} from './GlobalSettingsTab';

View File

@@ -139,11 +139,14 @@ export function useIssues(options: UseIssuesOptions = {}): UseIssuesReturn {
// Group by status
const issuesByStatus: Record<Issue['status'], Issue[]> = {
open: [],
in_progress: [],
resolved: [],
closed: [],
registered: [],
planning: [],
planned: [],
queued: [],
executing: [],
completed: [],
failed: [],
paused: [],
};
for (const issue of allIssues) {
@@ -184,7 +187,7 @@ export function useIssues(options: UseIssuesOptions = {}): UseIssuesReturn {
allIssues,
issuesByStatus,
issuesByPriority,
openCount: issuesByStatus.open.length + issuesByStatus.in_progress.length,
openCount: issuesByStatus.registered.length + issuesByStatus.planning.length + issuesByStatus.planned.length,
criticalCount: issuesByPriority.critical.length,
isLoading: issuesQuery.isLoading,
isFetching: issuesQuery.isFetching || historyQuery.isFetching,

View File

@@ -326,3 +326,140 @@ export function useImportSettings() {
error: mutation.error,
};
}
// ========================================
// Specs Settings Hooks
// ========================================
import {
getSystemSettings,
updateSystemSettings,
installRecommendedHooks,
getSpecStats,
type SystemSettings,
type UpdateSystemSettingsInput,
type InstallRecommendedHooksResponse,
type SpecStats,
} from '../lib/api';
// Query keys for specs settings
export const specsSettingsKeys = {
all: ['specsSettings'] as const,
systemSettings: () => [...specsSettingsKeys.all, 'systemSettings'] as const,
specStats: (projectPath?: string) => [...specsSettingsKeys.all, 'specStats', projectPath] as const,
};
// ========================================
// System Settings Query Hook
// ========================================
export interface UseSystemSettingsReturn {
data: SystemSettings | undefined;
isLoading: boolean;
error: Error | null;
refetch: () => void;
}
/**
* Hook to fetch system settings (injection control, personal spec defaults, recommended hooks)
*/
export function useSystemSettings(): UseSystemSettingsReturn {
const query = useQuery({
queryKey: specsSettingsKeys.systemSettings(),
queryFn: getSystemSettings,
staleTime: STALE_TIME,
retry: 1,
});
return {
data: query.data,
isLoading: query.isLoading,
error: query.error,
refetch: () => { query.refetch(); },
};
}
// ========================================
// Update System Settings Mutation Hook
// ========================================
export function useUpdateSystemSettings() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (data: UpdateSystemSettingsInput) => updateSystemSettings(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: specsSettingsKeys.systemSettings() });
},
});
return {
updateSettings: mutation.mutateAsync,
isPending: mutation.isPending,
error: mutation.error,
};
}
// ========================================
// Install Recommended Hooks Mutation Hook
// ========================================
export function useInstallRecommendedHooks() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: ({ hookIds, scope }: { hookIds: string[]; scope?: 'global' | 'project' }) =>
installRecommendedHooks(hookIds, scope),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: specsSettingsKeys.systemSettings() });
},
});
return {
installHooks: (hookIds: string[], scope?: 'global' | 'project') =>
mutation.mutateAsync({ hookIds, scope }),
isPending: mutation.isPending,
error: mutation.error,
data: mutation.data,
};
}
// ========================================
// Spec Stats Query Hook
// ========================================
export interface UseSpecStatsOptions {
projectPath?: string;
enabled?: boolean;
staleTime?: number;
}
export interface UseSpecStatsReturn {
data: SpecStats | undefined;
isLoading: boolean;
error: Error | null;
refetch: () => void;
}
/**
* Hook to fetch spec statistics (dimensions count, injection length info)
* @param options - Options including projectPath for workspace isolation
*/
export function useSpecStats(options: UseSpecStatsOptions = {}): UseSpecStatsReturn {
const { projectPath, enabled = true, staleTime = STALE_TIME } = options;
const query = useQuery({
queryKey: specsSettingsKeys.specStats(projectPath),
queryFn: () => getSpecStats(projectPath),
staleTime,
enabled,
retry: 1,
});
return {
data: query.data,
isLoading: query.isLoading,
error: query.error,
refetch: () => { query.refetch(); },
};
}

View File

@@ -728,13 +728,26 @@ export interface Issue {
id: string;
title: string;
context?: string;
status: 'open' | 'in_progress' | 'resolved' | 'closed' | 'completed';
status: 'registered' | 'planning' | 'planned' | 'queued' | 'executing' | 'completed' | 'failed' | 'paused';
priority: 'low' | 'medium' | 'high' | 'critical';
createdAt: string;
updatedAt?: string;
plannedAt?: string;
queuedAt?: string;
completedAt?: string;
solutions?: IssueSolution[];
labels?: string[];
assignee?: string;
tags?: string[];
source?: 'github' | 'text' | 'discovery';
sourceUrl?: string;
boundSolutionId?: string | null;
feedback?: Array<{
type: 'failure' | 'clarification' | 'rejection';
stage: string;
content: string;
createdAt: string;
}>;
attachments?: Attachment[];
}
@@ -7133,6 +7146,121 @@ export async function triggerReindex(
);
}
// ========== System Settings API ==========
/**
* System settings response from /api/system/settings
*/
export interface SystemSettings {
injectionControl: {
maxLength: number;
warnThreshold: number;
truncateOnExceed: boolean;
};
personalSpecDefaults: {
defaultReadMode: 'required' | 'optional' | 'keywords';
autoEnable: boolean;
};
recommendedHooks: Array<{
id: string;
event: string;
name: string;
command: string;
description: string;
scope: 'global' | 'project';
autoInstall: boolean;
}>;
}
/**
* Update system settings request
*/
export interface UpdateSystemSettingsInput {
injectionControl?: Partial<SystemSettings['injectionControl']>;
personalSpecDefaults?: Partial<SystemSettings['personalSpecDefaults']>;
}
/**
* Install recommended hooks request
*/
export interface InstallRecommendedHooksInput {
hookIds: string[];
scope?: 'global' | 'project';
}
/**
* Installed hook result
*/
export interface InstalledHook {
id: string;
event: string;
status: 'installed' | 'already-exists';
}
/**
* Install recommended hooks response
*/
export interface InstallRecommendedHooksResponse {
success: boolean;
installed: InstalledHook[];
}
/**
* Fetch system settings (injection control, personal spec defaults, recommended hooks)
*/
export async function getSystemSettings(): Promise<SystemSettings> {
return fetchApi<SystemSettings>('/api/system/settings');
}
/**
* Update system settings
*/
export async function updateSystemSettings(data: UpdateSystemSettingsInput): Promise<{ success: boolean; settings?: Record<string, unknown> }> {
return fetchApi<{ success: boolean; settings?: Record<string, unknown> }>('/api/system/settings', {
method: 'POST',
body: JSON.stringify(data),
});
}
/**
* Install recommended hooks
*/
export async function installRecommendedHooks(
hookIds: string[],
scope?: 'global' | 'project'
): Promise<InstallRecommendedHooksResponse> {
return fetchApi<InstallRecommendedHooksResponse>('/api/system/hooks/install-recommended', {
method: 'POST',
body: JSON.stringify({ hookIds, scope } as InstallRecommendedHooksInput),
});
}
// ========== Spec Stats API ==========
/**
* Spec stats response from /api/specs/stats
*/
export interface SpecStats {
dimensions: Record<string, { count: number; requiredCount: number }>;
injectionLength: {
requiredOnly: number;
withKeywords: number;
maxLength: number;
percentage: number;
};
}
/**
* Fetch spec statistics for a specific workspace
* @param projectPath - Optional project path to filter data by workspace
*/
export async function getSpecStats(projectPath?: string): Promise<SpecStats> {
const url = projectPath
? `/api/specs/stats?path=${encodeURIComponent(projectPath)}`
: '/api/specs/stats';
return fetchApi<SpecStats>(url);
}
// ========== Analysis API ==========
import type { AnalysisSessionSummary, AnalysisSessionDetail } from '../types/analysis';

View File

@@ -38,7 +38,8 @@
"teams": "Team Execution",
"terminalDashboard": "Terminal Dashboard",
"skillHub": "Skill Hub",
"analysis": "Analysis Viewer"
"analysis": "Analysis Viewer",
"specs": "Spec Settings"
},
"sidebar": {
"collapse": "Collapse",

View File

@@ -38,7 +38,8 @@
"teams": "团队执行",
"terminalDashboard": "终端仪表板",
"skillHub": "技能中心",
"analysis": "分析查看器"
"analysis": "分析查看器",
"specs": "规范设置"
},
"sidebar": {
"collapse": "收起",

View File

@@ -17,9 +17,8 @@ import {
import { IssueHubHeader } from '@/components/issue/hub/IssueHubHeader';
import { IssueHubTabs, type IssueTab } from '@/components/issue/hub/IssueHubTabs';
const VALID_TABS: IssueTab[] = ['issues', 'board', 'queue', 'discovery'];
const VALID_TABS: IssueTab[] = ['issues', 'queue', 'discovery'];
import { IssuesPanel } from '@/components/issue/hub/IssuesPanel';
import { IssueBoardPanel } from '@/components/issue/hub/IssueBoardPanel';
import { QueuePanel } from '@/components/issue/hub/QueuePanel';
import { DiscoveryPanel } from '@/components/issue/hub/DiscoveryPanel';
// ExecutionPanel hidden - import { ExecutionPanel } from '@/components/issue/hub/ExecutionPanel';
@@ -366,7 +365,6 @@ export function IssueHubPage() {
const renderActionButtons = () => {
switch (currentTab) {
case 'issues':
case 'board':
return (
<>
<Button variant="outline" onClick={handleIssuesRefresh} disabled={isFetchingIssues}>
@@ -437,7 +435,6 @@ export function IssueHubPage() {
<IssueHubTabs currentTab={currentTab} onTabChange={setCurrentTab} />
{currentTab === 'issues' && <IssuesPanel onCreateIssue={() => setIsNewIssueOpen(true)} />}
{currentTab === 'board' && <IssueBoardPanel />}
{currentTab === 'queue' && <QueuePanel />}
{currentTab === 'discovery' && <DiscoveryPanel />}

View File

@@ -0,0 +1,353 @@
/**
* Specs Settings Page
*
* Main page for managing spec settings, hooks, injection control, and global settings.
* Uses 5 tabs: Project Specs | Personal Specs | Hooks | Injection | Settings
*/
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollText, User, Plug, Gauge, Settings, RefreshCw, Search } from 'lucide-react';
import { SpecCard, SpecDialog, type Spec, type SpecFormData } from '@/components/specs';
import { HookCard, HookDialog, type HookConfig } from '@/components/specs';
import { InjectionControlTab } from '@/components/specs/InjectionControlTab';
import { GlobalSettingsTab } from '@/components/specs/GlobalSettingsTab';
import { useSpecStats } from '@/hooks/useSystemSettings';
type SettingsTab = 'project-specs' | 'personal-specs' | 'hooks' | 'injection' | 'settings';
export function SpecsSettingsPage() {
const { formatMessage } = useIntl();
const [activeTab, setActiveTab] = useState<SettingsTab>('project-specs');
const [searchQuery, setSearchQuery] = useState('');
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [hookDialogOpen, setHookDialogOpen] = useState(false);
const [editingSpec, setEditingSpec] = useState<Spec | null>(null);
const [editingHook, setEditingHook] = useState<HookConfig | null>(null);
// Mock data for demonstration - will be replaced with real API calls
const [projectSpecs] = useState<Spec[]>([]);
const [personalSpecs] = useState<Spec[]>([]);
const [hooks] = useState<HookConfig[]>([]);
const [isLoading] = useState(false);
const { data: statsData, refetch: refetchStats } = useSpecStats();
const handleSpecEdit = (spec: Spec) => {
setEditingSpec(spec);
setEditDialogOpen(true);
};
const handleSpecSave = async (specId: string, data: SpecFormData) => {
console.log('Saving spec:', specId, data);
// TODO: Implement save logic
setEditDialogOpen(false);
};
const handleSpecToggle = async (specId: string, enabled: boolean) => {
console.log('Toggling spec:', specId, enabled);
// TODO: Implement toggle logic
};
const handleSpecDelete = async (specId: string) => {
console.log('Deleting spec:', specId);
// TODO: Implement delete logic
};
const handleHookEdit = (hook: HookConfig) => {
setEditingHook(hook);
setHookDialogOpen(true);
};
const handleHookSave = async (hookId: string | null, data: Partial<HookConfig>) => {
console.log('Saving hook:', hookId, data);
// TODO: Implement save logic
setHookDialogOpen(false);
};
const handleHookToggle = async (hookId: string, enabled: boolean) => {
console.log('Toggling hook:', hookId, enabled);
// TODO: Implement toggle logic
};
const handleHookDelete = async (hookId: string) => {
console.log('Deleting hook:', hookId);
// TODO: Implement delete logic
};
const handleRebuildIndex = async () => {
console.log('Rebuilding index...');
// TODO: Implement rebuild logic
};
const filterSpecs = (specs: Spec[]) => {
if (!searchQuery.trim()) return specs;
const query = searchQuery.toLowerCase();
return specs.filter(spec =>
spec.title.toLowerCase().includes(query) ||
spec.keywords.some(k => k.toLowerCase().includes(query))
);
};
const renderSpecsTab = (dimension: 'project' | 'personal') => {
const specs = dimension === 'project' ? projectSpecs : personalSpecs;
const filteredSpecs = filterSpecs(specs);
return (
<div className="space-y-4">
{/* Search and Actions */}
<div className="flex items-center gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={formatMessage({ id: 'specs.searchPlaceholder', defaultMessage: 'Search specs...' })}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Button variant="outline" onClick={handleRebuildIndex}>
<RefreshCw className="h-4 w-4 mr-2" />
{formatMessage({ id: 'specs.rebuildIndex', defaultMessage: 'Rebuild Index' })}
</Button>
</div>
{/* Stats Summary */}
{statsData && (
<div className="grid grid-cols-4 gap-4">
{Object.entries(statsData.dimensions).map(([dim, data]) => (
<Card key={dim}>
<CardContent className="pt-4">
<div className="text-sm text-muted-foreground capitalize">{dim}</div>
<div className="text-2xl font-bold">{(data as { count: number }).count}</div>
<div className="text-xs text-muted-foreground">
{(data as { requiredCount: number }).requiredCount} required
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Specs Grid */}
{filteredSpecs.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
{isLoading
? formatMessage({ id: 'specs.loading', defaultMessage: 'Loading specs...' })
: formatMessage({ id: 'specs.noSpecs', defaultMessage: 'No specs found. Create specs in .workflow/ directory.' })
}
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{filteredSpecs.map(spec => (
<SpecCard
key={spec.id}
spec={spec}
onEdit={handleSpecEdit}
onToggle={handleSpecToggle}
onDelete={handleSpecDelete}
/>
))}
</div>
)}
</div>
);
};
const renderHooksTab = () => {
const filteredHooks = hooks.filter(hook => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
return hook.name.toLowerCase().includes(query) ||
hook.event.toLowerCase().includes(query);
});
// Recommended hooks
const recommendedHooks: HookConfig[] = [
{
id: 'spec-injection-session',
name: 'Spec Context Injection (Session)',
event: 'SessionStart',
command: 'ccw spec load --stdin',
scope: 'global',
enabled: true,
timeout: 5000,
failMode: 'silent'
},
{
id: 'spec-injection-prompt',
name: 'Spec Context Injection (Prompt)',
event: 'UserPromptSubmit',
command: 'ccw spec load --stdin',
scope: 'project',
enabled: true,
timeout: 5000,
failMode: 'silent'
}
];
return (
<div className="space-y-6">
{/* Recommended Hooks Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Plug className="h-5 w-5" />
{formatMessage({ id: 'specs.recommendedHooks', defaultMessage: 'Recommended Hooks' })}
</CardTitle>
<CardDescription>
{formatMessage({ id: 'specs.recommendedHooksDesc', defaultMessage: 'One-click install system-preset spec injection hooks' })}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex gap-4 mb-4">
<Button onClick={() => console.log('Install all')}>
{formatMessage({ id: 'specs.installAll', defaultMessage: 'Install All Recommended Hooks' })}
</Button>
<div className="text-sm text-muted-foreground flex items-center">
{hooks.filter(h => recommendedHooks.some(r => r.command === h.command)).length} / {recommendedHooks.length} installed
</div>
</div>
<div className="grid gap-3">
{recommendedHooks.map(hook => (
<HookCard
key={hook.id}
hook={hook}
isRecommended={true}
onInstall={() => console.log('Install:', hook.id)}
onEdit={handleHookEdit}
onToggle={handleHookToggle}
onDelete={handleHookDelete}
/>
))}
</div>
</CardContent>
</Card>
{/* Installed Hooks Section */}
<Card>
<CardHeader>
<CardTitle>{formatMessage({ id: 'specs.installedHooks', defaultMessage: 'Installed Hooks' })}</CardTitle>
<CardDescription>
{formatMessage({ id: 'specs.installedHooksDesc', defaultMessage: 'Manage your installed hooks configuration' })}
</CardDescription>
</CardHeader>
<CardContent>
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={formatMessage({ id: 'specs.searchHooks', defaultMessage: 'Search hooks...' })}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
{filteredHooks.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
{formatMessage({ id: 'specs.noHooks', defaultMessage: 'No hooks installed. Install recommended hooks above.' })}
</div>
) : (
<div className="grid gap-3">
{filteredHooks.map(hook => (
<HookCard
key={hook.id}
hook={hook}
onEdit={handleHookEdit}
onToggle={handleHookToggle}
onDelete={handleHookDelete}
/>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
};
return (
<div className="container py-6 max-w-6xl">
{/* Page Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold flex items-center gap-2">
<ScrollText className="h-6 w-6" />
{formatMessage({ id: 'specs.pageTitle', defaultMessage: 'Spec Settings' })}
</h1>
<p className="text-muted-foreground mt-1">
{formatMessage({ id: 'specs.pageDescription', defaultMessage: 'Manage specification injection, hooks, and system settings' })}
</p>
</div>
{/* Main Tabs */}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as SettingsTab)}>
<TabsList className="grid grid-cols-5 mb-6">
<TabsTrigger value="project-specs" className="flex items-center gap-2">
<ScrollText className="h-4 w-4" />
<span className="hidden sm:inline">{formatMessage({ id: 'specs.tabProjectSpecs', defaultMessage: 'Project Specs' })}</span>
</TabsTrigger>
<TabsTrigger value="personal-specs" className="flex items-center gap-2">
<User className="h-4 w-4" />
<span className="hidden sm:inline">{formatMessage({ id: 'specs.tabPersonalSpecs', defaultMessage: 'Personal' })}</span>
</TabsTrigger>
<TabsTrigger value="hooks" className="flex items-center gap-2">
<Plug className="h-4 w-4" />
<span className="hidden sm:inline">{formatMessage({ id: 'specs.tabHooks', defaultMessage: 'Hooks' })}</span>
</TabsTrigger>
<TabsTrigger value="injection" className="flex items-center gap-2">
<Gauge className="h-4 w-4" />
<span className="hidden sm:inline">{formatMessage({ id: 'specs.tabInjection', defaultMessage: 'Injection' })}</span>
</TabsTrigger>
<TabsTrigger value="settings" className="flex items-center gap-2">
<Settings className="h-4 w-4" />
<span className="hidden sm:inline">{formatMessage({ id: 'specs.tabSettings', defaultMessage: 'Settings' })}</span>
</TabsTrigger>
</TabsList>
<TabsContent value="project-specs">
{renderSpecsTab('project')}
</TabsContent>
<TabsContent value="personal-specs">
{renderSpecsTab('personal')}
</TabsContent>
<TabsContent value="hooks">
{renderHooksTab()}
</TabsContent>
<TabsContent value="injection">
<InjectionControlTab />
</TabsContent>
<TabsContent value="settings">
<GlobalSettingsTab />
</TabsContent>
</Tabs>
{/* Edit Spec Dialog */}
<SpecDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
spec={editingSpec}
onSave={handleSpecSave}
/>
{/* Edit Hook Dialog */}
<HookDialog
open={hookDialogOpen}
onOpenChange={setHookDialogOpen}
hook={editingHook}
onSave={handleHookSave}
/>
</div>
);
}
export default SpecsSettingsPage;

View File

@@ -38,3 +38,4 @@ export { TeamPage } from './TeamPage';
export { TerminalDashboardPage } from './TerminalDashboardPage';
export { SkillHubPage } from './SkillHubPage';
export { AnalysisPage } from './AnalysisPage';
export { SpecsSettingsPage } from './SpecsSettingsPage';

View File

@@ -37,6 +37,7 @@ import {
TeamPage,
TerminalDashboardPage,
AnalysisPage,
SpecsSettingsPage,
} from '@/pages';
/**
@@ -146,6 +147,10 @@ const routes: RouteObject[] = [
path: 'settings/rules',
element: <RulesManagerPage />,
},
{
path: 'settings/specs',
element: <SpecsSettingsPage />,
},
{
path: 'settings/codexlens',
element: <CodexLensManagerPage />,