@bamptee/aia-code 2.0.1 → 2.0.2
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 +55 -4
- package/package.json +7 -1
- package/src/commands/init.js +11 -1
- package/src/models.js +19 -2
- package/src/prompt-builder.js +33 -1
- package/src/providers/cli-runner.js +2 -1
- package/src/services/config.js +53 -2
- package/src/services/flow-analyzer.js +32 -0
- package/src/services/runner.js +2 -2
- package/src/services/status.js +14 -0
- package/src/services/suggestions.js +166 -0
- package/src/services/worktrunk.js +197 -0
- package/src/ui/api/config.js +55 -2
- package/src/ui/api/features.js +301 -6
- package/src/ui/api/index.js +2 -0
- package/src/ui/api/worktrunk.js +153 -0
- package/src/ui/public/components/config-view.js +196 -2
- package/src/ui/public/components/dashboard.js +64 -4
- package/src/ui/public/components/feature-detail.js +584 -94
- package/src/ui/public/components/terminal.js +197 -0
- package/src/ui/public/components/worktrunk-panel.js +205 -0
- package/src/ui/public/main.js +23 -1
- package/src/ui/router.js +1 -1
- package/src/ui/server.js +85 -0
- package/src/utils/prompt.js +38 -0
|
@@ -1,5 +1,94 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { api, streamPost } from '/main.js';
|
|
3
|
+
import { WorktrunkPanel } from '/components/worktrunk-panel.js';
|
|
4
|
+
|
|
5
|
+
function formatFileSize(bytes) {
|
|
6
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
7
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
8
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function AttachmentZone({ feature, attachments, onUpload, onRemove }) {
|
|
12
|
+
const inputRef = React.useRef(null);
|
|
13
|
+
const [uploading, setUploading] = React.useState(false);
|
|
14
|
+
const [error, setError] = React.useState(null);
|
|
15
|
+
|
|
16
|
+
const handleFiles = async (files) => {
|
|
17
|
+
if (!files.length) return;
|
|
18
|
+
|
|
19
|
+
setUploading(true);
|
|
20
|
+
setError(null);
|
|
21
|
+
|
|
22
|
+
const formData = new FormData();
|
|
23
|
+
for (const file of files) {
|
|
24
|
+
if (file.size > 10 * 1024 * 1024) {
|
|
25
|
+
setError(`File "${file.name}" exceeds 10MB limit`);
|
|
26
|
+
setUploading(false);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
formData.append('files', file);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const res = await api.upload(`/features/${feature}/attachments`, formData);
|
|
34
|
+
if (res.ok && res.files) {
|
|
35
|
+
onUpload(res.files);
|
|
36
|
+
}
|
|
37
|
+
} catch (e) {
|
|
38
|
+
setError(e.message || 'Upload failed');
|
|
39
|
+
}
|
|
40
|
+
setUploading(false);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const handleRemove = async (filename) => {
|
|
44
|
+
try {
|
|
45
|
+
await api.delete(`/features/${feature}/attachments/${filename}`);
|
|
46
|
+
onRemove(filename);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
setError(e.message || 'Delete failed');
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
return React.createElement('div', { className: 'border border-dashed border-slate-600 rounded p-3 mb-3' },
|
|
53
|
+
React.createElement('input', {
|
|
54
|
+
ref: inputRef,
|
|
55
|
+
type: 'file',
|
|
56
|
+
multiple: true,
|
|
57
|
+
accept: 'image/*,application/pdf,text/*,.md,.txt,.json,.yaml,.yml',
|
|
58
|
+
className: 'hidden',
|
|
59
|
+
onChange: (e) => handleFiles(Array.from(e.target.files)),
|
|
60
|
+
}),
|
|
61
|
+
React.createElement('div', { className: 'flex items-center gap-2' },
|
|
62
|
+
React.createElement('button', {
|
|
63
|
+
onClick: () => inputRef.current?.click(),
|
|
64
|
+
disabled: uploading,
|
|
65
|
+
className: 'text-xs text-slate-400 hover:text-slate-200 flex items-center gap-1',
|
|
66
|
+
},
|
|
67
|
+
uploading ? React.createElement('span', { className: 'animate-spin' }, '⟳') : '+',
|
|
68
|
+
uploading ? ' Uploading...' : ' Add attachments (images, PDF, text)',
|
|
69
|
+
),
|
|
70
|
+
React.createElement('span', { className: 'text-xs text-slate-600' }, 'Max 10MB/file'),
|
|
71
|
+
),
|
|
72
|
+
error && React.createElement('p', { className: 'text-red-400 text-xs mt-1' }, error),
|
|
73
|
+
attachments.length > 0 && React.createElement('div', { className: 'mt-2 flex flex-wrap gap-2' },
|
|
74
|
+
...attachments.map(a => React.createElement('span', {
|
|
75
|
+
key: a.filename,
|
|
76
|
+
className: 'bg-slate-700 text-slate-300 text-xs px-2 py-1 rounded flex items-center gap-2',
|
|
77
|
+
title: a.path,
|
|
78
|
+
},
|
|
79
|
+
a.mimeType?.startsWith('image/') && React.createElement('span', { className: 'text-emerald-400' }, '🖼'),
|
|
80
|
+
a.mimeType === 'application/pdf' && React.createElement('span', { className: 'text-red-400' }, '📄'),
|
|
81
|
+
a.originalName || a.filename,
|
|
82
|
+
a.size && React.createElement('span', { className: 'text-slate-500' }, `(${formatFileSize(a.size)})`),
|
|
83
|
+
React.createElement('button', {
|
|
84
|
+
onClick: () => handleRemove(a.filename),
|
|
85
|
+
className: 'text-red-400 hover:text-red-300 ml-1',
|
|
86
|
+
title: 'Remove attachment',
|
|
87
|
+
}, '×'),
|
|
88
|
+
)),
|
|
89
|
+
),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
3
92
|
|
|
4
93
|
const STATUS_CLASSES = {
|
|
5
94
|
done: 'step-done',
|
|
@@ -65,63 +154,36 @@ function FileEditor({ name, filename, onSaved }) {
|
|
|
65
154
|
);
|
|
66
155
|
}
|
|
67
156
|
|
|
68
|
-
function
|
|
69
|
-
const [
|
|
70
|
-
const [description, setDescription] = React.useState('');
|
|
71
|
-
const [expanded, setExpanded] = React.useState(false);
|
|
72
|
-
const [err, setErr] = React.useState(null);
|
|
73
|
-
const [logs, setLogs] = React.useState([]);
|
|
74
|
-
|
|
75
|
-
async function run() {
|
|
76
|
-
setRunning(true);
|
|
77
|
-
setErr(null);
|
|
78
|
-
setLogs([]);
|
|
79
|
-
|
|
80
|
-
const res = await streamPost(`/features/${name}/quick`, { description }, {
|
|
81
|
-
onLog: (text) => setLogs(prev => [...prev, text]),
|
|
82
|
-
onStatus: (data) => setLogs(prev => [...prev, `[${data.status}] ${data.mode || ''}\n`]),
|
|
83
|
-
});
|
|
157
|
+
function FlowSelector({ name, currentFlow, onFlowChanged }) {
|
|
158
|
+
const [saving, setSaving] = React.useState(false);
|
|
84
159
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
160
|
+
const handleChange = async (newFlow) => {
|
|
161
|
+
if (newFlow === currentFlow) return;
|
|
162
|
+
setSaving(true);
|
|
163
|
+
try {
|
|
164
|
+
await api.patch(`/features/${name}/flow`, { flow: newFlow });
|
|
165
|
+
if (onFlowChanged) onFlowChanged(newFlow);
|
|
166
|
+
} catch (e) {
|
|
167
|
+
console.error('Failed to update flow:', e);
|
|
89
168
|
}
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (!expanded) {
|
|
94
|
-
return React.createElement('button', {
|
|
95
|
-
onClick: () => setExpanded(true),
|
|
96
|
-
className: 'bg-amber-500/20 text-amber-400 border border-amber-500/30 rounded px-3 py-1.5 text-xs hover:bg-amber-500/30',
|
|
97
|
-
}, 'Quick Ticket (dev-plan \u2192 implement \u2192 review)');
|
|
98
|
-
}
|
|
169
|
+
setSaving(false);
|
|
170
|
+
};
|
|
99
171
|
|
|
100
|
-
return React.createElement('div', { className: '
|
|
101
|
-
React.createElement('
|
|
102
|
-
React.createElement('
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
disabled: running,
|
|
115
|
-
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',
|
|
116
|
-
}, running ? 'Running...' : 'Run Quick'),
|
|
117
|
-
React.createElement('button', {
|
|
118
|
-
onClick: () => setExpanded(false),
|
|
119
|
-
disabled: running,
|
|
120
|
-
className: 'text-slate-500 hover:text-slate-300 text-xs',
|
|
121
|
-
}, 'Cancel'),
|
|
172
|
+
return React.createElement('div', { className: 'flex items-center gap-2' },
|
|
173
|
+
React.createElement('span', { className: 'text-xs text-slate-500' }, 'Flow:'),
|
|
174
|
+
React.createElement('button', {
|
|
175
|
+
onClick: () => handleChange('quick'),
|
|
176
|
+
disabled: saving,
|
|
177
|
+
className: `px-3 py-1 text-xs rounded border transition-all ${currentFlow === 'quick' ? 'bg-amber-500/20 text-amber-400 border-amber-500/30' : 'bg-slate-800 text-slate-400 border-slate-600 hover:border-slate-500'}`,
|
|
178
|
+
}, 'Quick'),
|
|
179
|
+
React.createElement('button', {
|
|
180
|
+
onClick: () => handleChange('full'),
|
|
181
|
+
disabled: saving,
|
|
182
|
+
className: `px-3 py-1 text-xs rounded border transition-all ${currentFlow === 'full' ? 'bg-violet-500/20 text-violet-400 border-violet-500/30' : 'bg-slate-800 text-slate-400 border-slate-600 hover:border-slate-500'}`,
|
|
183
|
+
}, 'Full'),
|
|
184
|
+
React.createElement('span', { className: 'text-xs text-slate-600 ml-2' },
|
|
185
|
+
currentFlow === 'quick' ? '(dev-plan \u2192 implement \u2192 review)' : '(brief \u2192 ... \u2192 review)'
|
|
122
186
|
),
|
|
123
|
-
React.createElement(LogViewer, { logs }),
|
|
124
|
-
err && React.createElement('p', { className: 'text-red-400 text-xs' }, err),
|
|
125
187
|
);
|
|
126
188
|
}
|
|
127
189
|
|
|
@@ -132,12 +194,53 @@ function LogViewer({ logs }) {
|
|
|
132
194
|
}, [logs]);
|
|
133
195
|
|
|
134
196
|
if (!logs.length) return null;
|
|
197
|
+
|
|
135
198
|
return React.createElement('pre', {
|
|
136
199
|
ref,
|
|
137
200
|
className: 'bg-black/50 border border-aia-border rounded p-3 text-xs text-slate-400 overflow-auto max-h-64 whitespace-pre-wrap',
|
|
138
201
|
}, logs.join(''));
|
|
139
202
|
}
|
|
140
203
|
|
|
204
|
+
function VerbosePanel({ logs, expanded, onToggle }) {
|
|
205
|
+
if (!logs.length) return null;
|
|
206
|
+
return React.createElement('div', { className: 'mt-2' },
|
|
207
|
+
React.createElement('button', {
|
|
208
|
+
onClick: onToggle,
|
|
209
|
+
'aria-expanded': expanded, // F13: Accessibility
|
|
210
|
+
'aria-label': `Toggle verbose logs, ${logs.length} lines`,
|
|
211
|
+
className: 'text-xs text-slate-500 hover:text-slate-300 flex items-center gap-1',
|
|
212
|
+
}, expanded ? '\u25BC' : '\u25B6', `Verbose (${logs.length} lines)`),
|
|
213
|
+
expanded && React.createElement('pre', {
|
|
214
|
+
className: 'bg-black/70 border border-slate-700 rounded p-2 mt-1 text-xs text-slate-500 overflow-auto max-h-48 whitespace-pre-wrap',
|
|
215
|
+
role: 'log',
|
|
216
|
+
'aria-label': 'Verbose output',
|
|
217
|
+
}, logs.join(''))
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function ChatLog({ messages }) {
|
|
222
|
+
const ref = React.useRef(null);
|
|
223
|
+
React.useEffect(() => {
|
|
224
|
+
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
|
|
225
|
+
}, [messages]);
|
|
226
|
+
|
|
227
|
+
if (!messages.length) return null;
|
|
228
|
+
|
|
229
|
+
return React.createElement('div', {
|
|
230
|
+
ref,
|
|
231
|
+
className: 'bg-slate-900 border border-aia-border rounded p-3 font-mono text-sm overflow-auto max-h-80',
|
|
232
|
+
role: 'log', // F13: Accessibility
|
|
233
|
+
'aria-label': 'Chat history',
|
|
234
|
+
}, messages.map((msg) =>
|
|
235
|
+
React.createElement('div', { key: msg.id || msg.content.slice(0, 20), className: 'mb-2' }, // F11: Unique key
|
|
236
|
+
React.createElement('span', {
|
|
237
|
+
className: msg.role === 'user' ? 'text-cyan-400' : 'text-emerald-400'
|
|
238
|
+
}, msg.role === 'user' ? '> you: ' : 'agent: '),
|
|
239
|
+
React.createElement('span', { className: 'text-slate-300 whitespace-pre-wrap' }, msg.content)
|
|
240
|
+
)
|
|
241
|
+
));
|
|
242
|
+
}
|
|
243
|
+
|
|
141
244
|
function ModelSelect({ model, onChange, disabled }) {
|
|
142
245
|
const [models, setModels] = React.useState([]);
|
|
143
246
|
|
|
@@ -156,66 +259,337 @@ function ModelSelect({ model, onChange, disabled }) {
|
|
|
156
259
|
);
|
|
157
260
|
}
|
|
158
261
|
|
|
262
|
+
function InitPanel({ name, onFlowSelected, onCancel, onEnriched }) {
|
|
263
|
+
const [description, setDescription] = React.useState('');
|
|
264
|
+
const [suggestion, setSuggestion] = React.useState(null);
|
|
265
|
+
const [loading, setLoading] = React.useState(false);
|
|
266
|
+
const [statusMsg, setStatusMsg] = React.useState('');
|
|
267
|
+
const [logs, setLogs] = React.useState([]);
|
|
268
|
+
const [err, setErr] = React.useState(null);
|
|
269
|
+
|
|
270
|
+
const handleSubmit = async () => {
|
|
271
|
+
setLoading(true);
|
|
272
|
+
setErr(null);
|
|
273
|
+
setLogs([]);
|
|
274
|
+
setStatusMsg('Structuring your description...');
|
|
275
|
+
|
|
276
|
+
const res = await streamPost(`/features/${name}/init`, { description }, {
|
|
277
|
+
onLog: (text) => setLogs(prev => [...prev, text]),
|
|
278
|
+
onStatus: (data) => setStatusMsg(data.message || data.status),
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
if (res.ok) {
|
|
282
|
+
setSuggestion(res.suggestion);
|
|
283
|
+
setStatusMsg('');
|
|
284
|
+
if (onEnriched) onEnriched();
|
|
285
|
+
} else {
|
|
286
|
+
setErr(res.error || 'Failed to enrich description');
|
|
287
|
+
}
|
|
288
|
+
setLoading(false);
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const handleFlowChoice = (flow) => {
|
|
292
|
+
onFlowSelected(flow);
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
return React.createElement('div', { className: 'bg-aia-card border border-aia-border rounded p-4 space-y-4' },
|
|
296
|
+
React.createElement('h3', { className: 'text-sm font-semibold text-cyan-400' }, 'Describe your feature'),
|
|
297
|
+
|
|
298
|
+
// Textarea (hidden when loading or has suggestion)
|
|
299
|
+
!loading && !suggestion && React.createElement('textarea', {
|
|
300
|
+
value: description,
|
|
301
|
+
onChange: e => setDescription(e.target.value),
|
|
302
|
+
placeholder: 'Describe what you want to build...\n\nBe as detailed as needed. The AI will structure your description.',
|
|
303
|
+
rows: 8,
|
|
304
|
+
className: 'w-full bg-aia-card border border-aia-border rounded px-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:border-aia-accent focus:outline-none resize-y max-h-96 overflow-auto',
|
|
305
|
+
}),
|
|
306
|
+
|
|
307
|
+
// Character count and buttons
|
|
308
|
+
!loading && !suggestion && React.createElement('div', { className: 'flex items-center justify-between' },
|
|
309
|
+
React.createElement('span', { className: 'text-xs text-slate-500' }, `${description.length} characters`),
|
|
310
|
+
React.createElement('div', { className: 'flex gap-2' },
|
|
311
|
+
React.createElement('button', {
|
|
312
|
+
onClick: onCancel,
|
|
313
|
+
className: 'text-slate-500 hover:text-slate-300 text-xs',
|
|
314
|
+
}, 'Cancel'),
|
|
315
|
+
React.createElement('button', {
|
|
316
|
+
onClick: handleSubmit,
|
|
317
|
+
disabled: !description.trim(),
|
|
318
|
+
className: 'bg-aia-accent/20 text-aia-accent border border-aia-accent/30 rounded px-4 py-2 text-sm hover:bg-aia-accent/30 disabled:opacity-40',
|
|
319
|
+
}, 'Continue'),
|
|
320
|
+
),
|
|
321
|
+
),
|
|
322
|
+
|
|
323
|
+
// Loading state with logs
|
|
324
|
+
loading && React.createElement('div', { className: 'space-y-3' },
|
|
325
|
+
React.createElement('div', { className: 'flex items-center gap-2' },
|
|
326
|
+
React.createElement('div', { className: 'animate-spin w-4 h-4 border-2 border-cyan-400 border-t-transparent rounded-full' }),
|
|
327
|
+
React.createElement('span', { className: 'text-sm text-cyan-400' }, statusMsg || 'Processing...'),
|
|
328
|
+
),
|
|
329
|
+
logs.length > 0 && React.createElement(LogViewer, { logs }),
|
|
330
|
+
),
|
|
331
|
+
|
|
332
|
+
// Error
|
|
333
|
+
err && React.createElement('p', { className: 'text-red-400 text-xs' }, err),
|
|
334
|
+
|
|
335
|
+
// Flow suggestion
|
|
336
|
+
suggestion && React.createElement('div', { className: 'space-y-3' },
|
|
337
|
+
React.createElement('div', { className: 'flex items-center gap-2' },
|
|
338
|
+
React.createElement('span', { className: 'text-emerald-400' }, '\u2713'),
|
|
339
|
+
React.createElement('span', { className: 'text-sm text-slate-300' }, 'Feature spec created in init.md'),
|
|
340
|
+
),
|
|
341
|
+
React.createElement('p', { className: 'text-sm text-slate-400' },
|
|
342
|
+
`Suggested: ${suggestion === 'quick' ? 'Quick Flow (dev-plan \u2192 implement \u2192 review)' : 'Full Flow (8 steps)'}`
|
|
343
|
+
),
|
|
344
|
+
React.createElement('div', { className: 'flex gap-2' },
|
|
345
|
+
React.createElement('button', {
|
|
346
|
+
onClick: () => handleFlowChoice('quick'),
|
|
347
|
+
className: `px-4 py-2 text-sm rounded border ${suggestion === 'quick' ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' : 'bg-slate-700 text-slate-300 border-slate-600'} hover:brightness-110`,
|
|
348
|
+
}, 'Quick Flow'),
|
|
349
|
+
React.createElement('button', {
|
|
350
|
+
onClick: () => handleFlowChoice('full'),
|
|
351
|
+
className: `px-4 py-2 text-sm rounded border ${suggestion === 'full' ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' : 'bg-slate-700 text-slate-300 border-slate-600'} hover:brightness-110`,
|
|
352
|
+
}, 'Full Flow'),
|
|
353
|
+
),
|
|
354
|
+
),
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function StepGuidance({ step, feature }) {
|
|
359
|
+
const [guidance, setGuidance] = React.useState(null);
|
|
360
|
+
|
|
361
|
+
React.useEffect(() => {
|
|
362
|
+
api.get(`/features/${feature}/guidance/${step}`).then(setGuidance).catch(() => {});
|
|
363
|
+
}, [step, feature]);
|
|
364
|
+
|
|
365
|
+
if (!guidance) return null;
|
|
366
|
+
|
|
367
|
+
return React.createElement('div', { className: 'bg-emerald-500/10 border border-emerald-500/30 rounded p-4 mt-4' },
|
|
368
|
+
React.createElement('h4', { className: 'text-emerald-400 font-semibold text-sm mb-2' }, guidance.summary),
|
|
369
|
+
React.createElement('div', { className: 'text-slate-300 text-xs space-y-1' },
|
|
370
|
+
...guidance.actions.map((action, i) =>
|
|
371
|
+
React.createElement('p', { key: i }, `\u2022 ${action.replace('<feature>', feature)}`)
|
|
372
|
+
),
|
|
373
|
+
),
|
|
374
|
+
guidance.next && React.createElement('p', { className: 'text-cyan-400 text-xs mt-2' },
|
|
375
|
+
`Next step: ${guidance.next}`
|
|
376
|
+
),
|
|
377
|
+
guidance.tips.length > 0 && React.createElement('p', { className: 'text-amber-400 text-xs mt-2' },
|
|
378
|
+
`Tip: ${guidance.tips[0]}`
|
|
379
|
+
),
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const MAX_MESSAGES = 100;
|
|
384
|
+
const MAX_VERBOSE_LOGS = 500;
|
|
385
|
+
|
|
159
386
|
function RunPanel({ name, step, stepStatus, onDone }) {
|
|
160
387
|
const isDone = stepStatus === 'done';
|
|
161
|
-
const [
|
|
388
|
+
const [inputText, setInputText] = React.useState('');
|
|
162
389
|
const [instructions, setInstructions] = React.useState('');
|
|
163
390
|
const [model, setModel] = React.useState('');
|
|
164
391
|
const [apply, setApply] = React.useState(false);
|
|
165
392
|
const [running, setRunning] = React.useState(false);
|
|
166
393
|
const [result, setResult] = React.useState(null);
|
|
167
394
|
const [err, setErr] = React.useState(null);
|
|
168
|
-
const [
|
|
395
|
+
const [messages, setMessages] = React.useState([]);
|
|
396
|
+
const [verboseLogs, setVerboseLogs] = React.useState([]);
|
|
397
|
+
const [verboseExpanded, setVerboseExpanded] = React.useState(false);
|
|
398
|
+
const [history, setHistory] = React.useState([]);
|
|
399
|
+
const [attachments, setAttachments] = React.useState([]);
|
|
400
|
+
const agentBuffer = React.useRef('');
|
|
401
|
+
const requestId = React.useRef(0); // F4: Track request to prevent race conditions
|
|
402
|
+
const [resetting, setResetting] = React.useState(false); // F14: Track reset state
|
|
403
|
+
|
|
404
|
+
// Load attachments when step changes
|
|
405
|
+
React.useEffect(() => {
|
|
406
|
+
api.get(`/features/${name}/attachments`)
|
|
407
|
+
.then(files => setAttachments(files))
|
|
408
|
+
.catch(() => setAttachments([]));
|
|
409
|
+
}, [name, step]);
|
|
410
|
+
|
|
411
|
+
// Reset chat state when step changes
|
|
412
|
+
React.useEffect(() => {
|
|
413
|
+
requestId.current++; // F5: Invalidate pending requests on step change
|
|
414
|
+
setMessages([]);
|
|
415
|
+
setVerboseLogs([]);
|
|
416
|
+
setHistory([]);
|
|
417
|
+
setResult(null);
|
|
418
|
+
setErr(null);
|
|
419
|
+
agentBuffer.current = '';
|
|
420
|
+
}, [step]);
|
|
421
|
+
|
|
422
|
+
const handleAttachmentUpload = (files) => {
|
|
423
|
+
setAttachments(prev => [...prev, ...files]);
|
|
424
|
+
};
|
|
169
425
|
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
onStatus: (data) => setLogs(prev => [...prev, `[${data.status}] ${data.step || ''}\n`]),
|
|
426
|
+
const handleAttachmentRemove = (filename) => {
|
|
427
|
+
setAttachments(prev => prev.filter(a => a.filename !== filename));
|
|
173
428
|
};
|
|
174
429
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
430
|
+
// F4: Create callbacks bound to current request
|
|
431
|
+
const createSseCallbacks = (currentRequestId) => ({
|
|
432
|
+
onLog: (text, type) => {
|
|
433
|
+
if (requestId.current !== currentRequestId) return; // Ignore stale callbacks
|
|
434
|
+
if (type === 'stderr') {
|
|
435
|
+
setVerboseLogs(prev => [...prev, text].slice(-MAX_VERBOSE_LOGS)); // F8: Limit size
|
|
436
|
+
} else {
|
|
437
|
+
agentBuffer.current += text;
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
onStatus: (data) => {
|
|
441
|
+
if (requestId.current !== currentRequestId) return;
|
|
442
|
+
setVerboseLogs(prev => [...prev, `[${data.status}] ${data.step || ''}\n`].slice(-MAX_VERBOSE_LOGS));
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
async function handleSend() {
|
|
447
|
+
if (!inputText.trim() || running) return;
|
|
448
|
+
|
|
449
|
+
const userMessage = inputText.trim();
|
|
450
|
+
const msgId = Date.now(); // F11: Unique key for messages
|
|
451
|
+
setInputText('');
|
|
452
|
+
setMessages(prev => [...prev, { id: msgId, role: 'user', content: userMessage }].slice(-MAX_MESSAGES));
|
|
453
|
+
setRunning(true);
|
|
454
|
+
setResult(null);
|
|
455
|
+
setErr(null);
|
|
456
|
+
setVerboseLogs([]);
|
|
457
|
+
agentBuffer.current = '';
|
|
458
|
+
|
|
459
|
+
const currentRequestId = ++requestId.current; // F4: New request ID
|
|
460
|
+
const newHistory = [...history, { role: 'user', content: userMessage }];
|
|
461
|
+
setHistory(newHistory);
|
|
462
|
+
|
|
463
|
+
const res = await streamPost(`/features/${name}/run/${step}`, {
|
|
464
|
+
description: userMessage,
|
|
465
|
+
apply,
|
|
466
|
+
model: model || undefined,
|
|
467
|
+
history: newHistory.slice(0, -1),
|
|
468
|
+
attachments: attachments.map(a => ({ filename: a.filename, path: a.path })),
|
|
469
|
+
}, createSseCallbacks(currentRequestId));
|
|
470
|
+
|
|
471
|
+
// F4: Check if this request is still current
|
|
472
|
+
if (requestId.current !== currentRequestId) return;
|
|
473
|
+
|
|
474
|
+
if (agentBuffer.current.trim()) {
|
|
475
|
+
const agentResponse = agentBuffer.current.trim();
|
|
476
|
+
setMessages(prev => [...prev, { id: Date.now(), role: 'agent', content: agentResponse }].slice(-MAX_MESSAGES));
|
|
477
|
+
setHistory(prev => [...prev, { role: 'agent', content: agentResponse }]);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (res.ok) {
|
|
481
|
+
setResult('Step completed.');
|
|
482
|
+
if (onDone) onDone();
|
|
483
|
+
} else {
|
|
484
|
+
setErr(res.error);
|
|
485
|
+
}
|
|
180
486
|
setRunning(false);
|
|
181
487
|
}
|
|
182
488
|
|
|
183
489
|
async function iterate() {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
490
|
+
if (!instructions.trim() || running) return;
|
|
491
|
+
|
|
492
|
+
const msgId = Date.now();
|
|
493
|
+
const iterationInstructions = instructions;
|
|
494
|
+
setMessages(prev => [...prev, { id: msgId, role: 'user', content: iterationInstructions }].slice(-MAX_MESSAGES));
|
|
495
|
+
setRunning(true);
|
|
496
|
+
setResult(null);
|
|
497
|
+
setErr(null);
|
|
498
|
+
setVerboseLogs([]);
|
|
499
|
+
agentBuffer.current = '';
|
|
500
|
+
|
|
501
|
+
const currentRequestId = ++requestId.current;
|
|
502
|
+
// F7: Pass history in iterate mode too
|
|
503
|
+
const newHistory = [...history, { role: 'user', content: iterationInstructions }];
|
|
504
|
+
setHistory(newHistory);
|
|
505
|
+
|
|
506
|
+
const res = await streamPost(`/features/${name}/iterate/${step}`, {
|
|
507
|
+
instructions: iterationInstructions,
|
|
508
|
+
apply,
|
|
509
|
+
model: model || undefined,
|
|
510
|
+
history: newHistory.slice(0, -1),
|
|
511
|
+
attachments: attachments.map(a => ({ filename: a.filename, path: a.path })),
|
|
512
|
+
}, createSseCallbacks(currentRequestId));
|
|
513
|
+
|
|
514
|
+
if (requestId.current !== currentRequestId) return;
|
|
515
|
+
|
|
516
|
+
if (agentBuffer.current.trim()) {
|
|
517
|
+
setMessages(prev => [...prev, { id: Date.now(), role: 'agent', content: agentBuffer.current.trim() }].slice(-MAX_MESSAGES));
|
|
518
|
+
setHistory(prev => [...prev, { role: 'agent', content: agentBuffer.current.trim() }]);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (res.ok) {
|
|
522
|
+
setResult('Iteration completed.');
|
|
523
|
+
setInstructions('');
|
|
524
|
+
if (onDone) onDone();
|
|
525
|
+
} else {
|
|
526
|
+
setErr(res.error);
|
|
527
|
+
}
|
|
188
528
|
setRunning(false);
|
|
189
529
|
}
|
|
190
530
|
|
|
531
|
+
// F14: Better reset with state tracking
|
|
191
532
|
async function reset() {
|
|
192
|
-
|
|
193
|
-
|
|
533
|
+
setResetting(true);
|
|
534
|
+
setErr(null);
|
|
535
|
+
try {
|
|
536
|
+
await api.post(`/features/${name}/reset/${step}`);
|
|
537
|
+
requestId.current++;
|
|
538
|
+
setMessages([]);
|
|
539
|
+
setVerboseLogs([]);
|
|
540
|
+
setHistory([]);
|
|
541
|
+
setResult(null);
|
|
542
|
+
if (onDone) onDone();
|
|
543
|
+
} catch (e) {
|
|
544
|
+
setErr(`Reset failed: ${e.message}`);
|
|
545
|
+
} finally {
|
|
546
|
+
setResetting(false);
|
|
547
|
+
}
|
|
194
548
|
}
|
|
195
549
|
|
|
550
|
+
const handleKeyDown = (e, submitFn) => {
|
|
551
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
552
|
+
e.preventDefault();
|
|
553
|
+
submitFn();
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
|
|
196
557
|
return React.createElement('div', { className: 'space-y-3' },
|
|
197
558
|
|
|
559
|
+
// --- Chat log ---
|
|
560
|
+
React.createElement(ChatLog, { messages }),
|
|
561
|
+
|
|
562
|
+
// --- Attachments zone ---
|
|
563
|
+
React.createElement(AttachmentZone, {
|
|
564
|
+
feature: name,
|
|
565
|
+
attachments,
|
|
566
|
+
onUpload: handleAttachmentUpload,
|
|
567
|
+
onRemove: handleAttachmentRemove,
|
|
568
|
+
}),
|
|
569
|
+
|
|
198
570
|
// --- Run block (when step is not done) ---
|
|
199
571
|
!isDone && React.createElement('div', { className: 'bg-slate-900 border border-aia-border rounded p-4 space-y-3' },
|
|
200
572
|
React.createElement('h4', { className: 'text-sm font-semibold text-emerald-400' }, `Run: ${step}`),
|
|
201
|
-
React.createElement('
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
placeholder: '
|
|
573
|
+
React.createElement('textarea', {
|
|
574
|
+
value: inputText,
|
|
575
|
+
onChange: e => setInputText(e.target.value),
|
|
576
|
+
onKeyDown: e => handleKeyDown(e, handleSend),
|
|
577
|
+
placeholder: 'Describe what you want... (Enter to send, Shift+Enter for newline)',
|
|
206
578
|
disabled: running,
|
|
207
|
-
|
|
579
|
+
rows: 3,
|
|
580
|
+
className: 'w-full bg-aia-card border border-aia-border rounded px-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:border-emerald-400 focus:outline-none resize-y max-h-96 overflow-auto',
|
|
208
581
|
}),
|
|
582
|
+
inputText.length > 0 && React.createElement('span', { className: 'text-xs text-slate-500' }, `${inputText.length} characters`),
|
|
209
583
|
React.createElement('div', { className: 'flex items-center gap-4 flex-wrap' },
|
|
210
584
|
React.createElement(ModelSelect, { model, onChange: setModel, disabled: running }),
|
|
211
|
-
React.createElement('label', { className: 'flex items-center gap-2 text-xs text-slate-400 cursor-pointer' },
|
|
585
|
+
React.createElement('label', { className: 'flex items-center gap-2 text-xs text-slate-400 cursor-pointer', title: 'Allow AI to edit files in your project' },
|
|
212
586
|
React.createElement('input', { type: 'checkbox', checked: apply, onChange: e => setApply(e.target.checked), disabled: running, className: 'rounded' }),
|
|
213
|
-
'Agent mode
|
|
587
|
+
'Agent mode'
|
|
214
588
|
),
|
|
215
589
|
React.createElement('button', {
|
|
216
|
-
onClick:
|
|
590
|
+
onClick: handleSend, disabled: running || !inputText.trim(),
|
|
217
591
|
className: 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 rounded px-4 py-1.5 text-sm hover:bg-emerald-500/30 disabled:opacity-40',
|
|
218
|
-
}, running ? 'Running...' : '
|
|
592
|
+
}, running ? 'Running...' : 'Send'),
|
|
219
593
|
),
|
|
220
594
|
),
|
|
221
595
|
|
|
@@ -230,30 +604,40 @@ function RunPanel({ name, step, stepStatus, onDone }) {
|
|
|
230
604
|
React.createElement('textarea', {
|
|
231
605
|
value: instructions,
|
|
232
606
|
onChange: e => setInstructions(e.target.value),
|
|
233
|
-
|
|
607
|
+
onKeyDown: e => handleKeyDown(e, iterate),
|
|
608
|
+
placeholder: 'e.g. "Add error handling for edge cases"... (Enter to send, Shift+Enter for newline)',
|
|
234
609
|
disabled: running,
|
|
235
610
|
rows: 3,
|
|
236
|
-
className: 'w-full bg-aia-card border border-aia-border rounded px-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:border-violet-400 focus:outline-none resize-y',
|
|
611
|
+
className: 'w-full bg-aia-card border border-aia-border rounded px-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:border-violet-400 focus:outline-none resize-y max-h-96 overflow-auto',
|
|
237
612
|
}),
|
|
613
|
+
instructions.length > 0 && React.createElement('span', { className: 'text-xs text-slate-500' }, `${instructions.length} characters`),
|
|
238
614
|
React.createElement('div', { className: 'flex items-center gap-4 flex-wrap' },
|
|
239
615
|
React.createElement(ModelSelect, { model, onChange: setModel, disabled: running }),
|
|
240
|
-
React.createElement('label', { className: 'flex items-center gap-2 text-xs text-slate-400 cursor-pointer' },
|
|
616
|
+
React.createElement('label', { className: 'flex items-center gap-2 text-xs text-slate-400 cursor-pointer', title: 'Allow AI to edit files in your project' },
|
|
241
617
|
React.createElement('input', { type: 'checkbox', checked: apply, onChange: e => setApply(e.target.checked), disabled: running, className: 'rounded' }),
|
|
242
|
-
'Agent mode
|
|
618
|
+
'Agent mode'
|
|
243
619
|
),
|
|
244
620
|
React.createElement('button', {
|
|
245
621
|
onClick: iterate, disabled: running || !instructions.trim(),
|
|
246
622
|
className: 'bg-violet-500/20 text-violet-400 border border-violet-500/30 rounded px-4 py-1.5 text-sm hover:bg-violet-500/30 disabled:opacity-40',
|
|
247
623
|
}, running ? 'Iterating...' : 'Iterate'),
|
|
248
624
|
React.createElement('button', {
|
|
249
|
-
onClick: reset, disabled: running,
|
|
250
|
-
className: 'text-slate-500 hover:text-slate-300 text-xs',
|
|
251
|
-
}, 'Reset to pending'),
|
|
625
|
+
onClick: reset, disabled: running || resetting,
|
|
626
|
+
className: 'text-slate-500 hover:text-slate-300 text-xs disabled:opacity-40',
|
|
627
|
+
}, resetting ? 'Resetting...' : 'Reset to pending'),
|
|
252
628
|
),
|
|
629
|
+
// Step guidance for completed steps
|
|
630
|
+
React.createElement(StepGuidance, { step, feature: name }),
|
|
253
631
|
),
|
|
254
632
|
|
|
255
|
-
// ---
|
|
256
|
-
React.createElement(
|
|
633
|
+
// --- Verbose panel (stderr logs) ---
|
|
634
|
+
React.createElement(VerbosePanel, {
|
|
635
|
+
logs: verboseLogs,
|
|
636
|
+
expanded: verboseExpanded,
|
|
637
|
+
onToggle: () => setVerboseExpanded(!verboseExpanded),
|
|
638
|
+
}),
|
|
639
|
+
|
|
640
|
+
// --- Results ---
|
|
257
641
|
result && React.createElement('p', { className: 'text-emerald-400 text-xs' }, result),
|
|
258
642
|
err && React.createElement('p', { className: 'text-red-400 text-xs' }, err),
|
|
259
643
|
);
|
|
@@ -264,16 +648,104 @@ export function FeatureDetail({ name }) {
|
|
|
264
648
|
const [loading, setLoading] = React.useState(true);
|
|
265
649
|
const [activeFile, setActiveFile] = React.useState('init.md');
|
|
266
650
|
const [activeStep, setActiveStep] = React.useState(null);
|
|
651
|
+
const [showInitPanel, setShowInitPanel] = React.useState(false);
|
|
652
|
+
const [selectedFlow, setSelectedFlow] = React.useState(null);
|
|
653
|
+
const [fileVersion, setFileVersion] = React.useState(0);
|
|
267
654
|
|
|
268
|
-
async function load() {
|
|
655
|
+
async function load(checkInitPanel = true) {
|
|
269
656
|
try {
|
|
270
657
|
const data = await api.get(`/features/${name}`);
|
|
271
658
|
setFeature(data);
|
|
659
|
+
const steps = data.steps || {};
|
|
660
|
+
|
|
661
|
+
// Load persisted flow from status
|
|
662
|
+
if (data.flow && !selectedFlow) {
|
|
663
|
+
setSelectedFlow(data.flow);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Check if no steps have been started and init.md is empty (only on initial load)
|
|
667
|
+
if (checkInitPanel) {
|
|
668
|
+
const allPending = Object.values(steps).every(s => s === 'pending');
|
|
669
|
+
const persistedFlow = data.flow || selectedFlow;
|
|
670
|
+
|
|
671
|
+
if (allPending && !persistedFlow) {
|
|
672
|
+
// Check if init.md has content (enriched)
|
|
673
|
+
try {
|
|
674
|
+
const initFile = await api.get(`/features/${name}/files/init.md`);
|
|
675
|
+
const content = initFile.content || '';
|
|
676
|
+
// If init.md has been enriched (contains ## Summary which is added by the agent), don't show InitPanel
|
|
677
|
+
// The default template only has ## Description, ## Existing specs, ## Constraints
|
|
678
|
+
const isEnriched = content.includes('## Summary') || content.includes('## Problem');
|
|
679
|
+
if (!isEnriched) {
|
|
680
|
+
setShowInitPanel(true);
|
|
681
|
+
} else {
|
|
682
|
+
// Auto-select first pending step based on flow
|
|
683
|
+
const flow = persistedFlow || 'full';
|
|
684
|
+
const firstStep = flow === 'quick' ? 'dev-plan' : 'brief';
|
|
685
|
+
if (!activeStep) {
|
|
686
|
+
setActiveStep(firstStep);
|
|
687
|
+
setActiveFile(`${firstStep}.md`);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
} catch {
|
|
691
|
+
setShowInitPanel(true);
|
|
692
|
+
}
|
|
693
|
+
} else if (!activeStep) {
|
|
694
|
+
// Auto-select current step or first pending based on flow
|
|
695
|
+
const flow = persistedFlow || 'full';
|
|
696
|
+
const currentStep = data.current_step;
|
|
697
|
+
if (currentStep) {
|
|
698
|
+
setActiveStep(currentStep);
|
|
699
|
+
setActiveFile(`${currentStep}.md`);
|
|
700
|
+
} else {
|
|
701
|
+
// Use flow to determine first step
|
|
702
|
+
const firstStep = flow === 'quick' ? 'dev-plan' : 'brief';
|
|
703
|
+
const stepStatus = steps[firstStep];
|
|
704
|
+
if (stepStatus === 'pending') {
|
|
705
|
+
setActiveStep(firstStep);
|
|
706
|
+
setActiveFile(`${firstStep}.md`);
|
|
707
|
+
} else {
|
|
708
|
+
const firstPending = Object.entries(steps).find(([_, status]) => status === 'pending');
|
|
709
|
+
if (firstPending) {
|
|
710
|
+
setActiveStep(firstPending[0]);
|
|
711
|
+
setActiveFile(`${firstPending[0]}.md`);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
272
717
|
} catch {}
|
|
273
718
|
setLoading(false);
|
|
274
719
|
}
|
|
275
720
|
|
|
276
|
-
React.useEffect(() => { load(); }, [name]);
|
|
721
|
+
React.useEffect(() => { load(true); }, [name]);
|
|
722
|
+
|
|
723
|
+
const handleFlowSelected = async (flow) => {
|
|
724
|
+
// Persist flow to status.yaml
|
|
725
|
+
try {
|
|
726
|
+
await api.patch(`/features/${name}/flow`, { flow });
|
|
727
|
+
} catch (e) {
|
|
728
|
+
console.error('Failed to save flow:', e);
|
|
729
|
+
}
|
|
730
|
+
setSelectedFlow(flow);
|
|
731
|
+
setShowInitPanel(false);
|
|
732
|
+
// Select the first step based on flow type
|
|
733
|
+
const firstStep = flow === 'quick' ? 'dev-plan' : 'brief';
|
|
734
|
+
setActiveStep(firstStep);
|
|
735
|
+
setActiveFile(`${firstStep}.md`);
|
|
736
|
+
// Refresh file viewer
|
|
737
|
+
setFileVersion(v => v + 1);
|
|
738
|
+
load(false); // Reload without checking init panel
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
const handleFlowChanged = (newFlow) => {
|
|
742
|
+
setSelectedFlow(newFlow);
|
|
743
|
+
// Select the first step based on new flow type
|
|
744
|
+
const firstStep = newFlow === 'quick' ? 'dev-plan' : 'brief';
|
|
745
|
+
setActiveStep(firstStep);
|
|
746
|
+
setActiveFile(`${firstStep}.md`);
|
|
747
|
+
load(false);
|
|
748
|
+
};
|
|
277
749
|
|
|
278
750
|
if (loading) return React.createElement('p', { className: 'text-slate-500' }, 'Loading...');
|
|
279
751
|
if (!feature) return React.createElement('p', { className: 'text-red-400' }, `Feature "${name}" not found.`);
|
|
@@ -288,8 +760,26 @@ export function FeatureDetail({ name }) {
|
|
|
288
760
|
feature.current_step && React.createElement('span', { className: 'text-xs bg-aia-accent/20 text-aia-accent px-2 py-0.5 rounded' }, feature.current_step),
|
|
289
761
|
),
|
|
290
762
|
|
|
291
|
-
//
|
|
292
|
-
React.createElement(
|
|
763
|
+
// Init panel (when no steps started)
|
|
764
|
+
showInitPanel && React.createElement(InitPanel, {
|
|
765
|
+
name,
|
|
766
|
+
onFlowSelected: handleFlowSelected,
|
|
767
|
+
onCancel: () => setShowInitPanel(false),
|
|
768
|
+
onEnriched: () => {
|
|
769
|
+
setActiveFile('init.md');
|
|
770
|
+
setFileVersion(v => v + 1);
|
|
771
|
+
},
|
|
772
|
+
}),
|
|
773
|
+
|
|
774
|
+
// Flow selector (only show if not showing init panel)
|
|
775
|
+
!showInitPanel && React.createElement(FlowSelector, {
|
|
776
|
+
name,
|
|
777
|
+
currentFlow: selectedFlow || 'full',
|
|
778
|
+
onFlowChanged: handleFlowChanged,
|
|
779
|
+
}),
|
|
780
|
+
|
|
781
|
+
// Worktrunk panel
|
|
782
|
+
!showInitPanel && React.createElement(WorktrunkPanel, { featureName: name }),
|
|
293
783
|
|
|
294
784
|
// Pipeline
|
|
295
785
|
React.createElement('div', { className: 'flex flex-wrap gap-2' },
|
|
@@ -305,7 +795,7 @@ export function FeatureDetail({ name }) {
|
|
|
305
795
|
),
|
|
306
796
|
|
|
307
797
|
// Run / Iterate panel
|
|
308
|
-
activeStep && React.createElement(RunPanel, { name, step: activeStep, stepStatus: steps[activeStep], onDone: load }),
|
|
798
|
+
activeStep && React.createElement(RunPanel, { name, step: activeStep, stepStatus: steps[activeStep], onDone: () => { setFileVersion(v => v + 1); load(false); } }),
|
|
309
799
|
|
|
310
800
|
// File tabs
|
|
311
801
|
React.createElement('div', { className: 'flex gap-1 border-b border-aia-border' },
|
|
@@ -319,6 +809,6 @@ export function FeatureDetail({ name }) {
|
|
|
319
809
|
),
|
|
320
810
|
|
|
321
811
|
// Editor
|
|
322
|
-
activeFile && React.createElement(FileEditor, { key: `${name}-${activeFile}`, name, filename: activeFile, onSaved: load }),
|
|
812
|
+
activeFile && React.createElement(FileEditor, { key: `${name}-${activeFile}-${fileVersion}`, name, filename: activeFile, onSaved: () => { setFileVersion(v => v + 1); load(false); } }),
|
|
323
813
|
);
|
|
324
814
|
}
|