@evolve.labs/devflow 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/agents/architect.md +1162 -0
- package/.claude/commands/agents/architect.meta.yaml +124 -0
- package/.claude/commands/agents/builder.md +1432 -0
- package/.claude/commands/agents/builder.meta.yaml +117 -0
- package/.claude/commands/agents/chronicler.md +633 -0
- package/.claude/commands/agents/chronicler.meta.yaml +217 -0
- package/.claude/commands/agents/guardian.md +456 -0
- package/.claude/commands/agents/guardian.meta.yaml +127 -0
- package/.claude/commands/agents/strategist.md +483 -0
- package/.claude/commands/agents/strategist.meta.yaml +158 -0
- package/.claude/commands/agents/system-designer.md +1137 -0
- package/.claude/commands/agents/system-designer.meta.yaml +156 -0
- package/.claude/commands/devflow-help.md +93 -0
- package/.claude/commands/devflow-status.md +60 -0
- package/.claude/commands/quick/create-adr.md +82 -0
- package/.claude/commands/quick/new-feature.md +57 -0
- package/.claude/commands/quick/security-check.md +54 -0
- package/.claude/commands/quick/system-design.md +58 -0
- package/.claude_project +52 -0
- package/.devflow/agents/architect.meta.yaml +122 -0
- package/.devflow/agents/builder.meta.yaml +116 -0
- package/.devflow/agents/chronicler.meta.yaml +222 -0
- package/.devflow/agents/guardian.meta.yaml +127 -0
- package/.devflow/agents/strategist.meta.yaml +158 -0
- package/.devflow/agents/system-designer.meta.yaml +265 -0
- package/.devflow/project.yaml +242 -0
- package/.gitignore-template +84 -0
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/bin/devflow.js +54 -0
- package/lib/autopilot.js +235 -0
- package/lib/autopilotConstants.js +213 -0
- package/lib/constants.js +95 -0
- package/lib/init.js +200 -0
- package/lib/update.js +181 -0
- package/lib/utils.js +157 -0
- package/lib/web.js +119 -0
- package/package.json +57 -0
- package/web/CHANGELOG.md +192 -0
- package/web/README.md +156 -0
- package/web/app/api/autopilot/execute/route.ts +102 -0
- package/web/app/api/autopilot/terminal-execute/route.ts +124 -0
- package/web/app/api/files/route.ts +280 -0
- package/web/app/api/files/tree/route.ts +160 -0
- package/web/app/api/git/route.ts +201 -0
- package/web/app/api/health/route.ts +94 -0
- package/web/app/api/project/open/route.ts +134 -0
- package/web/app/api/search/route.ts +247 -0
- package/web/app/api/specs/route.ts +405 -0
- package/web/app/api/terminal/route.ts +222 -0
- package/web/app/globals.css +160 -0
- package/web/app/ide/layout.tsx +43 -0
- package/web/app/ide/page.tsx +216 -0
- package/web/app/layout.tsx +34 -0
- package/web/app/page.tsx +303 -0
- package/web/components/agents/AgentIcons.tsx +281 -0
- package/web/components/autopilot/AutopilotConfigModal.tsx +245 -0
- package/web/components/autopilot/AutopilotPanel.tsx +299 -0
- package/web/components/dashboard/DashboardPanel.tsx +393 -0
- package/web/components/editor/Breadcrumbs.tsx +134 -0
- package/web/components/editor/EditorPanel.tsx +120 -0
- package/web/components/editor/EditorTabs.tsx +229 -0
- package/web/components/editor/MarkdownPreview.tsx +154 -0
- package/web/components/editor/MermaidDiagram.tsx +113 -0
- package/web/components/editor/MonacoEditor.tsx +177 -0
- package/web/components/editor/TabContextMenu.tsx +207 -0
- package/web/components/git/GitPanel.tsx +534 -0
- package/web/components/layout/Shell.tsx +15 -0
- package/web/components/layout/StatusBar.tsx +100 -0
- package/web/components/modals/CommandPalette.tsx +393 -0
- package/web/components/modals/GlobalSearch.tsx +348 -0
- package/web/components/modals/QuickOpen.tsx +241 -0
- package/web/components/modals/RecentFiles.tsx +208 -0
- package/web/components/projects/ProjectSelector.tsx +147 -0
- package/web/components/settings/SettingItem.tsx +150 -0
- package/web/components/settings/SettingsPanel.tsx +323 -0
- package/web/components/specs/SpecsPanel.tsx +1091 -0
- package/web/components/terminal/TerminalPanel.tsx +683 -0
- package/web/components/ui/ContextMenu.tsx +182 -0
- package/web/components/ui/LoadingSpinner.tsx +66 -0
- package/web/components/ui/ResizeHandle.tsx +110 -0
- package/web/components/ui/Skeleton.tsx +108 -0
- package/web/components/ui/SkipLinks.tsx +37 -0
- package/web/components/ui/Toaster.tsx +57 -0
- package/web/hooks/useFocusTrap.ts +141 -0
- package/web/hooks/useKeyboardShortcuts.ts +169 -0
- package/web/hooks/useListNavigation.ts +237 -0
- package/web/lib/autopilotConstants.ts +213 -0
- package/web/lib/constants/agents.ts +67 -0
- package/web/lib/git.ts +339 -0
- package/web/lib/ptyManager.ts +191 -0
- package/web/lib/specsParser.ts +299 -0
- package/web/lib/stores/autopilotStore.ts +288 -0
- package/web/lib/stores/fileStore.ts +550 -0
- package/web/lib/stores/gitStore.ts +386 -0
- package/web/lib/stores/projectStore.ts +196 -0
- package/web/lib/stores/settingsStore.ts +126 -0
- package/web/lib/stores/specsStore.ts +297 -0
- package/web/lib/stores/uiStore.ts +175 -0
- package/web/lib/types/index.ts +177 -0
- package/web/lib/utils.ts +98 -0
- package/web/next.config.js +50 -0
- package/web/package.json +54 -0
- package/web/postcss.config.js +6 -0
- package/web/tailwind.config.ts +68 -0
- package/web/tsconfig.json +41 -0
|
@@ -0,0 +1,1091 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useRef, useCallback, KeyboardEvent } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
FileText,
|
|
6
|
+
Cpu,
|
|
7
|
+
ListTodo,
|
|
8
|
+
Plus,
|
|
9
|
+
ChevronRight,
|
|
10
|
+
CheckCircle2,
|
|
11
|
+
Circle,
|
|
12
|
+
Clock,
|
|
13
|
+
AlertCircle,
|
|
14
|
+
Sparkles,
|
|
15
|
+
RefreshCw,
|
|
16
|
+
ExternalLink,
|
|
17
|
+
X,
|
|
18
|
+
Loader2,
|
|
19
|
+
Rocket,
|
|
20
|
+
} from 'lucide-react';
|
|
21
|
+
import type { SpecPhase, Spec, Requirement, DesignDecision, Task } from '@/lib/types';
|
|
22
|
+
import { useSpecsStore, type SpecProgress } from '@/lib/stores/specsStore';
|
|
23
|
+
import { useFileStore } from '@/lib/stores/fileStore';
|
|
24
|
+
import { useAutopilotStore } from '@/lib/stores/autopilotStore';
|
|
25
|
+
import { AGENTS } from '@/lib/constants/agents';
|
|
26
|
+
import { cn } from '@/lib/utils';
|
|
27
|
+
import { useListNavigation } from '@/hooks/useListNavigation';
|
|
28
|
+
|
|
29
|
+
interface SpecsPanelProps {
|
|
30
|
+
projectPaths: string[];
|
|
31
|
+
activeProjectPath: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Workflow phases
|
|
35
|
+
const PHASES: { id: SpecPhase; name: string; icon: React.ReactNode; color: string }[] = [
|
|
36
|
+
{ id: 'requirements', name: 'Requirements', icon: <FileText className="w-4 h-4" />, color: 'text-blue-400' },
|
|
37
|
+
{ id: 'design', name: 'Design', icon: <Cpu className="w-4 h-4" />, color: 'text-purple-400' },
|
|
38
|
+
{ id: 'tasks', name: 'Tasks', icon: <ListTodo className="w-4 h-4" />, color: 'text-amber-400' },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
export function SpecsPanel({ projectPaths, activeProjectPath }: SpecsPanelProps) {
|
|
42
|
+
const [showCreateModal, setShowCreateModal] = useState(false);
|
|
43
|
+
|
|
44
|
+
const {
|
|
45
|
+
specs,
|
|
46
|
+
isLoading,
|
|
47
|
+
error,
|
|
48
|
+
activePhase,
|
|
49
|
+
selectedSpecId,
|
|
50
|
+
filterProject,
|
|
51
|
+
loadSpecs,
|
|
52
|
+
setActivePhase,
|
|
53
|
+
setSelectedSpec,
|
|
54
|
+
setFilterProject,
|
|
55
|
+
getFilteredRequirements,
|
|
56
|
+
getFilteredDecisions,
|
|
57
|
+
getFilteredTasks,
|
|
58
|
+
} = useSpecsStore();
|
|
59
|
+
|
|
60
|
+
const { openFile } = useFileStore();
|
|
61
|
+
const { status: autopilotStatus } = useAutopilotStore();
|
|
62
|
+
|
|
63
|
+
// Load specs when project paths change
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (projectPaths.length > 0) {
|
|
66
|
+
loadSpecs(projectPaths);
|
|
67
|
+
}
|
|
68
|
+
}, [projectPaths.join(','), loadSpecs]);
|
|
69
|
+
|
|
70
|
+
// Refresh specs when autopilot completes
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (autopilotStatus === 'completed' || autopilotStatus === 'failed') {
|
|
73
|
+
const timer = setTimeout(() => {
|
|
74
|
+
loadSpecs(projectPaths);
|
|
75
|
+
}, 1000);
|
|
76
|
+
return () => clearTimeout(timer);
|
|
77
|
+
}
|
|
78
|
+
}, [autopilotStatus, projectPaths, loadSpecs]);
|
|
79
|
+
|
|
80
|
+
const handleOpenSpec = (spec: Spec) => {
|
|
81
|
+
if (spec.filePath) {
|
|
82
|
+
openFile(spec.filePath);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Get filtered data
|
|
87
|
+
const requirements = getFilteredRequirements();
|
|
88
|
+
const decisions = getFilteredDecisions();
|
|
89
|
+
const tasks = getFilteredTasks();
|
|
90
|
+
|
|
91
|
+
// Filter specs by phase and project
|
|
92
|
+
const requirementSpecs = specs.filter(s =>
|
|
93
|
+
s.phase === 'requirements' && (!filterProject || s.sourceProject === filterProject)
|
|
94
|
+
);
|
|
95
|
+
const designSpecs = specs.filter(s =>
|
|
96
|
+
s.phase === 'design' && (!filterProject || s.sourceProject === filterProject)
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// Unique project names for filter
|
|
100
|
+
const projectNames = [...new Set(specs.map(s => s.sourceProject).filter(Boolean))] as string[];
|
|
101
|
+
const getProjectName = (p: string) => p.split('/').pop() || p;
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div className="h-full flex flex-col bg-[#0a0a0f] text-white">
|
|
105
|
+
{/* Header */}
|
|
106
|
+
<div className="px-3 sm:px-4 py-2 sm:py-3 border-b border-white/10">
|
|
107
|
+
<div className="flex items-center justify-between mb-2 sm:mb-3">
|
|
108
|
+
<h2 className="font-semibold flex items-center gap-1.5 sm:gap-2 text-sm sm:text-base">
|
|
109
|
+
<Sparkles className="w-4 h-4 text-purple-400 flex-shrink-0" aria-hidden="true" />
|
|
110
|
+
<span className="truncate">Specs</span>
|
|
111
|
+
</h2>
|
|
112
|
+
<div className="flex items-center gap-0.5 sm:gap-1 flex-shrink-0" role="toolbar" aria-label="Specs actions">
|
|
113
|
+
<button
|
|
114
|
+
onClick={() => loadSpecs(projectPaths)}
|
|
115
|
+
className="p-1 sm:p-1.5 hover:bg-white/10 rounded-lg transition-colors text-gray-400 hover:text-white"
|
|
116
|
+
title="Refresh"
|
|
117
|
+
aria-label="Refresh specs"
|
|
118
|
+
>
|
|
119
|
+
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} aria-hidden="true" />
|
|
120
|
+
</button>
|
|
121
|
+
<button
|
|
122
|
+
onClick={() => setShowCreateModal(true)}
|
|
123
|
+
className="p-1 sm:p-1.5 hover:bg-white/10 rounded-lg transition-colors text-gray-400 hover:text-white"
|
|
124
|
+
title="New Spec"
|
|
125
|
+
aria-label="Create new spec"
|
|
126
|
+
>
|
|
127
|
+
<Plus className="w-4 h-4" aria-hidden="true" />
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
{/* Project Filter - only show with multiple projects */}
|
|
133
|
+
{projectNames.length > 1 && (
|
|
134
|
+
<div className="flex gap-1 mb-2 overflow-x-auto scrollbar-hide">
|
|
135
|
+
<button
|
|
136
|
+
onClick={() => setFilterProject(null)}
|
|
137
|
+
className={cn(
|
|
138
|
+
'px-2 py-1 rounded-md text-[10px] font-medium transition-all whitespace-nowrap',
|
|
139
|
+
!filterProject
|
|
140
|
+
? 'bg-purple-500/20 text-purple-400 border border-purple-500/30'
|
|
141
|
+
: 'bg-white/5 text-gray-400 hover:bg-white/10 border border-transparent'
|
|
142
|
+
)}
|
|
143
|
+
>
|
|
144
|
+
All
|
|
145
|
+
</button>
|
|
146
|
+
{projectNames.map((p) => (
|
|
147
|
+
<button
|
|
148
|
+
key={p}
|
|
149
|
+
onClick={() => setFilterProject(filterProject === p ? null : p)}
|
|
150
|
+
className={cn(
|
|
151
|
+
'px-2 py-1 rounded-md text-[10px] font-medium transition-all whitespace-nowrap',
|
|
152
|
+
filterProject === p
|
|
153
|
+
? 'bg-purple-500/20 text-purple-400 border border-purple-500/30'
|
|
154
|
+
: 'bg-white/5 text-gray-400 hover:bg-white/10 border border-transparent'
|
|
155
|
+
)}
|
|
156
|
+
>
|
|
157
|
+
{getProjectName(p)}
|
|
158
|
+
</button>
|
|
159
|
+
))}
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
|
|
163
|
+
{/* Phase Tabs - Responsive */}
|
|
164
|
+
<div
|
|
165
|
+
className="flex gap-1 bg-white/5 rounded-lg p-1 overflow-x-auto scrollbar-hide"
|
|
166
|
+
role="tablist"
|
|
167
|
+
aria-label="Spec phases"
|
|
168
|
+
>
|
|
169
|
+
{PHASES.map((phase) => {
|
|
170
|
+
const count = phase.id === 'requirements'
|
|
171
|
+
? requirements.length
|
|
172
|
+
: phase.id === 'design'
|
|
173
|
+
? decisions.length
|
|
174
|
+
: tasks.length;
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<button
|
|
178
|
+
key={phase.id}
|
|
179
|
+
onClick={() => setActivePhase(phase.id)}
|
|
180
|
+
role="tab"
|
|
181
|
+
aria-selected={activePhase === phase.id}
|
|
182
|
+
aria-controls={`${phase.id}-panel`}
|
|
183
|
+
id={`${phase.id}-tab`}
|
|
184
|
+
className={cn(
|
|
185
|
+
'flex-1 min-w-0 flex items-center justify-center gap-1 px-2 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap',
|
|
186
|
+
activePhase === phase.id
|
|
187
|
+
? 'bg-white/10 text-white'
|
|
188
|
+
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
|
189
|
+
)}
|
|
190
|
+
>
|
|
191
|
+
<span className={cn('flex-shrink-0', phase.color)} aria-hidden="true">{phase.icon}</span>
|
|
192
|
+
<span className="hidden sm:inline truncate">{phase.name}</span>
|
|
193
|
+
{count > 0 && (
|
|
194
|
+
<span className="flex-shrink-0 px-1.5 py-0.5 bg-white/10 rounded text-[10px]" aria-label={`${count} items`}>
|
|
195
|
+
{count}
|
|
196
|
+
</span>
|
|
197
|
+
)}
|
|
198
|
+
</button>
|
|
199
|
+
);
|
|
200
|
+
})}
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
{/* Error Message */}
|
|
205
|
+
{error && (
|
|
206
|
+
<div className="mx-3 sm:mx-4 mt-2 sm:mt-3 p-2 sm:p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-xs sm:text-sm text-red-400">
|
|
207
|
+
{error}
|
|
208
|
+
</div>
|
|
209
|
+
)}
|
|
210
|
+
|
|
211
|
+
{/* Content */}
|
|
212
|
+
<div
|
|
213
|
+
className="flex-1 overflow-auto p-3 sm:p-4"
|
|
214
|
+
role="tabpanel"
|
|
215
|
+
id={`${activePhase}-panel`}
|
|
216
|
+
aria-labelledby={`${activePhase}-tab`}
|
|
217
|
+
>
|
|
218
|
+
{isLoading ? (
|
|
219
|
+
<div className="h-full flex items-center justify-center" aria-label="Loading specs">
|
|
220
|
+
<RefreshCw className="w-6 h-6 animate-spin text-purple-400" aria-hidden="true" />
|
|
221
|
+
</div>
|
|
222
|
+
) : (
|
|
223
|
+
<>
|
|
224
|
+
{activePhase === 'requirements' && (
|
|
225
|
+
<RequirementsView
|
|
226
|
+
requirements={requirements}
|
|
227
|
+
specs={requirementSpecs}
|
|
228
|
+
projectPath={activeProjectPath || projectPaths[0] || ''}
|
|
229
|
+
onOpenSpec={handleOpenSpec}
|
|
230
|
+
onCreateNew={() => setShowCreateModal(true)}
|
|
231
|
+
showProjectBadge={projectPaths.length > 1}
|
|
232
|
+
/>
|
|
233
|
+
)}
|
|
234
|
+
{activePhase === 'design' && (
|
|
235
|
+
<DesignView
|
|
236
|
+
decisions={decisions}
|
|
237
|
+
specs={designSpecs}
|
|
238
|
+
onOpenSpec={handleOpenSpec}
|
|
239
|
+
onCreateNew={() => setShowCreateModal(true)}
|
|
240
|
+
showProjectBadge={projectPaths.length > 1}
|
|
241
|
+
/>
|
|
242
|
+
)}
|
|
243
|
+
{activePhase === 'tasks' && (
|
|
244
|
+
<TasksView
|
|
245
|
+
tasks={tasks}
|
|
246
|
+
onCreateNew={() => setShowCreateModal(true)}
|
|
247
|
+
showProjectBadge={projectPaths.length > 1}
|
|
248
|
+
/>
|
|
249
|
+
)}
|
|
250
|
+
</>
|
|
251
|
+
)}
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
{/* Create Modal */}
|
|
255
|
+
{showCreateModal && (
|
|
256
|
+
<CreateSpecModal
|
|
257
|
+
projectPath={activeProjectPath || projectPaths[0] || ''}
|
|
258
|
+
activePhase={activePhase}
|
|
259
|
+
onClose={() => setShowCreateModal(false)}
|
|
260
|
+
/>
|
|
261
|
+
)}
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Progress Bar Component
|
|
267
|
+
function ProgressBar({ progress }: { progress: SpecProgress }) {
|
|
268
|
+
if (progress.total === 0) return null;
|
|
269
|
+
|
|
270
|
+
const getBarColor = () => {
|
|
271
|
+
if (progress.status === 'completed') return 'bg-green-500';
|
|
272
|
+
if (progress.percentage >= 50) return 'bg-blue-500';
|
|
273
|
+
return 'bg-amber-500';
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
return (
|
|
277
|
+
<div className="mt-2">
|
|
278
|
+
<div className="flex items-center justify-between text-[10px] text-gray-500 mb-1">
|
|
279
|
+
<span>{progress.completed}/{progress.total} tasks</span>
|
|
280
|
+
<span className={cn(
|
|
281
|
+
progress.status === 'completed' && 'text-green-400',
|
|
282
|
+
progress.status === 'in_progress' && 'text-blue-400'
|
|
283
|
+
)}>
|
|
284
|
+
{progress.percentage}%
|
|
285
|
+
</span>
|
|
286
|
+
</div>
|
|
287
|
+
<div className="h-1.5 bg-white/10 rounded-full overflow-hidden">
|
|
288
|
+
<div
|
|
289
|
+
className={cn('h-full rounded-full transition-all duration-300', getBarColor())}
|
|
290
|
+
style={{ width: `${progress.percentage}%` }}
|
|
291
|
+
/>
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Requirements View
|
|
298
|
+
function RequirementsView({
|
|
299
|
+
requirements,
|
|
300
|
+
specs,
|
|
301
|
+
projectPath,
|
|
302
|
+
onOpenSpec,
|
|
303
|
+
onCreateNew,
|
|
304
|
+
showProjectBadge = false,
|
|
305
|
+
}: {
|
|
306
|
+
requirements: Requirement[];
|
|
307
|
+
specs: Spec[];
|
|
308
|
+
projectPath: string;
|
|
309
|
+
onOpenSpec: (spec: Spec) => void;
|
|
310
|
+
onCreateNew: () => void;
|
|
311
|
+
showProjectBadge?: boolean;
|
|
312
|
+
}) {
|
|
313
|
+
const { getSpecProgress } = useSpecsStore();
|
|
314
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
315
|
+
|
|
316
|
+
const handleSelect = useCallback((req: Requirement) => {
|
|
317
|
+
const spec = specs.find(s => s.id === req.specId);
|
|
318
|
+
if (spec) {
|
|
319
|
+
onOpenSpec(spec);
|
|
320
|
+
}
|
|
321
|
+
}, [specs, onOpenSpec]);
|
|
322
|
+
|
|
323
|
+
const { selectedIndex, handleKeyDown, isSelected } = useListNavigation({
|
|
324
|
+
items: requirements,
|
|
325
|
+
onSelect: handleSelect,
|
|
326
|
+
getItemText: (req) => req.title,
|
|
327
|
+
typeAhead: true,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
if (requirements.length === 0) {
|
|
331
|
+
return (
|
|
332
|
+
<EmptyState
|
|
333
|
+
icon={<FileText className="w-8 h-8" />}
|
|
334
|
+
title="No Requirements Yet"
|
|
335
|
+
description="Start by describing what you want to build. Create user stories with acceptance criteria."
|
|
336
|
+
action="Create Requirement"
|
|
337
|
+
onAction={onCreateNew}
|
|
338
|
+
/>
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return (
|
|
343
|
+
<div
|
|
344
|
+
ref={containerRef}
|
|
345
|
+
className="space-y-2 sm:space-y-3 focus:outline-none"
|
|
346
|
+
role="listbox"
|
|
347
|
+
aria-label="Requirements list"
|
|
348
|
+
tabIndex={0}
|
|
349
|
+
onKeyDown={handleKeyDown}
|
|
350
|
+
>
|
|
351
|
+
{requirements.map((req, index) => {
|
|
352
|
+
const spec = specs.find(s => s.id === req.specId);
|
|
353
|
+
const progress = getSpecProgress(req.specId);
|
|
354
|
+
return (
|
|
355
|
+
<RequirementCard
|
|
356
|
+
key={req.id}
|
|
357
|
+
requirement={req}
|
|
358
|
+
spec={spec}
|
|
359
|
+
progress={progress}
|
|
360
|
+
projectPath={projectPath}
|
|
361
|
+
onClick={() => spec && onOpenSpec(spec)}
|
|
362
|
+
isSelected={isSelected(index)}
|
|
363
|
+
showProjectBadge={showProjectBadge}
|
|
364
|
+
/>
|
|
365
|
+
);
|
|
366
|
+
})}
|
|
367
|
+
</div>
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function RequirementCard({
|
|
372
|
+
requirement,
|
|
373
|
+
spec,
|
|
374
|
+
progress,
|
|
375
|
+
projectPath,
|
|
376
|
+
onClick,
|
|
377
|
+
isSelected = false,
|
|
378
|
+
showProjectBadge = false,
|
|
379
|
+
}: {
|
|
380
|
+
requirement: Requirement;
|
|
381
|
+
spec?: Spec;
|
|
382
|
+
progress: SpecProgress;
|
|
383
|
+
projectPath: string;
|
|
384
|
+
onClick: () => void;
|
|
385
|
+
isSelected?: boolean;
|
|
386
|
+
showProjectBadge?: boolean;
|
|
387
|
+
}) {
|
|
388
|
+
const cardRef = useRef<HTMLDivElement>(null);
|
|
389
|
+
|
|
390
|
+
// Scroll into view when selected
|
|
391
|
+
useEffect(() => {
|
|
392
|
+
if (isSelected && cardRef.current) {
|
|
393
|
+
cardRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
394
|
+
}
|
|
395
|
+
}, [isSelected]);
|
|
396
|
+
const { openConfigModal, status: autopilotStatus, specId: autopilotSpecId } = useAutopilotStore();
|
|
397
|
+
|
|
398
|
+
const priorityColors = {
|
|
399
|
+
must: 'bg-red-500/20 text-red-400 border-red-500/30',
|
|
400
|
+
should: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
|
401
|
+
could: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
|
402
|
+
wont: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
// Dynamic status based on progress
|
|
406
|
+
const getStatusIcon = () => {
|
|
407
|
+
if (progress.status === 'completed') {
|
|
408
|
+
return <CheckCircle2 className="w-4 h-4 text-green-400" />;
|
|
409
|
+
}
|
|
410
|
+
if (progress.status === 'in_progress') {
|
|
411
|
+
return <Clock className="w-4 h-4 text-blue-400" />;
|
|
412
|
+
}
|
|
413
|
+
if (requirement.status === 'approved') {
|
|
414
|
+
return <CheckCircle2 className="w-4 h-4 text-amber-400" />;
|
|
415
|
+
}
|
|
416
|
+
return <Circle className="w-4 h-4 text-gray-400" />;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
// Border color based on progress
|
|
420
|
+
const getBorderClass = () => {
|
|
421
|
+
if (progress.status === 'completed') return 'border-green-500/30 bg-green-500/5';
|
|
422
|
+
if (progress.status === 'in_progress') return 'border-blue-500/30 bg-blue-500/5';
|
|
423
|
+
return 'border-white/10';
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
// Handle Autopilot button click
|
|
427
|
+
const handleAutopilotClick = (e: React.MouseEvent) => {
|
|
428
|
+
e.stopPropagation();
|
|
429
|
+
if (spec) {
|
|
430
|
+
// Read spec content from the file
|
|
431
|
+
const specContent = `# ${requirement.title}\n\n${requirement.description}\n\n## Acceptance Criteria\n${requirement.acceptanceCriteria.map(c => `- ${c}`).join('\n')}`;
|
|
432
|
+
openConfigModal(spec.id, requirement.title, specContent, spec.filePath);
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
const isAutopilotRunning = autopilotStatus === 'running' && autopilotSpecId === spec?.id;
|
|
437
|
+
|
|
438
|
+
return (
|
|
439
|
+
<div
|
|
440
|
+
ref={cardRef}
|
|
441
|
+
onClick={onClick}
|
|
442
|
+
className={cn(
|
|
443
|
+
'p-2 sm:p-3 bg-white/5 border rounded-lg hover:border-purple-500/30 hover:bg-white/[0.07] transition-all cursor-pointer group',
|
|
444
|
+
getBorderClass(),
|
|
445
|
+
isSelected && 'ring-1 ring-inset ring-purple-500/50'
|
|
446
|
+
)}
|
|
447
|
+
role="option"
|
|
448
|
+
aria-selected={isSelected}
|
|
449
|
+
tabIndex={isSelected ? 0 : -1}
|
|
450
|
+
>
|
|
451
|
+
<div className="flex items-start gap-2 sm:gap-3">
|
|
452
|
+
<div className="flex-shrink-0 mt-0.5" aria-hidden="true">
|
|
453
|
+
{getStatusIcon()}
|
|
454
|
+
</div>
|
|
455
|
+
<div className="flex-1 min-w-0 overflow-hidden">
|
|
456
|
+
<div className="flex items-start sm:items-center gap-1 sm:gap-2 mb-1 flex-wrap">
|
|
457
|
+
<span className="font-medium text-sm text-white break-words line-clamp-2 sm:line-clamp-1">{requirement.title}</span>
|
|
458
|
+
<span className={cn(
|
|
459
|
+
'flex-shrink-0 px-1.5 py-0.5 rounded text-[10px] font-medium border',
|
|
460
|
+
priorityColors[requirement.priority]
|
|
461
|
+
)}>
|
|
462
|
+
{requirement.priority.toUpperCase()}
|
|
463
|
+
</span>
|
|
464
|
+
{progress.status === 'completed' && (
|
|
465
|
+
<span className="flex-shrink-0 px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-500/20 text-green-400 border border-green-500/30">
|
|
466
|
+
DONE
|
|
467
|
+
</span>
|
|
468
|
+
)}
|
|
469
|
+
{showProjectBadge && requirement.sourceProject && (
|
|
470
|
+
<span className="flex-shrink-0 px-1.5 py-0.5 rounded text-[10px] bg-white/10 text-gray-400">
|
|
471
|
+
{requirement.sourceProject.split('/').pop()}
|
|
472
|
+
</span>
|
|
473
|
+
)}
|
|
474
|
+
</div>
|
|
475
|
+
<p className="text-xs text-gray-400 line-clamp-2 break-words">{requirement.description}</p>
|
|
476
|
+
|
|
477
|
+
{/* Progress Bar */}
|
|
478
|
+
<ProgressBar progress={progress} />
|
|
479
|
+
|
|
480
|
+
{/* Autopilot Button */}
|
|
481
|
+
{progress.status !== 'completed' && spec && (
|
|
482
|
+
<div className="mt-2 flex items-center gap-2">
|
|
483
|
+
<button
|
|
484
|
+
onClick={handleAutopilotClick}
|
|
485
|
+
disabled={!!isAutopilotRunning}
|
|
486
|
+
aria-label={isAutopilotRunning ? 'Autopilot running' : 'Start Autopilot'}
|
|
487
|
+
className={cn(
|
|
488
|
+
'flex items-center gap-1 text-xs px-2 py-1 rounded-md transition-all',
|
|
489
|
+
isAutopilotRunning
|
|
490
|
+
? 'bg-purple-500/20 text-purple-400 cursor-wait'
|
|
491
|
+
: 'bg-white/10 text-gray-400 hover:bg-purple-500/20 hover:text-purple-400 opacity-0 group-hover:opacity-100'
|
|
492
|
+
)}
|
|
493
|
+
>
|
|
494
|
+
{isAutopilotRunning ? (
|
|
495
|
+
<>
|
|
496
|
+
<Loader2 className="w-3 h-3 animate-spin" aria-hidden="true" />
|
|
497
|
+
Running...
|
|
498
|
+
</>
|
|
499
|
+
) : (
|
|
500
|
+
<>
|
|
501
|
+
<Rocket className="w-3 h-3" aria-hidden="true" />
|
|
502
|
+
Autopilot
|
|
503
|
+
</>
|
|
504
|
+
)}
|
|
505
|
+
</button>
|
|
506
|
+
</div>
|
|
507
|
+
)}
|
|
508
|
+
|
|
509
|
+
{requirement.acceptanceCriteria.length > 0 && progress.total === 0 && !spec && (
|
|
510
|
+
<div className="mt-2 flex items-center gap-1 text-xs text-gray-500">
|
|
511
|
+
<CheckCircle2 className="w-3 h-3 flex-shrink-0" aria-hidden="true" />
|
|
512
|
+
<span className="truncate">{requirement.acceptanceCriteria.length} criteria</span>
|
|
513
|
+
</div>
|
|
514
|
+
)}
|
|
515
|
+
</div>
|
|
516
|
+
<ExternalLink className="w-4 h-4 flex-shrink-0 text-gray-500 opacity-0 group-hover:opacity-100 transition-opacity hidden sm:block" aria-hidden="true" />
|
|
517
|
+
</div>
|
|
518
|
+
</div>
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Design View
|
|
523
|
+
function DesignView({
|
|
524
|
+
decisions,
|
|
525
|
+
specs,
|
|
526
|
+
onOpenSpec,
|
|
527
|
+
onCreateNew,
|
|
528
|
+
showProjectBadge = false,
|
|
529
|
+
}: {
|
|
530
|
+
decisions: DesignDecision[];
|
|
531
|
+
specs: Spec[];
|
|
532
|
+
onOpenSpec: (spec: Spec) => void;
|
|
533
|
+
onCreateNew: () => void;
|
|
534
|
+
showProjectBadge?: boolean;
|
|
535
|
+
}) {
|
|
536
|
+
const { getSpecProgress } = useSpecsStore();
|
|
537
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
538
|
+
|
|
539
|
+
const handleSelect = useCallback((dec: DesignDecision) => {
|
|
540
|
+
const spec = specs.find(s => s.id === dec.specId);
|
|
541
|
+
if (spec) {
|
|
542
|
+
onOpenSpec(spec);
|
|
543
|
+
}
|
|
544
|
+
}, [specs, onOpenSpec]);
|
|
545
|
+
|
|
546
|
+
const { selectedIndex, handleKeyDown, isSelected } = useListNavigation({
|
|
547
|
+
items: decisions,
|
|
548
|
+
onSelect: handleSelect,
|
|
549
|
+
getItemText: (dec) => dec.title,
|
|
550
|
+
typeAhead: true,
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
if (decisions.length === 0) {
|
|
554
|
+
return (
|
|
555
|
+
<EmptyState
|
|
556
|
+
icon={<Cpu className="w-8 h-8" />}
|
|
557
|
+
title="No Design Decisions"
|
|
558
|
+
description="Create Architecture Decision Records (ADRs) to document technical choices."
|
|
559
|
+
action="Create ADR"
|
|
560
|
+
onAction={onCreateNew}
|
|
561
|
+
/>
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return (
|
|
566
|
+
<div
|
|
567
|
+
ref={containerRef}
|
|
568
|
+
className="space-y-2 sm:space-y-3 focus:outline-none"
|
|
569
|
+
role="listbox"
|
|
570
|
+
aria-label="Design decisions list"
|
|
571
|
+
tabIndex={0}
|
|
572
|
+
onKeyDown={handleKeyDown}
|
|
573
|
+
>
|
|
574
|
+
{decisions.map((dec, index) => {
|
|
575
|
+
const spec = specs.find(s => s.id === dec.specId);
|
|
576
|
+
const progress = getSpecProgress(dec.specId);
|
|
577
|
+
return (
|
|
578
|
+
<DecisionCard
|
|
579
|
+
key={dec.id}
|
|
580
|
+
decision={dec}
|
|
581
|
+
progress={progress}
|
|
582
|
+
onClick={() => spec && onOpenSpec(spec)}
|
|
583
|
+
isSelected={isSelected(index)}
|
|
584
|
+
showProjectBadge={showProjectBadge}
|
|
585
|
+
/>
|
|
586
|
+
);
|
|
587
|
+
})}
|
|
588
|
+
</div>
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function DecisionCard({
|
|
593
|
+
decision,
|
|
594
|
+
progress,
|
|
595
|
+
onClick,
|
|
596
|
+
isSelected = false,
|
|
597
|
+
showProjectBadge = false,
|
|
598
|
+
}: {
|
|
599
|
+
decision: DesignDecision;
|
|
600
|
+
progress: SpecProgress;
|
|
601
|
+
onClick: () => void;
|
|
602
|
+
isSelected?: boolean;
|
|
603
|
+
showProjectBadge?: boolean;
|
|
604
|
+
}) {
|
|
605
|
+
const cardRef = useRef<HTMLDivElement>(null);
|
|
606
|
+
|
|
607
|
+
// Scroll into view when selected
|
|
608
|
+
useEffect(() => {
|
|
609
|
+
if (isSelected && cardRef.current) {
|
|
610
|
+
cardRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
611
|
+
}
|
|
612
|
+
}, [isSelected]);
|
|
613
|
+
|
|
614
|
+
const statusColors = {
|
|
615
|
+
proposed: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
|
616
|
+
accepted: 'bg-green-500/20 text-green-400 border-green-500/30',
|
|
617
|
+
deprecated: 'bg-red-500/20 text-red-400 border-red-500/30',
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
// Border color based on progress
|
|
621
|
+
const getBorderClass = () => {
|
|
622
|
+
if (progress.status === 'completed') return 'border-green-500/30 bg-green-500/5';
|
|
623
|
+
if (progress.status === 'in_progress') return 'border-purple-500/30 bg-purple-500/5';
|
|
624
|
+
return 'border-white/10';
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
return (
|
|
628
|
+
<div
|
|
629
|
+
ref={cardRef}
|
|
630
|
+
onClick={onClick}
|
|
631
|
+
className={cn(
|
|
632
|
+
'p-2 sm:p-3 bg-white/5 border rounded-lg hover:border-purple-500/30 hover:bg-white/[0.07] transition-all cursor-pointer group',
|
|
633
|
+
getBorderClass(),
|
|
634
|
+
isSelected && 'ring-1 ring-inset ring-purple-500/50'
|
|
635
|
+
)}
|
|
636
|
+
role="option"
|
|
637
|
+
aria-selected={isSelected}
|
|
638
|
+
tabIndex={isSelected ? 0 : -1}
|
|
639
|
+
>
|
|
640
|
+
<div className="flex items-start gap-2 sm:gap-3">
|
|
641
|
+
<Cpu className="w-4 h-4 text-purple-400 mt-0.5 flex-shrink-0" aria-hidden="true" />
|
|
642
|
+
<div className="flex-1 min-w-0 overflow-hidden">
|
|
643
|
+
<div className="flex items-start sm:items-center gap-1 sm:gap-2 mb-1 flex-wrap">
|
|
644
|
+
<span className="font-medium text-sm text-white break-words line-clamp-2 sm:line-clamp-1">{decision.title}</span>
|
|
645
|
+
<span className={cn(
|
|
646
|
+
'flex-shrink-0 px-1.5 py-0.5 rounded text-[10px] font-medium border',
|
|
647
|
+
statusColors[decision.status]
|
|
648
|
+
)}>
|
|
649
|
+
{decision.status.toUpperCase()}
|
|
650
|
+
</span>
|
|
651
|
+
{progress.status === 'completed' && (
|
|
652
|
+
<span className="flex-shrink-0 px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-500/20 text-green-400 border border-green-500/30">
|
|
653
|
+
DONE
|
|
654
|
+
</span>
|
|
655
|
+
)}
|
|
656
|
+
{showProjectBadge && decision.sourceProject && (
|
|
657
|
+
<span className="flex-shrink-0 px-1.5 py-0.5 rounded text-[10px] bg-white/10 text-gray-400">
|
|
658
|
+
{decision.sourceProject.split('/').pop()}
|
|
659
|
+
</span>
|
|
660
|
+
)}
|
|
661
|
+
</div>
|
|
662
|
+
<p className="text-xs text-gray-400 line-clamp-2 break-words">{decision.context}</p>
|
|
663
|
+
|
|
664
|
+
{/* Progress Bar */}
|
|
665
|
+
<ProgressBar progress={progress} />
|
|
666
|
+
|
|
667
|
+
{decision.consequences.length > 0 && progress.total === 0 && (
|
|
668
|
+
<div className="mt-2 flex items-center gap-1 text-xs text-gray-500">
|
|
669
|
+
<AlertCircle className="w-3 h-3 flex-shrink-0" aria-hidden="true" />
|
|
670
|
+
<span className="truncate">{decision.consequences.length} consequences</span>
|
|
671
|
+
</div>
|
|
672
|
+
)}
|
|
673
|
+
</div>
|
|
674
|
+
<ExternalLink className="w-4 h-4 flex-shrink-0 text-gray-500 opacity-0 group-hover:opacity-100 transition-opacity hidden sm:block" aria-hidden="true" />
|
|
675
|
+
</div>
|
|
676
|
+
</div>
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Tasks View
|
|
681
|
+
function TasksView({
|
|
682
|
+
tasks,
|
|
683
|
+
onCreateNew,
|
|
684
|
+
showProjectBadge = false,
|
|
685
|
+
}: {
|
|
686
|
+
tasks: Task[];
|
|
687
|
+
onCreateNew: () => void;
|
|
688
|
+
showProjectBadge?: boolean;
|
|
689
|
+
}) {
|
|
690
|
+
const { updateTaskStatus } = useSpecsStore();
|
|
691
|
+
|
|
692
|
+
if (tasks.length === 0) {
|
|
693
|
+
return (
|
|
694
|
+
<EmptyState
|
|
695
|
+
icon={<ListTodo className="w-8 h-8" />}
|
|
696
|
+
title="No Tasks Yet"
|
|
697
|
+
description="Tasks are extracted from your specs. Create a spec with checkbox items to see tasks here."
|
|
698
|
+
action="Create Spec"
|
|
699
|
+
onAction={onCreateNew}
|
|
700
|
+
/>
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const groupedTasks = {
|
|
705
|
+
in_progress: tasks.filter(t => t.status === 'in_progress'),
|
|
706
|
+
pending: tasks.filter(t => t.status === 'pending'),
|
|
707
|
+
blocked: tasks.filter(t => t.status === 'blocked'),
|
|
708
|
+
completed: tasks.filter(t => t.status === 'completed'),
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
return (
|
|
712
|
+
<div className="space-y-3 sm:space-y-4">
|
|
713
|
+
{groupedTasks.in_progress.length > 0 && (
|
|
714
|
+
<TaskGroup
|
|
715
|
+
title="In Progress"
|
|
716
|
+
tasks={groupedTasks.in_progress}
|
|
717
|
+
color="text-blue-400"
|
|
718
|
+
onToggle={(id, current) => updateTaskStatus(id, current === 'completed' ? 'pending' : 'completed')}
|
|
719
|
+
showProjectBadge={showProjectBadge}
|
|
720
|
+
/>
|
|
721
|
+
)}
|
|
722
|
+
{groupedTasks.pending.length > 0 && (
|
|
723
|
+
<TaskGroup
|
|
724
|
+
title="Pending"
|
|
725
|
+
tasks={groupedTasks.pending}
|
|
726
|
+
color="text-gray-400"
|
|
727
|
+
onToggle={(id, current) => updateTaskStatus(id, current === 'completed' ? 'pending' : 'completed')}
|
|
728
|
+
showProjectBadge={showProjectBadge}
|
|
729
|
+
/>
|
|
730
|
+
)}
|
|
731
|
+
{groupedTasks.blocked.length > 0 && (
|
|
732
|
+
<TaskGroup
|
|
733
|
+
title="Blocked"
|
|
734
|
+
tasks={groupedTasks.blocked}
|
|
735
|
+
color="text-red-400"
|
|
736
|
+
onToggle={(id, current) => updateTaskStatus(id, current === 'completed' ? 'pending' : 'completed')}
|
|
737
|
+
showProjectBadge={showProjectBadge}
|
|
738
|
+
/>
|
|
739
|
+
)}
|
|
740
|
+
{groupedTasks.completed.length > 0 && (
|
|
741
|
+
<TaskGroup
|
|
742
|
+
title="Completed"
|
|
743
|
+
tasks={groupedTasks.completed}
|
|
744
|
+
color="text-green-400"
|
|
745
|
+
onToggle={(id, current) => updateTaskStatus(id, current === 'completed' ? 'pending' : 'completed')}
|
|
746
|
+
showProjectBadge={showProjectBadge}
|
|
747
|
+
/>
|
|
748
|
+
)}
|
|
749
|
+
</div>
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function TaskGroup({
|
|
754
|
+
title,
|
|
755
|
+
tasks,
|
|
756
|
+
color,
|
|
757
|
+
onToggle,
|
|
758
|
+
showProjectBadge = false,
|
|
759
|
+
}: {
|
|
760
|
+
title: string;
|
|
761
|
+
tasks: Task[];
|
|
762
|
+
color: string;
|
|
763
|
+
onToggle: (id: string, currentStatus: Task['status']) => void;
|
|
764
|
+
showProjectBadge?: boolean;
|
|
765
|
+
}) {
|
|
766
|
+
return (
|
|
767
|
+
<div>
|
|
768
|
+
<h3 className={cn('text-xs font-medium mb-1.5 sm:mb-2 flex items-center gap-1.5 sm:gap-2', color)}>
|
|
769
|
+
<span className="truncate">{title}</span>
|
|
770
|
+
<span className="flex-shrink-0 px-1.5 py-0.5 bg-white/10 rounded text-[10px]">{tasks.length}</span>
|
|
771
|
+
</h3>
|
|
772
|
+
<div className="space-y-1.5 sm:space-y-2">
|
|
773
|
+
{tasks.map((task) => (
|
|
774
|
+
<TaskCard
|
|
775
|
+
key={task.id}
|
|
776
|
+
task={task}
|
|
777
|
+
onToggle={onToggle}
|
|
778
|
+
showProjectBadge={showProjectBadge}
|
|
779
|
+
/>
|
|
780
|
+
))}
|
|
781
|
+
</div>
|
|
782
|
+
</div>
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function TaskCard({
|
|
787
|
+
task,
|
|
788
|
+
onToggle,
|
|
789
|
+
showProjectBadge = false,
|
|
790
|
+
}: {
|
|
791
|
+
task: Task;
|
|
792
|
+
onToggle: (id: string, currentStatus: Task['status']) => void;
|
|
793
|
+
showProjectBadge?: boolean;
|
|
794
|
+
}) {
|
|
795
|
+
const priorityDots = {
|
|
796
|
+
low: 'bg-gray-400',
|
|
797
|
+
medium: 'bg-blue-400',
|
|
798
|
+
high: 'bg-amber-400',
|
|
799
|
+
critical: 'bg-red-400',
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
const agentColors: Record<string, string> = {
|
|
803
|
+
strategist: 'text-blue-400 bg-blue-400/10',
|
|
804
|
+
architect: 'text-cyan-400 bg-cyan-400/10',
|
|
805
|
+
builder: 'text-amber-400 bg-amber-400/10',
|
|
806
|
+
guardian: 'text-green-400 bg-green-400/10',
|
|
807
|
+
chronicler: 'text-purple-400 bg-purple-400/10',
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
const isCompleted = task.status === 'completed';
|
|
811
|
+
const isInProgress = task.status === 'in_progress';
|
|
812
|
+
const isBlocked = task.status === 'blocked';
|
|
813
|
+
|
|
814
|
+
// Status-based styling
|
|
815
|
+
const getStatusStyles = () => {
|
|
816
|
+
if (isCompleted) return 'bg-green-500/5 border-green-500/20';
|
|
817
|
+
if (isInProgress) return 'bg-blue-500/10 border-blue-500/30';
|
|
818
|
+
if (isBlocked) return 'bg-red-500/5 border-red-500/20 opacity-60';
|
|
819
|
+
// Pending - not yet started
|
|
820
|
+
return 'bg-white/5 border-white/10 hover:border-white/20';
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
return (
|
|
824
|
+
<div
|
|
825
|
+
className={cn(
|
|
826
|
+
'p-2 sm:p-3 border rounded-lg transition-all group',
|
|
827
|
+
getStatusStyles()
|
|
828
|
+
)}
|
|
829
|
+
>
|
|
830
|
+
<div className="flex items-start gap-2 sm:gap-3">
|
|
831
|
+
<button
|
|
832
|
+
onClick={() => onToggle(task.id, task.status)}
|
|
833
|
+
className="mt-0.5 flex-shrink-0"
|
|
834
|
+
>
|
|
835
|
+
{isCompleted ? (
|
|
836
|
+
<CheckCircle2 className="w-4 h-4 text-green-400" />
|
|
837
|
+
) : isInProgress ? (
|
|
838
|
+
<Loader2 className="w-4 h-4 text-blue-400 animate-spin" />
|
|
839
|
+
) : isBlocked ? (
|
|
840
|
+
<AlertCircle className="w-4 h-4 text-red-400" />
|
|
841
|
+
) : (
|
|
842
|
+
<Circle className="w-4 h-4 text-gray-500 hover:text-white transition-colors" />
|
|
843
|
+
)}
|
|
844
|
+
</button>
|
|
845
|
+
<div className="flex-1 min-w-0 overflow-hidden">
|
|
846
|
+
<div className="flex items-center gap-1.5 sm:gap-2">
|
|
847
|
+
<div className={cn('w-2 h-2 rounded-full flex-shrink-0', priorityDots[task.priority])} />
|
|
848
|
+
<span className={cn(
|
|
849
|
+
'font-medium text-sm break-words line-clamp-2',
|
|
850
|
+
isCompleted ? 'line-through text-gray-500' : 'text-white'
|
|
851
|
+
)}>
|
|
852
|
+
{task.title}
|
|
853
|
+
</span>
|
|
854
|
+
</div>
|
|
855
|
+
{task.description && (
|
|
856
|
+
<p className="text-xs text-gray-500 mt-1 line-clamp-1 break-words">{task.description}</p>
|
|
857
|
+
)}
|
|
858
|
+
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
|
859
|
+
{/* Status Badge */}
|
|
860
|
+
{isInProgress && (
|
|
861
|
+
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-500/20 text-blue-400">
|
|
862
|
+
In Progress
|
|
863
|
+
</span>
|
|
864
|
+
)}
|
|
865
|
+
{isBlocked && (
|
|
866
|
+
<span className="text-xs px-2 py-0.5 rounded-full bg-red-500/20 text-red-400">
|
|
867
|
+
Blocked
|
|
868
|
+
</span>
|
|
869
|
+
)}
|
|
870
|
+
{isCompleted && (
|
|
871
|
+
<span className="text-xs px-2 py-0.5 rounded-full bg-green-500/20 text-green-400">
|
|
872
|
+
Done
|
|
873
|
+
</span>
|
|
874
|
+
)}
|
|
875
|
+
{task.status === 'pending' && (
|
|
876
|
+
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-500/20 text-gray-400">
|
|
877
|
+
Pending
|
|
878
|
+
</span>
|
|
879
|
+
)}
|
|
880
|
+
|
|
881
|
+
{/* Agent Badge */}
|
|
882
|
+
{task.assignedAgent && (
|
|
883
|
+
<span className={cn(
|
|
884
|
+
'text-xs px-2 py-0.5 rounded-full',
|
|
885
|
+
agentColors[task.assignedAgent] || 'text-gray-400 bg-gray-400/10'
|
|
886
|
+
)}>
|
|
887
|
+
@{task.assignedAgent}
|
|
888
|
+
</span>
|
|
889
|
+
)}
|
|
890
|
+
|
|
891
|
+
{/* Project Badge */}
|
|
892
|
+
{showProjectBadge && task.sourceProject && (
|
|
893
|
+
<span className="text-xs px-2 py-0.5 rounded-full bg-white/10 text-gray-400">
|
|
894
|
+
{task.sourceProject.split('/').pop()}
|
|
895
|
+
</span>
|
|
896
|
+
)}
|
|
897
|
+
</div>
|
|
898
|
+
</div>
|
|
899
|
+
</div>
|
|
900
|
+
</div>
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Empty State
|
|
905
|
+
function EmptyState({
|
|
906
|
+
icon,
|
|
907
|
+
title,
|
|
908
|
+
description,
|
|
909
|
+
action,
|
|
910
|
+
onAction,
|
|
911
|
+
}: {
|
|
912
|
+
icon: React.ReactNode;
|
|
913
|
+
title: string;
|
|
914
|
+
description: string;
|
|
915
|
+
action: string;
|
|
916
|
+
onAction: () => void;
|
|
917
|
+
}) {
|
|
918
|
+
return (
|
|
919
|
+
<div className="h-full flex flex-col items-center justify-center text-center p-4 sm:p-6">
|
|
920
|
+
<div className="w-12 h-12 sm:w-16 sm:h-16 bg-white/5 rounded-xl sm:rounded-2xl flex items-center justify-center text-gray-500 mb-3 sm:mb-4">
|
|
921
|
+
{icon}
|
|
922
|
+
</div>
|
|
923
|
+
<h3 className="font-medium mb-1 sm:mb-2 text-white text-sm sm:text-base">{title}</h3>
|
|
924
|
+
<p className="text-xs sm:text-sm text-gray-500 mb-3 sm:mb-4 max-w-[200px] sm:max-w-[220px]">{description}</p>
|
|
925
|
+
<button
|
|
926
|
+
onClick={onAction}
|
|
927
|
+
className="px-3 sm:px-4 py-1.5 sm:py-2 bg-purple-600 hover:bg-purple-500 text-white text-xs sm:text-sm font-medium rounded-lg transition-colors"
|
|
928
|
+
>
|
|
929
|
+
{action}
|
|
930
|
+
</button>
|
|
931
|
+
</div>
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Create Spec Modal
|
|
936
|
+
function CreateSpecModal({
|
|
937
|
+
projectPath,
|
|
938
|
+
activePhase,
|
|
939
|
+
onClose,
|
|
940
|
+
}: {
|
|
941
|
+
projectPath: string;
|
|
942
|
+
activePhase: SpecPhase;
|
|
943
|
+
onClose: () => void;
|
|
944
|
+
}) {
|
|
945
|
+
const [type, setType] = useState<'story' | 'adr' | 'spec'>(
|
|
946
|
+
activePhase === 'design' ? 'adr' : 'story'
|
|
947
|
+
);
|
|
948
|
+
const [title, setTitle] = useState('');
|
|
949
|
+
const [description, setDescription] = useState('');
|
|
950
|
+
const [priority, setPriority] = useState('should');
|
|
951
|
+
const [isCreating, setIsCreating] = useState(false);
|
|
952
|
+
|
|
953
|
+
const { createSpec } = useSpecsStore();
|
|
954
|
+
const { openFile } = useFileStore();
|
|
955
|
+
|
|
956
|
+
const handleCreate = async () => {
|
|
957
|
+
if (!title.trim()) return;
|
|
958
|
+
|
|
959
|
+
setIsCreating(true);
|
|
960
|
+
|
|
961
|
+
const id = await createSpec(projectPath, {
|
|
962
|
+
type,
|
|
963
|
+
title: title.trim(),
|
|
964
|
+
description: description.trim() || undefined,
|
|
965
|
+
priority,
|
|
966
|
+
phase: activePhase,
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
setIsCreating(false);
|
|
970
|
+
|
|
971
|
+
if (id) {
|
|
972
|
+
onClose();
|
|
973
|
+
}
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
return (
|
|
977
|
+
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
|
978
|
+
<div className="w-full max-w-md bg-[#12121a] border border-white/10 rounded-xl sm:rounded-2xl shadow-xl max-h-[90vh] overflow-hidden flex flex-col">
|
|
979
|
+
{/* Header */}
|
|
980
|
+
<div className="flex items-center justify-between px-4 sm:px-6 py-3 sm:py-4 border-b border-white/10 flex-shrink-0">
|
|
981
|
+
<h3 className="font-semibold text-white text-sm sm:text-base">Create New Spec</h3>
|
|
982
|
+
<button
|
|
983
|
+
onClick={onClose}
|
|
984
|
+
className="p-1 hover:bg-white/10 rounded-lg transition-colors text-gray-400 hover:text-white"
|
|
985
|
+
>
|
|
986
|
+
<X className="w-5 h-5" />
|
|
987
|
+
</button>
|
|
988
|
+
</div>
|
|
989
|
+
|
|
990
|
+
{/* Content */}
|
|
991
|
+
<div className="p-4 sm:p-6 space-y-4 overflow-y-auto flex-1">
|
|
992
|
+
{/* Type */}
|
|
993
|
+
<div>
|
|
994
|
+
<label className="block text-xs sm:text-sm font-medium text-gray-400 mb-2">Type</label>
|
|
995
|
+
<div className="flex gap-1.5 sm:gap-2">
|
|
996
|
+
{[
|
|
997
|
+
{ id: 'story', label: 'Story', fullLabel: 'User Story', icon: <FileText className="w-4 h-4" /> },
|
|
998
|
+
{ id: 'adr', label: 'ADR', fullLabel: 'ADR', icon: <Cpu className="w-4 h-4" /> },
|
|
999
|
+
{ id: 'spec', label: 'Spec', fullLabel: 'Spec', icon: <ListTodo className="w-4 h-4" /> },
|
|
1000
|
+
].map((t) => (
|
|
1001
|
+
<button
|
|
1002
|
+
key={t.id}
|
|
1003
|
+
onClick={() => setType(t.id as 'story' | 'adr' | 'spec')}
|
|
1004
|
+
className={cn(
|
|
1005
|
+
'flex-1 flex items-center justify-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-2 rounded-lg border text-xs sm:text-sm transition-all',
|
|
1006
|
+
type === t.id
|
|
1007
|
+
? 'bg-purple-500/20 border-purple-500/50 text-purple-300'
|
|
1008
|
+
: 'bg-white/5 border-white/10 text-gray-400 hover:bg-white/10'
|
|
1009
|
+
)}
|
|
1010
|
+
>
|
|
1011
|
+
{t.icon}
|
|
1012
|
+
<span className="hidden sm:inline">{t.fullLabel}</span>
|
|
1013
|
+
<span className="sm:hidden">{t.label}</span>
|
|
1014
|
+
</button>
|
|
1015
|
+
))}
|
|
1016
|
+
</div>
|
|
1017
|
+
</div>
|
|
1018
|
+
|
|
1019
|
+
{/* Title */}
|
|
1020
|
+
<div>
|
|
1021
|
+
<label className="block text-xs sm:text-sm font-medium text-gray-400 mb-2">Title</label>
|
|
1022
|
+
<input
|
|
1023
|
+
type="text"
|
|
1024
|
+
value={title}
|
|
1025
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
1026
|
+
placeholder={type === 'story' ? 'User authentication flow' : type === 'adr' ? 'Use PostgreSQL for database' : 'Feature specification'}
|
|
1027
|
+
className="w-full px-3 sm:px-4 py-2 sm:py-2.5 bg-white/5 border border-white/10 rounded-lg text-white text-sm placeholder-gray-500 focus:outline-none focus:border-purple-500 transition-colors"
|
|
1028
|
+
autoFocus
|
|
1029
|
+
/>
|
|
1030
|
+
</div>
|
|
1031
|
+
|
|
1032
|
+
{/* Description */}
|
|
1033
|
+
<div>
|
|
1034
|
+
<label className="block text-xs sm:text-sm font-medium text-gray-400 mb-2">Description</label>
|
|
1035
|
+
<textarea
|
|
1036
|
+
value={description}
|
|
1037
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
1038
|
+
placeholder="Brief description..."
|
|
1039
|
+
rows={3}
|
|
1040
|
+
className="w-full px-3 sm:px-4 py-2 sm:py-2.5 bg-white/5 border border-white/10 rounded-lg text-white text-sm placeholder-gray-500 focus:outline-none focus:border-purple-500 transition-colors resize-none"
|
|
1041
|
+
/>
|
|
1042
|
+
</div>
|
|
1043
|
+
|
|
1044
|
+
{/* Priority (for stories) */}
|
|
1045
|
+
{type === 'story' && (
|
|
1046
|
+
<div>
|
|
1047
|
+
<label className="block text-xs sm:text-sm font-medium text-gray-400 mb-2">Priority</label>
|
|
1048
|
+
<div className="flex gap-1.5 sm:gap-2">
|
|
1049
|
+
{[
|
|
1050
|
+
{ id: 'must', label: 'Must', color: 'text-red-400' },
|
|
1051
|
+
{ id: 'should', label: 'Should', color: 'text-amber-400' },
|
|
1052
|
+
{ id: 'could', label: 'Could', color: 'text-blue-400' },
|
|
1053
|
+
].map((p) => (
|
|
1054
|
+
<button
|
|
1055
|
+
key={p.id}
|
|
1056
|
+
onClick={() => setPriority(p.id)}
|
|
1057
|
+
className={cn(
|
|
1058
|
+
'flex-1 px-2 sm:px-3 py-2 rounded-lg border text-xs sm:text-sm transition-all',
|
|
1059
|
+
priority === p.id
|
|
1060
|
+
? 'bg-white/10 border-white/20 text-white'
|
|
1061
|
+
: 'bg-white/5 border-white/10 text-gray-400 hover:bg-white/10'
|
|
1062
|
+
)}
|
|
1063
|
+
>
|
|
1064
|
+
<span className={p.color}>{p.label}</span>
|
|
1065
|
+
</button>
|
|
1066
|
+
))}
|
|
1067
|
+
</div>
|
|
1068
|
+
</div>
|
|
1069
|
+
)}
|
|
1070
|
+
</div>
|
|
1071
|
+
|
|
1072
|
+
{/* Footer */}
|
|
1073
|
+
<div className="flex items-center justify-end gap-2 sm:gap-3 px-4 sm:px-6 py-3 sm:py-4 border-t border-white/10 flex-shrink-0">
|
|
1074
|
+
<button
|
|
1075
|
+
onClick={onClose}
|
|
1076
|
+
className="px-3 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm text-gray-400 hover:text-white transition-colors"
|
|
1077
|
+
>
|
|
1078
|
+
Cancel
|
|
1079
|
+
</button>
|
|
1080
|
+
<button
|
|
1081
|
+
onClick={handleCreate}
|
|
1082
|
+
disabled={!title.trim() || isCreating}
|
|
1083
|
+
className="px-3 sm:px-4 py-1.5 sm:py-2 bg-purple-600 hover:bg-purple-500 text-white text-xs sm:text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
1084
|
+
>
|
|
1085
|
+
{isCreating ? 'Creating...' : 'Create'}
|
|
1086
|
+
</button>
|
|
1087
|
+
</div>
|
|
1088
|
+
</div>
|
|
1089
|
+
</div>
|
|
1090
|
+
);
|
|
1091
|
+
}
|