@cluesmith/codev 1.1.1 → 1.2.1

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.
Files changed (41) hide show
  1. package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
  2. package/dist/agent-farm/commands/spawn.js +3 -0
  3. package/dist/agent-farm/commands/spawn.js.map +1 -1
  4. package/dist/agent-farm/commands/start.d.ts.map +1 -1
  5. package/dist/agent-farm/commands/start.js +1 -0
  6. package/dist/agent-farm/commands/start.js.map +1 -1
  7. package/dist/agent-farm/servers/dashboard-server.js +12 -0
  8. package/dist/agent-farm/servers/dashboard-server.js.map +1 -1
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +15 -0
  11. package/dist/cli.js.map +1 -1
  12. package/dist/commands/adopt.d.ts.map +1 -1
  13. package/dist/commands/adopt.js +27 -6
  14. package/dist/commands/adopt.js.map +1 -1
  15. package/dist/commands/doctor.d.ts.map +1 -1
  16. package/dist/commands/doctor.js +59 -3
  17. package/dist/commands/doctor.js.map +1 -1
  18. package/dist/commands/import.d.ts +16 -0
  19. package/dist/commands/import.d.ts.map +1 -0
  20. package/dist/commands/import.js +278 -0
  21. package/dist/commands/import.js.map +1 -0
  22. package/dist/commands/init.d.ts.map +1 -1
  23. package/dist/commands/init.js +27 -6
  24. package/dist/commands/init.js.map +1 -1
  25. package/package.json +4 -3
  26. package/skeleton/DEPENDENCIES.md +1 -0
  27. package/skeleton/docs/commands/overview.md +1 -0
  28. package/skeleton/maintain/.gitkeep +2 -0
  29. package/skeleton/protocols/maintain/protocol.md +288 -21
  30. package/skeleton/protocols/maintain/templates/maintenance-run.md +64 -0
  31. package/skeleton/protocols/spider/protocol.md +2 -2
  32. package/skeleton/resources/workflow-reference.md +13 -0
  33. package/skeleton/roles/architect.md +185 -134
  34. package/skeleton/templates/lessons-learned.md +28 -0
  35. package/templates/dashboard-split.html +2984 -0
  36. package/templates/dashboard.html +149 -0
  37. package/templates/open.html +1109 -0
  38. package/templates/tower.html +1032 -0
  39. package/skeleton/agents/architecture-documenter.md +0 -189
  40. package/skeleton/agents/codev-updater.md +0 -277
  41. package/skeleton/agents/spider-protocol-updater.md +0 -118
@@ -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)}">&times;</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, '&amp;')
1561
+ .replace(/</g, '&lt;')
1562
+ .replace(/>/g, '&gt;')
1563
+ .replace(/"/g, '&quot;')
1564
+ .replace(/'/g, '&#39;');
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>