feat: add SpecDialog component for editing spec frontmatter

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

feat: create index file for specs components

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

feat: implement SpecsSettingsPage for managing specs and hooks

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

feat: add spec management API routes

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

View File

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

View File

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

View File

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