@cluesmith/codev 1.2.0 → 1.2.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/open-server.js +53 -6
- package/dist/agent-farm/servers/open-server.js.map +1 -1
- package/dist/commands/adopt.d.ts.map +1 -1
- package/dist/commands/adopt.js +27 -6
- package/dist/commands/adopt.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +27 -6
- package/dist/commands/init.js.map +1 -1
- package/package.json +3 -2
- package/templates/dashboard-split.html +2984 -0
- package/templates/dashboard.html +149 -0
- package/templates/open.html +1336 -0
- package/templates/tower.html +1032 -0
|
@@ -0,0 +1,2984 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>AF: {{PROJECT_NAME}}</title>
|
|
7
|
+
<style>
|
|
8
|
+
* {
|
|
9
|
+
box-sizing: border-box;
|
|
10
|
+
margin: 0;
|
|
11
|
+
padding: 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
:root {
|
|
15
|
+
--bg-primary: #1a1a1a;
|
|
16
|
+
--bg-secondary: #252525;
|
|
17
|
+
--bg-tertiary: #2a2a2a;
|
|
18
|
+
--border: #333;
|
|
19
|
+
--text-primary: #fff;
|
|
20
|
+
--text-secondary: #ccc;
|
|
21
|
+
--text-muted: #666;
|
|
22
|
+
--accent: #3b82f6;
|
|
23
|
+
--tab-active: #333;
|
|
24
|
+
--tab-hover: #2a2a2a;
|
|
25
|
+
/* Status indicator colors per spec 0019 */
|
|
26
|
+
--status-active: #22c55e; /* Green: spawning, implementing */
|
|
27
|
+
--status-waiting: #eab308; /* Yellow: pr-ready (waiting for review) */
|
|
28
|
+
--status-error: #ef4444; /* Red: blocked */
|
|
29
|
+
--status-complete: #9e9e9e; /* Gray: complete */
|
|
30
|
+
/* Project lifecycle status colors per spec 0045 */
|
|
31
|
+
--project-conceived: #eab308; /* Yellow */
|
|
32
|
+
--project-specified: #3b82f6; /* Blue */
|
|
33
|
+
--project-planned: #3b82f6; /* Blue */
|
|
34
|
+
--project-implementing: #f97316; /* Orange */
|
|
35
|
+
--project-implemented: #a855f7; /* Purple */
|
|
36
|
+
--project-committed: #22c55e; /* Green */
|
|
37
|
+
--project-integrated: #9e9e9e; /* Gray */
|
|
38
|
+
--project-abandoned: #ef4444; /* Red */
|
|
39
|
+
--project-on-hold: #9e9e9e; /* Gray */
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
body {
|
|
43
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
44
|
+
background: var(--bg-primary);
|
|
45
|
+
color: var(--text-primary);
|
|
46
|
+
height: 100vh;
|
|
47
|
+
display: flex;
|
|
48
|
+
flex-direction: column;
|
|
49
|
+
overflow: hidden;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* Header */
|
|
53
|
+
.header {
|
|
54
|
+
display: flex;
|
|
55
|
+
justify-content: space-between;
|
|
56
|
+
align-items: center;
|
|
57
|
+
padding: 12px 16px;
|
|
58
|
+
background: var(--bg-secondary);
|
|
59
|
+
border-bottom: 1px solid var(--border);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.header h1 {
|
|
63
|
+
font-size: 16px;
|
|
64
|
+
font-weight: 600;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.header-actions {
|
|
68
|
+
display: flex;
|
|
69
|
+
gap: 8px;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.btn {
|
|
73
|
+
padding: 6px 12px;
|
|
74
|
+
border-radius: 4px;
|
|
75
|
+
border: 1px solid var(--border);
|
|
76
|
+
background: var(--bg-tertiary);
|
|
77
|
+
color: var(--text-secondary);
|
|
78
|
+
cursor: pointer;
|
|
79
|
+
font-size: 13px;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.btn:hover {
|
|
83
|
+
background: var(--tab-active);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.btn-danger {
|
|
87
|
+
border-color: #ef4444;
|
|
88
|
+
color: #ef4444;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.btn-danger:hover {
|
|
92
|
+
background: rgba(239, 68, 68, 0.1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* Main content area */
|
|
96
|
+
.main {
|
|
97
|
+
display: flex;
|
|
98
|
+
flex: 1;
|
|
99
|
+
overflow: hidden;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* Left pane - Architect */
|
|
103
|
+
.left-pane {
|
|
104
|
+
width: 50%;
|
|
105
|
+
min-width: 20%;
|
|
106
|
+
max-width: 80%;
|
|
107
|
+
resize: horizontal;
|
|
108
|
+
overflow: auto;
|
|
109
|
+
border-right: 1px solid var(--border);
|
|
110
|
+
display: flex;
|
|
111
|
+
flex-direction: column;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.pane-header {
|
|
115
|
+
padding: 8px 12px;
|
|
116
|
+
background: var(--bg-secondary);
|
|
117
|
+
border-bottom: 1px solid var(--border);
|
|
118
|
+
font-size: 12px;
|
|
119
|
+
color: var(--text-muted);
|
|
120
|
+
text-transform: uppercase;
|
|
121
|
+
letter-spacing: 0.5px;
|
|
122
|
+
display: flex;
|
|
123
|
+
align-items: center;
|
|
124
|
+
gap: 6px;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.pane-header .status-dot {
|
|
128
|
+
width: 8px;
|
|
129
|
+
height: 8px;
|
|
130
|
+
border-radius: 50%;
|
|
131
|
+
background: var(--status-active);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.pane-header .status-dot.inactive {
|
|
135
|
+
background: var(--text-muted);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#architect-content {
|
|
139
|
+
flex: 1;
|
|
140
|
+
display: flex;
|
|
141
|
+
flex-direction: column;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.left-pane iframe {
|
|
145
|
+
flex: 1;
|
|
146
|
+
width: 100%;
|
|
147
|
+
border: none;
|
|
148
|
+
background: #000;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.architect-placeholder {
|
|
152
|
+
flex: 1;
|
|
153
|
+
display: flex;
|
|
154
|
+
flex-direction: column;
|
|
155
|
+
align-items: center;
|
|
156
|
+
justify-content: center;
|
|
157
|
+
color: var(--text-muted);
|
|
158
|
+
gap: 16px;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.architect-placeholder code {
|
|
162
|
+
background: var(--bg-tertiary);
|
|
163
|
+
padding: 4px 8px;
|
|
164
|
+
border-radius: 4px;
|
|
165
|
+
font-size: 13px;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* Right pane - Tabs */
|
|
169
|
+
.right-pane {
|
|
170
|
+
width: 50%;
|
|
171
|
+
display: flex;
|
|
172
|
+
flex-direction: column;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* Tab bar */
|
|
176
|
+
.tab-bar {
|
|
177
|
+
display: flex;
|
|
178
|
+
align-items: center;
|
|
179
|
+
background: var(--bg-secondary);
|
|
180
|
+
border-bottom: 1px solid var(--border);
|
|
181
|
+
min-height: 40px;
|
|
182
|
+
overflow: visible; /* Allow overflow menu dropdown to be visible */
|
|
183
|
+
position: relative; /* Position context for overflow menu */
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.tabs-scroll {
|
|
187
|
+
display: flex;
|
|
188
|
+
overflow-x: auto;
|
|
189
|
+
flex: 1;
|
|
190
|
+
scrollbar-width: none;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.tabs-scroll::-webkit-scrollbar {
|
|
194
|
+
display: none;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.tab {
|
|
198
|
+
display: flex;
|
|
199
|
+
align-items: center;
|
|
200
|
+
gap: 6px;
|
|
201
|
+
padding: 8px 12px;
|
|
202
|
+
cursor: pointer;
|
|
203
|
+
border-right: 1px solid var(--border);
|
|
204
|
+
border-bottom: 2px solid transparent; /* Reserve space for active indicator */
|
|
205
|
+
white-space: nowrap;
|
|
206
|
+
flex-shrink: 0;
|
|
207
|
+
position: relative;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.tab:hover {
|
|
211
|
+
background: var(--tab-hover);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.tab.active {
|
|
215
|
+
background: var(--bg-tertiary);
|
|
216
|
+
border-bottom: 2px solid var(--accent); /* Blue accent line */
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.tab.new-tab {
|
|
220
|
+
animation: tab-pulse 0.5s ease-out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
@keyframes tab-pulse {
|
|
224
|
+
0% { background: var(--accent); }
|
|
225
|
+
100% { background: var(--tab-active); }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.tab .icon {
|
|
229
|
+
font-size: 14px;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.tab .name {
|
|
233
|
+
font-size: 13px;
|
|
234
|
+
max-width: 120px;
|
|
235
|
+
overflow: hidden;
|
|
236
|
+
text-overflow: ellipsis;
|
|
237
|
+
color: var(--text-secondary);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.tab.active .name {
|
|
241
|
+
color: var(--text-primary);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.tab .status-dot {
|
|
245
|
+
width: 6px;
|
|
246
|
+
height: 6px;
|
|
247
|
+
border-radius: 50%;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/* Shape modifiers for accessibility (not just color) */
|
|
251
|
+
.tab .status-dot--diamond {
|
|
252
|
+
border-radius: 1px;
|
|
253
|
+
transform: rotate(45deg);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/* Ring shape for pr-ready (accessibility: distinct from circle) */
|
|
257
|
+
.tab .status-dot--ring {
|
|
258
|
+
box-shadow: inset 0 0 0 1.5px currentColor;
|
|
259
|
+
background: transparent !important;
|
|
260
|
+
color: var(--status-waiting);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/* Distinct animations per status category (spec 0019) */
|
|
264
|
+
@keyframes status-pulse {
|
|
265
|
+
/* Pulsing: Active/working (spawning, implementing) */
|
|
266
|
+
0%, 100% { opacity: 1; transform: scale(1); }
|
|
267
|
+
50% { opacity: 0.7; transform: scale(0.9); }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
@keyframes status-blink-slow {
|
|
271
|
+
/* Slow blink: Idle/waiting (pr-ready) */
|
|
272
|
+
0%, 100% { opacity: 1; }
|
|
273
|
+
50% { opacity: 0.3; }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
@keyframes status-blink-fast {
|
|
277
|
+
/* Fast blink: Error/blocked */
|
|
278
|
+
0%, 100% { opacity: 1; }
|
|
279
|
+
50% { opacity: 0.2; }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.tab .status-dot--pulse {
|
|
283
|
+
animation: status-pulse 2s ease-in-out infinite;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.tab .status-dot--blink-slow {
|
|
287
|
+
animation: status-blink-slow 3s ease-in-out infinite;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.tab .status-dot--blink-fast {
|
|
291
|
+
animation: status-blink-fast 0.8s ease-in-out infinite;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/* Respect reduced motion preference (WCAG 2.3.3) */
|
|
295
|
+
/* Motion-independent differentiators remain: diamond for blocked, ring for pr-ready */
|
|
296
|
+
@media (prefers-reduced-motion: reduce) {
|
|
297
|
+
.tab .status-dot--pulse,
|
|
298
|
+
.tab .status-dot--blink-slow,
|
|
299
|
+
.tab .status-dot--blink-fast {
|
|
300
|
+
animation: none;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
.tab .close {
|
|
305
|
+
opacity: 0.6; /* Always clearly visible */
|
|
306
|
+
margin-left: 6px;
|
|
307
|
+
font-size: 16px;
|
|
308
|
+
font-weight: 500;
|
|
309
|
+
color: var(--text-secondary);
|
|
310
|
+
padding: 4px 8px;
|
|
311
|
+
border-radius: 4px;
|
|
312
|
+
cursor: pointer;
|
|
313
|
+
line-height: 1;
|
|
314
|
+
min-width: 24px;
|
|
315
|
+
min-height: 24px;
|
|
316
|
+
display: flex;
|
|
317
|
+
align-items: center;
|
|
318
|
+
justify-content: center;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.tab:hover .close {
|
|
322
|
+
opacity: 0.9;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.tab .close:hover {
|
|
326
|
+
opacity: 1;
|
|
327
|
+
background: rgba(239, 68, 68, 0.2); /* Red tint on hover */
|
|
328
|
+
color: #ef4444;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/* Add buttons */
|
|
332
|
+
.add-buttons {
|
|
333
|
+
display: flex;
|
|
334
|
+
gap: 4px;
|
|
335
|
+
padding: 0 8px;
|
|
336
|
+
flex-shrink: 0;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
.add-btn {
|
|
340
|
+
padding: 4px 8px;
|
|
341
|
+
border-radius: 4px;
|
|
342
|
+
border: 1px dashed var(--border);
|
|
343
|
+
background: transparent;
|
|
344
|
+
color: var(--text-muted);
|
|
345
|
+
cursor: pointer;
|
|
346
|
+
font-size: 12px;
|
|
347
|
+
display: flex;
|
|
348
|
+
align-items: center;
|
|
349
|
+
gap: 4px;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
.add-btn:hover {
|
|
353
|
+
border-style: solid;
|
|
354
|
+
color: var(--text-secondary);
|
|
355
|
+
background: var(--bg-tertiary);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/* Overflow indicator */
|
|
359
|
+
.overflow-btn {
|
|
360
|
+
padding: 8px 12px;
|
|
361
|
+
background: var(--bg-tertiary);
|
|
362
|
+
border: none;
|
|
363
|
+
border-left: 1px solid var(--border);
|
|
364
|
+
color: var(--text-secondary);
|
|
365
|
+
cursor: pointer;
|
|
366
|
+
display: none; /* Hidden by default, shown via JS */
|
|
367
|
+
align-items: center;
|
|
368
|
+
gap: 4px;
|
|
369
|
+
flex-shrink: 0;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
.overflow-btn:hover {
|
|
373
|
+
background: var(--tab-hover);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
.overflow-btn:focus {
|
|
377
|
+
outline: 2px solid var(--accent);
|
|
378
|
+
outline-offset: -2px;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
.overflow-count {
|
|
382
|
+
font-size: 11px;
|
|
383
|
+
background: var(--accent);
|
|
384
|
+
color: white;
|
|
385
|
+
padding: 1px 5px;
|
|
386
|
+
border-radius: 8px;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/* Overflow menu dropdown */
|
|
390
|
+
.overflow-menu {
|
|
391
|
+
position: absolute;
|
|
392
|
+
right: 0;
|
|
393
|
+
top: 100%;
|
|
394
|
+
background: var(--bg-secondary);
|
|
395
|
+
border: 1px solid var(--border);
|
|
396
|
+
border-radius: 4px;
|
|
397
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
|
398
|
+
max-height: 300px;
|
|
399
|
+
overflow-y: auto;
|
|
400
|
+
min-width: 200px;
|
|
401
|
+
z-index: 100;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
.overflow-menu.hidden {
|
|
405
|
+
display: none;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
.overflow-menu-item {
|
|
409
|
+
padding: 8px 12px;
|
|
410
|
+
cursor: pointer;
|
|
411
|
+
display: flex;
|
|
412
|
+
align-items: center;
|
|
413
|
+
gap: 8px;
|
|
414
|
+
font-size: 13px;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
.overflow-menu-item:hover,
|
|
418
|
+
.overflow-menu-item:focus {
|
|
419
|
+
background: var(--tab-hover);
|
|
420
|
+
outline: none;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
.overflow-menu-item.active {
|
|
424
|
+
background: var(--tab-active);
|
|
425
|
+
border-left: 2px solid var(--accent);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
.overflow-menu-item .icon {
|
|
429
|
+
font-size: 14px;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
.overflow-menu-item .name {
|
|
433
|
+
flex: 1;
|
|
434
|
+
overflow: hidden;
|
|
435
|
+
text-overflow: ellipsis;
|
|
436
|
+
white-space: nowrap;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
.overflow-menu-item .open-external {
|
|
440
|
+
opacity: 0.5;
|
|
441
|
+
cursor: pointer;
|
|
442
|
+
padding: 2px 6px;
|
|
443
|
+
font-size: 12px;
|
|
444
|
+
border-radius: 3px;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
.overflow-menu-item .open-external:hover {
|
|
448
|
+
opacity: 1;
|
|
449
|
+
background: rgba(255, 255, 255, 0.1);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/* Tab content */
|
|
453
|
+
.tab-content {
|
|
454
|
+
flex: 1;
|
|
455
|
+
display: flex;
|
|
456
|
+
flex-direction: column;
|
|
457
|
+
overflow: hidden;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
.tab-content iframe {
|
|
461
|
+
flex: 1;
|
|
462
|
+
width: 100%;
|
|
463
|
+
border: none;
|
|
464
|
+
background: #000;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
.empty-state {
|
|
468
|
+
flex: 1;
|
|
469
|
+
display: flex;
|
|
470
|
+
flex-direction: column;
|
|
471
|
+
align-items: center;
|
|
472
|
+
justify-content: center;
|
|
473
|
+
color: var(--text-muted);
|
|
474
|
+
gap: 12px;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
.empty-state .hint {
|
|
478
|
+
font-size: 13px;
|
|
479
|
+
text-align: center;
|
|
480
|
+
max-width: 300px;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/* Status bar */
|
|
484
|
+
.status-bar {
|
|
485
|
+
padding: 8px 16px;
|
|
486
|
+
background: var(--bg-secondary);
|
|
487
|
+
border-top: 1px solid var(--border);
|
|
488
|
+
font-size: 12px;
|
|
489
|
+
color: var(--text-muted);
|
|
490
|
+
display: flex;
|
|
491
|
+
gap: 16px;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
.status-item {
|
|
495
|
+
display: flex;
|
|
496
|
+
align-items: center;
|
|
497
|
+
gap: 6px;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
.status-item .dot {
|
|
501
|
+
width: 6px;
|
|
502
|
+
height: 6px;
|
|
503
|
+
border-radius: 50%;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/* Dialogs */
|
|
507
|
+
.dialog-overlay {
|
|
508
|
+
position: fixed;
|
|
509
|
+
top: 0;
|
|
510
|
+
left: 0;
|
|
511
|
+
right: 0;
|
|
512
|
+
bottom: 0;
|
|
513
|
+
background: rgba(0, 0, 0, 0.6);
|
|
514
|
+
display: flex;
|
|
515
|
+
align-items: center;
|
|
516
|
+
justify-content: center;
|
|
517
|
+
z-index: 1000;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
.dialog-overlay.hidden {
|
|
521
|
+
display: none;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
.dialog {
|
|
525
|
+
background: var(--bg-secondary);
|
|
526
|
+
border: 1px solid var(--border);
|
|
527
|
+
border-radius: 8px;
|
|
528
|
+
padding: 20px;
|
|
529
|
+
min-width: 320px;
|
|
530
|
+
max-width: 90%;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
.dialog h3 {
|
|
534
|
+
margin-bottom: 16px;
|
|
535
|
+
font-size: 16px;
|
|
536
|
+
font-weight: 500;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
.dialog input {
|
|
540
|
+
width: 100%;
|
|
541
|
+
padding: 8px 12px;
|
|
542
|
+
border-radius: 4px;
|
|
543
|
+
border: 1px solid var(--border);
|
|
544
|
+
background: var(--bg-tertiary);
|
|
545
|
+
color: var(--text-primary);
|
|
546
|
+
font-size: 14px;
|
|
547
|
+
margin-bottom: 16px;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
.dialog input:focus {
|
|
551
|
+
outline: none;
|
|
552
|
+
border-color: var(--accent);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
.dialog-actions {
|
|
556
|
+
display: flex;
|
|
557
|
+
justify-content: flex-end;
|
|
558
|
+
gap: 8px;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
.quick-paths {
|
|
562
|
+
display: flex;
|
|
563
|
+
flex-wrap: wrap;
|
|
564
|
+
gap: 8px;
|
|
565
|
+
margin-bottom: 12px;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
.quick-path {
|
|
569
|
+
padding: 4px 8px;
|
|
570
|
+
border-radius: 4px;
|
|
571
|
+
background: var(--bg-tertiary);
|
|
572
|
+
border: 1px solid var(--border);
|
|
573
|
+
color: var(--text-secondary);
|
|
574
|
+
cursor: pointer;
|
|
575
|
+
font-size: 12px;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
.quick-path:hover {
|
|
579
|
+
background: var(--tab-hover);
|
|
580
|
+
border-color: var(--accent);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/* Toast notifications */
|
|
584
|
+
.toast-container {
|
|
585
|
+
position: fixed;
|
|
586
|
+
bottom: 60px;
|
|
587
|
+
right: 16px;
|
|
588
|
+
z-index: 2000;
|
|
589
|
+
display: flex;
|
|
590
|
+
flex-direction: column;
|
|
591
|
+
gap: 8px;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
.toast {
|
|
595
|
+
padding: 12px 16px;
|
|
596
|
+
background: var(--bg-secondary);
|
|
597
|
+
border: 1px solid var(--border);
|
|
598
|
+
border-radius: 6px;
|
|
599
|
+
font-size: 13px;
|
|
600
|
+
display: flex;
|
|
601
|
+
align-items: center;
|
|
602
|
+
gap: 8px;
|
|
603
|
+
animation: toast-in 0.3s ease-out;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
.toast.error {
|
|
607
|
+
border-color: #ef4444;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
.toast.success {
|
|
611
|
+
border-color: #22c55e;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
@keyframes toast-in {
|
|
615
|
+
from {
|
|
616
|
+
opacity: 0;
|
|
617
|
+
transform: translateY(10px);
|
|
618
|
+
}
|
|
619
|
+
to {
|
|
620
|
+
opacity: 1;
|
|
621
|
+
transform: translateY(0);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/* Context menu */
|
|
626
|
+
.context-menu {
|
|
627
|
+
position: fixed;
|
|
628
|
+
background: var(--bg-secondary);
|
|
629
|
+
border: 1px solid var(--border);
|
|
630
|
+
border-radius: 4px;
|
|
631
|
+
padding: 4px 0;
|
|
632
|
+
min-width: 150px;
|
|
633
|
+
z-index: 1000;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
.context-menu.hidden {
|
|
637
|
+
display: none;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
.context-menu-item {
|
|
641
|
+
padding: 8px 12px;
|
|
642
|
+
cursor: pointer;
|
|
643
|
+
font-size: 13px;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
.context-menu-item:hover {
|
|
647
|
+
background: var(--tab-hover);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
.context-menu-item.danger {
|
|
651
|
+
color: #ef4444;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/* Projects Tab Styles (Spec 0045) */
|
|
655
|
+
.projects-container {
|
|
656
|
+
flex: 1;
|
|
657
|
+
overflow-y: auto;
|
|
658
|
+
padding: 16px;
|
|
659
|
+
display: flex;
|
|
660
|
+
flex-direction: column;
|
|
661
|
+
gap: 16px;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/* Welcome Screen */
|
|
665
|
+
.projects-welcome {
|
|
666
|
+
max-width: 600px;
|
|
667
|
+
margin: 40px auto;
|
|
668
|
+
text-align: center;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
.projects-welcome h2 {
|
|
672
|
+
font-size: 24px;
|
|
673
|
+
margin-bottom: 16px;
|
|
674
|
+
color: var(--text-primary);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
.projects-welcome p {
|
|
678
|
+
color: var(--text-secondary);
|
|
679
|
+
line-height: 1.6;
|
|
680
|
+
margin-bottom: 16px;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
.projects-welcome ol {
|
|
684
|
+
text-align: left;
|
|
685
|
+
margin: 24px 0;
|
|
686
|
+
padding-left: 24px;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
.projects-welcome li {
|
|
690
|
+
margin-bottom: 8px;
|
|
691
|
+
color: var(--text-secondary);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
.projects-welcome li strong {
|
|
695
|
+
color: var(--text-primary);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
.projects-welcome .quick-tip {
|
|
699
|
+
margin-top: 24px;
|
|
700
|
+
padding: 12px;
|
|
701
|
+
background: var(--bg-tertiary);
|
|
702
|
+
border-radius: 6px;
|
|
703
|
+
border-left: 3px solid var(--accent);
|
|
704
|
+
color: var(--text-secondary);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
.projects-welcome hr {
|
|
708
|
+
border: none;
|
|
709
|
+
border-top: 1px solid var(--border);
|
|
710
|
+
margin: 24px 0;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/* Status Summary */
|
|
714
|
+
.status-summary {
|
|
715
|
+
background: var(--bg-secondary);
|
|
716
|
+
border: 1px solid var(--border);
|
|
717
|
+
border-radius: 6px;
|
|
718
|
+
padding: 12px 16px;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
.status-summary-header {
|
|
722
|
+
display: flex;
|
|
723
|
+
justify-content: space-between;
|
|
724
|
+
align-items: center;
|
|
725
|
+
margin-bottom: 8px;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
.status-summary-header span {
|
|
729
|
+
font-size: 11px;
|
|
730
|
+
text-transform: uppercase;
|
|
731
|
+
letter-spacing: 0.5px;
|
|
732
|
+
color: var(--text-muted);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
.status-summary-header button {
|
|
736
|
+
padding: 4px 8px;
|
|
737
|
+
border-radius: 4px;
|
|
738
|
+
border: 1px solid var(--border);
|
|
739
|
+
background: var(--bg-tertiary);
|
|
740
|
+
color: var(--text-secondary);
|
|
741
|
+
cursor: pointer;
|
|
742
|
+
font-size: 14px;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
.status-summary-header button:hover {
|
|
746
|
+
background: var(--tab-hover);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
.status-summary .active-projects {
|
|
750
|
+
margin-bottom: 8px;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
.status-summary .active-count {
|
|
754
|
+
font-size: 14px;
|
|
755
|
+
color: var(--text-primary);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
.status-summary .active-list {
|
|
759
|
+
margin-top: 4px;
|
|
760
|
+
padding-left: 16px;
|
|
761
|
+
font-size: 13px;
|
|
762
|
+
color: var(--text-secondary);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
.status-summary .active-list li {
|
|
766
|
+
margin: 2px 0;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
.status-summary .completed {
|
|
770
|
+
font-size: 13px;
|
|
771
|
+
color: var(--text-muted);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/* Kanban Grid */
|
|
775
|
+
.kanban-grid {
|
|
776
|
+
width: 100%;
|
|
777
|
+
border-collapse: collapse;
|
|
778
|
+
font-size: 13px;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
.kanban-grid th,
|
|
782
|
+
.kanban-grid td {
|
|
783
|
+
padding: 8px 6px;
|
|
784
|
+
text-align: center;
|
|
785
|
+
border-bottom: 1px solid var(--border);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
.kanban-grid th {
|
|
789
|
+
background: var(--bg-secondary);
|
|
790
|
+
font-size: 10px;
|
|
791
|
+
text-transform: uppercase;
|
|
792
|
+
letter-spacing: 0.5px;
|
|
793
|
+
color: var(--text-muted);
|
|
794
|
+
position: sticky;
|
|
795
|
+
top: 0;
|
|
796
|
+
z-index: 1;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
.kanban-grid th:first-child,
|
|
800
|
+
.kanban-grid td:first-child {
|
|
801
|
+
text-align: left;
|
|
802
|
+
padding-left: 12px;
|
|
803
|
+
width: 40%;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
.kanban-grid th:not(:first-child),
|
|
807
|
+
.kanban-grid td:not(:first-child) {
|
|
808
|
+
width: 8%;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
.kanban-grid tbody tr {
|
|
812
|
+
cursor: default;
|
|
813
|
+
transition: background 0.15s;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
.kanban-grid tbody tr:hover {
|
|
817
|
+
background: var(--bg-secondary);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
.kanban-grid tbody tr:focus {
|
|
821
|
+
outline: 2px solid var(--accent);
|
|
822
|
+
outline-offset: -2px;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
.kanban-grid .project-cell {
|
|
826
|
+
display: flex;
|
|
827
|
+
align-items: center;
|
|
828
|
+
gap: 8px;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
.kanban-grid .project-id {
|
|
832
|
+
font-family: monospace;
|
|
833
|
+
color: var(--text-muted);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
.kanban-grid .project-title {
|
|
837
|
+
overflow: hidden;
|
|
838
|
+
text-overflow: ellipsis;
|
|
839
|
+
white-space: nowrap;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
.kanban-grid .project-cell.clickable {
|
|
843
|
+
cursor: pointer;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
.kanban-grid .project-cell.clickable:hover .project-title {
|
|
847
|
+
text-decoration: underline;
|
|
848
|
+
color: var(--accent);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
.kanban-grid .tick-badge {
|
|
852
|
+
font-size: 10px;
|
|
853
|
+
padding: 1px 4px;
|
|
854
|
+
background: var(--bg-tertiary);
|
|
855
|
+
border-radius: 3px;
|
|
856
|
+
color: var(--text-muted);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/* Stage cell styling */
|
|
860
|
+
.stage-cell {
|
|
861
|
+
font-size: 12px;
|
|
862
|
+
position: relative;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
.stage-cell .checkmark {
|
|
866
|
+
color: #22c55e;
|
|
867
|
+
font-weight: bold;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
.stage-cell .current-indicator {
|
|
871
|
+
display: inline-block;
|
|
872
|
+
width: 12px;
|
|
873
|
+
height: 12px;
|
|
874
|
+
border: 2px solid #f97316;
|
|
875
|
+
border-radius: 50%;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
.stage-cell .celebration {
|
|
879
|
+
font-size: 16px;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
.stage-cell a {
|
|
883
|
+
color: var(--text-primary);
|
|
884
|
+
text-decoration: underline;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/* Arrow between columns */
|
|
888
|
+
.kanban-grid th:not(:first-child):not(:last-child)::after,
|
|
889
|
+
.kanban-grid td.stage-cell:not(:last-child)::after {
|
|
890
|
+
content: '→';
|
|
891
|
+
position: absolute;
|
|
892
|
+
right: -8px;
|
|
893
|
+
color: var(--text-muted);
|
|
894
|
+
font-size: 10px;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/* Projects info header */
|
|
898
|
+
.projects-info {
|
|
899
|
+
background: var(--bg-secondary);
|
|
900
|
+
border: 1px solid var(--border);
|
|
901
|
+
border-radius: 6px;
|
|
902
|
+
padding: 12px 16px;
|
|
903
|
+
margin-bottom: 12px;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
.projects-info p {
|
|
907
|
+
color: var(--text-secondary);
|
|
908
|
+
font-size: 13px;
|
|
909
|
+
margin: 0 0 8px 0;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
.projects-info p:last-child {
|
|
913
|
+
margin-bottom: 0;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
.projects-info strong {
|
|
917
|
+
color: var(--text-primary);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
.projects-info a {
|
|
921
|
+
color: var(--accent);
|
|
922
|
+
text-decoration: none;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
.projects-info a:hover {
|
|
926
|
+
text-decoration: underline;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/* Project details row */
|
|
930
|
+
.project-details-row td {
|
|
931
|
+
padding: 0 !important;
|
|
932
|
+
border-bottom: 1px solid var(--border);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
.project-details-content {
|
|
936
|
+
padding: 16px;
|
|
937
|
+
background: var(--bg-secondary);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
.project-details-content h3 {
|
|
941
|
+
font-size: 16px;
|
|
942
|
+
margin-bottom: 8px;
|
|
943
|
+
color: var(--text-primary);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
.project-details-content p {
|
|
947
|
+
margin-bottom: 8px;
|
|
948
|
+
color: var(--text-secondary);
|
|
949
|
+
font-size: 13px;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
.project-details-content .notes {
|
|
953
|
+
font-style: italic;
|
|
954
|
+
color: var(--text-muted);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
.project-details-links {
|
|
958
|
+
display: flex;
|
|
959
|
+
gap: 8px;
|
|
960
|
+
margin-top: 12px;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
.project-details-links a {
|
|
964
|
+
padding: 4px 10px;
|
|
965
|
+
background: var(--bg-tertiary);
|
|
966
|
+
border: 1px solid var(--border);
|
|
967
|
+
border-radius: 4px;
|
|
968
|
+
color: var(--text-secondary);
|
|
969
|
+
text-decoration: none;
|
|
970
|
+
font-size: 12px;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
.project-details-links a:hover {
|
|
974
|
+
background: var(--tab-hover);
|
|
975
|
+
color: var(--text-primary);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
.project-dependencies {
|
|
979
|
+
margin-top: 8px;
|
|
980
|
+
font-size: 12px;
|
|
981
|
+
color: var(--text-muted);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
.project-ticks {
|
|
985
|
+
margin-top: 8px;
|
|
986
|
+
font-size: 12px;
|
|
987
|
+
display: flex;
|
|
988
|
+
align-items: center;
|
|
989
|
+
gap: 6px;
|
|
990
|
+
flex-wrap: wrap;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
.project-ticks .tick-badge {
|
|
994
|
+
background: #238636;
|
|
995
|
+
color: white;
|
|
996
|
+
padding: 2px 6px;
|
|
997
|
+
border-radius: 3px;
|
|
998
|
+
font-size: 11px;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/* Collapsible project sections */
|
|
1002
|
+
.project-section {
|
|
1003
|
+
border: 1px solid var(--border);
|
|
1004
|
+
border-radius: 6px;
|
|
1005
|
+
background: var(--bg-secondary);
|
|
1006
|
+
margin-bottom: 12px;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
.project-section summary {
|
|
1010
|
+
padding: 12px 16px;
|
|
1011
|
+
cursor: pointer;
|
|
1012
|
+
font-size: 14px;
|
|
1013
|
+
font-weight: 500;
|
|
1014
|
+
color: var(--text-primary);
|
|
1015
|
+
display: flex;
|
|
1016
|
+
align-items: center;
|
|
1017
|
+
gap: 8px;
|
|
1018
|
+
user-select: none;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
.project-section summary:hover {
|
|
1022
|
+
background: var(--bg-tertiary);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
.project-section summary::marker {
|
|
1026
|
+
content: '';
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
.project-section summary::before {
|
|
1030
|
+
content: '▶';
|
|
1031
|
+
font-size: 10px;
|
|
1032
|
+
transition: transform 0.2s;
|
|
1033
|
+
color: var(--text-muted);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
.project-section[open] summary::before {
|
|
1037
|
+
transform: rotate(90deg);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
.project-section .section-count {
|
|
1041
|
+
font-size: 12px;
|
|
1042
|
+
color: var(--text-muted);
|
|
1043
|
+
font-weight: normal;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
.project-section .kanban-grid {
|
|
1047
|
+
margin: 0;
|
|
1048
|
+
border-radius: 0 0 6px 6px;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/* Terminal projects section */
|
|
1052
|
+
.terminal-projects {
|
|
1053
|
+
margin-top: 16px;
|
|
1054
|
+
border: 1px solid var(--border);
|
|
1055
|
+
border-radius: 6px;
|
|
1056
|
+
background: var(--bg-secondary);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
.terminal-projects summary {
|
|
1060
|
+
padding: 12px 16px;
|
|
1061
|
+
cursor: pointer;
|
|
1062
|
+
font-size: 13px;
|
|
1063
|
+
color: var(--text-muted);
|
|
1064
|
+
display: flex;
|
|
1065
|
+
align-items: center;
|
|
1066
|
+
gap: 8px;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
.terminal-projects summary:hover {
|
|
1070
|
+
background: var(--bg-tertiary);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
.terminal-projects summary::marker {
|
|
1074
|
+
content: '';
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
.terminal-projects summary::before {
|
|
1078
|
+
content: '▶';
|
|
1079
|
+
font-size: 10px;
|
|
1080
|
+
transition: transform 0.2s;
|
|
1081
|
+
color: var(--text-muted);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
.terminal-projects[open] summary::before {
|
|
1085
|
+
transform: rotate(90deg);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
.terminal-projects ul {
|
|
1089
|
+
list-style: none;
|
|
1090
|
+
padding: 0 16px 16px;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
.terminal-projects li {
|
|
1094
|
+
padding: 8px 0;
|
|
1095
|
+
border-bottom: 1px solid var(--border);
|
|
1096
|
+
display: flex;
|
|
1097
|
+
gap: 8px;
|
|
1098
|
+
align-items: center;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
.terminal-projects li:last-child {
|
|
1102
|
+
border-bottom: none;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
.terminal-projects .project-abandoned {
|
|
1106
|
+
color: var(--project-abandoned);
|
|
1107
|
+
text-decoration: line-through;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
.terminal-projects .project-on-hold {
|
|
1111
|
+
color: var(--project-on-hold);
|
|
1112
|
+
font-style: italic;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/* Error banner */
|
|
1116
|
+
.projects-error {
|
|
1117
|
+
padding: 16px;
|
|
1118
|
+
background: rgba(239, 68, 68, 0.1);
|
|
1119
|
+
border: 1px solid var(--status-error);
|
|
1120
|
+
border-radius: 6px;
|
|
1121
|
+
display: flex;
|
|
1122
|
+
align-items: center;
|
|
1123
|
+
gap: 12px;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
.projects-error-message {
|
|
1127
|
+
flex: 1;
|
|
1128
|
+
color: var(--text-secondary);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
.projects-error button {
|
|
1132
|
+
padding: 6px 12px;
|
|
1133
|
+
background: var(--bg-tertiary);
|
|
1134
|
+
border: 1px solid var(--border);
|
|
1135
|
+
border-radius: 4px;
|
|
1136
|
+
color: var(--text-secondary);
|
|
1137
|
+
cursor: pointer;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
.projects-error button:hover {
|
|
1141
|
+
background: var(--tab-hover);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/* Stage link styling */
|
|
1145
|
+
.stage-link {
|
|
1146
|
+
text-decoration: none;
|
|
1147
|
+
color: inherit;
|
|
1148
|
+
cursor: pointer;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
.stage-link:hover .stage-indicator {
|
|
1152
|
+
transform: scale(1.2);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/* Projects tab without close button */
|
|
1156
|
+
.tab.tab-uncloseable .close {
|
|
1157
|
+
display: none;
|
|
1158
|
+
}
|
|
1159
|
+
</style>
|
|
1160
|
+
</head>
|
|
1161
|
+
<body>
|
|
1162
|
+
<header class="header">
|
|
1163
|
+
<h1>Agent Farm - {{PROJECT_NAME}}</h1>
|
|
1164
|
+
</header>
|
|
1165
|
+
|
|
1166
|
+
<main class="main">
|
|
1167
|
+
<!-- Left pane: Architect terminal -->
|
|
1168
|
+
<div class="left-pane">
|
|
1169
|
+
<div class="pane-header">
|
|
1170
|
+
<span class="status-dot" id="architect-status"></span>
|
|
1171
|
+
<span>Architect</span>
|
|
1172
|
+
</div>
|
|
1173
|
+
<div id="architect-content"></div>
|
|
1174
|
+
</div>
|
|
1175
|
+
|
|
1176
|
+
<!-- Right pane: Tabbed interface -->
|
|
1177
|
+
<div class="right-pane">
|
|
1178
|
+
<div class="tab-bar">
|
|
1179
|
+
<div class="tabs-scroll" id="tabs-container"></div>
|
|
1180
|
+
<button class="overflow-btn" id="overflow-btn" onclick="toggleOverflowMenu()" aria-haspopup="true" aria-expanded="false" title="Show all tabs">
|
|
1181
|
+
<span>...</span>
|
|
1182
|
+
<span class="overflow-count" id="overflow-count">+0</span>
|
|
1183
|
+
</button>
|
|
1184
|
+
<div class="overflow-menu hidden" id="overflow-menu" role="menu"></div>
|
|
1185
|
+
<div class="add-buttons">
|
|
1186
|
+
<button class="add-btn" onclick="showFileDialog()" title="Open file">+ 📄</button>
|
|
1187
|
+
<button class="add-btn" onclick="spawnBuilder()" title="Spawn worktree builder">+ 🔨</button>
|
|
1188
|
+
<button class="add-btn" onclick="spawnShell()" title="New shell">+ >_</button>
|
|
1189
|
+
</div>
|
|
1190
|
+
</div>
|
|
1191
|
+
<div class="tab-content" id="tab-content"></div>
|
|
1192
|
+
</div>
|
|
1193
|
+
</main>
|
|
1194
|
+
|
|
1195
|
+
<footer class="status-bar">
|
|
1196
|
+
<div class="status-item" id="status-architect">
|
|
1197
|
+
<span class="dot" style="background: var(--text-muted)"></span>
|
|
1198
|
+
<span>Architect: stopped</span>
|
|
1199
|
+
</div>
|
|
1200
|
+
<div class="status-item" id="status-builders">
|
|
1201
|
+
<span>0 builders</span>
|
|
1202
|
+
</div>
|
|
1203
|
+
<div class="status-item" id="status-shells">
|
|
1204
|
+
<span>0 shells</span>
|
|
1205
|
+
</div>
|
|
1206
|
+
<div class="status-item" id="status-files">
|
|
1207
|
+
<span>0 files</span>
|
|
1208
|
+
</div>
|
|
1209
|
+
</footer>
|
|
1210
|
+
|
|
1211
|
+
<!-- File picker dialog -->
|
|
1212
|
+
<div class="dialog-overlay hidden" id="file-dialog">
|
|
1213
|
+
<div class="dialog">
|
|
1214
|
+
<h3>Open File</h3>
|
|
1215
|
+
<div class="quick-paths">
|
|
1216
|
+
<button class="quick-path" onclick="setFilePath('codev/specs/')">codev/specs/</button>
|
|
1217
|
+
<button class="quick-path" onclick="setFilePath('codev/plans/')">codev/plans/</button>
|
|
1218
|
+
<button class="quick-path" onclick="setFilePath('codev/reviews/')">codev/reviews/</button>
|
|
1219
|
+
</div>
|
|
1220
|
+
<input type="text" id="file-path-input" placeholder="Enter file path..." />
|
|
1221
|
+
<div class="dialog-actions">
|
|
1222
|
+
<button class="btn" onclick="hideFileDialog()">Cancel</button>
|
|
1223
|
+
<button class="btn" onclick="openFile()">Open</button>
|
|
1224
|
+
</div>
|
|
1225
|
+
</div>
|
|
1226
|
+
</div>
|
|
1227
|
+
|
|
1228
|
+
<!-- Close confirmation dialog -->
|
|
1229
|
+
<div class="dialog-overlay hidden" id="close-dialog">
|
|
1230
|
+
<div class="dialog">
|
|
1231
|
+
<h3 id="close-dialog-title">Close tab?</h3>
|
|
1232
|
+
<p id="close-dialog-message" style="color: var(--text-secondary); margin-bottom: 16px; font-size: 14px;"></p>
|
|
1233
|
+
<div class="dialog-actions">
|
|
1234
|
+
<button class="btn" onclick="hideCloseDialog()">Cancel</button>
|
|
1235
|
+
<button class="btn btn-danger" onclick="confirmClose()">Close</button>
|
|
1236
|
+
</div>
|
|
1237
|
+
</div>
|
|
1238
|
+
</div>
|
|
1239
|
+
|
|
1240
|
+
<!-- Context menu -->
|
|
1241
|
+
<div class="context-menu hidden" id="context-menu" role="menu">
|
|
1242
|
+
<div class="context-menu-item" role="menuitem" tabindex="0" data-action="openContextTab" onclick="openContextTab()" onkeydown="handleContextMenuKeydown(event)">Open in New Tab</div>
|
|
1243
|
+
<div class="context-menu-item" role="menuitem" tabindex="-1" data-action="closeActiveTab" onclick="closeActiveTab()" onkeydown="handleContextMenuKeydown(event)">Close</div>
|
|
1244
|
+
<div class="context-menu-item" role="menuitem" tabindex="-1" data-action="closeOtherTabs" onclick="closeOtherTabs()" onkeydown="handleContextMenuKeydown(event)">Close Others</div>
|
|
1245
|
+
<div class="context-menu-item danger" role="menuitem" tabindex="-1" data-action="closeAllTabs" onclick="closeAllTabs()" onkeydown="handleContextMenuKeydown(event)">Close All</div>
|
|
1246
|
+
</div>
|
|
1247
|
+
|
|
1248
|
+
<!-- Toast container -->
|
|
1249
|
+
<div class="toast-container" id="toast-container"></div>
|
|
1250
|
+
|
|
1251
|
+
<script>
|
|
1252
|
+
// STATE_INJECTION_POINT
|
|
1253
|
+
|
|
1254
|
+
// State management
|
|
1255
|
+
const state = window.INITIAL_STATE || {
|
|
1256
|
+
architect: null,
|
|
1257
|
+
builders: [],
|
|
1258
|
+
utils: [],
|
|
1259
|
+
annotations: []
|
|
1260
|
+
};
|
|
1261
|
+
|
|
1262
|
+
// Tab state
|
|
1263
|
+
let tabs = [];
|
|
1264
|
+
let activeTabId = null;
|
|
1265
|
+
let pendingCloseTabId = null;
|
|
1266
|
+
let contextMenuTabId = null;
|
|
1267
|
+
|
|
1268
|
+
// Initialize
|
|
1269
|
+
function init() {
|
|
1270
|
+
buildTabsFromState();
|
|
1271
|
+
renderArchitect();
|
|
1272
|
+
renderTabs();
|
|
1273
|
+
renderTabContent();
|
|
1274
|
+
updateStatusBar();
|
|
1275
|
+
startPolling();
|
|
1276
|
+
setupBroadcastChannel();
|
|
1277
|
+
setupOverflowDetection();
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// Set up overflow detection for the tab bar
|
|
1281
|
+
function setupOverflowDetection() {
|
|
1282
|
+
const container = document.getElementById('tabs-container');
|
|
1283
|
+
|
|
1284
|
+
// Check on load
|
|
1285
|
+
checkTabOverflow();
|
|
1286
|
+
|
|
1287
|
+
// Check on window resize (debounced)
|
|
1288
|
+
let resizeTimeout;
|
|
1289
|
+
window.addEventListener('resize', () => {
|
|
1290
|
+
clearTimeout(resizeTimeout);
|
|
1291
|
+
resizeTimeout = setTimeout(checkTabOverflow, 100);
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1294
|
+
// Check on scroll (debounced) - updates +N count when user scrolls tabs
|
|
1295
|
+
if (container) {
|
|
1296
|
+
let scrollTimeout;
|
|
1297
|
+
container.addEventListener('scroll', () => {
|
|
1298
|
+
clearTimeout(scrollTimeout);
|
|
1299
|
+
scrollTimeout = setTimeout(checkTabOverflow, 50);
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
// Also use ResizeObserver for the tabs container if available
|
|
1304
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
1305
|
+
if (container) {
|
|
1306
|
+
const observer = new ResizeObserver(() => {
|
|
1307
|
+
checkTabOverflow();
|
|
1308
|
+
});
|
|
1309
|
+
observer.observe(container);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// Set up BroadcastChannel for cross-tab communication
|
|
1315
|
+
// This allows terminal file clicks to open files in the dashboard
|
|
1316
|
+
function setupBroadcastChannel() {
|
|
1317
|
+
const channel = new BroadcastChannel('agent-farm');
|
|
1318
|
+
channel.onmessage = async (event) => {
|
|
1319
|
+
const { type, path, line } = event.data;
|
|
1320
|
+
if (type === 'openFile' && path) {
|
|
1321
|
+
await openFileFromMessage(path, line);
|
|
1322
|
+
}
|
|
1323
|
+
};
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// Open a file from a BroadcastChannel message
|
|
1327
|
+
async function openFileFromMessage(filePath, lineNumber) {
|
|
1328
|
+
try {
|
|
1329
|
+
// Check if file is already open
|
|
1330
|
+
const existingTab = tabs.find(t => t.type === 'file' && t.path === filePath);
|
|
1331
|
+
if (existingTab) {
|
|
1332
|
+
// Just switch to the existing tab
|
|
1333
|
+
selectTab(existingTab.id);
|
|
1334
|
+
showToast(`Switched to ${getFileName(filePath)}`, 'success');
|
|
1335
|
+
// TODO: scroll to line if lineNumber provided
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// Open the file via API
|
|
1340
|
+
const response = await fetch('/api/tabs/file', {
|
|
1341
|
+
method: 'POST',
|
|
1342
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1343
|
+
body: JSON.stringify({ path: filePath })
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
if (!response.ok) {
|
|
1347
|
+
throw new Error(await response.text());
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
const result = await response.json();
|
|
1351
|
+
|
|
1352
|
+
// Refresh state and switch to the new tab
|
|
1353
|
+
await refresh();
|
|
1354
|
+
|
|
1355
|
+
// Find and select the new file tab
|
|
1356
|
+
const newTab = tabs.find(t => t.type === 'file' && (t.path === filePath || t.annotationId === result.id));
|
|
1357
|
+
if (newTab) {
|
|
1358
|
+
selectTab(newTab.id);
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
showToast(`Opened ${getFileName(filePath)}${lineNumber ? ':' + lineNumber : ''}`, 'success');
|
|
1362
|
+
} catch (err) {
|
|
1363
|
+
showToast('Failed to open file: ' + err.message, 'error');
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// Track known tab IDs to detect new tabs
|
|
1368
|
+
let knownTabIds = new Set();
|
|
1369
|
+
|
|
1370
|
+
// Projects tab state
|
|
1371
|
+
let projectsData = [];
|
|
1372
|
+
let projectlistHash = null;
|
|
1373
|
+
let expandedProjectId = null;
|
|
1374
|
+
let projectlistError = null;
|
|
1375
|
+
let projectlistDebounce = null;
|
|
1376
|
+
|
|
1377
|
+
// Build tabs from initial state
|
|
1378
|
+
function buildTabsFromState() {
|
|
1379
|
+
const previousTabIds = new Set(tabs.map(t => t.id));
|
|
1380
|
+
tabs = [];
|
|
1381
|
+
|
|
1382
|
+
// Projects tab is ALWAYS first and uncloseable (Spec 0045)
|
|
1383
|
+
tabs.push({
|
|
1384
|
+
id: 'projects',
|
|
1385
|
+
type: 'projects',
|
|
1386
|
+
name: 'Projects',
|
|
1387
|
+
closeable: false
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
// Add file tabs from annotations
|
|
1391
|
+
for (const annotation of state.annotations || []) {
|
|
1392
|
+
tabs.push({
|
|
1393
|
+
id: `file-${annotation.id}`,
|
|
1394
|
+
type: 'file',
|
|
1395
|
+
name: getFileName(annotation.file),
|
|
1396
|
+
path: annotation.file,
|
|
1397
|
+
port: annotation.port,
|
|
1398
|
+
annotationId: annotation.id
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// Add builder tabs
|
|
1403
|
+
for (const builder of state.builders || []) {
|
|
1404
|
+
tabs.push({
|
|
1405
|
+
id: `builder-${builder.id}`,
|
|
1406
|
+
type: 'builder',
|
|
1407
|
+
name: builder.name || `Builder ${builder.id}`,
|
|
1408
|
+
projectId: builder.id,
|
|
1409
|
+
port: builder.port,
|
|
1410
|
+
status: builder.status
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// Add shell tabs
|
|
1415
|
+
for (const util of state.utils || []) {
|
|
1416
|
+
tabs.push({
|
|
1417
|
+
id: `shell-${util.id}`,
|
|
1418
|
+
type: 'shell',
|
|
1419
|
+
name: util.name,
|
|
1420
|
+
port: util.port,
|
|
1421
|
+
utilId: util.id
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// Detect new tabs and auto-switch to them (skip projects tab)
|
|
1426
|
+
for (const tab of tabs) {
|
|
1427
|
+
if (tab.id !== 'projects' && !knownTabIds.has(tab.id) && previousTabIds.size > 0) {
|
|
1428
|
+
// This is a new tab - switch to it
|
|
1429
|
+
activeTabId = tab.id;
|
|
1430
|
+
break;
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// Update known tab IDs
|
|
1435
|
+
knownTabIds = new Set(tabs.map(t => t.id));
|
|
1436
|
+
|
|
1437
|
+
// Set active tab to Projects on first load if none selected
|
|
1438
|
+
if (!activeTabId) {
|
|
1439
|
+
activeTabId = 'projects';
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
// Get filename from path
|
|
1444
|
+
function getFileName(path) {
|
|
1445
|
+
const parts = path.split('/');
|
|
1446
|
+
return parts[parts.length - 1];
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// Track current architect port to avoid re-rendering iframe unnecessarily
|
|
1450
|
+
let currentArchitectPort = null;
|
|
1451
|
+
|
|
1452
|
+
// Render architect pane
|
|
1453
|
+
function renderArchitect() {
|
|
1454
|
+
const content = document.getElementById('architect-content');
|
|
1455
|
+
const statusDot = document.getElementById('architect-status');
|
|
1456
|
+
|
|
1457
|
+
if (state.architect && state.architect.port) {
|
|
1458
|
+
statusDot.classList.remove('inactive');
|
|
1459
|
+
// Only update iframe if port changed (avoid flashing on poll)
|
|
1460
|
+
if (currentArchitectPort !== state.architect.port) {
|
|
1461
|
+
currentArchitectPort = state.architect.port;
|
|
1462
|
+
content.innerHTML = `<iframe src="http://localhost:${state.architect.port}" title="Architect Terminal" allow="clipboard-read; clipboard-write"></iframe>`;
|
|
1463
|
+
}
|
|
1464
|
+
} else {
|
|
1465
|
+
if (currentArchitectPort !== null) {
|
|
1466
|
+
currentArchitectPort = null;
|
|
1467
|
+
content.innerHTML = `
|
|
1468
|
+
<div class="architect-placeholder">
|
|
1469
|
+
<p>Architect not running</p>
|
|
1470
|
+
<p>Run <code>agent-farm start</code> to begin</p>
|
|
1471
|
+
</div>
|
|
1472
|
+
`;
|
|
1473
|
+
}
|
|
1474
|
+
statusDot.classList.add('inactive');
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// Render tabs
|
|
1479
|
+
function renderTabs() {
|
|
1480
|
+
const container = document.getElementById('tabs-container');
|
|
1481
|
+
|
|
1482
|
+
if (tabs.length === 0) {
|
|
1483
|
+
container.innerHTML = '';
|
|
1484
|
+
checkTabOverflow(); // Update overflow state when tabs cleared
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
container.innerHTML = tabs.map(tab => {
|
|
1489
|
+
const isActive = tab.id === activeTabId;
|
|
1490
|
+
const icon = getTabIcon(tab.type);
|
|
1491
|
+
const statusDot = tab.type === 'builder' ? getStatusDot(tab.status) : '';
|
|
1492
|
+
const tooltip = getTabTooltip(tab);
|
|
1493
|
+
const isUncloseable = tab.closeable === false;
|
|
1494
|
+
|
|
1495
|
+
return `
|
|
1496
|
+
<div class="tab ${isActive ? 'active' : ''} ${isUncloseable ? 'tab-uncloseable' : ''}"
|
|
1497
|
+
onclick="selectTab('${tab.id}')"
|
|
1498
|
+
oncontextmenu="showContextMenu(event, '${tab.id}')"
|
|
1499
|
+
data-tab-id="${tab.id}"
|
|
1500
|
+
title="${tooltip}">
|
|
1501
|
+
<span class="icon">${icon}</span>
|
|
1502
|
+
<span class="name">${tab.name}</span>
|
|
1503
|
+
${statusDot}
|
|
1504
|
+
${!isUncloseable ? `<span class="close"
|
|
1505
|
+
onclick="event.stopPropagation(); closeTab('${tab.id}', event)"
|
|
1506
|
+
role="button"
|
|
1507
|
+
tabindex="0"
|
|
1508
|
+
aria-label="Close ${tab.name}"
|
|
1509
|
+
onkeydown="if(event.key==='Enter'||event.key===' '){event.stopPropagation();closeTab('${tab.id}',event)}">×</span>` : ''}
|
|
1510
|
+
</div>
|
|
1511
|
+
`;
|
|
1512
|
+
}).join('');
|
|
1513
|
+
|
|
1514
|
+
// Check overflow after tabs are rendered
|
|
1515
|
+
checkTabOverflow();
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// Get tab icon
|
|
1519
|
+
function getTabIcon(type) {
|
|
1520
|
+
switch (type) {
|
|
1521
|
+
case 'projects': return '📋';
|
|
1522
|
+
case 'file': return '📄';
|
|
1523
|
+
case 'builder': return '🔨';
|
|
1524
|
+
case 'shell': return '>_';
|
|
1525
|
+
default: return '?';
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// Status configuration - hoisted for performance (per Codex review)
|
|
1530
|
+
// Colors per spec 0019: green=active, yellow=waiting, red=blocked, gray=complete
|
|
1531
|
+
// Animations per spec 0019: pulse=active, blink-slow=waiting, blink-fast=blocked, static=complete
|
|
1532
|
+
// Shapes for accessibility: circle=default, diamond=blocked, ring=waiting
|
|
1533
|
+
const STATUS_CONFIG = {
|
|
1534
|
+
'spawning': { color: 'var(--status-active)', label: 'Spawning', shape: 'circle', animation: 'pulse' },
|
|
1535
|
+
'implementing': { color: 'var(--status-active)', label: 'Implementing', shape: 'circle', animation: 'pulse' },
|
|
1536
|
+
'blocked': { color: 'var(--status-error)', label: 'Blocked', shape: 'diamond', animation: 'blink-fast' },
|
|
1537
|
+
'pr-ready': { color: 'var(--status-waiting)', label: 'PR Ready', shape: 'ring', animation: 'blink-slow' },
|
|
1538
|
+
'complete': { color: 'var(--status-complete)', label: 'Complete', shape: 'circle', animation: null }
|
|
1539
|
+
};
|
|
1540
|
+
const DEFAULT_STATUS_CONFIG = { color: 'var(--text-muted)', label: 'Unknown', shape: 'circle', animation: null };
|
|
1541
|
+
|
|
1542
|
+
// Get status dot HTML with accessibility support
|
|
1543
|
+
// Accessibility: distinct animations per status, shapes for reduced-motion users
|
|
1544
|
+
// Uses role="img" instead of role="status" to avoid screen reader chatter on poll (per Codex review)
|
|
1545
|
+
function getStatusDot(status) {
|
|
1546
|
+
const config = STATUS_CONFIG[status] || { ...DEFAULT_STATUS_CONFIG, label: status || 'Unknown' };
|
|
1547
|
+
// Build CSS classes for accessibility
|
|
1548
|
+
const classes = ['status-dot'];
|
|
1549
|
+
if (config.shape === 'diamond') classes.push('status-dot--diamond');
|
|
1550
|
+
if (config.shape === 'ring') classes.push('status-dot--ring');
|
|
1551
|
+
if (config.animation === 'pulse') classes.push('status-dot--pulse');
|
|
1552
|
+
if (config.animation === 'blink-slow') classes.push('status-dot--blink-slow');
|
|
1553
|
+
if (config.animation === 'blink-fast') classes.push('status-dot--blink-fast');
|
|
1554
|
+
return `<span class="${classes.join(' ')}" style="background: ${config.color}" title="${config.label}" role="img" aria-label="${config.label}"></span>`;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
// Escape HTML special characters to prevent XSS
|
|
1558
|
+
function escapeHtml(text) {
|
|
1559
|
+
return String(text)
|
|
1560
|
+
.replace(/&/g, '&')
|
|
1561
|
+
.replace(/</g, '<')
|
|
1562
|
+
.replace(/>/g, '>')
|
|
1563
|
+
.replace(/"/g, '"')
|
|
1564
|
+
.replace(/'/g, ''');
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
// Generate tooltip text for tab hover
|
|
1568
|
+
function getTabTooltip(tab) {
|
|
1569
|
+
const lines = [tab.name];
|
|
1570
|
+
|
|
1571
|
+
if (tab.type === 'builder') {
|
|
1572
|
+
if (tab.port) lines.push(`Port: ${tab.port}`);
|
|
1573
|
+
lines.push(`Status: ${tab.status || 'unknown'}`);
|
|
1574
|
+
// Extract project ID from tab id (e.g., "builder-0037" -> "0037")
|
|
1575
|
+
const projectId = tab.id.replace('builder-', '');
|
|
1576
|
+
lines.push(`Worktree: .builders/${projectId}`);
|
|
1577
|
+
} else if (tab.type === 'file') {
|
|
1578
|
+
lines.push(`Path: ${tab.path}`);
|
|
1579
|
+
if (tab.port) lines.push(`Port: ${tab.port}`);
|
|
1580
|
+
} else if (tab.type === 'shell') {
|
|
1581
|
+
if (tab.port) lines.push(`Port: ${tab.port}`);
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
return escapeHtml(lines.join('\n'));
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
// Track current tab content to avoid re-rendering iframe unnecessarily
|
|
1588
|
+
let currentTabPort = null;
|
|
1589
|
+
let currentTabType = null;
|
|
1590
|
+
|
|
1591
|
+
// Render tab content
|
|
1592
|
+
function renderTabContent() {
|
|
1593
|
+
const content = document.getElementById('tab-content');
|
|
1594
|
+
|
|
1595
|
+
if (!activeTabId || tabs.length === 0) {
|
|
1596
|
+
if (currentTabPort !== null || currentTabType !== null) {
|
|
1597
|
+
currentTabPort = null;
|
|
1598
|
+
currentTabType = null;
|
|
1599
|
+
content.innerHTML = `
|
|
1600
|
+
<div class="empty-state">
|
|
1601
|
+
<p>No tabs open</p>
|
|
1602
|
+
<p class="hint">Click the + buttons above or ask the architect to open files/builders</p>
|
|
1603
|
+
</div>
|
|
1604
|
+
`;
|
|
1605
|
+
}
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
const tab = tabs.find(t => t.id === activeTabId);
|
|
1610
|
+
if (!tab) {
|
|
1611
|
+
if (currentTabPort !== null || currentTabType !== null) {
|
|
1612
|
+
currentTabPort = null;
|
|
1613
|
+
currentTabType = null;
|
|
1614
|
+
content.innerHTML = '<div class="empty-state"><p>Tab not found</p></div>';
|
|
1615
|
+
}
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
// Handle projects tab specially (no iframe, inline content)
|
|
1620
|
+
if (tab.type === 'projects') {
|
|
1621
|
+
if (currentTabType !== 'projects') {
|
|
1622
|
+
currentTabType = 'projects';
|
|
1623
|
+
currentTabPort = null;
|
|
1624
|
+
renderProjectsTab();
|
|
1625
|
+
}
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// For other tabs, only update iframe if port changed (avoid flashing on poll)
|
|
1630
|
+
if (currentTabPort !== tab.port || currentTabType !== tab.type) {
|
|
1631
|
+
currentTabPort = tab.port;
|
|
1632
|
+
currentTabType = tab.type;
|
|
1633
|
+
content.innerHTML = `<iframe src="http://localhost:${tab.port}" title="${tab.name}" allow="clipboard-read; clipboard-write"></iframe>`;
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
// Update status bar
|
|
1638
|
+
function updateStatusBar() {
|
|
1639
|
+
// Architect status
|
|
1640
|
+
const archStatus = document.getElementById('status-architect');
|
|
1641
|
+
if (state.architect) {
|
|
1642
|
+
archStatus.innerHTML = `
|
|
1643
|
+
<span class="dot" style="background: var(--status-active)"></span>
|
|
1644
|
+
<span>Architect: running</span>
|
|
1645
|
+
`;
|
|
1646
|
+
} else {
|
|
1647
|
+
archStatus.innerHTML = `
|
|
1648
|
+
<span class="dot" style="background: var(--text-muted)"></span>
|
|
1649
|
+
<span>Architect: stopped</span>
|
|
1650
|
+
`;
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// Counts
|
|
1654
|
+
const builderCount = (state.builders || []).length;
|
|
1655
|
+
const shellCount = (state.utils || []).length;
|
|
1656
|
+
const fileCount = (state.annotations || []).length;
|
|
1657
|
+
|
|
1658
|
+
document.getElementById('status-builders').innerHTML = `<span>${builderCount} builder${builderCount !== 1 ? 's' : ''}</span>`;
|
|
1659
|
+
document.getElementById('status-shells').innerHTML = `<span>${shellCount} shell${shellCount !== 1 ? 's' : ''}</span>`;
|
|
1660
|
+
document.getElementById('status-files').innerHTML = `<span>${fileCount} file${fileCount !== 1 ? 's' : ''}</span>`;
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// Select tab
|
|
1664
|
+
function selectTab(tabId) {
|
|
1665
|
+
activeTabId = tabId;
|
|
1666
|
+
renderTabs();
|
|
1667
|
+
renderTabContent();
|
|
1668
|
+
// Scroll the active tab into view if needed
|
|
1669
|
+
scrollActiveTabIntoView();
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
// Scroll the active tab into view
|
|
1673
|
+
function scrollActiveTabIntoView() {
|
|
1674
|
+
const container = document.getElementById('tabs-container');
|
|
1675
|
+
const activeTab = container.querySelector('.tab.active');
|
|
1676
|
+
if (activeTab) {
|
|
1677
|
+
activeTab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
// Check if tabs are overflowing and update the overflow button
|
|
1682
|
+
function checkTabOverflow() {
|
|
1683
|
+
const container = document.getElementById('tabs-container');
|
|
1684
|
+
const overflowBtn = document.getElementById('overflow-btn');
|
|
1685
|
+
const overflowCount = document.getElementById('overflow-count');
|
|
1686
|
+
|
|
1687
|
+
if (!container || !overflowBtn) return;
|
|
1688
|
+
|
|
1689
|
+
const isOverflowing = container.scrollWidth > container.clientWidth;
|
|
1690
|
+
overflowBtn.style.display = isOverflowing ? 'flex' : 'none';
|
|
1691
|
+
|
|
1692
|
+
if (isOverflowing) {
|
|
1693
|
+
// Count hidden tabs (those partially or fully outside visible area - both sides)
|
|
1694
|
+
const tabElements = container.querySelectorAll('.tab');
|
|
1695
|
+
const containerRect = container.getBoundingClientRect();
|
|
1696
|
+
let hiddenCount = 0;
|
|
1697
|
+
|
|
1698
|
+
tabElements.forEach(tab => {
|
|
1699
|
+
const rect = tab.getBoundingClientRect();
|
|
1700
|
+
// Tab is hidden if scrolled off the right edge
|
|
1701
|
+
if (rect.right > containerRect.right + 1) {
|
|
1702
|
+
hiddenCount++;
|
|
1703
|
+
}
|
|
1704
|
+
// Tab is hidden if scrolled off the left edge
|
|
1705
|
+
else if (rect.left < containerRect.left - 1) {
|
|
1706
|
+
hiddenCount++;
|
|
1707
|
+
}
|
|
1708
|
+
});
|
|
1709
|
+
|
|
1710
|
+
overflowCount.textContent = `+${hiddenCount}`;
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
// Toggle the overflow menu
|
|
1715
|
+
function toggleOverflowMenu() {
|
|
1716
|
+
const menu = document.getElementById('overflow-menu');
|
|
1717
|
+
const btn = document.getElementById('overflow-btn');
|
|
1718
|
+
const isHidden = menu.classList.contains('hidden');
|
|
1719
|
+
|
|
1720
|
+
if (isHidden) {
|
|
1721
|
+
showOverflowMenu();
|
|
1722
|
+
} else {
|
|
1723
|
+
hideOverflowMenu();
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
// Show the overflow menu
|
|
1728
|
+
function showOverflowMenu() {
|
|
1729
|
+
const menu = document.getElementById('overflow-menu');
|
|
1730
|
+
const btn = document.getElementById('overflow-btn');
|
|
1731
|
+
|
|
1732
|
+
// Build menu items for all tabs
|
|
1733
|
+
menu.innerHTML = tabs.map((tab, index) => {
|
|
1734
|
+
const icon = getTabIcon(tab.type);
|
|
1735
|
+
const isActive = tab.id === activeTabId;
|
|
1736
|
+
return `
|
|
1737
|
+
<div class="overflow-menu-item ${isActive ? 'active' : ''}"
|
|
1738
|
+
role="menuitem"
|
|
1739
|
+
tabindex="${index === 0 ? 0 : -1}"
|
|
1740
|
+
data-tab-id="${tab.id}"
|
|
1741
|
+
onclick="selectTabFromMenu('${tab.id}')"
|
|
1742
|
+
onkeydown="handleOverflowMenuKeydown(event, '${tab.id}')">
|
|
1743
|
+
<span class="icon">${icon}</span>
|
|
1744
|
+
<span class="name">${tab.name}</span>
|
|
1745
|
+
<span class="open-external"
|
|
1746
|
+
onclick="event.stopPropagation(); openInNewTabFromMenu('${tab.id}')"
|
|
1747
|
+
onkeydown="if(event.key==='Enter'||event.key===' '){event.stopPropagation();openInNewTabFromMenu('${tab.id}')}"
|
|
1748
|
+
title="Open in new tab"
|
|
1749
|
+
role="button"
|
|
1750
|
+
tabindex="0"
|
|
1751
|
+
aria-label="Open ${tab.name} in new tab">↗</span>
|
|
1752
|
+
</div>
|
|
1753
|
+
`;
|
|
1754
|
+
}).join('');
|
|
1755
|
+
|
|
1756
|
+
menu.classList.remove('hidden');
|
|
1757
|
+
btn.setAttribute('aria-expanded', 'true');
|
|
1758
|
+
|
|
1759
|
+
// Focus the first item
|
|
1760
|
+
const firstItem = menu.querySelector('.overflow-menu-item');
|
|
1761
|
+
if (firstItem) firstItem.focus();
|
|
1762
|
+
|
|
1763
|
+
// Close on click outside (after a small delay to avoid immediate close)
|
|
1764
|
+
setTimeout(() => {
|
|
1765
|
+
document.addEventListener('click', handleOverflowClickOutside);
|
|
1766
|
+
}, 0);
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
// Hide the overflow menu
|
|
1770
|
+
function hideOverflowMenu() {
|
|
1771
|
+
const menu = document.getElementById('overflow-menu');
|
|
1772
|
+
const btn = document.getElementById('overflow-btn');
|
|
1773
|
+
menu.classList.add('hidden');
|
|
1774
|
+
btn.setAttribute('aria-expanded', 'false');
|
|
1775
|
+
document.removeEventListener('click', handleOverflowClickOutside);
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
// Handle click outside overflow menu
|
|
1779
|
+
function handleOverflowClickOutside(event) {
|
|
1780
|
+
const menu = document.getElementById('overflow-menu');
|
|
1781
|
+
const btn = document.getElementById('overflow-btn');
|
|
1782
|
+
if (!menu.contains(event.target) && !btn.contains(event.target)) {
|
|
1783
|
+
hideOverflowMenu();
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
// Select tab from overflow menu
|
|
1788
|
+
function selectTabFromMenu(tabId) {
|
|
1789
|
+
hideOverflowMenu();
|
|
1790
|
+
selectTab(tabId);
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
// Open tab in new window from overflow menu
|
|
1794
|
+
function openInNewTabFromMenu(tabId) {
|
|
1795
|
+
hideOverflowMenu();
|
|
1796
|
+
openInNewTab(tabId);
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
// Handle keyboard navigation in overflow menu
|
|
1800
|
+
function handleOverflowMenuKeydown(event, tabId) {
|
|
1801
|
+
const menu = document.getElementById('overflow-menu');
|
|
1802
|
+
const items = Array.from(menu.querySelectorAll('.overflow-menu-item'));
|
|
1803
|
+
const currentIndex = items.findIndex(item => item === document.activeElement);
|
|
1804
|
+
|
|
1805
|
+
switch (event.key) {
|
|
1806
|
+
case 'ArrowDown':
|
|
1807
|
+
event.preventDefault();
|
|
1808
|
+
const nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
|
|
1809
|
+
items[nextIndex].focus();
|
|
1810
|
+
break;
|
|
1811
|
+
case 'ArrowUp':
|
|
1812
|
+
event.preventDefault();
|
|
1813
|
+
const prevIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
|
|
1814
|
+
items[prevIndex].focus();
|
|
1815
|
+
break;
|
|
1816
|
+
case 'Enter':
|
|
1817
|
+
case ' ':
|
|
1818
|
+
event.preventDefault();
|
|
1819
|
+
selectTabFromMenu(tabId);
|
|
1820
|
+
break;
|
|
1821
|
+
case 'Escape':
|
|
1822
|
+
event.preventDefault();
|
|
1823
|
+
hideOverflowMenu();
|
|
1824
|
+
document.getElementById('overflow-btn').focus();
|
|
1825
|
+
break;
|
|
1826
|
+
case 'Tab':
|
|
1827
|
+
// Allow Tab to close menu and move focus
|
|
1828
|
+
hideOverflowMenu();
|
|
1829
|
+
break;
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
// Close tab
|
|
1834
|
+
function closeTab(tabId, event) {
|
|
1835
|
+
const tab = tabs.find(t => t.id === tabId);
|
|
1836
|
+
if (!tab) return;
|
|
1837
|
+
|
|
1838
|
+
// Shift+click bypasses confirmation
|
|
1839
|
+
if (event && event.shiftKey) {
|
|
1840
|
+
doCloseTab(tabId);
|
|
1841
|
+
return;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
// Files don't need confirmation
|
|
1845
|
+
if (tab.type === 'file') {
|
|
1846
|
+
doCloseTab(tabId);
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
// Show confirmation for builders and shells
|
|
1851
|
+
pendingCloseTabId = tabId;
|
|
1852
|
+
const dialog = document.getElementById('close-dialog');
|
|
1853
|
+
const title = document.getElementById('close-dialog-title');
|
|
1854
|
+
const message = document.getElementById('close-dialog-message');
|
|
1855
|
+
|
|
1856
|
+
if (tab.type === 'builder') {
|
|
1857
|
+
title.textContent = `Stop builder ${tab.name}?`;
|
|
1858
|
+
message.textContent = 'This will terminate the builder process.';
|
|
1859
|
+
} else {
|
|
1860
|
+
title.textContent = `Close shell ${tab.name}?`;
|
|
1861
|
+
message.textContent = 'This will terminate the shell process.';
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
dialog.classList.remove('hidden');
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
// Actually close the tab
|
|
1868
|
+
async function doCloseTab(tabId) {
|
|
1869
|
+
const tab = tabs.find(t => t.id === tabId);
|
|
1870
|
+
if (!tab) return;
|
|
1871
|
+
|
|
1872
|
+
try {
|
|
1873
|
+
// Call API to close the tab
|
|
1874
|
+
await fetch(`/api/tabs/${encodeURIComponent(tabId)}`, { method: 'DELETE' });
|
|
1875
|
+
|
|
1876
|
+
// Remove from local state
|
|
1877
|
+
tabs = tabs.filter(t => t.id !== tabId);
|
|
1878
|
+
|
|
1879
|
+
// If closing active tab, switch to another
|
|
1880
|
+
if (activeTabId === tabId) {
|
|
1881
|
+
activeTabId = tabs.length > 0 ? tabs[tabs.length - 1].id : null;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
renderTabs();
|
|
1885
|
+
renderTabContent();
|
|
1886
|
+
showToast('Tab closed', 'success');
|
|
1887
|
+
} catch (err) {
|
|
1888
|
+
showToast('Failed to close tab: ' + err.message, 'error');
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
// Confirm close from dialog
|
|
1893
|
+
function confirmClose() {
|
|
1894
|
+
if (pendingCloseTabId) {
|
|
1895
|
+
doCloseTab(pendingCloseTabId);
|
|
1896
|
+
hideCloseDialog();
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
function hideCloseDialog() {
|
|
1901
|
+
document.getElementById('close-dialog').classList.add('hidden');
|
|
1902
|
+
pendingCloseTabId = null;
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
// Context menu
|
|
1906
|
+
function showContextMenu(event, tabId) {
|
|
1907
|
+
event.preventDefault();
|
|
1908
|
+
contextMenuTabId = tabId;
|
|
1909
|
+
|
|
1910
|
+
const menu = document.getElementById('context-menu');
|
|
1911
|
+
menu.style.left = event.clientX + 'px';
|
|
1912
|
+
menu.style.top = event.clientY + 'px';
|
|
1913
|
+
menu.classList.remove('hidden');
|
|
1914
|
+
|
|
1915
|
+
// Focus first item for keyboard navigation
|
|
1916
|
+
const firstItem = menu.querySelector('.context-menu-item');
|
|
1917
|
+
if (firstItem) firstItem.focus();
|
|
1918
|
+
|
|
1919
|
+
// Close on click outside
|
|
1920
|
+
setTimeout(() => {
|
|
1921
|
+
document.addEventListener('click', hideContextMenu, { once: true });
|
|
1922
|
+
}, 0);
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
function hideContextMenu() {
|
|
1926
|
+
document.getElementById('context-menu').classList.add('hidden');
|
|
1927
|
+
contextMenuTabId = null;
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
// Handle keyboard navigation in context menu
|
|
1931
|
+
function handleContextMenuKeydown(event) {
|
|
1932
|
+
const menu = document.getElementById('context-menu');
|
|
1933
|
+
const items = Array.from(menu.querySelectorAll('.context-menu-item'));
|
|
1934
|
+
const currentIndex = items.findIndex(item => item === document.activeElement);
|
|
1935
|
+
|
|
1936
|
+
switch (event.key) {
|
|
1937
|
+
case 'ArrowDown':
|
|
1938
|
+
event.preventDefault();
|
|
1939
|
+
const nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
|
|
1940
|
+
items[nextIndex].focus();
|
|
1941
|
+
break;
|
|
1942
|
+
case 'ArrowUp':
|
|
1943
|
+
event.preventDefault();
|
|
1944
|
+
const prevIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
|
|
1945
|
+
items[prevIndex].focus();
|
|
1946
|
+
break;
|
|
1947
|
+
case 'Enter':
|
|
1948
|
+
case ' ':
|
|
1949
|
+
event.preventDefault();
|
|
1950
|
+
const actionName = event.target.dataset.action;
|
|
1951
|
+
if (actionName && typeof window[actionName] === 'function') {
|
|
1952
|
+
window[actionName]();
|
|
1953
|
+
}
|
|
1954
|
+
break;
|
|
1955
|
+
case 'Escape':
|
|
1956
|
+
event.preventDefault();
|
|
1957
|
+
hideContextMenu();
|
|
1958
|
+
break;
|
|
1959
|
+
case 'Tab':
|
|
1960
|
+
hideContextMenu();
|
|
1961
|
+
break;
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
function closeActiveTab() {
|
|
1966
|
+
if (contextMenuTabId) {
|
|
1967
|
+
closeTab(contextMenuTabId);
|
|
1968
|
+
}
|
|
1969
|
+
hideContextMenu();
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
function closeOtherTabs() {
|
|
1973
|
+
if (contextMenuTabId) {
|
|
1974
|
+
// Skip uncloseable tabs (Projects tab)
|
|
1975
|
+
const otherTabs = tabs.filter(t => t.id !== contextMenuTabId && t.closeable !== false);
|
|
1976
|
+
otherTabs.forEach(t => doCloseTab(t.id));
|
|
1977
|
+
}
|
|
1978
|
+
hideContextMenu();
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
function closeAllTabs() {
|
|
1982
|
+
// Skip uncloseable tabs (Projects tab)
|
|
1983
|
+
tabs.filter(t => t.closeable !== false).forEach(t => doCloseTab(t.id));
|
|
1984
|
+
hideContextMenu();
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
// Open tab content in a new browser tab
|
|
1988
|
+
function openInNewTab(tabId) {
|
|
1989
|
+
const tab = tabs.find(t => t.id === tabId);
|
|
1990
|
+
if (!tab) return;
|
|
1991
|
+
|
|
1992
|
+
let url;
|
|
1993
|
+
if (tab.type === 'file') {
|
|
1994
|
+
// File tabs use the annotation port
|
|
1995
|
+
if (!tab.port) {
|
|
1996
|
+
showToast('Tab not ready', 'error');
|
|
1997
|
+
return;
|
|
1998
|
+
}
|
|
1999
|
+
url = `http://localhost:${tab.port}`;
|
|
2000
|
+
} else {
|
|
2001
|
+
// Builder or shell - direct port access
|
|
2002
|
+
if (!tab.port) {
|
|
2003
|
+
showToast('Tab not ready', 'error');
|
|
2004
|
+
return;
|
|
2005
|
+
}
|
|
2006
|
+
url = `http://localhost:${tab.port}`;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
window.open(url, '_blank', 'noopener,noreferrer');
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
// Open context menu tab in new tab
|
|
2013
|
+
function openContextTab() {
|
|
2014
|
+
if (contextMenuTabId) {
|
|
2015
|
+
openInNewTab(contextMenuTabId);
|
|
2016
|
+
}
|
|
2017
|
+
hideContextMenu();
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
// File dialog
|
|
2021
|
+
function showFileDialog() {
|
|
2022
|
+
document.getElementById('file-dialog').classList.remove('hidden');
|
|
2023
|
+
document.getElementById('file-path-input').focus();
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
function hideFileDialog() {
|
|
2027
|
+
document.getElementById('file-dialog').classList.add('hidden');
|
|
2028
|
+
document.getElementById('file-path-input').value = '';
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
function setFilePath(path) {
|
|
2032
|
+
document.getElementById('file-path-input').value = path;
|
|
2033
|
+
document.getElementById('file-path-input').focus();
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
async function openFile() {
|
|
2037
|
+
const path = document.getElementById('file-path-input').value.trim();
|
|
2038
|
+
if (!path) return;
|
|
2039
|
+
|
|
2040
|
+
try {
|
|
2041
|
+
const response = await fetch('/api/tabs/file', {
|
|
2042
|
+
method: 'POST',
|
|
2043
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2044
|
+
body: JSON.stringify({ path })
|
|
2045
|
+
});
|
|
2046
|
+
|
|
2047
|
+
if (!response.ok) {
|
|
2048
|
+
throw new Error(await response.text());
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
hideFileDialog();
|
|
2052
|
+
await refresh();
|
|
2053
|
+
showToast(`Opened ${path}`, 'success');
|
|
2054
|
+
} catch (err) {
|
|
2055
|
+
showToast('Failed to open file: ' + err.message, 'error');
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
// Spawn worktree builder (no dialog - spawns with random ID)
|
|
2060
|
+
async function spawnBuilder() {
|
|
2061
|
+
try {
|
|
2062
|
+
const response = await fetch('/api/tabs/builder', {
|
|
2063
|
+
method: 'POST',
|
|
2064
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2065
|
+
body: JSON.stringify({})
|
|
2066
|
+
});
|
|
2067
|
+
|
|
2068
|
+
if (!response.ok) {
|
|
2069
|
+
throw new Error(await response.text());
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
const result = await response.json();
|
|
2073
|
+
|
|
2074
|
+
// Add to local tabs and select it
|
|
2075
|
+
const newTab = {
|
|
2076
|
+
id: `builder-${result.id}`,
|
|
2077
|
+
type: 'builder',
|
|
2078
|
+
name: result.name,
|
|
2079
|
+
port: result.port
|
|
2080
|
+
};
|
|
2081
|
+
tabs.push(newTab);
|
|
2082
|
+
activeTabId = newTab.id;
|
|
2083
|
+
renderTabs();
|
|
2084
|
+
renderTabContent();
|
|
2085
|
+
showToast(`Builder ${result.name} spawned`, 'success');
|
|
2086
|
+
} catch (err) {
|
|
2087
|
+
showToast('Failed to spawn builder: ' + err.message, 'error');
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
// Spawn shell
|
|
2092
|
+
async function spawnShell() {
|
|
2093
|
+
try {
|
|
2094
|
+
const response = await fetch('/api/tabs/shell', {
|
|
2095
|
+
method: 'POST',
|
|
2096
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2097
|
+
body: JSON.stringify({})
|
|
2098
|
+
});
|
|
2099
|
+
|
|
2100
|
+
if (!response.ok) {
|
|
2101
|
+
throw new Error(await response.text());
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
const result = await response.json();
|
|
2105
|
+
|
|
2106
|
+
// Add to local tabs and select it
|
|
2107
|
+
const newTab = {
|
|
2108
|
+
id: `shell-${result.id}`,
|
|
2109
|
+
type: 'shell',
|
|
2110
|
+
name: result.name,
|
|
2111
|
+
port: result.port,
|
|
2112
|
+
utilId: result.id,
|
|
2113
|
+
pendingLoad: true // Mark as pending to delay iframe
|
|
2114
|
+
};
|
|
2115
|
+
tabs.push(newTab);
|
|
2116
|
+
activeTabId = newTab.id;
|
|
2117
|
+
renderTabs();
|
|
2118
|
+
|
|
2119
|
+
// Show loading state, then load iframe after delay
|
|
2120
|
+
const content = document.getElementById('tab-content');
|
|
2121
|
+
content.innerHTML = '<div class="empty-state"><p>Starting shell...</p></div>';
|
|
2122
|
+
|
|
2123
|
+
setTimeout(() => {
|
|
2124
|
+
delete newTab.pendingLoad;
|
|
2125
|
+
currentTabPort = null; // Force re-render
|
|
2126
|
+
renderTabContent();
|
|
2127
|
+
}, 800);
|
|
2128
|
+
|
|
2129
|
+
showToast('Shell spawned', 'success');
|
|
2130
|
+
} catch (err) {
|
|
2131
|
+
showToast('Failed to spawn shell: ' + err.message, 'error');
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
// Refresh state from API
|
|
2136
|
+
async function refresh() {
|
|
2137
|
+
try {
|
|
2138
|
+
const response = await fetch('/api/state');
|
|
2139
|
+
if (!response.ok) throw new Error('Failed to fetch state');
|
|
2140
|
+
|
|
2141
|
+
const newState = await response.json();
|
|
2142
|
+
Object.assign(state, newState);
|
|
2143
|
+
|
|
2144
|
+
buildTabsFromState();
|
|
2145
|
+
renderArchitect();
|
|
2146
|
+
renderTabs();
|
|
2147
|
+
renderTabContent();
|
|
2148
|
+
updateStatusBar();
|
|
2149
|
+
} catch (err) {
|
|
2150
|
+
console.error('Refresh error:', err);
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
// Toast notifications
|
|
2155
|
+
function showToast(message, type = 'info') {
|
|
2156
|
+
const container = document.getElementById('toast-container');
|
|
2157
|
+
const toast = document.createElement('div');
|
|
2158
|
+
toast.className = `toast ${type}`;
|
|
2159
|
+
toast.textContent = message;
|
|
2160
|
+
container.appendChild(toast);
|
|
2161
|
+
|
|
2162
|
+
setTimeout(() => {
|
|
2163
|
+
toast.remove();
|
|
2164
|
+
}, 3000);
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
// Polling for state updates
|
|
2168
|
+
let pollInterval = null;
|
|
2169
|
+
|
|
2170
|
+
function startPolling() {
|
|
2171
|
+
pollInterval = setInterval(refresh, 1000);
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
function stopPolling() {
|
|
2175
|
+
if (pollInterval) {
|
|
2176
|
+
clearInterval(pollInterval);
|
|
2177
|
+
pollInterval = null;
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
// Keyboard shortcuts
|
|
2182
|
+
document.addEventListener('keydown', (e) => {
|
|
2183
|
+
// Escape to close dialogs and menus
|
|
2184
|
+
if (e.key === 'Escape') {
|
|
2185
|
+
hideFileDialog();
|
|
2186
|
+
hideCloseDialog();
|
|
2187
|
+
hideContextMenu();
|
|
2188
|
+
hideOverflowMenu();
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
// Enter in dialogs
|
|
2192
|
+
if (e.key === 'Enter') {
|
|
2193
|
+
if (!document.getElementById('file-dialog').classList.contains('hidden')) {
|
|
2194
|
+
openFile();
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
// Ctrl+Tab / Ctrl+Shift+Tab to switch tabs
|
|
2199
|
+
if (e.ctrlKey && e.key === 'Tab') {
|
|
2200
|
+
e.preventDefault();
|
|
2201
|
+
if (tabs.length < 2) return;
|
|
2202
|
+
|
|
2203
|
+
const currentIndex = tabs.findIndex(t => t.id === activeTabId);
|
|
2204
|
+
let newIndex;
|
|
2205
|
+
|
|
2206
|
+
if (e.shiftKey) {
|
|
2207
|
+
newIndex = currentIndex <= 0 ? tabs.length - 1 : currentIndex - 1;
|
|
2208
|
+
} else {
|
|
2209
|
+
newIndex = currentIndex >= tabs.length - 1 ? 0 : currentIndex + 1;
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
selectTab(tabs[newIndex].id);
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
// Ctrl+W to close current tab
|
|
2216
|
+
if (e.ctrlKey && e.key === 'w') {
|
|
2217
|
+
e.preventDefault();
|
|
2218
|
+
if (activeTabId) {
|
|
2219
|
+
closeTab(activeTabId, e);
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
});
|
|
2223
|
+
|
|
2224
|
+
// ============================================
|
|
2225
|
+
// Projects Tab Functions (Spec 0045)
|
|
2226
|
+
// ============================================
|
|
2227
|
+
|
|
2228
|
+
// XSS-safe HTML escaping (used by escapeHtml above, same implementation)
|
|
2229
|
+
function escapeProjectHtml(text) {
|
|
2230
|
+
if (!text) return '';
|
|
2231
|
+
const div = document.createElement('div');
|
|
2232
|
+
div.textContent = String(text);
|
|
2233
|
+
return div.innerHTML;
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
// Simple DJB2 hash for change detection
|
|
2237
|
+
function hashString(str) {
|
|
2238
|
+
let hash = 5381;
|
|
2239
|
+
for (let i = 0; i < str.length; i++) {
|
|
2240
|
+
hash = ((hash << 5) + hash) + str.charCodeAt(i);
|
|
2241
|
+
}
|
|
2242
|
+
return hash >>> 0;
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
// Parse a single project entry from YAML-like text
|
|
2246
|
+
function parseProjectEntry(text) {
|
|
2247
|
+
const project = {};
|
|
2248
|
+
const lines = text.split('\n');
|
|
2249
|
+
|
|
2250
|
+
for (const line of lines) {
|
|
2251
|
+
// Match key: value or key: "value"
|
|
2252
|
+
// Also handle "- id:" YAML list format
|
|
2253
|
+
const match = line.match(/^\s*-?\s*(\w+):\s*(.*)$/);
|
|
2254
|
+
if (!match) continue;
|
|
2255
|
+
|
|
2256
|
+
const [, key, rawValue] = match;
|
|
2257
|
+
// Remove quotes if present
|
|
2258
|
+
let value = rawValue.trim();
|
|
2259
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
2260
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
2261
|
+
value = value.slice(1, -1);
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
// Handle nested files object
|
|
2265
|
+
if (key === 'files') {
|
|
2266
|
+
project.files = {};
|
|
2267
|
+
continue;
|
|
2268
|
+
}
|
|
2269
|
+
if (key === 'spec' || key === 'plan' || key === 'review') {
|
|
2270
|
+
if (!project.files) project.files = {};
|
|
2271
|
+
project.files[key] = value === 'null' ? null : value;
|
|
2272
|
+
continue;
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
// Handle nested timestamps object
|
|
2276
|
+
if (key === 'timestamps') {
|
|
2277
|
+
project.timestamps = {};
|
|
2278
|
+
continue;
|
|
2279
|
+
}
|
|
2280
|
+
const timestampFields = ['conceived_at', 'specified_at', 'planned_at',
|
|
2281
|
+
'implementing_at', 'implemented_at', 'committed_at', 'integrated_at'];
|
|
2282
|
+
if (timestampFields.includes(key)) {
|
|
2283
|
+
if (!project.timestamps) project.timestamps = {};
|
|
2284
|
+
project.timestamps[key] = value === 'null' ? null : value;
|
|
2285
|
+
continue;
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
// Handle arrays (simple inline format)
|
|
2289
|
+
if (key === 'dependencies' || key === 'tags' || key === 'ticks') {
|
|
2290
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
2291
|
+
const inner = value.slice(1, -1);
|
|
2292
|
+
if (inner.trim() === '') {
|
|
2293
|
+
project[key] = [];
|
|
2294
|
+
} else {
|
|
2295
|
+
project[key] = inner.split(',').map(s => s.trim().replace(/^["']|["']$/g, ''));
|
|
2296
|
+
}
|
|
2297
|
+
} else {
|
|
2298
|
+
project[key] = [];
|
|
2299
|
+
}
|
|
2300
|
+
continue;
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
// Regular string values
|
|
2304
|
+
if (value !== 'null') {
|
|
2305
|
+
project[key] = value;
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
return project;
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
// Validate that a project entry is valid
|
|
2313
|
+
function isValidProject(project) {
|
|
2314
|
+
// Must have id (4-digit string, not "NNNN")
|
|
2315
|
+
if (!project.id || project.id === 'NNNN' || !/^\d{4}$/.test(project.id)) {
|
|
2316
|
+
return false;
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
// Must have status
|
|
2320
|
+
const validStatuses = ['conceived', 'specified', 'planned', 'implementing',
|
|
2321
|
+
'implemented', 'committed', 'integrated', 'abandoned', 'on-hold'];
|
|
2322
|
+
if (!project.status || !validStatuses.includes(project.status)) {
|
|
2323
|
+
return false;
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
// Must have title
|
|
2327
|
+
if (!project.title) {
|
|
2328
|
+
return false;
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
// Filter out example entries
|
|
2332
|
+
if (project.tags && project.tags.includes('example')) {
|
|
2333
|
+
return false;
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
return true;
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
// Parse projectlist.md content into array of projects
|
|
2340
|
+
function parseProjectlist(content) {
|
|
2341
|
+
const projects = [];
|
|
2342
|
+
|
|
2343
|
+
try {
|
|
2344
|
+
// Extract YAML code blocks
|
|
2345
|
+
const yamlBlockRegex = /```yaml\n([\s\S]*?)```/g;
|
|
2346
|
+
let match;
|
|
2347
|
+
|
|
2348
|
+
while ((match = yamlBlockRegex.exec(content)) !== null) {
|
|
2349
|
+
const block = match[1];
|
|
2350
|
+
|
|
2351
|
+
// Split by project entries (lines starting with " - id:")
|
|
2352
|
+
// Handle both top-level and indented entries
|
|
2353
|
+
const projectMatches = block.split(/\n(?=\s*- id:)/);
|
|
2354
|
+
|
|
2355
|
+
for (const projectText of projectMatches) {
|
|
2356
|
+
if (!projectText.trim() || !projectText.includes('id:')) continue;
|
|
2357
|
+
|
|
2358
|
+
const project = parseProjectEntry(projectText);
|
|
2359
|
+
if (isValidProject(project)) {
|
|
2360
|
+
projects.push(project);
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
} catch (err) {
|
|
2365
|
+
console.error('Error parsing projectlist:', err);
|
|
2366
|
+
return [];
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
return projects;
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
// Render the welcome screen for new users
|
|
2373
|
+
function renderWelcomeScreen() {
|
|
2374
|
+
return `
|
|
2375
|
+
<div class="projects-welcome">
|
|
2376
|
+
<h2>Welcome to Codev</h2>
|
|
2377
|
+
<p>Codev helps you build software with AI assistance. Projects flow through 7 stages from idea to production:</p>
|
|
2378
|
+
<ol>
|
|
2379
|
+
<li><strong>Conceived</strong> - Tell the architect what you want to build</li>
|
|
2380
|
+
<li><strong>Specified</strong> - AI writes a spec, you approve it</li>
|
|
2381
|
+
<li><strong>Planned</strong> - AI creates an implementation plan</li>
|
|
2382
|
+
<li><strong>Implementing</strong> - Builder AI writes the code</li>
|
|
2383
|
+
<li><strong>Implemented</strong> - Code complete, PR ready for review</li>
|
|
2384
|
+
<li><strong>Committed</strong> - PR merged to main</li>
|
|
2385
|
+
<li><strong>Integrated</strong> - Validated in production</li>
|
|
2386
|
+
</ol>
|
|
2387
|
+
<hr>
|
|
2388
|
+
<p class="quick-tip">
|
|
2389
|
+
<strong>Quick tip:</strong> Say "I want to build a [feature]" and the architect will guide you through the process.
|
|
2390
|
+
</p>
|
|
2391
|
+
</div>
|
|
2392
|
+
`;
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
// Render the error banner
|
|
2396
|
+
function renderErrorBanner(message) {
|
|
2397
|
+
return `
|
|
2398
|
+
<div class="projects-error">
|
|
2399
|
+
<span class="projects-error-message">${escapeProjectHtml(message)}</span>
|
|
2400
|
+
<button onclick="reloadProjectlist()">Retry</button>
|
|
2401
|
+
</div>
|
|
2402
|
+
`;
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
// Group projects by status for summary
|
|
2406
|
+
function groupByStatus(projects, statuses) {
|
|
2407
|
+
const groups = {};
|
|
2408
|
+
for (const status of statuses) {
|
|
2409
|
+
groups[status] = projects.filter(p => p.status === status);
|
|
2410
|
+
}
|
|
2411
|
+
return groups;
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
// Render the status summary section
|
|
2415
|
+
function renderStatusSummary(projects) {
|
|
2416
|
+
const activeStatuses = ['conceived', 'specified', 'planned', 'implementing', 'implemented', 'committed'];
|
|
2417
|
+
const active = projects.filter(p => activeStatuses.includes(p.status));
|
|
2418
|
+
const completed = projects.filter(p => p.status === 'integrated');
|
|
2419
|
+
const byStatus = groupByStatus(active, activeStatuses);
|
|
2420
|
+
|
|
2421
|
+
const activeListItems = [];
|
|
2422
|
+
for (const status of activeStatuses) {
|
|
2423
|
+
const statusProjects = byStatus[status] || [];
|
|
2424
|
+
if (statusProjects.length > 0) {
|
|
2425
|
+
const names = statusProjects.slice(0, 3).map(p => `${p.id} ${p.title}`).join(', ');
|
|
2426
|
+
const more = statusProjects.length > 3 ? ` (+${statusProjects.length - 3} more)` : '';
|
|
2427
|
+
activeListItems.push(`<li>${statusProjects.length} ${status}: ${escapeProjectHtml(names)}${more}</li>`);
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
return `
|
|
2432
|
+
<div class="status-summary">
|
|
2433
|
+
<div class="status-summary-header">
|
|
2434
|
+
<span>Status Summary</span>
|
|
2435
|
+
<button onclick="reloadProjectlist()" title="Reload">↻</button>
|
|
2436
|
+
</div>
|
|
2437
|
+
<div class="active-projects">
|
|
2438
|
+
<span class="active-count">Active: ${active.length} project${active.length !== 1 ? 's' : ''}</span>
|
|
2439
|
+
${activeListItems.length > 0 ? `<ul class="active-list">${activeListItems.join('')}</ul>` : ''}
|
|
2440
|
+
</div>
|
|
2441
|
+
<div class="completed">Completed: ${completed.length} integrated</div>
|
|
2442
|
+
</div>
|
|
2443
|
+
`;
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
// Get the lifecycle stages in order
|
|
2447
|
+
const LIFECYCLE_STAGES = ['conceived', 'specified', 'planned', 'implementing', 'implemented', 'committed', 'integrated'];
|
|
2448
|
+
|
|
2449
|
+
// Abbreviated column headers
|
|
2450
|
+
const STAGE_HEADERS = {
|
|
2451
|
+
'conceived': "CONC'D",
|
|
2452
|
+
'specified': "SPEC'D",
|
|
2453
|
+
'planned': 'PLANNED',
|
|
2454
|
+
'implementing': 'IMPLING',
|
|
2455
|
+
'implemented': 'IMPLED',
|
|
2456
|
+
'committed': 'CMTD',
|
|
2457
|
+
'integrated': "INTGR'D"
|
|
2458
|
+
};
|
|
2459
|
+
|
|
2460
|
+
// Stage tooltips explaining purpose and exit criteria
|
|
2461
|
+
const STAGE_TOOLTIPS = {
|
|
2462
|
+
'conceived': "CONCEIVED: Idea has been captured.\nExit: Human approves the specification.",
|
|
2463
|
+
'specified': "SPECIFIED: Human approved the spec.\nExit: Architect creates an implementation plan.",
|
|
2464
|
+
'planned': "PLANNED: Implementation plan is ready.\nExit: Architect spawns a Builder.",
|
|
2465
|
+
'implementing': "IMPLEMENTING: Builder is working on the code.\nExit: Builder creates a PR.",
|
|
2466
|
+
'implemented': "IMPLEMENTED: PR is ready for review.\nExit: Builder merges after Architect review.",
|
|
2467
|
+
'committed': "COMMITTED: PR has been merged.\nExit: Human validates in production.",
|
|
2468
|
+
'integrated': "INTEGRATED: Validated in production.\nThis is the goal state."
|
|
2469
|
+
};
|
|
2470
|
+
|
|
2471
|
+
// Get stage index (for comparison)
|
|
2472
|
+
function getStageIndex(status) {
|
|
2473
|
+
return LIFECYCLE_STAGES.indexOf(status);
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
// Get the label and link for a stage cell
|
|
2477
|
+
function getStageCellContent(project, stage) {
|
|
2478
|
+
switch (stage) {
|
|
2479
|
+
case 'specified':
|
|
2480
|
+
if (project.files && project.files.spec) {
|
|
2481
|
+
return { label: 'Spec', link: project.files.spec };
|
|
2482
|
+
}
|
|
2483
|
+
return { label: '', link: null };
|
|
2484
|
+
case 'planned':
|
|
2485
|
+
if (project.files && project.files.plan) {
|
|
2486
|
+
return { label: 'Plan', link: project.files.plan };
|
|
2487
|
+
}
|
|
2488
|
+
return { label: '', link: null };
|
|
2489
|
+
case 'implemented':
|
|
2490
|
+
if (project.files && project.files.review) {
|
|
2491
|
+
return { label: 'Revw', link: project.files.review };
|
|
2492
|
+
}
|
|
2493
|
+
return { label: '', link: null };
|
|
2494
|
+
case 'committed':
|
|
2495
|
+
// PR link from notes (format: "PR #N merged")
|
|
2496
|
+
if (project.notes) {
|
|
2497
|
+
const prMatch = project.notes.match(/PR\s*#?(\d+)/i);
|
|
2498
|
+
if (prMatch) {
|
|
2499
|
+
return { label: 'PR', link: `https://github.com/cluesmith/codev/pull/${prMatch[1]}`, external: true };
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
return { label: '', link: null };
|
|
2503
|
+
default:
|
|
2504
|
+
return { label: '', link: null };
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
// Render a stage cell with appropriate styling
|
|
2509
|
+
function renderStageCell(project, stage) {
|
|
2510
|
+
const currentIndex = getStageIndex(project.status);
|
|
2511
|
+
const stageIndex = getStageIndex(stage);
|
|
2512
|
+
|
|
2513
|
+
let cellClass = 'stage-cell';
|
|
2514
|
+
let content = '';
|
|
2515
|
+
let ariaLabel = '';
|
|
2516
|
+
|
|
2517
|
+
if (stageIndex < currentIndex) {
|
|
2518
|
+
// Completed stage - green checkmark
|
|
2519
|
+
ariaLabel = `${stage}: completed`;
|
|
2520
|
+
|
|
2521
|
+
const cellContent = getStageCellContent(project, stage);
|
|
2522
|
+
if (cellContent.label && cellContent.link) {
|
|
2523
|
+
if (cellContent.external) {
|
|
2524
|
+
content = `<span class="checkmark">✓</span> <a href="${cellContent.link}" target="_blank" rel="noopener">${cellContent.label}</a>`;
|
|
2525
|
+
} else {
|
|
2526
|
+
content = `<span class="checkmark">✓</span> <a href="#" onclick="openProjectFile('${cellContent.link}'); return false;">${cellContent.label}</a>`;
|
|
2527
|
+
}
|
|
2528
|
+
} else {
|
|
2529
|
+
content = '<span class="checkmark">✓</span>';
|
|
2530
|
+
}
|
|
2531
|
+
} else if (stageIndex === currentIndex) {
|
|
2532
|
+
// Current stage - hollow orange circle (or confetti if recently integrated)
|
|
2533
|
+
if (stage === 'integrated' && isRecentlyIntegrated(project)) {
|
|
2534
|
+
ariaLabel = `${stage}: recently completed!`;
|
|
2535
|
+
content = '<span class="celebration">🎉</span>';
|
|
2536
|
+
} else {
|
|
2537
|
+
ariaLabel = `${stage}: in progress`;
|
|
2538
|
+
|
|
2539
|
+
const cellContent = getStageCellContent(project, stage);
|
|
2540
|
+
if (cellContent.label && cellContent.link) {
|
|
2541
|
+
if (cellContent.external) {
|
|
2542
|
+
content = `<span class="current-indicator"></span> <a href="${cellContent.link}" target="_blank" rel="noopener">${cellContent.label}</a>`;
|
|
2543
|
+
} else {
|
|
2544
|
+
content = `<span class="current-indicator"></span> <a href="#" onclick="openProjectFile('${cellContent.link}'); return false;">${cellContent.label}</a>`;
|
|
2545
|
+
}
|
|
2546
|
+
} else {
|
|
2547
|
+
content = '<span class="current-indicator"></span>';
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
} else {
|
|
2551
|
+
// Future stage - empty
|
|
2552
|
+
ariaLabel = `${stage}: pending`;
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
return `<td role="gridcell" class="${cellClass}" aria-label="${ariaLabel}">${content}</td>`;
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
// Get URL for stage-specific artifact
|
|
2559
|
+
function getStageLinkUrl(project, stage) {
|
|
2560
|
+
if (!project.files) return null;
|
|
2561
|
+
|
|
2562
|
+
switch (stage) {
|
|
2563
|
+
case 'specified':
|
|
2564
|
+
return project.files.spec || null;
|
|
2565
|
+
case 'planned':
|
|
2566
|
+
return project.files.plan || null;
|
|
2567
|
+
case 'integrated':
|
|
2568
|
+
return project.files.review || null;
|
|
2569
|
+
default:
|
|
2570
|
+
return null;
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
// Open a project file in a new annotation tab
|
|
2575
|
+
async function openProjectFile(path) {
|
|
2576
|
+
try {
|
|
2577
|
+
const response = await fetch('/api/tabs/file', {
|
|
2578
|
+
method: 'POST',
|
|
2579
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2580
|
+
body: JSON.stringify({ path })
|
|
2581
|
+
});
|
|
2582
|
+
|
|
2583
|
+
if (!response.ok) {
|
|
2584
|
+
throw new Error(await response.text());
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
await refresh();
|
|
2588
|
+
showToast(`Opened ${path}`, 'success');
|
|
2589
|
+
} catch (err) {
|
|
2590
|
+
showToast('Failed to open file: ' + err.message, 'error');
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
// Render a single project row
|
|
2595
|
+
function renderProjectRow(project) {
|
|
2596
|
+
const isExpanded = expandedProjectId === project.id;
|
|
2597
|
+
|
|
2598
|
+
const row = `
|
|
2599
|
+
<tr class="status-${project.status}"
|
|
2600
|
+
role="row"
|
|
2601
|
+
tabindex="0"
|
|
2602
|
+
aria-expanded="${isExpanded}"
|
|
2603
|
+
onkeydown="handleProjectRowKeydown(event, '${project.id}')">
|
|
2604
|
+
<td role="gridcell">
|
|
2605
|
+
<div class="project-cell clickable" onclick="toggleProjectDetails('${project.id}'); event.stopPropagation();">
|
|
2606
|
+
<span class="project-id">${escapeProjectHtml(project.id)}</span>
|
|
2607
|
+
<span class="project-title" title="${escapeProjectHtml(project.title)}">${escapeProjectHtml(project.title)}</span>
|
|
2608
|
+
</div>
|
|
2609
|
+
</td>
|
|
2610
|
+
${LIFECYCLE_STAGES.map(stage => renderStageCell(project, stage)).join('')}
|
|
2611
|
+
</tr>
|
|
2612
|
+
`;
|
|
2613
|
+
|
|
2614
|
+
if (isExpanded) {
|
|
2615
|
+
return row + renderProjectDetailsRow(project);
|
|
2616
|
+
}
|
|
2617
|
+
return row;
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
// Render the details row when expanded
|
|
2621
|
+
function renderProjectDetailsRow(project) {
|
|
2622
|
+
const links = [];
|
|
2623
|
+
if (project.files && project.files.review) {
|
|
2624
|
+
links.push(`<a href="#" onclick="openProjectFile('${project.files.review}'); return false;">Review</a>`);
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
const dependencies = project.dependencies && project.dependencies.length > 0
|
|
2628
|
+
? `<div class="project-dependencies">Dependencies: ${project.dependencies.map(d => escapeProjectHtml(d)).join(', ')}</div>`
|
|
2629
|
+
: '';
|
|
2630
|
+
|
|
2631
|
+
// Render TICKs if present
|
|
2632
|
+
const ticks = project.ticks && project.ticks.length > 0
|
|
2633
|
+
? `<div class="project-ticks">TICKs: ${project.ticks.map(t => `<span class="tick-badge">TICK-${escapeProjectHtml(t)}</span>`).join(' ')}</div>`
|
|
2634
|
+
: '';
|
|
2635
|
+
|
|
2636
|
+
return `
|
|
2637
|
+
<tr class="project-details-row" role="row">
|
|
2638
|
+
<td colspan="8">
|
|
2639
|
+
<div class="project-details-content">
|
|
2640
|
+
<h3>${escapeProjectHtml(project.title)}</h3>
|
|
2641
|
+
${project.summary ? `<p>${escapeProjectHtml(project.summary)}</p>` : ''}
|
|
2642
|
+
${project.notes ? `<p class="notes">${escapeProjectHtml(project.notes)}</p>` : ''}
|
|
2643
|
+
${ticks}
|
|
2644
|
+
${links.length > 0 ? `<div class="project-details-links">${links.join('')}</div>` : ''}
|
|
2645
|
+
${dependencies}
|
|
2646
|
+
</div>
|
|
2647
|
+
</td>
|
|
2648
|
+
</tr>
|
|
2649
|
+
`;
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
// Handle keyboard navigation on project rows
|
|
2653
|
+
function handleProjectRowKeydown(event, projectId) {
|
|
2654
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
2655
|
+
event.preventDefault();
|
|
2656
|
+
toggleProjectDetails(projectId);
|
|
2657
|
+
} else if (event.key === 'ArrowDown') {
|
|
2658
|
+
event.preventDefault();
|
|
2659
|
+
const currentRow = event.target.closest('tr');
|
|
2660
|
+
let nextRow = currentRow.nextElementSibling;
|
|
2661
|
+
// Skip details rows
|
|
2662
|
+
while (nextRow && nextRow.classList.contains('project-details-row')) {
|
|
2663
|
+
nextRow = nextRow.nextElementSibling;
|
|
2664
|
+
}
|
|
2665
|
+
if (nextRow) nextRow.focus();
|
|
2666
|
+
} else if (event.key === 'ArrowUp') {
|
|
2667
|
+
event.preventDefault();
|
|
2668
|
+
const currentRow = event.target.closest('tr');
|
|
2669
|
+
let prevRow = currentRow.previousElementSibling;
|
|
2670
|
+
// Skip details rows
|
|
2671
|
+
while (prevRow && prevRow.classList.contains('project-details-row')) {
|
|
2672
|
+
prevRow = prevRow.previousElementSibling;
|
|
2673
|
+
}
|
|
2674
|
+
if (prevRow && prevRow.getAttribute('tabindex') === '0') prevRow.focus();
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
// Toggle project details expansion
|
|
2679
|
+
function toggleProjectDetails(projectId) {
|
|
2680
|
+
if (expandedProjectId === projectId) {
|
|
2681
|
+
expandedProjectId = null;
|
|
2682
|
+
} else {
|
|
2683
|
+
expandedProjectId = projectId;
|
|
2684
|
+
}
|
|
2685
|
+
// Re-render the projects tab to update expansion state
|
|
2686
|
+
renderProjectsTabContent();
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
// Render a table for a list of projects
|
|
2690
|
+
function renderProjectTable(projectList) {
|
|
2691
|
+
if (projectList.length === 0) {
|
|
2692
|
+
return '<p style="color: var(--text-muted); text-align: center; padding: 20px;">No projects</p>';
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
return `
|
|
2696
|
+
<table class="kanban-grid" role="grid" aria-label="Project status grid">
|
|
2697
|
+
<thead>
|
|
2698
|
+
<tr role="row">
|
|
2699
|
+
<th role="columnheader">Project</th>
|
|
2700
|
+
${LIFECYCLE_STAGES.map(stage => `<th role="columnheader" title="${STAGE_TOOLTIPS[stage]}">${STAGE_HEADERS[stage]}</th>`).join('')}
|
|
2701
|
+
</tr>
|
|
2702
|
+
</thead>
|
|
2703
|
+
<tbody>
|
|
2704
|
+
${projectList.map(p => renderProjectRow(p)).join('')}
|
|
2705
|
+
</tbody>
|
|
2706
|
+
</table>
|
|
2707
|
+
`;
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
// Check if a project was integrated in the last 24 hours
|
|
2711
|
+
function isRecentlyIntegrated(project) {
|
|
2712
|
+
if (project.status !== 'integrated') return false;
|
|
2713
|
+
|
|
2714
|
+
// Look in timestamps.integrated_at (new format)
|
|
2715
|
+
const integratedAt = project.timestamps?.integrated_at;
|
|
2716
|
+
if (!integratedAt) return false;
|
|
2717
|
+
|
|
2718
|
+
const integratedDate = new Date(integratedAt);
|
|
2719
|
+
if (isNaN(integratedDate.getTime())) return false;
|
|
2720
|
+
|
|
2721
|
+
const now = new Date();
|
|
2722
|
+
const hoursDiff = (now - integratedDate) / (1000 * 60 * 60);
|
|
2723
|
+
|
|
2724
|
+
return hoursDiff <= 24;
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
// Render the Kanban grid with Active/Inactive sections
|
|
2728
|
+
function renderKanbanGrid(projects) {
|
|
2729
|
+
// Separate active (conceived through committed) from inactive (integrated)
|
|
2730
|
+
const activeStatuses = ['conceived', 'specified', 'planned', 'implementing', 'implemented', 'committed'];
|
|
2731
|
+
|
|
2732
|
+
// Status order for sorting (higher index = further along)
|
|
2733
|
+
const statusOrder = {
|
|
2734
|
+
'conceived': 0,
|
|
2735
|
+
'specified': 1,
|
|
2736
|
+
'planned': 2,
|
|
2737
|
+
'implementing': 3,
|
|
2738
|
+
'implemented': 4,
|
|
2739
|
+
'committed': 5,
|
|
2740
|
+
'integrated': 6
|
|
2741
|
+
};
|
|
2742
|
+
|
|
2743
|
+
// Include recently integrated projects in Active section
|
|
2744
|
+
const activeProjects = projects.filter(p =>
|
|
2745
|
+
activeStatuses.includes(p.status) || isRecentlyIntegrated(p)
|
|
2746
|
+
);
|
|
2747
|
+
|
|
2748
|
+
// Sort active projects by completion (most complete first)
|
|
2749
|
+
activeProjects.sort((a, b) => {
|
|
2750
|
+
const orderA = statusOrder[a.status] || 0;
|
|
2751
|
+
const orderB = statusOrder[b.status] || 0;
|
|
2752
|
+
// Higher status first (descending), then by ID (ascending) for tie-breaker
|
|
2753
|
+
if (orderB !== orderA) return orderB - orderA;
|
|
2754
|
+
return a.id.localeCompare(b.id);
|
|
2755
|
+
});
|
|
2756
|
+
|
|
2757
|
+
const inactiveProjects = projects.filter(p =>
|
|
2758
|
+
p.status === 'integrated' && !isRecentlyIntegrated(p)
|
|
2759
|
+
);
|
|
2760
|
+
|
|
2761
|
+
let html = '';
|
|
2762
|
+
|
|
2763
|
+
// Active section - expanded by default
|
|
2764
|
+
if (activeProjects.length > 0 || inactiveProjects.length === 0) {
|
|
2765
|
+
html += `
|
|
2766
|
+
<details class="project-section" open>
|
|
2767
|
+
<summary>Active <span class="section-count">(${activeProjects.length})</span></summary>
|
|
2768
|
+
${renderProjectTable(activeProjects)}
|
|
2769
|
+
</details>
|
|
2770
|
+
`;
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
// Inactive section - collapsed by default
|
|
2774
|
+
if (inactiveProjects.length > 0) {
|
|
2775
|
+
html += `
|
|
2776
|
+
<details class="project-section">
|
|
2777
|
+
<summary>Completed <span class="section-count">(${inactiveProjects.length})</span></summary>
|
|
2778
|
+
${renderProjectTable(inactiveProjects)}
|
|
2779
|
+
</details>
|
|
2780
|
+
`;
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
return html;
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
// Render the terminal projects section (abandoned, on-hold)
|
|
2787
|
+
function renderTerminalProjects(projects) {
|
|
2788
|
+
const terminal = projects.filter(p => ['abandoned', 'on-hold'].includes(p.status));
|
|
2789
|
+
|
|
2790
|
+
if (terminal.length === 0) return '';
|
|
2791
|
+
|
|
2792
|
+
const items = terminal.map(p => {
|
|
2793
|
+
const className = p.status === 'abandoned' ? 'project-abandoned' : 'project-on-hold';
|
|
2794
|
+
const statusText = p.status === 'on-hold' ? ' (on-hold)' : '';
|
|
2795
|
+
return `
|
|
2796
|
+
<li>
|
|
2797
|
+
<span class="${className}">
|
|
2798
|
+
<span class="project-id">${escapeProjectHtml(p.id)}</span>
|
|
2799
|
+
${escapeProjectHtml(p.title)}${statusText}
|
|
2800
|
+
</span>
|
|
2801
|
+
</li>
|
|
2802
|
+
`;
|
|
2803
|
+
}).join('');
|
|
2804
|
+
|
|
2805
|
+
return `
|
|
2806
|
+
<details class="terminal-projects">
|
|
2807
|
+
<summary>Terminal Projects (${terminal.length})</summary>
|
|
2808
|
+
<ul>${items}</ul>
|
|
2809
|
+
</details>
|
|
2810
|
+
`;
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
// Render the info header with helpful links
|
|
2814
|
+
function renderInfoHeader() {
|
|
2815
|
+
return `
|
|
2816
|
+
<div class="projects-info">
|
|
2817
|
+
<h1 style="font-size: 20px; margin-bottom: 12px; color: var(--text-primary);">Codev: Project View</h1>
|
|
2818
|
+
<p>This shows the state of all projects. Our goal is to move each project through all the stages until it reaches INTGR'D (integrated). Hover over column headers to learn about each stage.</p>
|
|
2819
|
+
<p>To add projects, update status, or approve stages, use the <strong>Architect</strong> terminal on the left.</p>
|
|
2820
|
+
<p>Docs: <a href="#" onclick="openProjectFile('codev/docs/lifecycle.md'); return false;">Lifecycle</a> · <a href="#" onclick="openProjectFile('codev/docs/commands/overview.md'); return false;">CLI Reference</a> · <a href="#" onclick="openProjectFile('codev/protocols/spider/protocol.md'); return false;">SPIDER Protocol</a> · <a href="https://github.com/cluesmith/codev#readme" target="_blank">README</a></p>
|
|
2821
|
+
</div>
|
|
2822
|
+
`;
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
// Render the projects tab content (internal - called after data is loaded)
|
|
2826
|
+
function renderProjectsTabContent() {
|
|
2827
|
+
const content = document.getElementById('tab-content');
|
|
2828
|
+
|
|
2829
|
+
if (projectlistError) {
|
|
2830
|
+
content.innerHTML = `
|
|
2831
|
+
<div class="projects-container">
|
|
2832
|
+
${renderErrorBanner(projectlistError)}
|
|
2833
|
+
</div>
|
|
2834
|
+
`;
|
|
2835
|
+
return;
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
if (projectsData.length === 0) {
|
|
2839
|
+
content.innerHTML = `
|
|
2840
|
+
<div class="projects-container">
|
|
2841
|
+
${renderWelcomeScreen()}
|
|
2842
|
+
</div>
|
|
2843
|
+
`;
|
|
2844
|
+
return;
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
content.innerHTML = `
|
|
2848
|
+
<div class="projects-container">
|
|
2849
|
+
${renderInfoHeader()}
|
|
2850
|
+
${renderKanbanGrid(projectsData)}
|
|
2851
|
+
${renderTerminalProjects(projectsData)}
|
|
2852
|
+
</div>
|
|
2853
|
+
`;
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2856
|
+
// Render the projects tab (entry point - loads data first)
|
|
2857
|
+
async function renderProjectsTab() {
|
|
2858
|
+
const content = document.getElementById('tab-content');
|
|
2859
|
+
content.innerHTML = '<div class="projects-container"><p style="color: var(--text-muted);">Loading projects...</p></div>';
|
|
2860
|
+
|
|
2861
|
+
await loadProjectlist();
|
|
2862
|
+
renderProjectsTabContent();
|
|
2863
|
+
checkStarterMode(); // Update polling state after initial load
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
// Load projectlist.md from disk
|
|
2867
|
+
async function loadProjectlist() {
|
|
2868
|
+
try {
|
|
2869
|
+
const response = await fetch('/file?path=codev/projectlist.md');
|
|
2870
|
+
|
|
2871
|
+
if (!response.ok) {
|
|
2872
|
+
if (response.status === 404) {
|
|
2873
|
+
// File not found - show welcome screen
|
|
2874
|
+
projectsData = [];
|
|
2875
|
+
projectlistError = null;
|
|
2876
|
+
return;
|
|
2877
|
+
}
|
|
2878
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
const text = await response.text();
|
|
2882
|
+
const newHash = hashString(text);
|
|
2883
|
+
|
|
2884
|
+
// Only re-parse if content changed
|
|
2885
|
+
if (newHash !== projectlistHash) {
|
|
2886
|
+
projectlistHash = newHash;
|
|
2887
|
+
projectsData = parseProjectlist(text);
|
|
2888
|
+
projectlistError = null;
|
|
2889
|
+
}
|
|
2890
|
+
} catch (err) {
|
|
2891
|
+
console.error('Failed to load projectlist:', err);
|
|
2892
|
+
projectlistError = 'Could not load projectlist.md: ' + err.message;
|
|
2893
|
+
// Preserve last good state if available
|
|
2894
|
+
if (projectsData.length === 0) {
|
|
2895
|
+
projectsData = [];
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
// Reload projectlist (manual refresh button)
|
|
2901
|
+
async function reloadProjectlist() {
|
|
2902
|
+
projectlistHash = null; // Force re-parse
|
|
2903
|
+
await loadProjectlist();
|
|
2904
|
+
renderProjectsTabContent();
|
|
2905
|
+
checkStarterMode(); // Update polling state after reload
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2908
|
+
// Poll projectlist for changes (every 5 seconds)
|
|
2909
|
+
async function pollProjectlist() {
|
|
2910
|
+
// Only poll if projects tab is active
|
|
2911
|
+
if (activeTabId !== 'projects') return;
|
|
2912
|
+
|
|
2913
|
+
try {
|
|
2914
|
+
const response = await fetch('/file?path=codev/projectlist.md');
|
|
2915
|
+
if (!response.ok) return;
|
|
2916
|
+
|
|
2917
|
+
const text = await response.text();
|
|
2918
|
+
const newHash = hashString(text);
|
|
2919
|
+
|
|
2920
|
+
if (newHash !== projectlistHash) {
|
|
2921
|
+
// Content changed - debounce to avoid reading mid-write
|
|
2922
|
+
clearTimeout(projectlistDebounce);
|
|
2923
|
+
projectlistDebounce = setTimeout(async () => {
|
|
2924
|
+
projectlistHash = newHash;
|
|
2925
|
+
projectsData = parseProjectlist(text);
|
|
2926
|
+
projectlistError = null;
|
|
2927
|
+
renderProjectsTabContent();
|
|
2928
|
+
checkStarterMode(); // Update polling state after content change
|
|
2929
|
+
}, 500);
|
|
2930
|
+
}
|
|
2931
|
+
} catch (err) {
|
|
2932
|
+
// Silently ignore polling errors
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
// Poll for projectlist.md creation when in starter mode (every 15 seconds)
|
|
2937
|
+
let starterModePollingInterval = null;
|
|
2938
|
+
|
|
2939
|
+
async function pollForProjectlistCreation() {
|
|
2940
|
+
try {
|
|
2941
|
+
const response = await fetch('/api/projectlist-exists');
|
|
2942
|
+
if (!response.ok) return;
|
|
2943
|
+
|
|
2944
|
+
const { exists } = await response.json();
|
|
2945
|
+
if (exists) {
|
|
2946
|
+
// projectlist.md was created - stop polling and reload
|
|
2947
|
+
if (starterModePollingInterval) {
|
|
2948
|
+
clearInterval(starterModePollingInterval);
|
|
2949
|
+
starterModePollingInterval = null;
|
|
2950
|
+
}
|
|
2951
|
+
window.location.reload();
|
|
2952
|
+
}
|
|
2953
|
+
} catch (err) {
|
|
2954
|
+
// Silently ignore polling errors
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
// Check if we should start starter mode polling
|
|
2959
|
+
function checkStarterMode() {
|
|
2960
|
+
// We're in starter mode ONLY if:
|
|
2961
|
+
// 1. projectsData is empty (no projects loaded)
|
|
2962
|
+
// 2. No error occurred
|
|
2963
|
+
// 3. projectlistHash is null (file was not found, not just empty)
|
|
2964
|
+
// This prevents infinite reload loop when file exists but is empty
|
|
2965
|
+
const isStarterMode = projectsData.length === 0 && !projectlistError && projectlistHash === null;
|
|
2966
|
+
|
|
2967
|
+
if (isStarterMode && !starterModePollingInterval) {
|
|
2968
|
+
// Start polling for projectlist.md creation
|
|
2969
|
+
starterModePollingInterval = setInterval(pollForProjectlistCreation, 15000);
|
|
2970
|
+
} else if (!isStarterMode && starterModePollingInterval) {
|
|
2971
|
+
// Stop polling - file exists now (even if empty)
|
|
2972
|
+
clearInterval(starterModePollingInterval);
|
|
2973
|
+
starterModePollingInterval = null;
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
// Start projectlist polling (separate from main state polling)
|
|
2978
|
+
setInterval(pollProjectlist, 5000);
|
|
2979
|
+
|
|
2980
|
+
// Initialize on load
|
|
2981
|
+
init();
|
|
2982
|
+
</script>
|
|
2983
|
+
</body>
|
|
2984
|
+
</html>
|