Files
Claude-Code-Workflow/ccw/frontend/src/components/issue/hub/IssueBoardPanel.tsx
catlog22 3fd55ebd4b feat: Add Role Analysis Reviewer Agent and validation template
- Introduced Role Analysis Reviewer Agent to validate role analysis outputs against templates and quality standards.
- Created a detailed validation ruleset for the system-architect role, including mandatory and recommended sections.
- Added JSON validation report structure for output.
- Implemented execution command for validation process.

test: Add UX tests for HookCard component

- Created comprehensive tests for HookCard component, focusing on delete confirmation UX pattern.
- Verified confirmation dialog appearance, deletion functionality, and button interactions.
- Ensured proper handling of state updates and visual feedback for enabled/disabled status.

test: Add UX tests for ThemeSelector component

- Developed tests for ThemeSelector component, emphasizing delete confirmation UX pattern.
- Validated confirmation dialog display, deletion actions, and toast notifications for undo functionality.
- Ensured proper management of theme slots and state updates.

feat: Implement useDebounce hook

- Added useDebounce hook to delay expensive computations or API calls, enhancing performance.

feat: Create System Architect Analysis Template

- Developed a comprehensive template for system architect role analysis, covering required sections such as architecture overview, data model, state machine, error handling strategy, observability requirements, configuration model, and boundary scenarios.
- Included examples and templates for each section to guide users in producing SPEC.md-level precision modeling.
2026-03-05 19:58:10 +08:00

456 lines
15 KiB
TypeScript

// ========================================
// Issue Board Panel
// ========================================
// Kanban board view for issues (status-driven) with local ordering.
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
import type { DropResult } from '@hello-pangea/dnd';
import { AlertCircle, LayoutGrid } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Switch } from '@/components/ui/Switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select';
import { KanbanBoard, type KanbanColumn, type KanbanItem } from '@/components/shared/KanbanBoard';
import { IssueCard } from '@/components/shared/IssueCard';
import { IssueDrawer } from '@/components/issue/hub/IssueDrawer';
import { cn } from '@/lib/utils';
import { useIssues, useIssueMutations } from '@/hooks';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import { createCliSession, executeInCliSession } from '@/lib/api';
import type { Issue } from '@/lib/api';
import { useTerminalPanelStore } from '@/stores/terminalPanelStore';
type IssueBoardStatus = Issue['status'];
type ToolName = 'claude' | 'codex' | 'gemini';
type ResumeStrategy = 'nativeResume' | 'promptConcat';
const BOARD_COLUMNS: Array<{ id: IssueBoardStatus; titleKey: string }> = [
{ 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: 'failed', titleKey: 'issues.status.failed' },
];
type BoardOrder = Partial<Record<IssueBoardStatus, string[]>>;
function storageKey(projectPath: string | null | undefined): string {
const base = projectPath ? encodeURIComponent(projectPath) : 'global';
return `ccw.issueBoard.order:${base}`;
}
interface AutoStartConfig {
enabled: boolean;
tool: ToolName;
mode: 'analysis' | 'write';
resumeStrategy: ResumeStrategy;
}
function autoStartStorageKey(projectPath: string | null | undefined): string {
const base = projectPath ? encodeURIComponent(projectPath) : 'global';
return `ccw.issueBoard.autoStart:${base}`;
}
function safeParseAutoStart(value: string | null): AutoStartConfig {
const defaults: AutoStartConfig = {
enabled: false,
tool: 'claude',
mode: 'write',
resumeStrategy: 'nativeResume',
};
if (!value) return defaults;
try {
const parsed = JSON.parse(value) as Partial<AutoStartConfig>;
return {
enabled: Boolean(parsed.enabled),
tool: parsed.tool === 'codex' || parsed.tool === 'gemini' ? parsed.tool : 'claude',
mode: parsed.mode === 'analysis' ? 'analysis' : 'write',
resumeStrategy: parsed.resumeStrategy === 'promptConcat' ? 'promptConcat' : 'nativeResume',
};
} catch {
return defaults;
}
}
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: IssueBoardStatus) => 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;
}
function buildIssueAutoPrompt(issue: Issue): string {
const lines: string[] = [];
lines.push(`Issue: ${issue.id}`);
lines.push(`Status: ${issue.status}`);
lines.push(`Priority: ${issue.priority}`);
lines.push('');
lines.push(`Title: ${issue.title}`);
if (issue.context) {
lines.push('');
lines.push('Context:');
lines.push(String(issue.context));
}
if (Array.isArray(issue.solutions) && issue.solutions.length > 0) {
lines.push('');
lines.push('Solutions:');
for (const s of issue.solutions) {
lines.push(`- [${s.status}] ${s.description}`);
if (s.approach) lines.push(` Approach: ${s.approach}`);
}
}
lines.push('');
lines.push('Instruction:');
lines.push(
'Start working on this issue in this repository. Prefer small, testable changes; run relevant tests; report blockers if any.'
);
return lines.join('\n');
}
import { useNotificationStore } from '@/stores';
// ...
export function IssueBoardPanel() {
const { formatMessage } = useIntl();
const projectPath = useWorkflowStore(selectProjectPath);
const { addToast } = useNotificationStore();
const { issues, isLoading, error } = useIssues();
const { updateIssue } = useIssueMutations();
// ...
}
const [order, setOrder] = useState<BoardOrder>({});
const [selectedIssue, setSelectedIssue] = useState<Issue | null>(null);
const [drawerInitialTab, setDrawerInitialTab] = useState<'overview' | 'terminal'>('overview');
const [optimisticError, setOptimisticError] = useState<string | null>(null);
const [autoStart, setAutoStart] = useState<AutoStartConfig>(() => safeParseAutoStart(null));
// Load order when project changes
useEffect(() => {
const key = storageKey(projectPath);
const loaded = safeParseOrder(localStorage.getItem(key));
setOrder(loaded);
}, [projectPath]);
// Load auto-start config when project changes
useEffect(() => {
const key = autoStartStorageKey(projectPath);
setAutoStart(safeParseAutoStart(localStorage.getItem(key)));
}, [projectPath]);
// Keep order consistent with current issues (status moves, deletions, new 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]);
// Persist auto-start config
useEffect(() => {
const key = autoStartStorageKey(projectPath);
try {
localStorage.setItem(key, JSON.stringify(autoStart));
} catch {
// ignore quota errors
}
}, [autoStart, projectPath]);
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 });
}),
[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 handleItemClick = useCallback((issue: Issue) => {
setDrawerInitialTab('overview');
setSelectedIssue(issue);
}, []);
const handleCloseDrawer = useCallback(() => {
setSelectedIssue(null);
setOptimisticError(null);
}, []);
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;
setOptimisticError(null);
const sourceStatus = sourceColumn as IssueBoardStatus;
const destStatus = destColumn as IssueBoardStatus;
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 });
// Auto action: drag to executing opens the drawer on terminal tab.
if (destStatus === 'executing' && sourceStatus !== 'executing') {
setDrawerInitialTab('terminal');
setSelectedIssue({ ...issue, status: destStatus });
if (autoStart.enabled) {
if (!projectPath) {
setOptimisticError('Auto-start failed: no project path selected');
return;
}
try {
const created = await createCliSession({
workingDir: projectPath,
preferredShell: 'bash',
tool: autoStart.tool,
resumeKey: issueId,
}, projectPath);
await executeInCliSession(created.session.sessionKey, {
tool: autoStart.tool,
prompt: buildIssueAutoPrompt({ ...issue, status: destStatus }),
mode: autoStart.mode,
resumeKey: issueId,
resumeStrategy: autoStart.resumeStrategy,
}, projectPath);
// Auto-open terminal panel to show execution output
useTerminalPanelStore.getState().openTerminal(created.session.sessionKey);
} catch (e) {
const errorMsg = `Auto-start failed: ${e instanceof Error ? e.message : String(e)}`;
setOptimisticError(errorMsg);
addToast('error', errorMsg);
}
}
}
} catch (e) {
setOptimisticError(e instanceof Error ? e.message : String(e));
}
}
},
[autoStart, issues, idsByStatus, projectPath, updateIssue, addToast]
);
if (error) {
return (
<Card className="p-12 text-center">
<AlertCircle className="w-16 h-16 mx-auto text-destructive/50" />
<h3 className="mt-4 text-lg font-medium text-foreground">
{formatMessage({ id: 'issues.queue.error.title' })}
</h3>
<p className="mt-2 text-muted-foreground">{error.message}</p>
</Card>
);
}
return (
<>
<div className="mb-3 flex flex-col gap-2">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Switch
checked={autoStart.enabled}
onCheckedChange={(checked) => setAutoStart((prev) => ({ ...prev, enabled: checked }))}
/>
<div className="text-sm text-foreground">
{formatMessage({ id: 'issues.board.autoStart.label' })}
</div>
</div>
<div className="flex items-center gap-2">
<Select
value={autoStart.tool}
onValueChange={(v) => setAutoStart((prev) => ({ ...prev, tool: v as ToolName }))}
disabled={!autoStart.enabled}
>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="claude">claude</SelectItem>
<SelectItem value="codex">codex</SelectItem>
<SelectItem value="gemini">gemini</SelectItem>
</SelectContent>
</Select>
<Select
value={autoStart.mode}
onValueChange={(v) => setAutoStart((prev) => ({ ...prev, mode: v as 'analysis' | 'write' }))}
disabled={!autoStart.enabled}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="analysis">analysis</SelectItem>
<SelectItem value="write">write</SelectItem>
</SelectContent>
</Select>
<Select
value={autoStart.resumeStrategy}
onValueChange={(v) => setAutoStart((prev) => ({ ...prev, resumeStrategy: v as ResumeStrategy }))}
disabled={!autoStart.enabled}
>
<SelectTrigger className="w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="nativeResume">nativeResume</SelectItem>
<SelectItem value="promptConcat">promptConcat</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{optimisticError && (
<div className="text-sm text-destructive">
{optimisticError}
</div>
)}
<KanbanBoard<Issue & KanbanItem>
columns={columns}
onDragEnd={handleDragEnd}
onItemClick={(item) => handleItemClick(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) => handleItemClick(i)}
innerRef={provided.innerRef}
draggableProps={provided.draggableProps}
dragHandleProps={provided.dragHandleProps}
className="w-full"
/>
)}
/>
<IssueDrawer
issue={selectedIssue}
isOpen={Boolean(selectedIssue)}
onClose={handleCloseDrawer}
initialTab={drawerInitialTab}
/>
</>
);
}
export default IssueBoardPanel;