@bamptee/aia-code 2.0.12 → 2.0.14

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.
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { api, streamPost } from '/main.js';
2
+ import { api } from '/main.js';
3
3
 
4
4
  const STATUS_CLASSES = {
5
5
  done: 'step-done',
@@ -8,172 +8,384 @@ const STATUS_CLASSES = {
8
8
  error: 'step-error',
9
9
  };
10
10
 
11
+ // Duplicated from constants.js to avoid async fetch on initial render
12
+ const QUICK_STEPS = ['dev-plan', 'implement', 'review'];
13
+ const FEATURE_TYPES = ['feature', 'bug'];
14
+ const DEFAULT_FEATURE_TYPE = 'feature';
15
+ const DELETION_FILTER = { ACTIVE: 'active', DELETED: 'deleted', ALL: 'all' };
16
+
17
+ // Sorting options
18
+ const SORT_OPTIONS = {
19
+ DATE_DESC: { field: 'date', order: 'desc', label: 'Newest first' },
20
+ DATE_ASC: { field: 'date', order: 'asc', label: 'Oldest first' },
21
+ NAME_ASC: { field: 'name', order: 'asc', label: 'Name (A-Z)' },
22
+ NAME_DESC: { field: 'name', order: 'desc', label: 'Name (Z-A)' },
23
+ };
24
+
25
+ // LocalStorage keys for persistence
26
+ const STORAGE_KEYS = {
27
+ SORT: 'aia-feature-list-sort',
28
+ SHOW_COMPLETED: 'aia-feature-list-show-completed',
29
+ TYPE_FILTER: 'aia-feature-list-type-filter',
30
+ APPS_FILTER: 'aia-feature-list-apps-filter',
31
+ WT_FILTER: 'aia-feature-list-wt-filter',
32
+ DELETION_FILTER: 'aia-feature-list-deletion-filter',
33
+ };
34
+
35
+ /**
36
+ * Load a value from localStorage with fallback
37
+ * @param {string} key - Storage key
38
+ * @param {*} defaultValue - Default value if not found
39
+ * @returns {*} Parsed value or default
40
+ */
41
+ function loadFromStorage(key, defaultValue) {
42
+ try {
43
+ const stored = localStorage.getItem(key);
44
+ return stored ? JSON.parse(stored) : defaultValue;
45
+ } catch {
46
+ return defaultValue;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Save a value to localStorage
52
+ * @param {string} key - Storage key
53
+ * @param {*} value - Value to store
54
+ */
55
+ function saveToStorage(key, value) {
56
+ try {
57
+ localStorage.setItem(key, JSON.stringify(value));
58
+ } catch {
59
+ // Ignore storage errors
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Sort features by the specified field and order
65
+ * @param {Array} features - Array of features to sort
66
+ * @param {string} field - 'date' or 'name'
67
+ * @param {string} order - 'asc' or 'desc'
68
+ * @returns {Array} Sorted array (new array, doesn't mutate original)
69
+ */
70
+ function sortFeatures(features, field, order) {
71
+ const sorted = [...features];
72
+
73
+ sorted.sort((a, b) => {
74
+ let comparison = 0;
75
+
76
+ if (field === 'name') {
77
+ comparison = a.name.localeCompare(b.name);
78
+ } else if (field === 'date') {
79
+ // Use createdAt if available, otherwise fall back to name (alphabetical as proxy)
80
+ const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
81
+ const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
82
+ comparison = dateA - dateB;
83
+ }
84
+
85
+ return order === 'desc' ? -comparison : comparison;
86
+ });
87
+
88
+ return sorted;
89
+ }
90
+
91
+ /**
92
+ * Check if a feature is completed (all relevant steps are done)
93
+ * Uses API-provided isCompleted field if available, otherwise calculates locally
94
+ * @param {Object} feature - Feature object with steps or isCompleted
95
+ * @returns {boolean}
96
+ */
97
+ function isFeatureCompleted(feature) {
98
+ // Use API-provided value if available (from minimal mode)
99
+ if (typeof feature.isCompleted === 'boolean') {
100
+ return feature.isCompleted;
101
+ }
102
+ // Fallback: calculate from steps (for lazy-loaded full data)
103
+ const steps = feature.steps || {};
104
+ const isQuickFlow = feature.flow === 'quick';
105
+ const relevantSteps = isQuickFlow
106
+ ? Object.entries(steps).filter(([k]) => QUICK_STEPS.includes(k))
107
+ : Object.entries(steps);
108
+
109
+ if (relevantSteps.length === 0) return false;
110
+ return relevantSteps.every(([_, status]) => status === 'done');
111
+ }
112
+
11
113
  function StepBadge({ step, status }) {
12
114
  return React.createElement('span', {
13
115
  className: `inline-block px-2 py-0.5 text-xs rounded border ${STATUS_CLASSES[status] || 'step-pending'}`,
14
116
  }, step);
15
117
  }
16
118
 
17
- function FeatureCard({ feature }) {
18
- const steps = feature.steps || {};
19
- const doneCount = Object.values(steps).filter(s => s === 'done').length;
20
- const totalCount = Object.keys(steps).length;
119
+ function TypeBadge({ type }) {
120
+ const isFeature = type !== 'bug';
121
+ return React.createElement('span', {
122
+ className: `text-xs px-1.5 py-0.5 rounded border ${isFeature ? 'type-feature' : 'type-bug'}`,
123
+ }, isFeature ? '\u2728 FEATURE' : '\uD83D\uDC1B BUG');
124
+ }
21
125
 
22
- return React.createElement('a', {
23
- href: `#/features/${feature.name}`,
24
- className: 'block bg-aia-card border border-aia-border rounded-lg p-4 hover:border-aia-accent/50 transition-colors',
126
+ function AppChip({ app, small = false }) {
127
+ return React.createElement('span', {
128
+ className: `app-chip ${small ? 'text-[10px] px-1.5' : ''}`,
129
+ title: app.path || app,
130
+ }, typeof app === 'object' ? `${app.icon || ''} ${app.name}` : app);
131
+ }
132
+
133
+ /**
134
+ * Skeleton loader component for feature cards
135
+ * Displays a loading placeholder while card state is being fetched
136
+ */
137
+ function FeatureCardSkeleton({ featureName }) {
138
+ return React.createElement('div', {
139
+ className: 'block bg-aia-card border border-aia-border rounded-lg p-4 animate-pulse',
140
+ 'aria-label': `Loading ${featureName}...`,
141
+ role: 'status',
25
142
  },
26
- React.createElement('div', { className: 'flex items-center justify-between mb-3' },
27
- React.createElement('div', { className: 'flex items-center gap-2' },
28
- React.createElement('h3', { className: 'text-slate-100 font-semibold' }, feature.name),
29
- feature.hasWorktree && React.createElement('span', {
30
- className: 'bg-orange-500/20 text-orange-400 text-xs px-1.5 py-0.5 rounded',
31
- title: 'Git worktree active',
32
- }, 'wt'),
33
- ),
34
- React.createElement('span', { className: 'text-xs text-slate-500' },
35
- `${doneCount}/${totalCount} steps`
36
- ),
143
+ // Header row skeleton
144
+ React.createElement('div', { className: 'flex items-center justify-between mb-2' },
145
+ React.createElement('div', { className: 'h-5 w-20 bg-slate-700 rounded' }),
146
+ React.createElement('div', { className: 'h-4 w-16 bg-slate-700 rounded' }),
37
147
  ),
38
- feature.current_step && React.createElement('p', { className: 'text-xs text-slate-400 mb-3' },
39
- 'Current: ', React.createElement('span', { className: 'text-aia-accent' }, feature.current_step)
148
+ // Name row skeleton - show actual name for context
149
+ React.createElement('div', { className: 'flex items-center gap-2 mb-2' },
150
+ React.createElement('h3', { className: 'font-semibold text-slate-400' }, featureName),
151
+ ),
152
+ // Progress bar skeleton
153
+ React.createElement('div', { className: 'h-1.5 bg-slate-700 rounded-full mb-2' }),
154
+ // Loading indicator
155
+ React.createElement('div', { className: 'flex items-center gap-2' },
156
+ React.createElement('div', { className: 'w-3 h-3 border-2 border-slate-600 border-t-aia-accent rounded-full animate-spin' }),
157
+ React.createElement('span', { className: 'text-xs text-slate-500' }, 'Loading details...'),
40
158
  ),
41
- React.createElement('div', { className: 'flex flex-wrap gap-1.5' },
42
- ...Object.entries(steps).map(([step, status]) =>
43
- React.createElement(StepBadge, { key: step, step, status })
44
- )
45
- )
46
159
  );
47
160
  }
48
161
 
49
- function NewFeatureForm({ onCreated }) {
50
- const [name, setName] = React.useState('');
51
- const [err, setErr] = React.useState('');
52
- const [loading, setLoading] = React.useState(false);
162
+ /**
163
+ * Hook to fetch individual card state asynchronously
164
+ * @param {string} featureName - The feature name to fetch state for
165
+ * @param {boolean} enabled - Whether to enable fetching
166
+ * @returns {{ state: Object|null, isLoading: boolean, error: string|null }}
167
+ */
168
+ function useCardState(featureName, enabled = true) {
169
+ const [state, setState] = React.useState(null);
170
+ const [isLoading, setIsLoading] = React.useState(true);
171
+ const [error, setError] = React.useState(null);
53
172
 
54
- async function handleSubmit(e) {
55
- e.preventDefault();
56
- setErr('');
57
- setLoading(true);
58
- try {
59
- await api.post('/features', { name });
60
- setName('');
61
- onCreated();
62
- } catch (e) {
63
- setErr(e.message);
64
- } finally {
65
- setLoading(false);
173
+ React.useEffect(() => {
174
+ if (!enabled || !featureName) {
175
+ setIsLoading(false);
176
+ return;
66
177
  }
67
- }
68
178
 
69
- return React.createElement('form', { onSubmit: handleSubmit, className: 'flex gap-2 items-start' },
70
- React.createElement('div', null,
71
- React.createElement('input', {
72
- type: 'text',
73
- value: name,
74
- onChange: e => setName(e.target.value),
75
- placeholder: 'feature-name',
76
- className: 'bg-aia-card border border-aia-border rounded px-3 py-1.5 text-sm text-slate-200 placeholder-slate-500 focus:border-aia-accent focus:outline-none',
77
- }),
78
- err && React.createElement('p', { className: 'text-red-400 text-xs mt-1' }, err),
79
- ),
80
- React.createElement('button', {
81
- type: 'submit',
82
- disabled: loading || !name,
83
- className: 'bg-aia-accent/20 text-aia-accent border border-aia-accent/30 rounded px-3 py-1.5 text-sm hover:bg-aia-accent/30 disabled:opacity-40',
84
- }, loading ? '...' : '+ New Feature')
85
- );
86
- }
179
+ let cancelled = false;
180
+ setIsLoading(true);
181
+ setError(null);
87
182
 
88
- function LogViewer({ logs }) {
89
- const ref = React.useRef(null);
90
- React.useEffect(() => {
91
- if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
92
- }, [logs]);
93
-
94
- if (!logs.length) return null;
95
- return React.createElement('pre', {
96
- ref,
97
- className: 'bg-black/50 border border-aia-border rounded p-3 text-xs text-slate-400 overflow-auto max-h-48 whitespace-pre-wrap',
98
- }, logs.join(''));
183
+ api.get(`/features/${featureName}`)
184
+ .then(data => {
185
+ if (!cancelled) {
186
+ setState(data);
187
+ setIsLoading(false);
188
+ }
189
+ })
190
+ .catch(err => {
191
+ if (!cancelled) {
192
+ setError(err.message || 'Failed to load');
193
+ setIsLoading(false);
194
+ }
195
+ });
196
+
197
+ return () => { cancelled = true; };
198
+ }, [featureName, enabled]);
199
+
200
+ return { state, isLoading, error };
99
201
  }
100
202
 
101
- function QuickTicketForm({ onDone }) {
102
- const [name, setName] = React.useState('');
103
- const [description, setDescription] = React.useState('');
104
- const [apply, setApply] = React.useState(false);
105
- const [running, setRunning] = React.useState(false);
106
- const [err, setErr] = React.useState('');
107
- const [logs, setLogs] = React.useState([]);
203
+ function FeatureCard({ feature, availableApps, onRestore, lazyLoad = false }) {
204
+ // Use lazy loading hook if enabled, otherwise use the feature data directly
205
+ const { state: lazyState, isLoading, error } = useCardState(
206
+ lazyLoad ? feature.name : null,
207
+ lazyLoad
208
+ );
108
209
 
109
- async function handleSubmit(e) {
110
- e.preventDefault();
111
- setErr('');
112
- setRunning(true);
113
- setLogs([]);
210
+ // Use lazy-loaded state if available, otherwise use the passed feature
211
+ const effectiveFeature = lazyLoad && lazyState ? { ...feature, ...lazyState } : feature;
114
212
 
115
- const res = await streamPost('/quick', { name, description, apply }, {
116
- onLog: (text) => setLogs(prev => [...prev, text]),
117
- onStatus: (data) => setLogs(prev => [...prev, `[${data.status}] ${data.name || ''}\n`]),
118
- });
213
+ const steps = effectiveFeature.steps || {};
214
+ const isQuickFlow = effectiveFeature.flow === 'quick';
215
+ const featureType = effectiveFeature.type || DEFAULT_FEATURE_TYPE;
216
+ const featureApps = effectiveFeature.apps || [];
217
+ const isDeleted = effectiveFeature.isDeleted || effectiveFeature.deletedAt != null;
218
+ const [restoring, setRestoring] = React.useState(false);
119
219
 
120
- if (res.ok) {
121
- setName('');
122
- setDescription('');
123
- setLogs([]);
124
- onDone();
125
- } else {
126
- setErr(res.error);
127
- }
128
- setRunning(false);
220
+ // Show skeleton while loading in lazy mode
221
+ if (lazyLoad && isLoading) {
222
+ return React.createElement(FeatureCardSkeleton, { featureName: feature.name });
223
+ }
224
+
225
+ // Show error state if lazy load failed
226
+ if (lazyLoad && error) {
227
+ return React.createElement('div', {
228
+ className: 'block bg-aia-card border border-red-500/30 rounded-lg p-4',
229
+ },
230
+ React.createElement('h3', { className: 'font-semibold text-slate-100 mb-2' }, feature.name),
231
+ React.createElement('p', { className: 'text-xs text-red-400' }, `Error: ${error}`),
232
+ );
129
233
  }
130
234
 
131
- return React.createElement('form', {
132
- onSubmit: handleSubmit,
133
- className: 'bg-aia-card border border-amber-500/30 rounded-lg p-4 space-y-3',
235
+ // Filter steps based on flow type
236
+ const relevantSteps = isQuickFlow
237
+ ? Object.entries(steps).filter(([k]) => QUICK_STEPS.includes(k))
238
+ : Object.entries(steps);
239
+
240
+ const doneCount = relevantSteps.filter(([_, s]) => s === 'done').length;
241
+ const totalCount = relevantSteps.length;
242
+
243
+ // Map app names to full app objects for icons
244
+ const appObjects = featureApps.map(appName => {
245
+ const found = availableApps.find(a => a.name === appName);
246
+ return found || { name: appName, icon: '\uD83D\uDCC1' };
247
+ });
248
+
249
+ const handleRestore = async (e) => {
250
+ e.preventDefault();
251
+ e.stopPropagation();
252
+ setRestoring(true);
253
+ try {
254
+ await api.post(`/features/${feature.name}/restore`);
255
+ if (onRestore) onRestore();
256
+ } catch (err) {
257
+ console.error('Failed to restore:', err);
258
+ }
259
+ setRestoring(false);
260
+ };
261
+
262
+ return React.createElement('a', {
263
+ href: `#/features/${feature.name}`,
264
+ className: `block bg-aia-card border rounded-lg p-4 transition-colors ${
265
+ isDeleted
266
+ ? 'border-red-500/30 opacity-75 hover:border-red-500/50'
267
+ : 'border-aia-border hover:border-aia-accent/50'
268
+ }`,
134
269
  },
135
- React.createElement('h3', { className: 'text-sm font-semibold text-amber-400' }, 'Quick Ticket'),
136
- React.createElement('p', { className: 'text-xs text-slate-500' }, 'dev-plan \u2192 implement \u2192 review'),
137
- React.createElement('div', { className: 'flex gap-2' },
138
- React.createElement('input', {
139
- type: 'text',
140
- value: name,
141
- onChange: e => setName(e.target.value),
142
- placeholder: 'ticket-name',
143
- disabled: running,
144
- className: 'bg-slate-900 border border-aia-border rounded px-3 py-1.5 text-sm text-slate-200 placeholder-slate-500 focus:border-amber-400 focus:outline-none flex-shrink-0',
145
- }),
146
- React.createElement('input', {
147
- type: 'text',
148
- value: description,
149
- onChange: e => setDescription(e.target.value),
150
- placeholder: 'Short description (optional)',
151
- disabled: running,
152
- className: 'bg-slate-900 border border-aia-border rounded px-3 py-1.5 text-sm text-slate-200 placeholder-slate-500 focus:border-amber-400 focus:outline-none flex-1',
153
- }),
270
+ // Header row: Type badge + step count / deleted badge
271
+ React.createElement('div', { className: 'flex items-center justify-between mb-2' },
272
+ isDeleted
273
+ ? React.createElement('span', {
274
+ className: 'text-xs px-1.5 py-0.5 rounded border bg-red-500/20 text-red-400 border-red-500/30',
275
+ }, '\uD83D\uDDD1 DELETED')
276
+ : React.createElement(TypeBadge, { type: featureType }),
277
+ !isDeleted && React.createElement('span', { className: 'text-xs text-slate-500' },
278
+ `${doneCount}/${totalCount} steps`
279
+ ),
280
+ isDeleted && React.createElement('button', {
281
+ onClick: handleRestore,
282
+ disabled: restoring,
283
+ className: 'text-xs bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 px-2 py-1 rounded hover:bg-emerald-500/30 disabled:opacity-40',
284
+ }, restoring ? 'Restoring...' : 'Restore'),
154
285
  ),
155
- React.createElement('div', { className: 'flex items-center gap-4' },
156
- React.createElement('label', { className: 'flex items-center gap-2 text-xs text-slate-400 cursor-pointer' },
157
- React.createElement('input', {
158
- type: 'checkbox',
159
- checked: apply,
160
- onChange: e => setApply(e.target.checked),
161
- disabled: running,
162
- }),
163
- 'Agent mode (--apply)'
286
+
287
+ // Feature name row with Quick/wt badges
288
+ React.createElement('div', { className: 'flex items-center gap-2 mb-2' },
289
+ React.createElement('h3', { className: `font-semibold ${isDeleted ? 'text-slate-500 line-through' : 'text-slate-100'}` }, effectiveFeature.name),
290
+ !isDeleted && isQuickFlow && React.createElement('span', {
291
+ className: 'bg-amber-500/20 text-amber-400 text-xs px-1.5 py-0.5 rounded',
292
+ title: 'Quick Flow',
293
+ }, 'QUICK'),
294
+ !isDeleted && effectiveFeature.hasWorktree && React.createElement('span', {
295
+ className: 'bg-orange-500/20 text-orange-400 text-xs px-1.5 py-0.5 rounded',
296
+ title: 'Git worktree active',
297
+ }, 'wt'),
298
+ !isDeleted && effectiveFeature.agentRunning && React.createElement('span', {
299
+ className: 'bg-blue-500/20 text-blue-400 text-xs px-1.5 py-0.5 rounded flex items-center gap-1',
300
+ title: 'Agent running',
301
+ },
302
+ React.createElement('span', { className: 'animate-pulse' }, '\u25CF'),
303
+ 'Running'
164
304
  ),
305
+ ),
306
+
307
+ // Progress bar
308
+ React.createElement('div', { className: 'h-1.5 bg-slate-700 rounded-full mb-2 overflow-hidden' },
309
+ React.createElement('div', {
310
+ className: 'h-full bg-aia-accent rounded-full transition-all',
311
+ style: { width: `${totalCount > 0 ? (doneCount / totalCount) * 100 : 0}%` },
312
+ })
313
+ ),
314
+
315
+ // Current step
316
+ effectiveFeature.current_step && React.createElement('p', { className: 'text-xs text-slate-400 mb-2' },
317
+ 'Current: ', React.createElement('span', { className: 'text-aia-accent' }, effectiveFeature.current_step)
318
+ ),
319
+
320
+ // App tags
321
+ appObjects.length > 0 && React.createElement('div', { className: 'flex flex-wrap gap-1 mt-2' },
322
+ ...appObjects.map(app =>
323
+ React.createElement(AppChip, { key: app.name, app, small: true })
324
+ )
325
+ ),
326
+ );
327
+ }
328
+
329
+ function TypeFilterTabs({ filter, onChange, counts }) {
330
+ const tabs = [
331
+ { key: 'all', label: 'All', count: counts.all },
332
+ { key: 'feature', label: '\u2728 Features', count: counts.features, colorClass: 'text-emerald-400' },
333
+ { key: 'bug', label: '\uD83D\uDC1B Bugs', count: counts.bugs, colorClass: 'text-red-400' },
334
+ ];
335
+
336
+ return React.createElement('div', { className: 'flex gap-2' },
337
+ ...tabs.map(tab =>
338
+ React.createElement('button', {
339
+ key: tab.key,
340
+ onClick: () => onChange(tab.key),
341
+ className: `px-3 py-1.5 text-xs rounded-full border transition-colors flex items-center gap-1.5 ${
342
+ filter === tab.key
343
+ ? tab.colorClass ? `${tab.colorClass} border-current bg-current/10` : 'border-aia-accent text-aia-accent bg-aia-accent/10'
344
+ : 'border-slate-600 text-slate-400 hover:text-slate-200 hover:border-slate-500'
345
+ }`,
346
+ },
347
+ tab.label,
348
+ React.createElement('span', {
349
+ className: 'text-slate-500',
350
+ }, `(${tab.count})`),
351
+ )
352
+ )
353
+ );
354
+ }
355
+
356
+ function AppsFilter({ apps, selected, onChange }) {
357
+ if (!apps.length) return null;
358
+
359
+ const enabledApps = apps.filter(a => a.enabled !== false);
360
+ if (!enabledApps.length) return null;
361
+
362
+ return React.createElement('div', { className: 'flex flex-wrap gap-1.5' },
363
+ React.createElement('span', { className: 'text-xs text-slate-500 self-center mr-1' }, 'Apps:'),
364
+ ...enabledApps.map(app =>
165
365
  React.createElement('button', {
166
- type: 'submit',
167
- disabled: running || !name,
168
- className: 'bg-amber-500/20 text-amber-400 border border-amber-500/30 rounded px-4 py-1.5 text-sm hover:bg-amber-500/30 disabled:opacity-40',
169
- }, running ? 'Running...' : 'Run Quick Ticket'),
366
+ key: app.name,
367
+ onClick: () => {
368
+ const isSelected = selected.includes(app.name);
369
+ onChange(isSelected
370
+ ? selected.filter(n => n !== app.name)
371
+ : [...selected, app.name]
372
+ );
373
+ },
374
+ className: `px-2 py-0.5 text-xs rounded-full border transition-colors ${
375
+ selected.includes(app.name)
376
+ ? 'app-chip-selected'
377
+ : 'app-chip hover:border-slate-500'
378
+ }`,
379
+ }, `${app.icon || '\uD83D\uDCC1'} ${app.name}`)
170
380
  ),
171
- React.createElement(LogViewer, { logs }),
172
- err && React.createElement('p', { className: 'text-red-400 text-xs' }, err),
381
+ selected.length > 0 && React.createElement('button', {
382
+ onClick: () => onChange([]),
383
+ className: 'text-xs text-slate-500 hover:text-slate-300 ml-1',
384
+ }, 'Clear'),
173
385
  );
174
386
  }
175
387
 
176
- function FilterTabs({ filter, onChange, counts }) {
388
+ function WorktreeFilter({ filter, onChange, counts }) {
177
389
  const tabs = [
178
390
  { key: 'all', label: 'All', count: counts.all },
179
391
  { key: 'with-wt', label: 'With Worktree', count: counts.withWt },
@@ -203,59 +415,507 @@ function FilterTabs({ filter, onChange, counts }) {
203
415
  );
204
416
  }
205
417
 
418
+ function DeletionFilter({ filter, onChange, deletedCount }) {
419
+ return React.createElement('div', { className: 'flex items-center gap-2' },
420
+ React.createElement('span', { className: 'text-xs text-slate-500' }, 'Show:'),
421
+ React.createElement('button', {
422
+ onClick: () => onChange(DELETION_FILTER.ACTIVE),
423
+ className: `px-3 py-1 text-xs rounded border transition-colors ${
424
+ filter === DELETION_FILTER.ACTIVE
425
+ ? 'bg-aia-accent/20 text-aia-accent border-aia-accent/30'
426
+ : 'bg-slate-800 text-slate-400 border-slate-600 hover:border-slate-500'
427
+ }`,
428
+ }, 'Active'),
429
+ React.createElement('button', {
430
+ onClick: () => onChange(DELETION_FILTER.DELETED),
431
+ className: `px-3 py-1 text-xs rounded border transition-colors flex items-center gap-1.5 ${
432
+ filter === DELETION_FILTER.DELETED
433
+ ? 'bg-red-500/20 text-red-400 border-red-500/30'
434
+ : 'bg-slate-800 text-slate-400 border-slate-600 hover:border-slate-500'
435
+ }`,
436
+ },
437
+ 'Deleted', // Text-only label, no trash icon
438
+ deletedCount > 0 && React.createElement('span', {
439
+ className: 'bg-red-500/30 text-red-400 text-xs px-1.5 py-0.5 rounded',
440
+ }, deletedCount),
441
+ ),
442
+ React.createElement('button', {
443
+ onClick: () => onChange(DELETION_FILTER.ALL),
444
+ className: `px-3 py-1 text-xs rounded border transition-colors ${
445
+ filter === DELETION_FILTER.ALL
446
+ ? 'bg-slate-600 text-slate-200 border-slate-500'
447
+ : 'bg-slate-800 text-slate-400 border-slate-600 hover:border-slate-500'
448
+ }`,
449
+ }, 'All'),
450
+ );
451
+ }
452
+
453
+ /**
454
+ * Completion filter toggle - show/hide completed features
455
+ */
456
+ function CompletionFilter({ showCompleted, onChange }) {
457
+ return React.createElement('div', { className: 'flex items-center gap-2' },
458
+ React.createElement('label', {
459
+ className: 'flex items-center gap-2 text-xs text-slate-400 cursor-pointer select-none',
460
+ },
461
+ React.createElement('input', {
462
+ type: 'checkbox',
463
+ checked: showCompleted,
464
+ onChange: (e) => onChange(e.target.checked),
465
+ className: 'w-3.5 h-3.5 rounded border-slate-500 bg-slate-800 text-aia-accent focus:ring-aia-accent focus:ring-offset-0',
466
+ }),
467
+ 'Show completed',
468
+ ),
469
+ );
470
+ }
471
+
472
+ /**
473
+ * Sort dropdown component for sorting features
474
+ */
475
+ function SortDropdown({ sortKey, onChange }) {
476
+ const [isOpen, setIsOpen] = React.useState(false);
477
+ const dropdownRef = React.useRef(null);
478
+
479
+ // Close dropdown when clicking outside
480
+ React.useEffect(() => {
481
+ function handleClickOutside(event) {
482
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
483
+ setIsOpen(false);
484
+ }
485
+ }
486
+ document.addEventListener('mousedown', handleClickOutside);
487
+ return () => document.removeEventListener('mousedown', handleClickOutside);
488
+ }, []);
489
+
490
+ const currentOption = SORT_OPTIONS[sortKey] || SORT_OPTIONS.DATE_DESC;
491
+
492
+ return React.createElement('div', { className: 'relative', ref: dropdownRef },
493
+ React.createElement('button', {
494
+ onClick: () => setIsOpen(!isOpen),
495
+ className: 'flex items-center gap-2 px-3 py-1.5 text-xs rounded border border-slate-600 bg-slate-800 text-slate-300 hover:border-slate-500 transition-colors',
496
+ },
497
+ React.createElement('span', { className: 'text-slate-500' }, 'Sort:'),
498
+ currentOption.label,
499
+ React.createElement('span', { className: 'text-slate-500' }, isOpen ? '\u25B2' : '\u25BC'),
500
+ ),
501
+ isOpen && React.createElement('div', {
502
+ className: 'absolute top-full left-0 mt-1 w-40 bg-slate-800 border border-slate-600 rounded shadow-lg z-10',
503
+ },
504
+ ...Object.entries(SORT_OPTIONS).map(([key, option]) =>
505
+ React.createElement('button', {
506
+ key,
507
+ onClick: () => {
508
+ onChange(key);
509
+ setIsOpen(false);
510
+ },
511
+ className: `w-full text-left px-3 py-2 text-xs transition-colors ${
512
+ sortKey === key
513
+ ? 'bg-aia-accent/20 text-aia-accent'
514
+ : 'text-slate-300 hover:bg-slate-700'
515
+ }`,
516
+ }, option.label)
517
+ ),
518
+ ),
519
+ );
520
+ }
521
+
522
+ function CreateFeatureModal({ apps, onCreated, onClose }) {
523
+ const [step, setStep] = React.useState(1);
524
+ const [type, setType] = React.useState(DEFAULT_FEATURE_TYPE);
525
+ const [selectedApps, setSelectedApps] = React.useState([]);
526
+ const [name, setName] = React.useState('');
527
+ const [description, setDescription] = React.useState('');
528
+ const [err, setErr] = React.useState('');
529
+ const [loading, setLoading] = React.useState(false);
530
+
531
+ const enabledApps = apps.filter(a => a.enabled !== false);
532
+ const totalSteps = enabledApps.length > 0 ? 3 : 2;
533
+
534
+ const handleNext = () => {
535
+ if (step === 1) {
536
+ // Skip step 2 if no apps
537
+ setStep(enabledApps.length > 0 ? 2 : 3);
538
+ } else if (step === 2) {
539
+ setStep(3);
540
+ }
541
+ };
542
+
543
+ const handleBack = () => {
544
+ if (step === 3) {
545
+ setStep(enabledApps.length > 0 ? 2 : 1);
546
+ } else if (step === 2) {
547
+ setStep(1);
548
+ }
549
+ };
550
+
551
+ const handleCreate = async () => {
552
+ // Clean up name: trim whitespace and leading/trailing hyphens
553
+ const cleanName = name.trim().replace(/^-+|-+$/g, '');
554
+ if (!cleanName) {
555
+ setErr('Name is required');
556
+ return;
557
+ }
558
+ setErr('');
559
+ setLoading(true);
560
+ try {
561
+ const result = await api.post('/features', {
562
+ name: cleanName,
563
+ type,
564
+ apps: selectedApps,
565
+ });
566
+ onClose();
567
+ // Navigate directly to the edit page for the new feature
568
+ window.location.hash = `#/features/${encodeURIComponent(result.name || cleanName)}`;
569
+ } catch (e) {
570
+ setErr(e.message);
571
+ setLoading(false);
572
+ }
573
+ };
574
+
575
+ const handleKeyDown = (e) => {
576
+ if (e.key === 'Escape') onClose();
577
+ if (e.key === 'Enter' && step === 3 && name.trim() && !loading) {
578
+ e.preventDefault();
579
+ handleCreate();
580
+ }
581
+ };
582
+
583
+ return React.createElement('div', {
584
+ className: 'fixed inset-0 bg-black/50 flex items-center justify-center z-50',
585
+ onClick: (e) => e.target === e.currentTarget && onClose(),
586
+ onKeyDown: handleKeyDown,
587
+ },
588
+ React.createElement('div', {
589
+ className: 'bg-aia-card border border-aia-border rounded-lg p-6 w-full max-w-md space-y-4',
590
+ },
591
+ // Header
592
+ React.createElement('div', { className: 'flex items-center justify-between' },
593
+ React.createElement('h2', { className: 'text-lg font-semibold text-slate-100' },
594
+ type === 'bug' ? '\uD83D\uDC1B New Bug' : '\u2728 New Feature'
595
+ ),
596
+ React.createElement('button', {
597
+ onClick: onClose,
598
+ className: 'text-slate-500 hover:text-slate-300',
599
+ }, '\u2715'),
600
+ ),
601
+
602
+ // Progress indicator
603
+ React.createElement('div', { className: 'flex gap-1' },
604
+ ...[1, 2, 3].slice(0, totalSteps).map(s =>
605
+ React.createElement('div', {
606
+ key: s,
607
+ className: `h-1 flex-1 rounded ${s <= step ? 'bg-aia-accent' : 'bg-slate-700'}`,
608
+ })
609
+ )
610
+ ),
611
+
612
+ // Step 1: Type selection
613
+ step === 1 && React.createElement('div', { className: 'space-y-4 modal-step-active' },
614
+ React.createElement('p', { className: 'text-sm text-slate-400' }, 'What are you creating?'),
615
+ React.createElement('div', { className: 'grid grid-cols-2 gap-3' },
616
+ React.createElement('button', {
617
+ onClick: () => setType('feature'),
618
+ className: `p-4 rounded-lg border-2 text-center transition-all ${
619
+ type === 'feature'
620
+ ? 'border-emerald-500 bg-emerald-500/10'
621
+ : 'border-slate-600 hover:border-slate-500'
622
+ }`,
623
+ },
624
+ React.createElement('span', { className: 'text-3xl block mb-2' }, '\u2728'),
625
+ React.createElement('span', { className: 'text-sm font-medium text-slate-200' }, 'Feature'),
626
+ React.createElement('p', { className: 'text-xs text-slate-500 mt-1' }, 'New functionality'),
627
+ ),
628
+ React.createElement('button', {
629
+ onClick: () => setType('bug'),
630
+ className: `p-4 rounded-lg border-2 text-center transition-all ${
631
+ type === 'bug'
632
+ ? 'border-red-500 bg-red-500/10'
633
+ : 'border-slate-600 hover:border-slate-500'
634
+ }`,
635
+ },
636
+ React.createElement('span', { className: 'text-3xl block mb-2' }, '\uD83D\uDC1B'),
637
+ React.createElement('span', { className: 'text-sm font-medium text-slate-200' }, 'Bug'),
638
+ React.createElement('p', { className: 'text-xs text-slate-500 mt-1' }, 'Quick flow'),
639
+ ),
640
+ ),
641
+ ),
642
+
643
+ // Step 2: App selection (only if apps exist)
644
+ step === 2 && React.createElement('div', { className: 'space-y-4 modal-step-active' },
645
+ React.createElement('p', { className: 'text-sm text-slate-400' }, 'Which apps are involved?'),
646
+ React.createElement('div', { className: 'flex flex-wrap gap-2' },
647
+ ...enabledApps.map(app =>
648
+ React.createElement('button', {
649
+ key: app.name,
650
+ onClick: () => {
651
+ setSelectedApps(prev =>
652
+ prev.includes(app.name)
653
+ ? prev.filter(n => n !== app.name)
654
+ : [...prev, app.name]
655
+ );
656
+ },
657
+ className: `px-3 py-2 rounded-lg border text-sm transition-all ${
658
+ selectedApps.includes(app.name)
659
+ ? 'border-aia-accent bg-aia-accent/10 text-aia-accent'
660
+ : 'border-slate-600 text-slate-300 hover:border-slate-500'
661
+ }`,
662
+ }, `${app.icon || '\uD83D\uDCC1'} ${app.name}`)
663
+ )
664
+ ),
665
+ React.createElement('p', { className: 'text-xs text-slate-500' },
666
+ selectedApps.length === 0 ? 'Optional: select apps to scope this work' : `${selectedApps.length} app(s) selected`
667
+ ),
668
+ ),
669
+
670
+ // Step 3: Name and description
671
+ step === 3 && React.createElement('div', { className: 'space-y-4 modal-step-active' },
672
+ React.createElement('div', null,
673
+ React.createElement('label', { className: 'text-xs text-slate-400 block mb-1' }, 'Name *'),
674
+ React.createElement('input', {
675
+ type: 'text',
676
+ value: name,
677
+ onChange: e => {
678
+ // Sanitize input: lowercase, replace invalid chars, collapse hyphens
679
+ // Don't trim leading/trailing hyphens here - let user type freely
680
+ const sanitized = e.target.value
681
+ .toLowerCase()
682
+ .replace(/[^a-z0-9-]/g, '')
683
+ .replace(/-+/g, '-');
684
+ setName(sanitized);
685
+ },
686
+ onBlur: () => {
687
+ // Trim leading/trailing hyphens on blur
688
+ setName(n => n.replace(/^-+|-+$/g, ''));
689
+ },
690
+ placeholder: type === 'bug' ? 'bug-name' : 'feature-name',
691
+ autoFocus: true,
692
+ className: 'w-full bg-slate-900 border border-aia-border rounded px-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:border-aia-accent focus:outline-none',
693
+ }),
694
+ React.createElement('p', { className: 'text-xs text-slate-500 mt-1' }, 'Lowercase with hyphens (e.g. fix-login-bug)'),
695
+ ),
696
+ err && React.createElement('p', { className: 'text-red-400 text-xs' }, err),
697
+ ),
698
+
699
+ // Footer buttons
700
+ React.createElement('div', { className: 'flex justify-between pt-2' },
701
+ step > 1
702
+ ? React.createElement('button', {
703
+ onClick: handleBack,
704
+ className: 'text-sm text-slate-400 hover:text-slate-200',
705
+ }, '\u2190 Back')
706
+ : React.createElement('div'),
707
+ step < totalSteps
708
+ ? React.createElement('button', {
709
+ onClick: handleNext,
710
+ className: 'bg-aia-accent/20 text-aia-accent border border-aia-accent/30 rounded px-4 py-1.5 text-sm hover:bg-aia-accent/30',
711
+ }, 'Next \u2192')
712
+ : React.createElement('button', {
713
+ onClick: handleCreate,
714
+ disabled: loading || !name.trim(),
715
+ className: 'bg-aia-accent text-slate-900 rounded px-4 py-1.5 text-sm font-medium hover:bg-aia-accent/90 disabled:opacity-40',
716
+ }, loading ? 'Creating...' : 'Create'),
717
+ ),
718
+ )
719
+ );
720
+ }
721
+
206
722
  export function Dashboard() {
207
723
  const [features, setFeatures] = React.useState([]);
724
+ const [deletedCount, setDeletedCount] = React.useState(0);
725
+ const [apps, setApps] = React.useState([]);
208
726
  const [loading, setLoading] = React.useState(true);
209
- const [filter, setFilter] = React.useState('all');
727
+
728
+ // Filter states with localStorage persistence
729
+ const [typeFilter, setTypeFilter] = React.useState(() => loadFromStorage(STORAGE_KEYS.TYPE_FILTER, 'all'));
730
+ const [appsFilter, setAppsFilter] = React.useState(() => loadFromStorage(STORAGE_KEYS.APPS_FILTER, []));
731
+ const [wtFilter, setWtFilter] = React.useState(() => loadFromStorage(STORAGE_KEYS.WT_FILTER, 'all'));
732
+ const [deletionFilter, setDeletionFilter] = React.useState(() => loadFromStorage(STORAGE_KEYS.DELETION_FILTER, DELETION_FILTER.ACTIVE));
733
+ const [showCompleted, setShowCompleted] = React.useState(() => loadFromStorage(STORAGE_KEYS.SHOW_COMPLETED, false));
734
+
735
+ // Sort state with localStorage persistence
736
+ const [sortKey, setSortKey] = React.useState(() => loadFromStorage(STORAGE_KEYS.SORT, 'DATE_DESC'));
737
+
738
+ const [showCreateModal, setShowCreateModal] = React.useState(false);
739
+ const [loadError, setLoadError] = React.useState(null);
740
+
741
+ // Persist filter/sort changes to localStorage
742
+ React.useEffect(() => { saveToStorage(STORAGE_KEYS.TYPE_FILTER, typeFilter); }, [typeFilter]);
743
+ React.useEffect(() => { saveToStorage(STORAGE_KEYS.APPS_FILTER, appsFilter); }, [appsFilter]);
744
+ React.useEffect(() => { saveToStorage(STORAGE_KEYS.WT_FILTER, wtFilter); }, [wtFilter]);
745
+ React.useEffect(() => { saveToStorage(STORAGE_KEYS.DELETION_FILTER, deletionFilter); }, [deletionFilter]);
746
+ React.useEffect(() => { saveToStorage(STORAGE_KEYS.SHOW_COMPLETED, showCompleted); }, [showCompleted]);
747
+ React.useEffect(() => { saveToStorage(STORAGE_KEYS.SORT, sortKey); }, [sortKey]);
748
+
749
+ // Track whether to use lazy loading for card states
750
+ const [useLazyLoad, setUseLazyLoad] = React.useState(true);
210
751
 
211
752
  async function load() {
753
+ setLoadError(null);
754
+ setLoading(true);
212
755
  try {
213
- const data = await api.get('/features');
214
- setFeatures(data);
215
- } catch {}
756
+ // Fetch minimal features data for fast initial render
757
+ // Card states will be loaded asynchronously per-card
758
+ const [featuresData, appsData, deletedData] = await Promise.all([
759
+ api.get(`/features?filter=${deletionFilter}&minimal=true`),
760
+ api.get('/apps'),
761
+ // Also fetch deleted count for the filter badge (minimal mode)
762
+ deletionFilter !== DELETION_FILTER.DELETED
763
+ ? api.get(`/features?filter=${DELETION_FILTER.DELETED}&minimal=true`)
764
+ : Promise.resolve([]),
765
+ ]);
766
+ setFeatures(featuresData);
767
+ setApps(appsData);
768
+ setUseLazyLoad(true); // Enable lazy loading for new data
769
+ // Set deleted count
770
+ if (deletionFilter === DELETION_FILTER.DELETED) {
771
+ setDeletedCount(featuresData.length);
772
+ } else {
773
+ setDeletedCount(deletedData.length);
774
+ }
775
+ } catch (e) {
776
+ setLoadError(e.message || 'Failed to load data');
777
+ }
216
778
  setLoading(false);
217
779
  }
218
780
 
219
- React.useEffect(() => { load(); }, []);
781
+ React.useEffect(() => { load(); }, [deletionFilter]);
782
+
783
+ // Count completed features for display
784
+ const completedCount = features.filter(isFeatureCompleted).length;
220
785
 
221
786
  // Filter features
222
- const filteredFeatures = features.filter(f => {
223
- if (filter === 'with-wt') return f.hasWorktree;
224
- if (filter === 'without-wt') return !f.hasWorktree;
225
- return true;
226
- });
787
+ const filteredFeatures = React.useMemo(() => {
788
+ let result = features.filter(f => {
789
+ // Completion filter - hide completed unless showCompleted is true
790
+ if (!showCompleted && isFeatureCompleted(f)) return false;
791
+
792
+ // Type filter
793
+ const fType = f.type || DEFAULT_FEATURE_TYPE;
794
+ if (typeFilter !== 'all' && fType !== typeFilter) return false;
795
+
796
+ // Apps filter
797
+ if (appsFilter.length > 0) {
798
+ const fApps = f.apps || [];
799
+ if (!appsFilter.some(a => fApps.includes(a))) return false;
800
+ }
801
+
802
+ // Worktree filter
803
+ if (wtFilter === 'with-wt' && !f.hasWorktree) return false;
804
+ if (wtFilter === 'without-wt' && f.hasWorktree) return false;
805
+
806
+ return true;
807
+ });
808
+
809
+ // Apply sorting
810
+ const sortOption = SORT_OPTIONS[sortKey] || SORT_OPTIONS.DATE_DESC;
811
+ result = sortFeatures(result, sortOption.field, sortOption.order);
812
+
813
+ return result;
814
+ }, [features, showCompleted, typeFilter, appsFilter, wtFilter, sortKey]);
815
+
816
+ // Counts for type tabs (exclude completed if not showing them)
817
+ const countBase = showCompleted ? features : features.filter(f => !isFeatureCompleted(f));
818
+ const typeCounts = {
819
+ all: countBase.length,
820
+ features: countBase.filter(f => (f.type || DEFAULT_FEATURE_TYPE) === 'feature').length,
821
+ bugs: countBase.filter(f => f.type === 'bug').length,
822
+ };
227
823
 
228
- // Counts for tabs
229
- const counts = {
230
- all: features.length,
231
- withWt: features.filter(f => f.hasWorktree).length,
232
- withoutWt: features.filter(f => !f.hasWorktree).length,
824
+ // Counts for worktree tabs
825
+ const wtCounts = {
826
+ all: countBase.length,
827
+ withWt: countBase.filter(f => f.hasWorktree).length,
828
+ withoutWt: countBase.filter(f => !f.hasWorktree).length,
233
829
  };
234
830
 
235
831
  return React.createElement('div', { className: 'space-y-6' },
832
+ // Header
236
833
  React.createElement('div', { className: 'flex items-center justify-between' },
237
834
  React.createElement('h1', { className: 'text-xl font-bold text-slate-100' }, 'Features'),
238
- React.createElement(NewFeatureForm, { onCreated: load }),
835
+ React.createElement('button', {
836
+ onClick: () => setShowCreateModal(true),
837
+ className: 'bg-aia-accent/20 text-aia-accent border border-aia-accent/30 rounded px-3 py-1.5 text-sm hover:bg-aia-accent/30',
838
+ }, '+ New'),
239
839
  ),
240
840
 
241
- // Quick ticket
242
- React.createElement(QuickTicketForm, { onDone: load }),
841
+ // Filters row
842
+ !loading && React.createElement('div', { className: 'space-y-3' },
843
+ // First row: Deletion filter + Sort + Completion toggle
844
+ React.createElement('div', { className: 'flex items-center justify-between flex-wrap gap-3' },
845
+ React.createElement(DeletionFilter, {
846
+ filter: deletionFilter,
847
+ onChange: setDeletionFilter,
848
+ deletedCount,
849
+ }),
850
+ React.createElement('div', { className: 'flex items-center gap-4' },
851
+ features.length > 0 && React.createElement(CompletionFilter, {
852
+ showCompleted,
853
+ onChange: setShowCompleted,
854
+ }),
855
+ completedCount > 0 && !showCompleted && React.createElement('span', {
856
+ className: 'text-xs text-slate-500',
857
+ }, `${completedCount} completed hidden`),
858
+ features.length > 0 && React.createElement(SortDropdown, {
859
+ sortKey,
860
+ onChange: setSortKey,
861
+ }),
862
+ ),
863
+ ),
243
864
 
244
- // Filter tabs
245
- !loading && features.length > 0 && React.createElement(FilterTabs, {
246
- filter,
247
- onChange: setFilter,
248
- counts,
249
- }),
865
+ // Type filter pills (only show if there are features to filter)
866
+ features.length > 0 && React.createElement(TypeFilterTabs, {
867
+ filter: typeFilter,
868
+ onChange: setTypeFilter,
869
+ counts: typeCounts,
870
+ }),
871
+
872
+ // Apps filter
873
+ features.length > 0 && React.createElement(AppsFilter, {
874
+ apps,
875
+ selected: appsFilter,
876
+ onChange: setAppsFilter,
877
+ }),
250
878
 
879
+ // Worktree filter tabs
880
+ features.length > 0 && wtCounts.withWt > 0 && React.createElement(WorktreeFilter, {
881
+ filter: wtFilter,
882
+ onChange: setWtFilter,
883
+ counts: wtCounts,
884
+ }),
885
+ ),
886
+
887
+ // Error message
888
+ loadError && React.createElement('p', { className: 'text-red-400 text-sm' }, `Error: ${loadError}`),
889
+
890
+ // Features grid
251
891
  loading
252
892
  ? React.createElement('p', { className: 'text-slate-500' }, 'Loading...')
253
893
  : features.length === 0
254
- ? React.createElement('p', { className: 'text-slate-500' }, 'No features yet. Create one to get started.')
894
+ ? React.createElement('div', { className: 'text-center py-8' },
895
+ deletionFilter === DELETION_FILTER.DELETED
896
+ ? React.createElement('div', { className: 'space-y-2' },
897
+ React.createElement('p', { className: 'text-slate-500' }, '\uD83D\uDDD1 No deleted features'),
898
+ React.createElement('p', { className: 'text-xs text-slate-600' }, 'Deleted features will appear here for recovery'),
899
+ )
900
+ : React.createElement('p', { className: 'text-slate-500' }, 'No features yet. Create one to get started.')
901
+ )
255
902
  : filteredFeatures.length === 0
256
- ? React.createElement('p', { className: 'text-slate-500' }, 'No features match this filter.')
903
+ ? React.createElement('p', { className: 'text-slate-500' }, 'No features match these filters.')
257
904
  : React.createElement('div', { className: 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4' },
258
- ...filteredFeatures.map(f => React.createElement(FeatureCard, { key: f.name, feature: f }))
259
- )
905
+ ...filteredFeatures.map(f => React.createElement(FeatureCard, {
906
+ key: f.name,
907
+ feature: f,
908
+ availableApps: apps,
909
+ onRestore: load,
910
+ lazyLoad: useLazyLoad,
911
+ }))
912
+ ),
913
+
914
+ // Create modal
915
+ showCreateModal && React.createElement(CreateFeatureModal, {
916
+ apps,
917
+ onCreated: load,
918
+ onClose: () => setShowCreateModal(false),
919
+ }),
260
920
  );
261
921
  }