@cluesmith/codev 1.4.1 → 1.4.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/dist/agent-farm/servers/dashboard-server.js +487 -5
- package/dist/agent-farm/servers/dashboard-server.js.map +1 -1
- package/dist/commands/adopt.d.ts.map +1 -1
- package/dist/commands/adopt.js +10 -0
- package/dist/commands/adopt.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +8 -0
- package/dist/commands/init.js.map +1 -1
- package/package.json +1 -1
- package/skeleton/templates/projectlist-archive.md +21 -0
- package/skeleton/templates/projectlist.md +17 -0
- package/templates/dashboard/css/activity.css +151 -0
- package/templates/dashboard/css/dialogs.css +149 -0
- package/templates/dashboard/css/files.css +530 -0
- package/templates/dashboard/css/layout.css +124 -0
- package/templates/dashboard/css/projects.css +501 -0
- package/templates/dashboard/css/statusbar.css +23 -0
- package/templates/dashboard/css/tabs.css +314 -0
- package/templates/dashboard/css/utilities.css +50 -0
- package/templates/dashboard/css/variables.css +45 -0
- package/templates/dashboard/index.html +158 -0
- package/templates/dashboard/js/activity.js +238 -0
- package/templates/dashboard/js/dialogs.js +328 -0
- package/templates/dashboard/js/files.js +436 -0
- package/templates/dashboard/js/main.js +487 -0
- package/templates/dashboard/js/projects.js +544 -0
- package/templates/dashboard/js/state.js +91 -0
- package/templates/dashboard/js/tabs.js +500 -0
- package/templates/dashboard/js/utils.js +57 -0
- package/templates/dashboard-split.html +1034 -14
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
// Projects Tab - Parsing and Rendering (Spec 0045)
|
|
2
|
+
|
|
3
|
+
// Lifecycle stages in order
|
|
4
|
+
const LIFECYCLE_STAGES = ['conceived', 'specified', 'planned', 'implementing', 'implemented', 'committed', 'integrated'];
|
|
5
|
+
|
|
6
|
+
// Abbreviated column headers
|
|
7
|
+
const STAGE_HEADERS = {
|
|
8
|
+
'conceived': "CONC'D",
|
|
9
|
+
'specified': "SPEC'D",
|
|
10
|
+
'planned': 'PLANNED',
|
|
11
|
+
'implementing': 'IMPLING',
|
|
12
|
+
'implemented': 'IMPLED',
|
|
13
|
+
'committed': 'CMTD',
|
|
14
|
+
'integrated': "INTGR'D"
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Stage tooltips
|
|
18
|
+
const STAGE_TOOLTIPS = {
|
|
19
|
+
'conceived': "CONCEIVED: Idea has been captured.\nExit: Human approves the specification.",
|
|
20
|
+
'specified': "SPECIFIED: Human approved the spec.\nExit: Architect creates an implementation plan.",
|
|
21
|
+
'planned': "PLANNED: Implementation plan is ready.\nExit: Architect spawns a Builder.",
|
|
22
|
+
'implementing': "IMPLEMENTING: Builder is working on the code.\nExit: Builder creates a PR.",
|
|
23
|
+
'implemented': "IMPLEMENTED: PR is ready for review.\nExit: Builder merges after Architect review.",
|
|
24
|
+
'committed': "COMMITTED: PR has been merged.\nExit: Human validates in production.",
|
|
25
|
+
'integrated': "INTEGRATED: Validated in production.\nThis is the goal state."
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Parse a single project entry from YAML-like text
|
|
29
|
+
function parseProjectEntry(text) {
|
|
30
|
+
const project = {};
|
|
31
|
+
const lines = text.split('\n');
|
|
32
|
+
|
|
33
|
+
for (const line of lines) {
|
|
34
|
+
const match = line.match(/^\s*-?\s*(\w+):\s*(.*)$/);
|
|
35
|
+
if (!match) continue;
|
|
36
|
+
|
|
37
|
+
const [, key, rawValue] = match;
|
|
38
|
+
let value = rawValue.trim();
|
|
39
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
40
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
41
|
+
value = value.slice(1, -1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (key === 'files') {
|
|
45
|
+
project.files = {};
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (key === 'spec' || key === 'plan' || key === 'review') {
|
|
49
|
+
if (!project.files) project.files = {};
|
|
50
|
+
project.files[key] = value === 'null' ? null : value;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (key === 'timestamps') {
|
|
55
|
+
project.timestamps = {};
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const timestampFields = ['conceived_at', 'specified_at', 'planned_at',
|
|
59
|
+
'implementing_at', 'implemented_at', 'committed_at', 'integrated_at'];
|
|
60
|
+
if (timestampFields.includes(key)) {
|
|
61
|
+
if (!project.timestamps) project.timestamps = {};
|
|
62
|
+
project.timestamps[key] = value === 'null' ? null : value;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (key === 'dependencies' || key === 'tags' || key === 'ticks') {
|
|
67
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
68
|
+
const inner = value.slice(1, -1);
|
|
69
|
+
if (inner.trim() === '') {
|
|
70
|
+
project[key] = [];
|
|
71
|
+
} else {
|
|
72
|
+
project[key] = inner.split(',').map(s => s.trim().replace(/^["']|["']$/g, ''));
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
project[key] = [];
|
|
76
|
+
}
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (value !== 'null') {
|
|
81
|
+
project[key] = value;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return project;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Validate that a project entry is valid
|
|
89
|
+
function isValidProject(project) {
|
|
90
|
+
if (!project.id || project.id === 'NNNN' || !/^\d{4}$/.test(project.id)) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const validStatuses = ['conceived', 'specified', 'planned', 'implementing',
|
|
95
|
+
'implemented', 'committed', 'integrated', 'abandoned', 'on-hold'];
|
|
96
|
+
if (!project.status || !validStatuses.includes(project.status)) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!project.title) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (project.tags && project.tags.includes('example')) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Parse projectlist.md content into array of projects
|
|
112
|
+
function parseProjectlist(content) {
|
|
113
|
+
const projects = [];
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const yamlBlockRegex = /```yaml\n([\s\S]*?)```/g;
|
|
117
|
+
let match;
|
|
118
|
+
|
|
119
|
+
while ((match = yamlBlockRegex.exec(content)) !== null) {
|
|
120
|
+
const block = match[1];
|
|
121
|
+
const projectMatches = block.split(/\n(?=\s*- id:)/);
|
|
122
|
+
|
|
123
|
+
for (const projectText of projectMatches) {
|
|
124
|
+
if (!projectText.trim() || !projectText.includes('id:')) continue;
|
|
125
|
+
|
|
126
|
+
const project = parseProjectEntry(projectText);
|
|
127
|
+
if (isValidProject(project)) {
|
|
128
|
+
projects.push(project);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} catch (err) {
|
|
133
|
+
console.error('Error parsing projectlist:', err);
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return projects;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Load projectlist.md from disk
|
|
141
|
+
async function loadProjectlist() {
|
|
142
|
+
try {
|
|
143
|
+
const response = await fetch('/file?path=codev/projectlist.md');
|
|
144
|
+
|
|
145
|
+
if (!response.ok) {
|
|
146
|
+
if (response.status === 404) {
|
|
147
|
+
projectsData = [];
|
|
148
|
+
projectlistError = null;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const text = await response.text();
|
|
155
|
+
const newHash = hashString(text);
|
|
156
|
+
|
|
157
|
+
if (newHash !== projectlistHash) {
|
|
158
|
+
projectlistHash = newHash;
|
|
159
|
+
projectsData = parseProjectlist(text);
|
|
160
|
+
projectlistError = null;
|
|
161
|
+
}
|
|
162
|
+
} catch (err) {
|
|
163
|
+
console.error('Failed to load projectlist:', err);
|
|
164
|
+
projectlistError = 'Could not load projectlist.md: ' + err.message;
|
|
165
|
+
if (projectsData.length === 0) {
|
|
166
|
+
projectsData = [];
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Reload projectlist (manual refresh button)
|
|
172
|
+
async function reloadProjectlist() {
|
|
173
|
+
projectlistHash = null;
|
|
174
|
+
await loadProjectlist();
|
|
175
|
+
renderProjectsTabContent();
|
|
176
|
+
checkStarterMode();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Poll projectlist for changes
|
|
180
|
+
async function pollProjectlist() {
|
|
181
|
+
if (activeTabId !== 'dashboard') return;
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const response = await fetch('/file?path=codev/projectlist.md');
|
|
185
|
+
if (!response.ok) return;
|
|
186
|
+
|
|
187
|
+
const text = await response.text();
|
|
188
|
+
const newHash = hashString(text);
|
|
189
|
+
|
|
190
|
+
if (newHash !== projectlistHash) {
|
|
191
|
+
clearTimeout(projectlistDebounce);
|
|
192
|
+
projectlistDebounce = setTimeout(async () => {
|
|
193
|
+
projectlistHash = newHash;
|
|
194
|
+
projectsData = parseProjectlist(text);
|
|
195
|
+
projectlistError = null;
|
|
196
|
+
renderProjectsTabContent();
|
|
197
|
+
checkStarterMode();
|
|
198
|
+
}, 500);
|
|
199
|
+
}
|
|
200
|
+
} catch (err) {
|
|
201
|
+
// Silently ignore polling errors
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Check if recently integrated
|
|
206
|
+
function isRecentlyIntegrated(project) {
|
|
207
|
+
if (project.status !== 'integrated') return false;
|
|
208
|
+
const integratedAt = project.timestamps?.integrated_at;
|
|
209
|
+
if (!integratedAt) return false;
|
|
210
|
+
|
|
211
|
+
const integratedDate = new Date(integratedAt);
|
|
212
|
+
if (isNaN(integratedDate.getTime())) return false;
|
|
213
|
+
|
|
214
|
+
const now = new Date();
|
|
215
|
+
const hoursDiff = (now - integratedDate) / (1000 * 60 * 60);
|
|
216
|
+
return hoursDiff <= 24;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Get stage index
|
|
220
|
+
function getStageIndex(status) {
|
|
221
|
+
return LIFECYCLE_STAGES.indexOf(status);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Get cell content for a stage
|
|
225
|
+
function getStageCellContent(project, stage) {
|
|
226
|
+
switch (stage) {
|
|
227
|
+
case 'specified':
|
|
228
|
+
if (project.files && project.files.spec) {
|
|
229
|
+
return { label: 'Spec', link: project.files.spec };
|
|
230
|
+
}
|
|
231
|
+
return { label: '', link: null };
|
|
232
|
+
case 'planned':
|
|
233
|
+
if (project.files && project.files.plan) {
|
|
234
|
+
return { label: 'Plan', link: project.files.plan };
|
|
235
|
+
}
|
|
236
|
+
return { label: '', link: null };
|
|
237
|
+
case 'implemented':
|
|
238
|
+
if (project.files && project.files.review) {
|
|
239
|
+
return { label: 'Revw', link: project.files.review };
|
|
240
|
+
}
|
|
241
|
+
return { label: '', link: null };
|
|
242
|
+
case 'committed':
|
|
243
|
+
if (project.notes) {
|
|
244
|
+
const prMatch = project.notes.match(/PR\s*#?(\d+)/i);
|
|
245
|
+
if (prMatch) {
|
|
246
|
+
return { label: 'PR', link: `https://github.com/cluesmith/codev/pull/${prMatch[1]}`, external: true };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return { label: '', link: null };
|
|
250
|
+
default:
|
|
251
|
+
return { label: '', link: null };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Render a stage cell
|
|
256
|
+
function renderStageCell(project, stage) {
|
|
257
|
+
const currentIndex = getStageIndex(project.status);
|
|
258
|
+
const stageIndex = getStageIndex(stage);
|
|
259
|
+
|
|
260
|
+
let cellClass = 'stage-cell';
|
|
261
|
+
let content = '';
|
|
262
|
+
let ariaLabel = '';
|
|
263
|
+
|
|
264
|
+
if (stageIndex < currentIndex) {
|
|
265
|
+
ariaLabel = `${stage}: completed`;
|
|
266
|
+
const cellContent = getStageCellContent(project, stage);
|
|
267
|
+
if (cellContent.label && cellContent.link) {
|
|
268
|
+
if (cellContent.external) {
|
|
269
|
+
content = `<span class="checkmark">✓</span> <a href="${cellContent.link}" target="_blank" rel="noopener">${cellContent.label}</a>`;
|
|
270
|
+
} else {
|
|
271
|
+
content = `<span class="checkmark">✓</span> <a href="#" onclick="openProjectFile('${cellContent.link}'); return false;">${cellContent.label}</a>`;
|
|
272
|
+
}
|
|
273
|
+
} else {
|
|
274
|
+
content = '<span class="checkmark">✓</span>';
|
|
275
|
+
}
|
|
276
|
+
} else if (stageIndex === currentIndex) {
|
|
277
|
+
if (stage === 'integrated' && isRecentlyIntegrated(project)) {
|
|
278
|
+
ariaLabel = `${stage}: recently completed!`;
|
|
279
|
+
content = '<span class="celebration">🎉</span>';
|
|
280
|
+
} else {
|
|
281
|
+
ariaLabel = `${stage}: in progress`;
|
|
282
|
+
const cellContent = getStageCellContent(project, stage);
|
|
283
|
+
if (cellContent.label && cellContent.link) {
|
|
284
|
+
if (cellContent.external) {
|
|
285
|
+
content = `<span class="current-indicator"></span> <a href="${cellContent.link}" target="_blank" rel="noopener">${cellContent.label}</a>`;
|
|
286
|
+
} else {
|
|
287
|
+
content = `<span class="current-indicator"></span> <a href="#" onclick="openProjectFile('${cellContent.link}'); return false;">${cellContent.label}</a>`;
|
|
288
|
+
}
|
|
289
|
+
} else {
|
|
290
|
+
content = '<span class="current-indicator"></span>';
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
} else {
|
|
294
|
+
ariaLabel = `${stage}: pending`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return `<td role="gridcell" class="${cellClass}" aria-label="${ariaLabel}">${content}</td>`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Open a project file in a new annotation tab
|
|
301
|
+
async function openProjectFile(path) {
|
|
302
|
+
try {
|
|
303
|
+
const response = await fetch('/api/tabs/file', {
|
|
304
|
+
method: 'POST',
|
|
305
|
+
headers: { 'Content-Type': 'application/json' },
|
|
306
|
+
body: JSON.stringify({ path })
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
if (!response.ok) {
|
|
310
|
+
throw new Error(await response.text());
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
await refresh();
|
|
314
|
+
showToast(`Opened ${path}`, 'success');
|
|
315
|
+
} catch (err) {
|
|
316
|
+
showToast('Failed to open file: ' + err.message, 'error');
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Render a single project row
|
|
321
|
+
function renderProjectRow(project) {
|
|
322
|
+
const isExpanded = expandedProjectId === project.id;
|
|
323
|
+
|
|
324
|
+
const row = `
|
|
325
|
+
<tr class="status-${project.status}"
|
|
326
|
+
role="row"
|
|
327
|
+
tabindex="0"
|
|
328
|
+
aria-expanded="${isExpanded}"
|
|
329
|
+
onkeydown="handleProjectRowKeydown(event, '${project.id}')">
|
|
330
|
+
<td role="gridcell">
|
|
331
|
+
<div class="project-cell clickable" onclick="toggleProjectDetails('${project.id}'); event.stopPropagation();">
|
|
332
|
+
<span class="project-id">${escapeProjectHtml(project.id)}</span>
|
|
333
|
+
<span class="project-title" title="${escapeProjectHtml(project.title)}">${escapeProjectHtml(project.title)}</span>
|
|
334
|
+
</div>
|
|
335
|
+
</td>
|
|
336
|
+
${LIFECYCLE_STAGES.map(stage => renderStageCell(project, stage)).join('')}
|
|
337
|
+
</tr>
|
|
338
|
+
`;
|
|
339
|
+
|
|
340
|
+
if (isExpanded) {
|
|
341
|
+
return row + renderProjectDetailsRow(project);
|
|
342
|
+
}
|
|
343
|
+
return row;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Render the details row when expanded
|
|
347
|
+
function renderProjectDetailsRow(project) {
|
|
348
|
+
const links = [];
|
|
349
|
+
if (project.files && project.files.review) {
|
|
350
|
+
links.push(`<a href="#" onclick="openProjectFile('${project.files.review}'); return false;">Review</a>`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const dependencies = project.dependencies && project.dependencies.length > 0
|
|
354
|
+
? `<div class="project-dependencies">Dependencies: ${project.dependencies.map(d => escapeProjectHtml(d)).join(', ')}</div>`
|
|
355
|
+
: '';
|
|
356
|
+
|
|
357
|
+
const ticks = project.ticks && project.ticks.length > 0
|
|
358
|
+
? `<div class="project-ticks">TICKs: ${project.ticks.map(t => `<span class="tick-badge">TICK-${escapeProjectHtml(t)}</span>`).join(' ')}</div>`
|
|
359
|
+
: '';
|
|
360
|
+
|
|
361
|
+
return `
|
|
362
|
+
<tr class="project-details-row" role="row">
|
|
363
|
+
<td colspan="8">
|
|
364
|
+
<div class="project-details-content">
|
|
365
|
+
<h3>${escapeProjectHtml(project.title)}</h3>
|
|
366
|
+
${project.summary ? `<p>${escapeProjectHtml(project.summary)}</p>` : ''}
|
|
367
|
+
${project.notes ? `<p class="notes">${escapeProjectHtml(project.notes)}</p>` : ''}
|
|
368
|
+
${ticks}
|
|
369
|
+
${links.length > 0 ? `<div class="project-details-links">${links.join('')}</div>` : ''}
|
|
370
|
+
${dependencies}
|
|
371
|
+
</div>
|
|
372
|
+
</td>
|
|
373
|
+
</tr>
|
|
374
|
+
`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Handle keyboard navigation on project rows
|
|
378
|
+
function handleProjectRowKeydown(event, projectId) {
|
|
379
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
380
|
+
event.preventDefault();
|
|
381
|
+
toggleProjectDetails(projectId);
|
|
382
|
+
} else if (event.key === 'ArrowDown') {
|
|
383
|
+
event.preventDefault();
|
|
384
|
+
const currentRow = event.target.closest('tr');
|
|
385
|
+
let nextRow = currentRow.nextElementSibling;
|
|
386
|
+
while (nextRow && nextRow.classList.contains('project-details-row')) {
|
|
387
|
+
nextRow = nextRow.nextElementSibling;
|
|
388
|
+
}
|
|
389
|
+
if (nextRow) nextRow.focus();
|
|
390
|
+
} else if (event.key === 'ArrowUp') {
|
|
391
|
+
event.preventDefault();
|
|
392
|
+
const currentRow = event.target.closest('tr');
|
|
393
|
+
let prevRow = currentRow.previousElementSibling;
|
|
394
|
+
while (prevRow && prevRow.classList.contains('project-details-row')) {
|
|
395
|
+
prevRow = prevRow.previousElementSibling;
|
|
396
|
+
}
|
|
397
|
+
if (prevRow && prevRow.getAttribute('tabindex') === '0') prevRow.focus();
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Toggle project details expansion
|
|
402
|
+
function toggleProjectDetails(projectId) {
|
|
403
|
+
if (expandedProjectId === projectId) {
|
|
404
|
+
expandedProjectId = null;
|
|
405
|
+
} else {
|
|
406
|
+
expandedProjectId = projectId;
|
|
407
|
+
}
|
|
408
|
+
renderProjectsTabContent();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Render a table for a list of projects
|
|
412
|
+
function renderProjectTable(projectList) {
|
|
413
|
+
if (projectList.length === 0) {
|
|
414
|
+
return '<p style="color: var(--text-muted); text-align: center; padding: 20px;">No projects</p>';
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return `
|
|
418
|
+
<table class="kanban-grid" role="grid" aria-label="Project status grid">
|
|
419
|
+
<thead>
|
|
420
|
+
<tr role="row">
|
|
421
|
+
<th role="columnheader">Project</th>
|
|
422
|
+
${LIFECYCLE_STAGES.map(stage => `<th role="columnheader" title="${STAGE_TOOLTIPS[stage]}">${STAGE_HEADERS[stage]}</th>`).join('')}
|
|
423
|
+
</tr>
|
|
424
|
+
</thead>
|
|
425
|
+
<tbody>
|
|
426
|
+
${projectList.map(p => renderProjectRow(p)).join('')}
|
|
427
|
+
</tbody>
|
|
428
|
+
</table>
|
|
429
|
+
`;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Render the Kanban grid with Active/Inactive sections
|
|
433
|
+
function renderKanbanGrid(projects) {
|
|
434
|
+
const activeStatuses = ['conceived', 'specified', 'planned', 'implementing', 'implemented', 'committed'];
|
|
435
|
+
const statusOrder = {
|
|
436
|
+
'conceived': 0, 'specified': 1, 'planned': 2, 'implementing': 3,
|
|
437
|
+
'implemented': 4, 'committed': 5, 'integrated': 6
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
const activeProjects = projects.filter(p =>
|
|
441
|
+
activeStatuses.includes(p.status) || isRecentlyIntegrated(p)
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
activeProjects.sort((a, b) => {
|
|
445
|
+
const orderA = statusOrder[a.status] || 0;
|
|
446
|
+
const orderB = statusOrder[b.status] || 0;
|
|
447
|
+
if (orderB !== orderA) return orderB - orderA;
|
|
448
|
+
return a.id.localeCompare(b.id);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
const inactiveProjects = projects.filter(p =>
|
|
452
|
+
p.status === 'integrated' && !isRecentlyIntegrated(p)
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
let html = '';
|
|
456
|
+
|
|
457
|
+
if (activeProjects.length > 0 || inactiveProjects.length === 0) {
|
|
458
|
+
html += `
|
|
459
|
+
<details class="project-section" open>
|
|
460
|
+
<summary>Active <span class="section-count">(${activeProjects.length})</span></summary>
|
|
461
|
+
${renderProjectTable(activeProjects)}
|
|
462
|
+
</details>
|
|
463
|
+
`;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (inactiveProjects.length > 0) {
|
|
467
|
+
html += `
|
|
468
|
+
<details class="project-section">
|
|
469
|
+
<summary>Completed <span class="section-count">(${inactiveProjects.length})</span></summary>
|
|
470
|
+
${renderProjectTable(inactiveProjects)}
|
|
471
|
+
</details>
|
|
472
|
+
`;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return html;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Render the terminal projects section
|
|
479
|
+
function renderTerminalProjects(projects) {
|
|
480
|
+
const terminal = projects.filter(p => ['abandoned', 'on-hold'].includes(p.status));
|
|
481
|
+
if (terminal.length === 0) return '';
|
|
482
|
+
|
|
483
|
+
const items = terminal.map(p => {
|
|
484
|
+
const className = p.status === 'abandoned' ? 'project-abandoned' : 'project-on-hold';
|
|
485
|
+
const statusText = p.status === 'on-hold' ? ' (on-hold)' : '';
|
|
486
|
+
return `
|
|
487
|
+
<li>
|
|
488
|
+
<span class="${className}">
|
|
489
|
+
<span class="project-id">${escapeProjectHtml(p.id)}</span>
|
|
490
|
+
${escapeProjectHtml(p.title)}${statusText}
|
|
491
|
+
</span>
|
|
492
|
+
</li>
|
|
493
|
+
`;
|
|
494
|
+
}).join('');
|
|
495
|
+
|
|
496
|
+
return `
|
|
497
|
+
<details class="terminal-projects">
|
|
498
|
+
<summary>Terminal Projects (${terminal.length})</summary>
|
|
499
|
+
<ul>${items}</ul>
|
|
500
|
+
</details>
|
|
501
|
+
`;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Render error banner
|
|
505
|
+
function renderErrorBanner(message) {
|
|
506
|
+
return `
|
|
507
|
+
<div class="projects-error">
|
|
508
|
+
<span class="projects-error-message">${escapeProjectHtml(message)}</span>
|
|
509
|
+
<button onclick="reloadProjectlist()">Retry</button>
|
|
510
|
+
</div>
|
|
511
|
+
`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Render the projects section for dashboard
|
|
515
|
+
function renderDashboardProjectsSection() {
|
|
516
|
+
if (projectlistError) {
|
|
517
|
+
return renderErrorBanner(projectlistError);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (projectsData.length === 0) {
|
|
521
|
+
return `
|
|
522
|
+
<div class="dashboard-empty-state" style="padding: 24px;">
|
|
523
|
+
No projects yet. Ask the Architect to create your first project.
|
|
524
|
+
</div>
|
|
525
|
+
`;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return `
|
|
529
|
+
${renderKanbanGrid(projectsData)}
|
|
530
|
+
${renderTerminalProjects(projectsData)}
|
|
531
|
+
`;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Legacy function for backward compatibility
|
|
535
|
+
function renderProjectsTabContent() {
|
|
536
|
+
if (activeTabId === 'dashboard') {
|
|
537
|
+
renderDashboardTabContent();
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Legacy function alias
|
|
542
|
+
async function renderProjectsTab() {
|
|
543
|
+
await renderDashboardTab();
|
|
544
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Dashboard State Management
|
|
2
|
+
|
|
3
|
+
// STATE_INJECTION_POINT - server injects window.INITIAL_STATE here
|
|
4
|
+
|
|
5
|
+
// Global state
|
|
6
|
+
const state = window.INITIAL_STATE || {
|
|
7
|
+
architect: null,
|
|
8
|
+
builders: [],
|
|
9
|
+
utils: [],
|
|
10
|
+
annotations: []
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// Tab state
|
|
14
|
+
let tabs = [];
|
|
15
|
+
let activeTabId = null;
|
|
16
|
+
let pendingCloseTabId = null;
|
|
17
|
+
let contextMenuTabId = null;
|
|
18
|
+
|
|
19
|
+
// Track known tab IDs to detect new tabs
|
|
20
|
+
let knownTabIds = new Set();
|
|
21
|
+
|
|
22
|
+
// Projects tab state
|
|
23
|
+
let projectsData = [];
|
|
24
|
+
let projectlistHash = null;
|
|
25
|
+
let expandedProjectId = null;
|
|
26
|
+
let projectlistError = null;
|
|
27
|
+
let projectlistDebounce = null;
|
|
28
|
+
|
|
29
|
+
// Files tab state (Spec 0055)
|
|
30
|
+
let filesTreeData = [];
|
|
31
|
+
let filesTreeExpanded = new Set(); // Set of expanded folder paths
|
|
32
|
+
let filesTreeError = null;
|
|
33
|
+
let filesTreeLoaded = false;
|
|
34
|
+
|
|
35
|
+
// File search state (Spec 0058)
|
|
36
|
+
let filesTreeFlat = []; // Flattened array of {name, path} objects for searching
|
|
37
|
+
let filesSearchQuery = '';
|
|
38
|
+
let filesSearchResults = [];
|
|
39
|
+
let filesSearchIndex = 0;
|
|
40
|
+
let filesSearchDebounceTimer = null;
|
|
41
|
+
|
|
42
|
+
// Cmd+P palette state (Spec 0058)
|
|
43
|
+
let paletteOpen = false;
|
|
44
|
+
let paletteQuery = '';
|
|
45
|
+
let paletteResults = [];
|
|
46
|
+
let paletteIndex = 0;
|
|
47
|
+
let paletteDebounceTimer = null;
|
|
48
|
+
|
|
49
|
+
// Activity state (Spec 0059)
|
|
50
|
+
let activityData = null;
|
|
51
|
+
|
|
52
|
+
// Collapsible section state (persisted to localStorage)
|
|
53
|
+
const SECTION_STATE_KEY = 'codev-dashboard-sections';
|
|
54
|
+
let sectionState = loadSectionState();
|
|
55
|
+
|
|
56
|
+
function loadSectionState() {
|
|
57
|
+
try {
|
|
58
|
+
const saved = localStorage.getItem(SECTION_STATE_KEY);
|
|
59
|
+
if (saved) return JSON.parse(saved);
|
|
60
|
+
} catch (e) { /* ignore */ }
|
|
61
|
+
return { tabs: true, files: true, projects: true };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function saveSectionState() {
|
|
65
|
+
try {
|
|
66
|
+
localStorage.setItem(SECTION_STATE_KEY, JSON.stringify(sectionState));
|
|
67
|
+
} catch (e) { /* ignore */ }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function toggleSection(section) {
|
|
71
|
+
sectionState[section] = !sectionState[section];
|
|
72
|
+
saveSectionState();
|
|
73
|
+
renderDashboardTabContent();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Track current architect port to avoid re-rendering iframe unnecessarily
|
|
77
|
+
let currentArchitectPort = null;
|
|
78
|
+
|
|
79
|
+
// Track current tab content to avoid re-rendering iframe unnecessarily
|
|
80
|
+
let currentTabPort = null;
|
|
81
|
+
let currentTabType = null;
|
|
82
|
+
|
|
83
|
+
// Polling state
|
|
84
|
+
let pollInterval = null;
|
|
85
|
+
let starterModePollingInterval = null;
|
|
86
|
+
|
|
87
|
+
// Hot reload state (Spec 0060)
|
|
88
|
+
// Hot reload only activates in dev mode (localhost or ?dev=1 query param)
|
|
89
|
+
let hotReloadEnabled = true;
|
|
90
|
+
let hotReloadInterval = null;
|
|
91
|
+
let hotReloadMtimes = {}; // Track file modification times
|