@bamptee/aia-code 2.0.12 → 2.0.13
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/README.md +408 -34
- package/package.json +11 -2
- package/src/constants.js +24 -0
- package/src/providers/anthropic.js +2 -21
- package/src/providers/cli-runner.js +5 -3
- package/src/services/agent-sessions.js +110 -0
- package/src/services/apps.js +132 -0
- package/src/services/config.js +41 -0
- package/src/services/feature.js +28 -7
- package/src/services/model-call.js +2 -2
- package/src/services/runner.js +23 -1
- package/src/services/status.js +69 -1
- package/src/services/test-quick.js +229 -0
- package/src/services/worktrunk.js +135 -21
- package/src/types/test-quick.js +88 -0
- package/src/ui/api/config.js +28 -1
- package/src/ui/api/features.js +160 -50
- package/src/ui/api/index.js +2 -0
- package/src/ui/api/test-quick.js +207 -0
- package/src/ui/api/worktrunk.js +63 -25
- package/src/ui/public/components/config-view.js +95 -0
- package/src/ui/public/components/dashboard.js +823 -163
- package/src/ui/public/components/feature-detail.js +517 -124
- package/src/ui/public/components/test-quick.js +276 -0
- package/src/ui/public/components/worktrunk-panel.js +187 -25
- package/src/ui/public/index.html +13 -0
- package/src/ui/public/main.js +5 -1
- package/src/ui/server.js +97 -67
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { api
|
|
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
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
className:
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
136
|
-
React.createElement('
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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(
|
|
172
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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 =
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
230
|
-
all:
|
|
231
|
-
withWt:
|
|
232
|
-
withoutWt:
|
|
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(
|
|
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
|
-
//
|
|
242
|
-
React.createElement(
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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('
|
|
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
|
|
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, {
|
|
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
|
}
|