mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
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:
@@ -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 });
|
||||
|
||||
|
||||
@@ -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' }) },
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
341
ccw/frontend/src/components/specs/GlobalSettingsTab.tsx
Normal file
341
ccw/frontend/src/components/specs/GlobalSettingsTab.tsx
Normal 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;
|
||||
318
ccw/frontend/src/components/specs/HookCard.tsx
Normal file
318
ccw/frontend/src/components/specs/HookCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
354
ccw/frontend/src/components/specs/HookDialog.tsx
Normal file
354
ccw/frontend/src/components/specs/HookDialog.tsx
Normal 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';
|
||||
474
ccw/frontend/src/components/specs/InjectionControlTab.tsx
Normal file
474
ccw/frontend/src/components/specs/InjectionControlTab.tsx
Normal 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;
|
||||
289
ccw/frontend/src/components/specs/SpecCard.tsx
Normal file
289
ccw/frontend/src/components/specs/SpecCard.tsx
Normal 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;
|
||||
319
ccw/frontend/src/components/specs/SpecDialog.tsx
Normal file
319
ccw/frontend/src/components/specs/SpecDialog.tsx
Normal 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;
|
||||
62
ccw/frontend/src/components/specs/index.ts
Normal file
62
ccw/frontend/src/components/specs/index.ts
Normal 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';
|
||||
@@ -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,
|
||||
|
||||
@@ -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(); },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -38,7 +38,8 @@
|
||||
"teams": "团队执行",
|
||||
"terminalDashboard": "终端仪表板",
|
||||
"skillHub": "技能中心",
|
||||
"analysis": "分析查看器"
|
||||
"analysis": "分析查看器",
|
||||
"specs": "规范设置"
|
||||
},
|
||||
"sidebar": {
|
||||
"collapse": "收起",
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
|
||||
353
ccw/frontend/src/pages/SpecsSettingsPage.tsx
Normal file
353
ccw/frontend/src/pages/SpecsSettingsPage.tsx
Normal 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;
|
||||
@@ -38,3 +38,4 @@ export { TeamPage } from './TeamPage';
|
||||
export { TerminalDashboardPage } from './TerminalDashboardPage';
|
||||
export { SkillHubPage } from './SkillHubPage';
|
||||
export { AnalysisPage } from './AnalysisPage';
|
||||
export { SpecsSettingsPage } from './SpecsSettingsPage';
|
||||
|
||||
@@ -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 />,
|
||||
|
||||
Reference in New Issue
Block a user