@cluesmith/codev 1.4.2 → 1.4.3

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.
@@ -1,4741 +0,0 @@
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
- /* Activity Summary Modal (Spec 0059) */
655
- .activity-dialog {
656
- width: 600px;
657
- max-width: 90vw;
658
- max-height: 80vh;
659
- display: flex;
660
- flex-direction: column;
661
- }
662
-
663
- .activity-dialog-header {
664
- display: flex;
665
- justify-content: space-between;
666
- align-items: center;
667
- margin-bottom: 16px;
668
- }
669
-
670
- .activity-dialog-header h3 {
671
- margin: 0;
672
- }
673
-
674
- .activity-close-btn {
675
- background: none;
676
- border: none;
677
- font-size: 24px;
678
- color: var(--text-muted);
679
- cursor: pointer;
680
- padding: 0 8px;
681
- line-height: 1;
682
- }
683
-
684
- .activity-close-btn:hover {
685
- color: var(--text-primary);
686
- }
687
-
688
- .activity-dialog-content {
689
- flex: 1;
690
- overflow-y: auto;
691
- max-height: 50vh;
692
- margin-bottom: 16px;
693
- }
694
-
695
- .activity-loading {
696
- display: flex;
697
- align-items: center;
698
- justify-content: center;
699
- gap: 12px;
700
- padding: 40px 20px;
701
- color: var(--text-muted);
702
- }
703
-
704
- .activity-spinner {
705
- width: 20px;
706
- height: 20px;
707
- border: 2px solid var(--border);
708
- border-top-color: var(--accent);
709
- border-radius: 50%;
710
- animation: spin 1s linear infinite;
711
- }
712
-
713
- @keyframes spin {
714
- to { transform: rotate(360deg); }
715
- }
716
-
717
- .activity-empty {
718
- text-align: center;
719
- padding: 40px 20px;
720
- color: var(--text-muted);
721
- }
722
-
723
- .activity-error {
724
- text-align: center;
725
- padding: 40px 20px;
726
- color: #ef4444;
727
- }
728
-
729
- .activity-summary {
730
- line-height: 1.6;
731
- }
732
-
733
- .activity-ai-summary {
734
- background: var(--bg-tertiary);
735
- border-left: 3px solid var(--accent);
736
- padding: 12px 16px;
737
- margin-bottom: 20px;
738
- font-style: italic;
739
- color: var(--text-secondary);
740
- }
741
-
742
- .activity-section {
743
- margin-bottom: 16px;
744
- }
745
-
746
- .activity-section h4 {
747
- font-size: 13px;
748
- text-transform: uppercase;
749
- color: var(--text-muted);
750
- margin: 0 0 8px 0;
751
- letter-spacing: 0.5px;
752
- }
753
-
754
- .activity-section ul {
755
- margin: 0;
756
- padding-left: 20px;
757
- color: var(--text-secondary);
758
- }
759
-
760
- .activity-section li {
761
- margin-bottom: 4px;
762
- }
763
-
764
- .activity-section p {
765
- margin: 4px 0;
766
- color: var(--text-secondary);
767
- }
768
-
769
- .activity-time-value {
770
- font-size: 18px;
771
- font-weight: 500;
772
- color: var(--text-primary);
773
- }
774
-
775
- .activity-dialog-footer {
776
- display: flex;
777
- justify-content: flex-end;
778
- gap: 8px;
779
- padding-top: 12px;
780
- border-top: 1px solid var(--border);
781
- }
782
-
783
- /* Activity Tab Styles (Spec 0059) */
784
- .activity-tab-container {
785
- padding: 24px;
786
- max-width: 700px;
787
- margin: 0 auto;
788
- }
789
-
790
- .activity-tab-container .activity-summary {
791
- background: var(--bg-secondary);
792
- border-radius: 8px;
793
- padding: 20px;
794
- }
795
-
796
- .activity-tab-container .activity-actions {
797
- margin-top: 20px;
798
- padding-top: 16px;
799
- border-top: 1px solid var(--border);
800
- display: flex;
801
- justify-content: flex-end;
802
- }
803
-
804
- /* Projects Tab Styles (Spec 0045) */
805
- .projects-container {
806
- flex: 1;
807
- overflow-y: auto;
808
- padding: 16px;
809
- display: flex;
810
- flex-direction: column;
811
- gap: 16px;
812
- }
813
-
814
- /* Welcome Screen */
815
- .projects-welcome {
816
- max-width: 600px;
817
- margin: 40px auto;
818
- text-align: center;
819
- }
820
-
821
- .projects-welcome h2 {
822
- font-size: 24px;
823
- margin-bottom: 16px;
824
- color: var(--text-primary);
825
- }
826
-
827
- .projects-welcome p {
828
- color: var(--text-secondary);
829
- line-height: 1.6;
830
- margin-bottom: 16px;
831
- }
832
-
833
- .projects-welcome ol {
834
- text-align: left;
835
- margin: 24px 0;
836
- padding-left: 24px;
837
- }
838
-
839
- .projects-welcome li {
840
- margin-bottom: 8px;
841
- color: var(--text-secondary);
842
- }
843
-
844
- .projects-welcome li strong {
845
- color: var(--text-primary);
846
- }
847
-
848
- .projects-welcome .quick-tip {
849
- margin-top: 24px;
850
- padding: 12px;
851
- background: var(--bg-tertiary);
852
- border-radius: 6px;
853
- border-left: 3px solid var(--accent);
854
- color: var(--text-secondary);
855
- }
856
-
857
- .projects-welcome hr {
858
- border: none;
859
- border-top: 1px solid var(--border);
860
- margin: 24px 0;
861
- }
862
-
863
- /* Status Summary */
864
- .status-summary {
865
- background: var(--bg-secondary);
866
- border: 1px solid var(--border);
867
- border-radius: 6px;
868
- padding: 12px 16px;
869
- }
870
-
871
- .status-summary-header {
872
- display: flex;
873
- justify-content: space-between;
874
- align-items: center;
875
- margin-bottom: 8px;
876
- }
877
-
878
- .status-summary-header span {
879
- font-size: 11px;
880
- text-transform: uppercase;
881
- letter-spacing: 0.5px;
882
- color: var(--text-muted);
883
- }
884
-
885
- .status-summary-header button {
886
- padding: 4px 8px;
887
- border-radius: 4px;
888
- border: 1px solid var(--border);
889
- background: var(--bg-tertiary);
890
- color: var(--text-secondary);
891
- cursor: pointer;
892
- font-size: 14px;
893
- }
894
-
895
- .status-summary-header button:hover {
896
- background: var(--tab-hover);
897
- }
898
-
899
- .status-summary .active-projects {
900
- margin-bottom: 8px;
901
- }
902
-
903
- .status-summary .active-count {
904
- font-size: 14px;
905
- color: var(--text-primary);
906
- }
907
-
908
- .status-summary .active-list {
909
- margin-top: 4px;
910
- padding-left: 16px;
911
- font-size: 13px;
912
- color: var(--text-secondary);
913
- }
914
-
915
- .status-summary .active-list li {
916
- margin: 2px 0;
917
- }
918
-
919
- .status-summary .completed {
920
- font-size: 13px;
921
- color: var(--text-muted);
922
- }
923
-
924
- /* Kanban Grid */
925
- .kanban-grid {
926
- width: 100%;
927
- border-collapse: collapse;
928
- font-size: 13px;
929
- }
930
-
931
- .kanban-grid th,
932
- .kanban-grid td {
933
- padding: 8px 6px;
934
- text-align: center;
935
- border-bottom: 1px solid var(--border);
936
- }
937
-
938
- .kanban-grid th {
939
- background: var(--bg-secondary);
940
- font-size: 10px;
941
- text-transform: uppercase;
942
- letter-spacing: 0.5px;
943
- color: var(--text-muted);
944
- position: sticky;
945
- top: 0;
946
- z-index: 1;
947
- }
948
-
949
- .kanban-grid th:first-child,
950
- .kanban-grid td:first-child {
951
- text-align: left;
952
- padding-left: 12px;
953
- width: 40%;
954
- }
955
-
956
- .kanban-grid th:not(:first-child),
957
- .kanban-grid td:not(:first-child) {
958
- width: 8%;
959
- }
960
-
961
- .kanban-grid tbody tr {
962
- cursor: default;
963
- transition: background 0.15s;
964
- }
965
-
966
- .kanban-grid tbody tr:hover {
967
- background: var(--bg-secondary);
968
- }
969
-
970
- .kanban-grid tbody tr:focus {
971
- outline: 2px solid var(--accent);
972
- outline-offset: -2px;
973
- }
974
-
975
- .kanban-grid .project-cell {
976
- display: flex;
977
- align-items: center;
978
- gap: 8px;
979
- }
980
-
981
- .kanban-grid .project-id {
982
- font-family: monospace;
983
- color: var(--text-muted);
984
- }
985
-
986
- .kanban-grid .project-title {
987
- overflow: hidden;
988
- text-overflow: ellipsis;
989
- white-space: nowrap;
990
- }
991
-
992
- .kanban-grid .project-cell.clickable {
993
- cursor: pointer;
994
- }
995
-
996
- .kanban-grid .project-cell.clickable:hover .project-title {
997
- text-decoration: underline;
998
- color: var(--accent);
999
- }
1000
-
1001
- .kanban-grid .tick-badge {
1002
- font-size: 10px;
1003
- padding: 1px 4px;
1004
- background: var(--bg-tertiary);
1005
- border-radius: 3px;
1006
- color: var(--text-muted);
1007
- }
1008
-
1009
- /* Stage cell styling */
1010
- .stage-cell {
1011
- font-size: 12px;
1012
- position: relative;
1013
- }
1014
-
1015
- .stage-cell .checkmark {
1016
- color: #22c55e;
1017
- font-weight: bold;
1018
- }
1019
-
1020
- .stage-cell .current-indicator {
1021
- display: inline-block;
1022
- width: 12px;
1023
- height: 12px;
1024
- border: 2px solid #f97316;
1025
- border-radius: 50%;
1026
- }
1027
-
1028
- .stage-cell .celebration {
1029
- font-size: 16px;
1030
- }
1031
-
1032
- .stage-cell a {
1033
- color: var(--text-primary);
1034
- text-decoration: underline;
1035
- }
1036
-
1037
- /* Arrow between columns */
1038
- .kanban-grid th:not(:first-child):not(:last-child)::after,
1039
- .kanban-grid td.stage-cell:not(:last-child)::after {
1040
- content: '→';
1041
- position: absolute;
1042
- right: -8px;
1043
- color: var(--text-muted);
1044
- font-size: 10px;
1045
- }
1046
-
1047
- /* Projects info header */
1048
- .projects-info {
1049
- background: var(--bg-secondary);
1050
- border: 1px solid var(--border);
1051
- border-radius: 6px;
1052
- padding: 12px 16px;
1053
- margin-bottom: 12px;
1054
- }
1055
-
1056
- .projects-info p {
1057
- color: var(--text-secondary);
1058
- font-size: 13px;
1059
- margin: 0 0 8px 0;
1060
- }
1061
-
1062
- .projects-info p:last-child {
1063
- margin-bottom: 0;
1064
- }
1065
-
1066
- .projects-info strong {
1067
- color: var(--text-primary);
1068
- }
1069
-
1070
- .projects-info a {
1071
- color: var(--accent);
1072
- text-decoration: none;
1073
- }
1074
-
1075
- .projects-info a:hover {
1076
- text-decoration: underline;
1077
- }
1078
-
1079
- /* Project details row */
1080
- .project-details-row td {
1081
- padding: 0 !important;
1082
- border-bottom: 1px solid var(--border);
1083
- }
1084
-
1085
- .project-details-content {
1086
- padding: 16px;
1087
- background: var(--bg-secondary);
1088
- }
1089
-
1090
- .project-details-content h3 {
1091
- font-size: 16px;
1092
- margin-bottom: 8px;
1093
- color: var(--text-primary);
1094
- }
1095
-
1096
- .project-details-content p {
1097
- margin-bottom: 8px;
1098
- color: var(--text-secondary);
1099
- font-size: 13px;
1100
- }
1101
-
1102
- .project-details-content .notes {
1103
- font-style: italic;
1104
- color: var(--text-muted);
1105
- }
1106
-
1107
- .project-details-links {
1108
- display: flex;
1109
- gap: 8px;
1110
- margin-top: 12px;
1111
- }
1112
-
1113
- .project-details-links a {
1114
- padding: 4px 10px;
1115
- background: var(--bg-tertiary);
1116
- border: 1px solid var(--border);
1117
- border-radius: 4px;
1118
- color: var(--text-secondary);
1119
- text-decoration: none;
1120
- font-size: 12px;
1121
- }
1122
-
1123
- .project-details-links a:hover {
1124
- background: var(--tab-hover);
1125
- color: var(--text-primary);
1126
- }
1127
-
1128
- .project-dependencies {
1129
- margin-top: 8px;
1130
- font-size: 12px;
1131
- color: var(--text-muted);
1132
- }
1133
-
1134
- .project-ticks {
1135
- margin-top: 8px;
1136
- font-size: 12px;
1137
- display: flex;
1138
- align-items: center;
1139
- gap: 6px;
1140
- flex-wrap: wrap;
1141
- }
1142
-
1143
- .project-ticks .tick-badge {
1144
- background: #238636;
1145
- color: white;
1146
- padding: 2px 6px;
1147
- border-radius: 3px;
1148
- font-size: 11px;
1149
- }
1150
-
1151
- /* Collapsible project sections */
1152
- .project-section {
1153
- border: 1px solid var(--border);
1154
- border-radius: 6px;
1155
- background: var(--bg-secondary);
1156
- margin-bottom: 12px;
1157
- }
1158
-
1159
- .project-section summary {
1160
- padding: 12px 16px;
1161
- cursor: pointer;
1162
- font-size: 14px;
1163
- font-weight: 500;
1164
- color: var(--text-primary);
1165
- display: flex;
1166
- align-items: center;
1167
- gap: 8px;
1168
- user-select: none;
1169
- }
1170
-
1171
- .project-section summary:hover {
1172
- background: var(--bg-tertiary);
1173
- }
1174
-
1175
- .project-section summary::marker {
1176
- content: '';
1177
- }
1178
-
1179
- .project-section summary::before {
1180
- content: '▶';
1181
- font-size: 10px;
1182
- transition: transform 0.2s;
1183
- color: var(--text-muted);
1184
- }
1185
-
1186
- .project-section[open] summary::before {
1187
- transform: rotate(90deg);
1188
- }
1189
-
1190
- .project-section .section-count {
1191
- font-size: 12px;
1192
- color: var(--text-muted);
1193
- font-weight: normal;
1194
- }
1195
-
1196
- .project-section .kanban-grid {
1197
- margin: 0;
1198
- border-radius: 0 0 6px 6px;
1199
- }
1200
-
1201
- /* Terminal projects section */
1202
- .terminal-projects {
1203
- margin-top: 16px;
1204
- border: 1px solid var(--border);
1205
- border-radius: 6px;
1206
- background: var(--bg-secondary);
1207
- }
1208
-
1209
- .terminal-projects summary {
1210
- padding: 12px 16px;
1211
- cursor: pointer;
1212
- font-size: 13px;
1213
- color: var(--text-muted);
1214
- display: flex;
1215
- align-items: center;
1216
- gap: 8px;
1217
- }
1218
-
1219
- .terminal-projects summary:hover {
1220
- background: var(--bg-tertiary);
1221
- }
1222
-
1223
- .terminal-projects summary::marker {
1224
- content: '';
1225
- }
1226
-
1227
- .terminal-projects summary::before {
1228
- content: '▶';
1229
- font-size: 10px;
1230
- transition: transform 0.2s;
1231
- color: var(--text-muted);
1232
- }
1233
-
1234
- .terminal-projects[open] summary::before {
1235
- transform: rotate(90deg);
1236
- }
1237
-
1238
- .terminal-projects ul {
1239
- list-style: none;
1240
- padding: 0 16px 16px;
1241
- }
1242
-
1243
- .terminal-projects li {
1244
- padding: 8px 0;
1245
- border-bottom: 1px solid var(--border);
1246
- display: flex;
1247
- gap: 8px;
1248
- align-items: center;
1249
- }
1250
-
1251
- .terminal-projects li:last-child {
1252
- border-bottom: none;
1253
- }
1254
-
1255
- .terminal-projects .project-abandoned {
1256
- color: var(--project-abandoned);
1257
- text-decoration: line-through;
1258
- }
1259
-
1260
- .terminal-projects .project-on-hold {
1261
- color: var(--project-on-hold);
1262
- font-style: italic;
1263
- }
1264
-
1265
- /* Error banner */
1266
- .projects-error {
1267
- padding: 16px;
1268
- background: rgba(239, 68, 68, 0.1);
1269
- border: 1px solid var(--status-error);
1270
- border-radius: 6px;
1271
- display: flex;
1272
- align-items: center;
1273
- gap: 12px;
1274
- }
1275
-
1276
- .projects-error-message {
1277
- flex: 1;
1278
- color: var(--text-secondary);
1279
- }
1280
-
1281
- .projects-error button {
1282
- padding: 6px 12px;
1283
- background: var(--bg-tertiary);
1284
- border: 1px solid var(--border);
1285
- border-radius: 4px;
1286
- color: var(--text-secondary);
1287
- cursor: pointer;
1288
- }
1289
-
1290
- .projects-error button:hover {
1291
- background: var(--tab-hover);
1292
- }
1293
-
1294
- /* Stage link styling */
1295
- .stage-link {
1296
- text-decoration: none;
1297
- color: inherit;
1298
- cursor: pointer;
1299
- }
1300
-
1301
- .stage-link:hover .stage-indicator {
1302
- transform: scale(1.2);
1303
- }
1304
-
1305
- /* Projects tab without close button */
1306
- .tab.tab-uncloseable .close {
1307
- display: none;
1308
- }
1309
-
1310
- /* Tree Styles (used by dashboard file browser) */
1311
- .tree-item {
1312
- display: flex;
1313
- align-items: center;
1314
- padding: 4px 8px;
1315
- cursor: pointer;
1316
- user-select: none;
1317
- }
1318
-
1319
- .tree-item:hover {
1320
- background: var(--bg-secondary);
1321
- }
1322
-
1323
- .tree-item.selected {
1324
- background: var(--tab-active);
1325
- }
1326
-
1327
- .tree-item-icon {
1328
- width: 16px;
1329
- height: 16px;
1330
- margin-right: 4px;
1331
- display: flex;
1332
- align-items: center;
1333
- justify-content: center;
1334
- font-size: 10px;
1335
- color: var(--text-muted);
1336
- }
1337
-
1338
- .tree-item-icon.folder-toggle {
1339
- cursor: pointer;
1340
- }
1341
-
1342
- .tree-item-icon.folder-toggle:hover {
1343
- color: var(--text-secondary);
1344
- }
1345
-
1346
- .tree-item-name {
1347
- font-size: 13px;
1348
- color: var(--text-secondary);
1349
- overflow: hidden;
1350
- text-overflow: ellipsis;
1351
- white-space: nowrap;
1352
- }
1353
-
1354
- .tree-item:hover .tree-item-name {
1355
- color: var(--text-primary);
1356
- }
1357
-
1358
- .tree-item[data-type="dir"] .tree-item-name {
1359
- color: var(--text-primary);
1360
- }
1361
-
1362
- .tree-item[data-type="file"]:hover .tree-item-name {
1363
- color: var(--accent);
1364
- }
1365
-
1366
- .tree-children {
1367
- overflow: hidden;
1368
- }
1369
-
1370
- .tree-children.collapsed {
1371
- display: none;
1372
- }
1373
-
1374
- /* Dashboard Tab Styles (Spec 0057) */
1375
- .dashboard-container {
1376
- flex: 1;
1377
- overflow-y: auto;
1378
- display: flex;
1379
- flex-direction: column;
1380
- }
1381
-
1382
- .dashboard-header {
1383
- display: flex;
1384
- gap: 16px;
1385
- padding: 16px;
1386
- flex-shrink: 0;
1387
- }
1388
-
1389
- @media (max-width: 900px) {
1390
- .dashboard-header {
1391
- flex-direction: column;
1392
- }
1393
- }
1394
-
1395
- /* Collapsible section styles */
1396
- .dashboard-section {
1397
- background: var(--bg-secondary);
1398
- border: 1px solid var(--border);
1399
- border-radius: 8px;
1400
- overflow: hidden;
1401
- display: flex;
1402
- flex-direction: column;
1403
- }
1404
-
1405
- .dashboard-section.section-tabs,
1406
- .dashboard-section.section-files {
1407
- flex: 1;
1408
- max-height: 280px;
1409
- }
1410
-
1411
- .dashboard-section.section-projects {
1412
- flex: 0 0 auto;
1413
- margin: 0 16px 16px 16px;
1414
- max-height: 50%;
1415
- overflow-y: auto;
1416
- }
1417
-
1418
- .dashboard-section.section-projects .dashboard-section-content {
1419
- flex: 0 0 auto;
1420
- }
1421
-
1422
- /* Tabs/Files expand to fill remaining space above Projects */
1423
- .dashboard-header {
1424
- flex: 1;
1425
- min-height: 0;
1426
- }
1427
-
1428
- .dashboard-section.section-tabs,
1429
- .dashboard-section.section-files {
1430
- max-height: none;
1431
- }
1432
-
1433
- .dashboard-section-header {
1434
- display: flex;
1435
- justify-content: space-between;
1436
- align-items: center;
1437
- padding: 8px 12px;
1438
- cursor: pointer;
1439
- user-select: none;
1440
- flex-shrink: 0;
1441
- border-bottom: 1px solid var(--border);
1442
- }
1443
-
1444
- .dashboard-section-header:hover {
1445
- background: var(--bg-tertiary);
1446
- }
1447
-
1448
- .dashboard-section-header h3 {
1449
- font-size: 12px;
1450
- text-transform: uppercase;
1451
- color: var(--text-muted);
1452
- letter-spacing: 0.5px;
1453
- margin: 0;
1454
- display: flex;
1455
- align-items: center;
1456
- gap: 6px;
1457
- }
1458
-
1459
- .dashboard-section-header .collapse-icon {
1460
- font-size: 10px;
1461
- transition: transform 0.2s;
1462
- }
1463
-
1464
- .dashboard-section.collapsed .collapse-icon {
1465
- transform: rotate(-90deg);
1466
- }
1467
-
1468
- .dashboard-section.collapsed .dashboard-section-header {
1469
- border-bottom: none;
1470
- }
1471
-
1472
- .dashboard-section-header .header-actions {
1473
- display: flex;
1474
- gap: 4px;
1475
- }
1476
-
1477
- .dashboard-section-header .header-actions button {
1478
- padding: 4px 8px;
1479
- border-radius: 4px;
1480
- border: 1px solid var(--border);
1481
- background: var(--bg-tertiary);
1482
- color: var(--text-secondary);
1483
- cursor: pointer;
1484
- font-size: 11px;
1485
- }
1486
-
1487
- .dashboard-section-header .header-actions button:hover {
1488
- background: var(--tab-hover);
1489
- color: var(--text-primary);
1490
- }
1491
-
1492
- .dashboard-section-content {
1493
- flex: 1;
1494
- overflow-y: auto;
1495
- padding: 8px 12px;
1496
- }
1497
-
1498
- .dashboard-section.collapsed .dashboard-section-content {
1499
- display: none;
1500
- }
1501
-
1502
- /* Legacy support */
1503
- .dashboard-column {
1504
- background: var(--bg-secondary);
1505
- border: 1px solid var(--border);
1506
- border-radius: 8px;
1507
- padding: 12px;
1508
- overflow: hidden;
1509
- display: flex;
1510
- flex-direction: column;
1511
- max-height: 280px;
1512
- }
1513
-
1514
- .dashboard-column-header {
1515
- display: flex;
1516
- justify-content: space-between;
1517
- align-items: center;
1518
- margin-bottom: 8px;
1519
- flex-shrink: 0;
1520
- }
1521
-
1522
- .dashboard-column-header h3 {
1523
- font-size: 12px;
1524
- text-transform: uppercase;
1525
- color: var(--text-muted);
1526
- letter-spacing: 0.5px;
1527
- margin: 0;
1528
- }
1529
-
1530
- .dashboard-column-header .header-actions {
1531
- display: flex;
1532
- gap: 4px;
1533
- }
1534
-
1535
- .dashboard-column-header .header-actions button {
1536
- padding: 4px 8px;
1537
- border-radius: 4px;
1538
- border: 1px solid var(--border);
1539
- background: var(--bg-tertiary);
1540
- color: var(--text-secondary);
1541
- cursor: pointer;
1542
- font-size: 11px;
1543
- }
1544
-
1545
- .dashboard-column-header .header-actions button:hover {
1546
- background: var(--tab-hover);
1547
- color: var(--text-primary);
1548
- }
1549
-
1550
- .dashboard-tabs-list {
1551
- flex: 1;
1552
- overflow-y: auto;
1553
- margin-bottom: 8px;
1554
- }
1555
-
1556
- .dashboard-tab-item {
1557
- display: flex;
1558
- align-items: center;
1559
- gap: 8px;
1560
- padding: 6px 8px;
1561
- border-radius: 4px;
1562
- cursor: pointer;
1563
- font-size: 13px;
1564
- color: var(--text-secondary);
1565
- }
1566
-
1567
- .dashboard-tab-item:hover {
1568
- background: var(--bg-tertiary);
1569
- }
1570
-
1571
- .dashboard-tab-item.active {
1572
- background: var(--accent);
1573
- color: white;
1574
- }
1575
-
1576
- .dashboard-tab-item .tab-icon {
1577
- font-size: 14px;
1578
- flex-shrink: 0;
1579
- }
1580
-
1581
- .dashboard-tab-item .tab-name {
1582
- flex: 1;
1583
- overflow: hidden;
1584
- text-overflow: ellipsis;
1585
- white-space: nowrap;
1586
- }
1587
-
1588
- .dashboard-actions {
1589
- flex-shrink: 0;
1590
- display: flex;
1591
- gap: 8px;
1592
- }
1593
-
1594
- .dashboard-actions .btn-action {
1595
- flex: 1;
1596
- padding: 8px 12px;
1597
- border-radius: 4px;
1598
- border: 1px dashed var(--border);
1599
- background: transparent;
1600
- color: var(--text-muted);
1601
- cursor: pointer;
1602
- font-size: 12px;
1603
- display: flex;
1604
- align-items: center;
1605
- justify-content: center;
1606
- gap: 4px;
1607
- }
1608
-
1609
- .dashboard-actions .btn-action:hover {
1610
- border-style: solid;
1611
- color: var(--text-secondary);
1612
- background: var(--bg-tertiary);
1613
- }
1614
-
1615
- .dashboard-files-list {
1616
- flex: 1;
1617
- overflow-y: auto;
1618
- }
1619
-
1620
- .dashboard-files-list .tree-item {
1621
- padding: 3px 6px;
1622
- font-size: 12px;
1623
- }
1624
-
1625
- .dashboard-files-list .tree-item-name {
1626
- font-size: 12px;
1627
- }
1628
-
1629
- /* File search styles (Spec 0058) */
1630
- .files-search-container {
1631
- display: flex;
1632
- align-items: center;
1633
- padding: 6px 8px;
1634
- gap: 6px;
1635
- border-bottom: 1px solid var(--border);
1636
- }
1637
-
1638
- .files-search-input {
1639
- flex: 1;
1640
- background: var(--bg-tertiary);
1641
- border: 1px solid var(--border);
1642
- border-radius: 4px;
1643
- padding: 6px 10px;
1644
- font-size: 12px;
1645
- color: var(--text-primary);
1646
- outline: none;
1647
- }
1648
-
1649
- .files-search-input:focus {
1650
- border-color: var(--accent);
1651
- }
1652
-
1653
- .files-search-input::placeholder {
1654
- color: var(--text-muted);
1655
- }
1656
-
1657
- .files-search-clear {
1658
- background: transparent;
1659
- border: none;
1660
- color: var(--text-muted);
1661
- cursor: pointer;
1662
- font-size: 14px;
1663
- padding: 2px 6px;
1664
- border-radius: 4px;
1665
- line-height: 1;
1666
- }
1667
-
1668
- .files-search-clear:hover {
1669
- color: var(--text-primary);
1670
- background: var(--bg-tertiary);
1671
- }
1672
-
1673
- .files-search-clear.hidden {
1674
- display: none;
1675
- }
1676
-
1677
- .files-search-results {
1678
- flex: 1;
1679
- overflow-y: auto;
1680
- }
1681
-
1682
- .files-search-result {
1683
- padding: 6px 12px;
1684
- cursor: pointer;
1685
- display: flex;
1686
- flex-direction: column;
1687
- gap: 2px;
1688
- }
1689
-
1690
- .files-search-result:hover,
1691
- .files-search-result.selected {
1692
- background: var(--bg-tertiary);
1693
- }
1694
-
1695
- .files-search-result-name {
1696
- font-size: 12px;
1697
- color: var(--text-primary);
1698
- }
1699
-
1700
- .files-search-result-path {
1701
- font-size: 11px;
1702
- color: var(--text-muted);
1703
- overflow: hidden;
1704
- text-overflow: ellipsis;
1705
- white-space: nowrap;
1706
- }
1707
-
1708
- .files-search-highlight {
1709
- color: var(--accent);
1710
- font-weight: 500;
1711
- }
1712
-
1713
- /* Cmd+P Palette styles (Spec 0058) */
1714
- .file-palette {
1715
- position: fixed;
1716
- inset: 0;
1717
- z-index: 1000;
1718
- display: flex;
1719
- justify-content: center;
1720
- padding-top: 80px;
1721
- }
1722
-
1723
- .file-palette.hidden {
1724
- display: none;
1725
- }
1726
-
1727
- .file-palette-backdrop {
1728
- position: absolute;
1729
- inset: 0;
1730
- background: rgba(0, 0, 0, 0.5);
1731
- }
1732
-
1733
- .file-palette-container {
1734
- position: relative;
1735
- width: 500px;
1736
- max-width: 90vw;
1737
- max-height: 450px;
1738
- background: var(--bg-secondary);
1739
- border: 1px solid var(--border);
1740
- border-radius: 8px;
1741
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
1742
- display: flex;
1743
- flex-direction: column;
1744
- overflow: hidden;
1745
- }
1746
-
1747
- .file-palette-input {
1748
- width: 100%;
1749
- padding: 14px 16px;
1750
- background: var(--bg-tertiary);
1751
- border: none;
1752
- border-bottom: 1px solid var(--border);
1753
- font-size: 14px;
1754
- color: var(--text-primary);
1755
- outline: none;
1756
- }
1757
-
1758
- .file-palette-input::placeholder {
1759
- color: var(--text-muted);
1760
- }
1761
-
1762
- .file-palette-results {
1763
- flex: 1;
1764
- overflow-y: auto;
1765
- max-height: 380px;
1766
- }
1767
-
1768
- .file-palette-result {
1769
- padding: 10px 16px;
1770
- cursor: pointer;
1771
- display: flex;
1772
- flex-direction: column;
1773
- gap: 2px;
1774
- }
1775
-
1776
- .file-palette-result:hover,
1777
- .file-palette-result.selected {
1778
- background: var(--bg-tertiary);
1779
- }
1780
-
1781
- .file-palette-result-name {
1782
- font-size: 13px;
1783
- color: var(--text-primary);
1784
- }
1785
-
1786
- .file-palette-result-path {
1787
- font-size: 12px;
1788
- color: var(--text-muted);
1789
- overflow: hidden;
1790
- text-overflow: ellipsis;
1791
- white-space: nowrap;
1792
- }
1793
-
1794
- .file-palette-empty {
1795
- padding: 16px;
1796
- text-align: center;
1797
- color: var(--text-muted);
1798
- font-size: 13px;
1799
- }
1800
-
1801
- .dashboard-empty-state {
1802
- color: var(--text-muted);
1803
- font-size: 13px;
1804
- padding: 12px;
1805
- text-align: center;
1806
- }
1807
-
1808
- /* Status indicators in dashboard tab list */
1809
- .dashboard-status-indicator {
1810
- width: 8px;
1811
- height: 8px;
1812
- border-radius: 50%;
1813
- flex-shrink: 0;
1814
- }
1815
-
1816
- .dashboard-status-working {
1817
- background: var(--status-active);
1818
- animation: status-pulse 2s ease-in-out infinite;
1819
- }
1820
-
1821
- .dashboard-status-idle {
1822
- background: var(--status-waiting);
1823
- animation: status-blink-slow 3s ease-in-out infinite;
1824
- }
1825
-
1826
- .dashboard-status-blocked {
1827
- background: var(--status-error);
1828
- animation: status-blink-fast 0.8s ease-in-out infinite;
1829
- }
1830
-
1831
- @media (prefers-reduced-motion: reduce) {
1832
- .dashboard-status-working,
1833
- .dashboard-status-idle,
1834
- .dashboard-status-blocked {
1835
- animation: none;
1836
- }
1837
- }
1838
- </style>
1839
- </head>
1840
- <body>
1841
- <header class="header">
1842
- <h1>Agent Farm - {{PROJECT_NAME}}</h1>
1843
- <div class="header-actions">
1844
- <button class="btn activity-summary-btn" onclick="showActivitySummary()" title="What did I do today?">
1845
- 🕐 Today
1846
- </button>
1847
- </div>
1848
- </header>
1849
-
1850
- <main class="main">
1851
- <!-- Left pane: Architect terminal -->
1852
- <div class="left-pane">
1853
- <div class="pane-header">
1854
- <span class="status-dot" id="architect-status"></span>
1855
- <span>Architect</span>
1856
- </div>
1857
- <div id="architect-content"></div>
1858
- </div>
1859
-
1860
- <!-- Right pane: Tabbed interface -->
1861
- <div class="right-pane">
1862
- <div class="tab-bar">
1863
- <div class="tabs-scroll" id="tabs-container"></div>
1864
- <button class="overflow-btn" id="overflow-btn" onclick="toggleOverflowMenu()" aria-haspopup="true" aria-expanded="false" title="Show all tabs">
1865
- <span>...</span>
1866
- <span class="overflow-count" id="overflow-count">+0</span>
1867
- </button>
1868
- <div class="overflow-menu hidden" id="overflow-menu" role="menu"></div>
1869
- </div>
1870
- <div class="tab-content" id="tab-content"></div>
1871
- </div>
1872
- </main>
1873
-
1874
- <footer class="status-bar">
1875
- <div class="status-item" id="status-architect">
1876
- <span class="dot" style="background: var(--text-muted)"></span>
1877
- <span>Architect: stopped</span>
1878
- </div>
1879
- <div class="status-item" id="status-builders">
1880
- <span>0 builders</span>
1881
- </div>
1882
- <div class="status-item" id="status-shells">
1883
- <span>0 shells</span>
1884
- </div>
1885
- <div class="status-item" id="status-files">
1886
- <span>0 files</span>
1887
- </div>
1888
- </footer>
1889
-
1890
- <!-- File picker dialog -->
1891
- <div class="dialog-overlay hidden" id="file-dialog">
1892
- <div class="dialog">
1893
- <h3>Open File</h3>
1894
- <div class="quick-paths">
1895
- <button class="quick-path" onclick="setFilePath('codev/specs/')">codev/specs/</button>
1896
- <button class="quick-path" onclick="setFilePath('codev/plans/')">codev/plans/</button>
1897
- <button class="quick-path" onclick="setFilePath('codev/reviews/')">codev/reviews/</button>
1898
- </div>
1899
- <input type="text" id="file-path-input" placeholder="Enter file path..." />
1900
- <div class="dialog-actions">
1901
- <button class="btn" onclick="hideFileDialog()">Cancel</button>
1902
- <button class="btn" onclick="openFile()">Open</button>
1903
- </div>
1904
- </div>
1905
- </div>
1906
-
1907
- <!-- Close confirmation dialog -->
1908
- <div class="dialog-overlay hidden" id="close-dialog">
1909
- <div class="dialog">
1910
- <h3 id="close-dialog-title">Close tab?</h3>
1911
- <p id="close-dialog-message" style="color: var(--text-secondary); margin-bottom: 16px; font-size: 14px;"></p>
1912
- <div class="dialog-actions">
1913
- <button class="btn" onclick="hideCloseDialog()">Cancel</button>
1914
- <button class="btn btn-danger" onclick="confirmClose()">Close</button>
1915
- </div>
1916
- </div>
1917
- </div>
1918
-
1919
- <!-- Context menu -->
1920
- <div class="context-menu hidden" id="context-menu" role="menu">
1921
- <div class="context-menu-item" role="menuitem" tabindex="0" data-action="openContextTab" onclick="openContextTab()" onkeydown="handleContextMenuKeydown(event)">Open in New Tab</div>
1922
- <div class="context-menu-item" role="menuitem" tabindex="-1" data-action="reloadContextTab" id="context-reload" onclick="reloadContextTab()" onkeydown="handleContextMenuKeydown(event)">Reload</div>
1923
- <div class="context-menu-item" role="menuitem" tabindex="-1" data-action="closeActiveTab" onclick="closeActiveTab()" onkeydown="handleContextMenuKeydown(event)">Close</div>
1924
- <div class="context-menu-item" role="menuitem" tabindex="-1" data-action="closeOtherTabs" onclick="closeOtherTabs()" onkeydown="handleContextMenuKeydown(event)">Close Others</div>
1925
- <div class="context-menu-item danger" role="menuitem" tabindex="-1" data-action="closeAllTabs" onclick="closeAllTabs()" onkeydown="handleContextMenuKeydown(event)">Close All</div>
1926
- </div>
1927
-
1928
- <!-- Toast container -->
1929
- <div class="toast-container" id="toast-container"></div>
1930
-
1931
- <!-- Activity Summary Modal (Spec 0059) -->
1932
- <div class="dialog-overlay hidden" id="activity-modal">
1933
- <div class="dialog activity-dialog">
1934
- <div class="activity-dialog-header">
1935
- <h3>Today's Summary</h3>
1936
- <button class="activity-close-btn" onclick="closeActivityModal()" title="Close (Esc)">×</button>
1937
- </div>
1938
- <div class="activity-dialog-content" id="activity-content">
1939
- <div class="activity-loading">
1940
- <span class="activity-spinner"></span>
1941
- Loading activity...
1942
- </div>
1943
- </div>
1944
- <div class="activity-dialog-footer">
1945
- <button class="btn" onclick="copyActivitySummary()">📋 Copy to Clipboard</button>
1946
- <button class="btn" onclick="closeActivityModal()">Close</button>
1947
- </div>
1948
- </div>
1949
- </div>
1950
-
1951
- <!-- File search palette (Cmd+P) - Spec 0058 -->
1952
- <div id="file-palette" class="file-palette hidden">
1953
- <div class="file-palette-backdrop" onclick="closePalette()"></div>
1954
- <div class="file-palette-container">
1955
- <input type="text"
1956
- id="palette-input"
1957
- class="file-palette-input"
1958
- placeholder="Search files by name..."
1959
- oninput="onPaletteInput(this.value)"
1960
- onkeydown="onPaletteKeydown(event)" />
1961
- <div id="palette-results" class="file-palette-results"></div>
1962
- </div>
1963
- </div>
1964
-
1965
- <script>
1966
- // STATE_INJECTION_POINT
1967
-
1968
- // State management
1969
- const state = window.INITIAL_STATE || {
1970
- architect: null,
1971
- builders: [],
1972
- utils: [],
1973
- annotations: []
1974
- };
1975
-
1976
- // Tab state
1977
- let tabs = [];
1978
- let activeTabId = null;
1979
- let pendingCloseTabId = null;
1980
- let contextMenuTabId = null;
1981
-
1982
- // Collapsible section state (persisted to localStorage)
1983
- const SECTION_STATE_KEY = 'codev-dashboard-sections';
1984
- let sectionState = loadSectionState();
1985
-
1986
- function loadSectionState() {
1987
- try {
1988
- const saved = localStorage.getItem(SECTION_STATE_KEY);
1989
- if (saved) return JSON.parse(saved);
1990
- } catch (e) { /* ignore */ }
1991
- return { tabs: true, files: true, projects: true };
1992
- }
1993
-
1994
- function saveSectionState() {
1995
- try {
1996
- localStorage.setItem(SECTION_STATE_KEY, JSON.stringify(sectionState));
1997
- } catch (e) { /* ignore */ }
1998
- }
1999
-
2000
- function toggleSection(section) {
2001
- sectionState[section] = !sectionState[section];
2002
- saveSectionState();
2003
- renderDashboardTabContent();
2004
- }
2005
-
2006
- // Initialize
2007
- function init() {
2008
- buildTabsFromState();
2009
- renderArchitect();
2010
- renderTabs();
2011
- renderTabContent();
2012
- updateStatusBar();
2013
- startPolling();
2014
- setupBroadcastChannel();
2015
- setupOverflowDetection();
2016
- }
2017
-
2018
- // Set up overflow detection for the tab bar
2019
- function setupOverflowDetection() {
2020
- const container = document.getElementById('tabs-container');
2021
-
2022
- // Check on load
2023
- checkTabOverflow();
2024
-
2025
- // Check on window resize (debounced)
2026
- let resizeTimeout;
2027
- window.addEventListener('resize', () => {
2028
- clearTimeout(resizeTimeout);
2029
- resizeTimeout = setTimeout(checkTabOverflow, 100);
2030
- });
2031
-
2032
- // Check on scroll (debounced) - updates +N count when user scrolls tabs
2033
- if (container) {
2034
- let scrollTimeout;
2035
- container.addEventListener('scroll', () => {
2036
- clearTimeout(scrollTimeout);
2037
- scrollTimeout = setTimeout(checkTabOverflow, 50);
2038
- });
2039
- }
2040
-
2041
- // Also use ResizeObserver for the tabs container if available
2042
- if (typeof ResizeObserver !== 'undefined') {
2043
- if (container) {
2044
- const observer = new ResizeObserver(() => {
2045
- checkTabOverflow();
2046
- });
2047
- observer.observe(container);
2048
- }
2049
- }
2050
- }
2051
-
2052
- // Set up BroadcastChannel for cross-tab communication
2053
- // This allows terminal file clicks to open files in the dashboard
2054
- function setupBroadcastChannel() {
2055
- const channel = new BroadcastChannel('agent-farm');
2056
- channel.onmessage = async (event) => {
2057
- const { type, path, line } = event.data;
2058
- if (type === 'openFile' && path) {
2059
- await openFileFromMessage(path, line);
2060
- }
2061
- };
2062
- }
2063
-
2064
- // Open a file from a BroadcastChannel message
2065
- async function openFileFromMessage(filePath, lineNumber) {
2066
- try {
2067
- // Check if file is already open
2068
- const existingTab = tabs.find(t => t.type === 'file' && t.path === filePath);
2069
- if (existingTab) {
2070
- // Switch to the existing tab and refresh content
2071
- selectTab(existingTab.id);
2072
- refreshFileTab(existingTab.id); // Refresh content in case file changed
2073
- showToast(`Switched to ${getFileName(filePath)}`, 'success');
2074
- // TODO: scroll to line if lineNumber provided
2075
- return;
2076
- }
2077
-
2078
- // Open the file via API
2079
- const response = await fetch('/api/tabs/file', {
2080
- method: 'POST',
2081
- headers: { 'Content-Type': 'application/json' },
2082
- body: JSON.stringify({ path: filePath })
2083
- });
2084
-
2085
- if (!response.ok) {
2086
- throw new Error(await response.text());
2087
- }
2088
-
2089
- const result = await response.json();
2090
-
2091
- // Refresh state and switch to the new tab
2092
- await refresh();
2093
-
2094
- // Find and select the new file tab
2095
- const newTab = tabs.find(t => t.type === 'file' && (t.path === filePath || t.annotationId === result.id));
2096
- if (newTab) {
2097
- selectTab(newTab.id);
2098
- }
2099
-
2100
- showToast(`Opened ${getFileName(filePath)}${lineNumber ? ':' + lineNumber : ''}`, 'success');
2101
- } catch (err) {
2102
- showToast('Failed to open file: ' + err.message, 'error');
2103
- }
2104
- }
2105
-
2106
- // Track known tab IDs to detect new tabs
2107
- let knownTabIds = new Set();
2108
-
2109
- // Projects tab state
2110
- let projectsData = [];
2111
- let projectlistHash = null;
2112
- let expandedProjectId = null;
2113
- let projectlistError = null;
2114
- let projectlistDebounce = null;
2115
-
2116
- // Files tab state (Spec 0055)
2117
- let filesTreeData = [];
2118
- let filesTreeExpanded = new Set(); // Set of expanded folder paths
2119
- let filesTreeError = null;
2120
- let filesTreeLoaded = false;
2121
-
2122
- // File search state (Spec 0058)
2123
- let filesTreeFlat = []; // Flattened array of {name, path} objects for searching
2124
- let filesSearchQuery = '';
2125
- let filesSearchResults = [];
2126
- let filesSearchIndex = 0;
2127
- let filesSearchDebounceTimer = null;
2128
-
2129
- // Cmd+P palette state (Spec 0058)
2130
- let paletteOpen = false;
2131
- let paletteQuery = '';
2132
- let paletteResults = [];
2133
- let paletteIndex = 0;
2134
- let paletteDebounceTimer = null;
2135
-
2136
- // Build tabs from initial state
2137
- function buildTabsFromState() {
2138
- const previousTabIds = new Set(tabs.map(t => t.id));
2139
- // Preserve client-side-only tabs (like activity)
2140
- const clientSideTabs = tabs.filter(t => t.type === 'activity');
2141
- tabs = [];
2142
-
2143
- // Dashboard tab is ALWAYS first and uncloseable (Spec 0045, 0057)
2144
- tabs.push({
2145
- id: 'dashboard',
2146
- type: 'dashboard',
2147
- name: 'Dashboard',
2148
- closeable: false
2149
- });
2150
-
2151
- // Add file tabs from annotations
2152
- for (const annotation of state.annotations || []) {
2153
- tabs.push({
2154
- id: `file-${annotation.id}`,
2155
- type: 'file',
2156
- name: getFileName(annotation.file),
2157
- path: annotation.file,
2158
- port: annotation.port,
2159
- annotationId: annotation.id
2160
- });
2161
- }
2162
-
2163
- // Add builder tabs
2164
- for (const builder of state.builders || []) {
2165
- tabs.push({
2166
- id: `builder-${builder.id}`,
2167
- type: 'builder',
2168
- name: builder.name || `Builder ${builder.id}`,
2169
- projectId: builder.id,
2170
- port: builder.port,
2171
- status: builder.status
2172
- });
2173
- }
2174
-
2175
- // Add shell tabs
2176
- for (const util of state.utils || []) {
2177
- tabs.push({
2178
- id: `shell-${util.id}`,
2179
- type: 'shell',
2180
- name: util.name,
2181
- port: util.port,
2182
- utilId: util.id
2183
- });
2184
- }
2185
-
2186
- // Re-add preserved client-side tabs
2187
- for (const tab of clientSideTabs) {
2188
- tabs.push(tab);
2189
- }
2190
-
2191
- // Detect new tabs and auto-switch to them (skip projects tab)
2192
- for (const tab of tabs) {
2193
- if (tab.id !== 'dashboard' && tab.id !== 'files' && !knownTabIds.has(tab.id) && previousTabIds.size > 0) {
2194
- // This is a new tab - switch to it
2195
- activeTabId = tab.id;
2196
- break;
2197
- }
2198
- }
2199
-
2200
- // Update known tab IDs
2201
- knownTabIds = new Set(tabs.map(t => t.id));
2202
-
2203
- // Set active tab to Dashboard on first load if none selected
2204
- if (!activeTabId) {
2205
- activeTabId = 'dashboard';
2206
- }
2207
- }
2208
-
2209
- // Get filename from path (includes parent dir for context)
2210
- function getFileName(path) {
2211
- const parts = path.split('/').filter(p => p);
2212
- if (parts.length >= 2) {
2213
- return parts.slice(-2).join('/');
2214
- }
2215
- return parts[parts.length - 1] || path;
2216
- }
2217
-
2218
- // Track current architect port to avoid re-rendering iframe unnecessarily
2219
- let currentArchitectPort = null;
2220
-
2221
- // Render architect pane
2222
- function renderArchitect() {
2223
- const content = document.getElementById('architect-content');
2224
- const statusDot = document.getElementById('architect-status');
2225
-
2226
- if (state.architect && state.architect.port) {
2227
- statusDot.classList.remove('inactive');
2228
- // Only update iframe if port changed (avoid flashing on poll)
2229
- if (currentArchitectPort !== state.architect.port) {
2230
- currentArchitectPort = state.architect.port;
2231
- content.innerHTML = `<iframe src="http://localhost:${state.architect.port}" title="Architect Terminal" allow="clipboard-read; clipboard-write"></iframe>`;
2232
- }
2233
- } else {
2234
- if (currentArchitectPort !== null) {
2235
- currentArchitectPort = null;
2236
- content.innerHTML = `
2237
- <div class="architect-placeholder">
2238
- <p>Architect not running</p>
2239
- <p>Run <code>agent-farm start</code> to begin</p>
2240
- </div>
2241
- `;
2242
- }
2243
- statusDot.classList.add('inactive');
2244
- }
2245
- }
2246
-
2247
- // Render tabs
2248
- function renderTabs() {
2249
- const container = document.getElementById('tabs-container');
2250
-
2251
- if (tabs.length === 0) {
2252
- container.innerHTML = '';
2253
- checkTabOverflow(); // Update overflow state when tabs cleared
2254
- return;
2255
- }
2256
-
2257
- container.innerHTML = tabs.map(tab => {
2258
- const isActive = tab.id === activeTabId;
2259
- const icon = getTabIcon(tab.type);
2260
- const statusDot = tab.type === 'builder' ? getStatusDot(tab.status) : '';
2261
- const tooltip = getTabTooltip(tab);
2262
- const isUncloseable = tab.closeable === false;
2263
-
2264
- return `
2265
- <div class="tab ${isActive ? 'active' : ''} ${isUncloseable ? 'tab-uncloseable' : ''}"
2266
- onclick="selectTab('${tab.id}')"
2267
- oncontextmenu="showContextMenu(event, '${tab.id}')"
2268
- data-tab-id="${tab.id}"
2269
- title="${tooltip}">
2270
- <span class="icon">${icon}</span>
2271
- <span class="name">${tab.name}</span>
2272
- ${statusDot}
2273
- ${!isUncloseable ? `<span class="close"
2274
- onclick="event.stopPropagation(); closeTab('${tab.id}', event)"
2275
- role="button"
2276
- tabindex="0"
2277
- aria-label="Close ${tab.name}"
2278
- onkeydown="if(event.key==='Enter'||event.key===' '){event.stopPropagation();closeTab('${tab.id}',event)}">&times;</span>` : ''}
2279
- </div>
2280
- `;
2281
- }).join('');
2282
-
2283
- // Check overflow after tabs are rendered
2284
- checkTabOverflow();
2285
- }
2286
-
2287
- // Get tab icon
2288
- function getTabIcon(type) {
2289
- switch (type) {
2290
- case 'dashboard': return '🏠';
2291
- case 'files': return '📁';
2292
- case 'file': return '📄';
2293
- case 'builder': return '🔨';
2294
- case 'shell': return '>_';
2295
- default: return '?';
2296
- }
2297
- }
2298
-
2299
- // Status configuration - hoisted for performance (per Codex review)
2300
- // Colors per spec 0019: green=active, yellow=waiting, red=blocked, gray=complete
2301
- // Animations per spec 0019: pulse=active, blink-slow=waiting, blink-fast=blocked, static=complete
2302
- // Shapes for accessibility: circle=default, diamond=blocked, ring=waiting
2303
- const STATUS_CONFIG = {
2304
- 'spawning': { color: 'var(--status-active)', label: 'Spawning', shape: 'circle', animation: 'pulse' },
2305
- 'implementing': { color: 'var(--status-active)', label: 'Implementing', shape: 'circle', animation: 'pulse' },
2306
- 'blocked': { color: 'var(--status-error)', label: 'Blocked', shape: 'diamond', animation: 'blink-fast' },
2307
- 'pr-ready': { color: 'var(--status-waiting)', label: 'PR Ready', shape: 'ring', animation: 'blink-slow' },
2308
- 'complete': { color: 'var(--status-complete)', label: 'Complete', shape: 'circle', animation: null }
2309
- };
2310
- const DEFAULT_STATUS_CONFIG = { color: 'var(--text-muted)', label: 'Unknown', shape: 'circle', animation: null };
2311
-
2312
- // Get status dot HTML with accessibility support
2313
- // Accessibility: distinct animations per status, shapes for reduced-motion users
2314
- // Uses role="img" instead of role="status" to avoid screen reader chatter on poll (per Codex review)
2315
- function getStatusDot(status) {
2316
- const config = STATUS_CONFIG[status] || { ...DEFAULT_STATUS_CONFIG, label: status || 'Unknown' };
2317
- // Build CSS classes for accessibility
2318
- const classes = ['status-dot'];
2319
- if (config.shape === 'diamond') classes.push('status-dot--diamond');
2320
- if (config.shape === 'ring') classes.push('status-dot--ring');
2321
- if (config.animation === 'pulse') classes.push('status-dot--pulse');
2322
- if (config.animation === 'blink-slow') classes.push('status-dot--blink-slow');
2323
- if (config.animation === 'blink-fast') classes.push('status-dot--blink-fast');
2324
- return `<span class="${classes.join(' ')}" style="background: ${config.color}" title="${config.label}" role="img" aria-label="${config.label}"></span>`;
2325
- }
2326
-
2327
- // Escape HTML special characters to prevent XSS
2328
- function escapeHtml(text) {
2329
- return String(text)
2330
- .replace(/&/g, '&amp;')
2331
- .replace(/</g, '&lt;')
2332
- .replace(/>/g, '&gt;')
2333
- .replace(/"/g, '&quot;')
2334
- .replace(/'/g, '&#39;');
2335
- }
2336
-
2337
- // Generate tooltip text for tab hover
2338
- function getTabTooltip(tab) {
2339
- const lines = [tab.name];
2340
-
2341
- if (tab.type === 'builder') {
2342
- if (tab.port) lines.push(`Port: ${tab.port}`);
2343
- lines.push(`Status: ${tab.status || 'unknown'}`);
2344
- // Extract project ID from tab id (e.g., "builder-0037" -> "0037")
2345
- const projectId = tab.id.replace('builder-', '');
2346
- lines.push(`Worktree: .builders/${projectId}`);
2347
- } else if (tab.type === 'file') {
2348
- lines.push(`Path: ${tab.path}`);
2349
- if (tab.port) lines.push(`Port: ${tab.port}`);
2350
- } else if (tab.type === 'shell') {
2351
- if (tab.port) lines.push(`Port: ${tab.port}`);
2352
- }
2353
-
2354
- return escapeHtml(lines.join('\n'));
2355
- }
2356
-
2357
- // Track current tab content to avoid re-rendering iframe unnecessarily
2358
- let currentTabPort = null;
2359
- let currentTabType = null;
2360
-
2361
- // Render tab content
2362
- function renderTabContent() {
2363
- const content = document.getElementById('tab-content');
2364
-
2365
- if (!activeTabId || tabs.length === 0) {
2366
- if (currentTabPort !== null || currentTabType !== null) {
2367
- currentTabPort = null;
2368
- currentTabType = null;
2369
- content.innerHTML = `
2370
- <div class="empty-state">
2371
- <p>No tabs open</p>
2372
- <p class="hint">Click the + buttons above or ask the architect to open files/builders</p>
2373
- </div>
2374
- `;
2375
- }
2376
- return;
2377
- }
2378
-
2379
- const tab = tabs.find(t => t.id === activeTabId);
2380
- if (!tab) {
2381
- if (currentTabPort !== null || currentTabType !== null) {
2382
- currentTabPort = null;
2383
- currentTabType = null;
2384
- content.innerHTML = '<div class="empty-state"><p>Tab not found</p></div>';
2385
- }
2386
- return;
2387
- }
2388
-
2389
- // Handle dashboard tab specially (no iframe, inline content)
2390
- if (tab.type === 'dashboard') {
2391
- if (currentTabType !== 'dashboard') {
2392
- currentTabType = 'dashboard';
2393
- currentTabPort = null;
2394
- renderDashboardTab();
2395
- }
2396
- return;
2397
- }
2398
-
2399
- // Handle activity tab specially (no iframe, inline content)
2400
- if (tab.type === 'activity') {
2401
- if (currentTabType !== 'activity') {
2402
- currentTabType = 'activity';
2403
- currentTabPort = null;
2404
- renderActivityTab();
2405
- }
2406
- return;
2407
- }
2408
-
2409
- // For other tabs, only update iframe if port changed (avoid flashing on poll)
2410
- if (currentTabPort !== tab.port || currentTabType !== tab.type) {
2411
- currentTabPort = tab.port;
2412
- currentTabType = tab.type;
2413
- content.innerHTML = `<iframe src="http://localhost:${tab.port}" title="${tab.name}" allow="clipboard-read; clipboard-write"></iframe>`;
2414
- }
2415
- }
2416
-
2417
- // Force refresh the iframe for a file tab (reloads content from server)
2418
- function refreshFileTab(tabId) {
2419
- const tab = tabs.find(t => t.id === tabId);
2420
- if (!tab || tab.type !== 'file' || !tab.port) return;
2421
-
2422
- // If this tab is currently active, force iframe reload
2423
- if (activeTabId === tabId) {
2424
- const content = document.getElementById('tab-content');
2425
- const iframe = content.querySelector('iframe');
2426
- if (iframe) {
2427
- // Add cache-busting query param to force reload
2428
- iframe.src = `http://localhost:${tab.port}?t=${Date.now()}`;
2429
- }
2430
- }
2431
- }
2432
-
2433
- // Update status bar
2434
- function updateStatusBar() {
2435
- // Architect status
2436
- const archStatus = document.getElementById('status-architect');
2437
- if (state.architect) {
2438
- archStatus.innerHTML = `
2439
- <span class="dot" style="background: var(--status-active)"></span>
2440
- <span>Architect: running</span>
2441
- `;
2442
- } else {
2443
- archStatus.innerHTML = `
2444
- <span class="dot" style="background: var(--text-muted)"></span>
2445
- <span>Architect: stopped</span>
2446
- `;
2447
- }
2448
-
2449
- // Counts
2450
- const builderCount = (state.builders || []).length;
2451
- const shellCount = (state.utils || []).length;
2452
- const fileCount = (state.annotations || []).length;
2453
-
2454
- document.getElementById('status-builders').innerHTML = `<span>${builderCount} builder${builderCount !== 1 ? 's' : ''}</span>`;
2455
- document.getElementById('status-shells').innerHTML = `<span>${shellCount} shell${shellCount !== 1 ? 's' : ''}</span>`;
2456
- document.getElementById('status-files').innerHTML = `<span>${fileCount} file${fileCount !== 1 ? 's' : ''}</span>`;
2457
- }
2458
-
2459
- // Select tab
2460
- function selectTab(tabId) {
2461
- activeTabId = tabId;
2462
- renderTabs();
2463
- renderTabContent();
2464
- // Scroll the active tab into view if needed
2465
- scrollActiveTabIntoView();
2466
- }
2467
-
2468
- // Scroll the active tab into view
2469
- function scrollActiveTabIntoView() {
2470
- const container = document.getElementById('tabs-container');
2471
- const activeTab = container.querySelector('.tab.active');
2472
- if (activeTab) {
2473
- activeTab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
2474
- }
2475
- }
2476
-
2477
- // Check if tabs are overflowing and update the overflow button
2478
- function checkTabOverflow() {
2479
- const container = document.getElementById('tabs-container');
2480
- const overflowBtn = document.getElementById('overflow-btn');
2481
- const overflowCount = document.getElementById('overflow-count');
2482
-
2483
- if (!container || !overflowBtn) return;
2484
-
2485
- const isOverflowing = container.scrollWidth > container.clientWidth;
2486
- overflowBtn.style.display = isOverflowing ? 'flex' : 'none';
2487
-
2488
- if (isOverflowing) {
2489
- // Count hidden tabs (those partially or fully outside visible area - both sides)
2490
- const tabElements = container.querySelectorAll('.tab');
2491
- const containerRect = container.getBoundingClientRect();
2492
- let hiddenCount = 0;
2493
-
2494
- tabElements.forEach(tab => {
2495
- const rect = tab.getBoundingClientRect();
2496
- // Tab is hidden if scrolled off the right edge
2497
- if (rect.right > containerRect.right + 1) {
2498
- hiddenCount++;
2499
- }
2500
- // Tab is hidden if scrolled off the left edge
2501
- else if (rect.left < containerRect.left - 1) {
2502
- hiddenCount++;
2503
- }
2504
- });
2505
-
2506
- overflowCount.textContent = `+${hiddenCount}`;
2507
- }
2508
- }
2509
-
2510
- // Toggle the overflow menu
2511
- function toggleOverflowMenu() {
2512
- const menu = document.getElementById('overflow-menu');
2513
- const btn = document.getElementById('overflow-btn');
2514
- const isHidden = menu.classList.contains('hidden');
2515
-
2516
- if (isHidden) {
2517
- showOverflowMenu();
2518
- } else {
2519
- hideOverflowMenu();
2520
- }
2521
- }
2522
-
2523
- // Show the overflow menu
2524
- function showOverflowMenu() {
2525
- const menu = document.getElementById('overflow-menu');
2526
- const btn = document.getElementById('overflow-btn');
2527
-
2528
- // Build menu items for all tabs
2529
- menu.innerHTML = tabs.map((tab, index) => {
2530
- const icon = getTabIcon(tab.type);
2531
- const isActive = tab.id === activeTabId;
2532
- return `
2533
- <div class="overflow-menu-item ${isActive ? 'active' : ''}"
2534
- role="menuitem"
2535
- tabindex="${index === 0 ? 0 : -1}"
2536
- data-tab-id="${tab.id}"
2537
- onclick="selectTabFromMenu('${tab.id}')"
2538
- onkeydown="handleOverflowMenuKeydown(event, '${tab.id}')">
2539
- <span class="icon">${icon}</span>
2540
- <span class="name">${tab.name}</span>
2541
- <span class="open-external"
2542
- onclick="event.stopPropagation(); openInNewTabFromMenu('${tab.id}')"
2543
- onkeydown="if(event.key==='Enter'||event.key===' '){event.stopPropagation();openInNewTabFromMenu('${tab.id}')}"
2544
- title="Open in new tab"
2545
- role="button"
2546
- tabindex="0"
2547
- aria-label="Open ${tab.name} in new tab">↗</span>
2548
- </div>
2549
- `;
2550
- }).join('');
2551
-
2552
- menu.classList.remove('hidden');
2553
- btn.setAttribute('aria-expanded', 'true');
2554
-
2555
- // Focus the first item
2556
- const firstItem = menu.querySelector('.overflow-menu-item');
2557
- if (firstItem) firstItem.focus();
2558
-
2559
- // Close on click outside (after a small delay to avoid immediate close)
2560
- setTimeout(() => {
2561
- document.addEventListener('click', handleOverflowClickOutside);
2562
- }, 0);
2563
- }
2564
-
2565
- // Hide the overflow menu
2566
- function hideOverflowMenu() {
2567
- const menu = document.getElementById('overflow-menu');
2568
- const btn = document.getElementById('overflow-btn');
2569
- menu.classList.add('hidden');
2570
- btn.setAttribute('aria-expanded', 'false');
2571
- document.removeEventListener('click', handleOverflowClickOutside);
2572
- }
2573
-
2574
- // Handle click outside overflow menu
2575
- function handleOverflowClickOutside(event) {
2576
- const menu = document.getElementById('overflow-menu');
2577
- const btn = document.getElementById('overflow-btn');
2578
- if (!menu.contains(event.target) && !btn.contains(event.target)) {
2579
- hideOverflowMenu();
2580
- }
2581
- }
2582
-
2583
- // Select tab from overflow menu
2584
- function selectTabFromMenu(tabId) {
2585
- hideOverflowMenu();
2586
- selectTab(tabId);
2587
- }
2588
-
2589
- // Open tab in new window from overflow menu
2590
- function openInNewTabFromMenu(tabId) {
2591
- hideOverflowMenu();
2592
- openInNewTab(tabId);
2593
- }
2594
-
2595
- // Handle keyboard navigation in overflow menu
2596
- function handleOverflowMenuKeydown(event, tabId) {
2597
- const menu = document.getElementById('overflow-menu');
2598
- const items = Array.from(menu.querySelectorAll('.overflow-menu-item'));
2599
- const currentIndex = items.findIndex(item => item === document.activeElement);
2600
-
2601
- switch (event.key) {
2602
- case 'ArrowDown':
2603
- event.preventDefault();
2604
- const nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
2605
- items[nextIndex].focus();
2606
- break;
2607
- case 'ArrowUp':
2608
- event.preventDefault();
2609
- const prevIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
2610
- items[prevIndex].focus();
2611
- break;
2612
- case 'Enter':
2613
- case ' ':
2614
- event.preventDefault();
2615
- selectTabFromMenu(tabId);
2616
- break;
2617
- case 'Escape':
2618
- event.preventDefault();
2619
- hideOverflowMenu();
2620
- document.getElementById('overflow-btn').focus();
2621
- break;
2622
- case 'Tab':
2623
- // Allow Tab to close menu and move focus
2624
- hideOverflowMenu();
2625
- break;
2626
- }
2627
- }
2628
-
2629
- // Close tab
2630
- function closeTab(tabId, event) {
2631
- const tab = tabs.find(t => t.id === tabId);
2632
- if (!tab) return;
2633
-
2634
- // Shift+click bypasses confirmation
2635
- if (event && event.shiftKey) {
2636
- doCloseTab(tabId);
2637
- return;
2638
- }
2639
-
2640
- // Files don't need confirmation
2641
- if (tab.type === 'file') {
2642
- doCloseTab(tabId);
2643
- return;
2644
- }
2645
-
2646
- // Show confirmation for builders and shells
2647
- pendingCloseTabId = tabId;
2648
- const dialog = document.getElementById('close-dialog');
2649
- const title = document.getElementById('close-dialog-title');
2650
- const message = document.getElementById('close-dialog-message');
2651
-
2652
- if (tab.type === 'builder') {
2653
- title.textContent = `Stop builder ${tab.name}?`;
2654
- message.textContent = 'This will terminate the builder process.';
2655
- } else {
2656
- title.textContent = `Close shell ${tab.name}?`;
2657
- message.textContent = 'This will terminate the shell process.';
2658
- }
2659
-
2660
- dialog.classList.remove('hidden');
2661
- }
2662
-
2663
- // Actually close the tab
2664
- async function doCloseTab(tabId) {
2665
- const tab = tabs.find(t => t.id === tabId);
2666
- if (!tab) return;
2667
-
2668
- try {
2669
- // Call API to close the tab
2670
- await fetch(`/api/tabs/${encodeURIComponent(tabId)}`, { method: 'DELETE' });
2671
-
2672
- // Remove from local state
2673
- tabs = tabs.filter(t => t.id !== tabId);
2674
-
2675
- // If closing active tab, switch to another
2676
- if (activeTabId === tabId) {
2677
- activeTabId = tabs.length > 0 ? tabs[tabs.length - 1].id : null;
2678
- }
2679
-
2680
- renderTabs();
2681
- renderTabContent();
2682
- showToast('Tab closed', 'success');
2683
- } catch (err) {
2684
- showToast('Failed to close tab: ' + err.message, 'error');
2685
- }
2686
- }
2687
-
2688
- // Confirm close from dialog
2689
- function confirmClose() {
2690
- if (pendingCloseTabId) {
2691
- doCloseTab(pendingCloseTabId);
2692
- hideCloseDialog();
2693
- }
2694
- }
2695
-
2696
- function hideCloseDialog() {
2697
- document.getElementById('close-dialog').classList.add('hidden');
2698
- pendingCloseTabId = null;
2699
- }
2700
-
2701
- // Context menu
2702
- function showContextMenu(event, tabId) {
2703
- event.preventDefault();
2704
- contextMenuTabId = tabId;
2705
-
2706
- const menu = document.getElementById('context-menu');
2707
- menu.style.left = event.clientX + 'px';
2708
- menu.style.top = event.clientY + 'px';
2709
- menu.classList.remove('hidden');
2710
-
2711
- // Show/hide reload option based on tab type
2712
- const tab = tabs.find(t => t.id === tabId);
2713
- const reloadItem = document.getElementById('context-reload');
2714
- if (reloadItem) {
2715
- reloadItem.style.display = (tab && tab.type === 'file') ? 'block' : 'none';
2716
- }
2717
-
2718
- // Focus first item for keyboard navigation
2719
- const firstItem = menu.querySelector('.context-menu-item');
2720
- if (firstItem) firstItem.focus();
2721
-
2722
- // Close on click outside
2723
- setTimeout(() => {
2724
- document.addEventListener('click', hideContextMenu, { once: true });
2725
- }, 0);
2726
- }
2727
-
2728
- // Reload file tab content
2729
- function reloadContextTab() {
2730
- if (contextMenuTabId) {
2731
- refreshFileTab(contextMenuTabId);
2732
- showToast('Reloaded', 'success');
2733
- }
2734
- hideContextMenu();
2735
- }
2736
-
2737
- function hideContextMenu() {
2738
- document.getElementById('context-menu').classList.add('hidden');
2739
- contextMenuTabId = null;
2740
- }
2741
-
2742
- // Handle keyboard navigation in context menu
2743
- function handleContextMenuKeydown(event) {
2744
- const menu = document.getElementById('context-menu');
2745
- const items = Array.from(menu.querySelectorAll('.context-menu-item'));
2746
- const currentIndex = items.findIndex(item => item === document.activeElement);
2747
-
2748
- switch (event.key) {
2749
- case 'ArrowDown':
2750
- event.preventDefault();
2751
- const nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
2752
- items[nextIndex].focus();
2753
- break;
2754
- case 'ArrowUp':
2755
- event.preventDefault();
2756
- const prevIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
2757
- items[prevIndex].focus();
2758
- break;
2759
- case 'Enter':
2760
- case ' ':
2761
- event.preventDefault();
2762
- const actionName = event.target.dataset.action;
2763
- if (actionName && typeof window[actionName] === 'function') {
2764
- window[actionName]();
2765
- }
2766
- break;
2767
- case 'Escape':
2768
- event.preventDefault();
2769
- hideContextMenu();
2770
- break;
2771
- case 'Tab':
2772
- hideContextMenu();
2773
- break;
2774
- }
2775
- }
2776
-
2777
- function closeActiveTab() {
2778
- if (contextMenuTabId) {
2779
- closeTab(contextMenuTabId);
2780
- }
2781
- hideContextMenu();
2782
- }
2783
-
2784
- function closeOtherTabs() {
2785
- if (contextMenuTabId) {
2786
- // Skip uncloseable tabs (Projects tab)
2787
- const otherTabs = tabs.filter(t => t.id !== contextMenuTabId && t.closeable !== false);
2788
- otherTabs.forEach(t => doCloseTab(t.id));
2789
- }
2790
- hideContextMenu();
2791
- }
2792
-
2793
- function closeAllTabs() {
2794
- // Skip uncloseable tabs (Projects tab)
2795
- tabs.filter(t => t.closeable !== false).forEach(t => doCloseTab(t.id));
2796
- hideContextMenu();
2797
- }
2798
-
2799
- // Open tab content in a new browser tab
2800
- function openInNewTab(tabId) {
2801
- const tab = tabs.find(t => t.id === tabId);
2802
- if (!tab) return;
2803
-
2804
- let url;
2805
- if (tab.type === 'file') {
2806
- // File tabs use the annotation port
2807
- if (!tab.port) {
2808
- showToast('Tab not ready', 'error');
2809
- return;
2810
- }
2811
- url = `http://localhost:${tab.port}`;
2812
- } else {
2813
- // Builder or shell - direct port access
2814
- if (!tab.port) {
2815
- showToast('Tab not ready', 'error');
2816
- return;
2817
- }
2818
- url = `http://localhost:${tab.port}`;
2819
- }
2820
-
2821
- window.open(url, '_blank', 'noopener,noreferrer');
2822
- }
2823
-
2824
- // Open context menu tab in new tab
2825
- function openContextTab() {
2826
- if (contextMenuTabId) {
2827
- openInNewTab(contextMenuTabId);
2828
- }
2829
- hideContextMenu();
2830
- }
2831
-
2832
- // File dialog
2833
- function showFileDialog() {
2834
- document.getElementById('file-dialog').classList.remove('hidden');
2835
- document.getElementById('file-path-input').focus();
2836
- }
2837
-
2838
- function hideFileDialog() {
2839
- document.getElementById('file-dialog').classList.add('hidden');
2840
- document.getElementById('file-path-input').value = '';
2841
- }
2842
-
2843
- function setFilePath(path) {
2844
- document.getElementById('file-path-input').value = path;
2845
- document.getElementById('file-path-input').focus();
2846
- }
2847
-
2848
- async function openFile() {
2849
- const path = document.getElementById('file-path-input').value.trim();
2850
- if (!path) return;
2851
-
2852
- try {
2853
- const response = await fetch('/api/tabs/file', {
2854
- method: 'POST',
2855
- headers: { 'Content-Type': 'application/json' },
2856
- body: JSON.stringify({ path })
2857
- });
2858
-
2859
- if (!response.ok) {
2860
- throw new Error(await response.text());
2861
- }
2862
-
2863
- hideFileDialog();
2864
- await refresh();
2865
- showToast(`Opened ${path}`, 'success');
2866
- } catch (err) {
2867
- showToast('Failed to open file: ' + err.message, 'error');
2868
- }
2869
- }
2870
-
2871
- // Spawn worktree builder (no dialog - spawns with random ID)
2872
- async function spawnBuilder() {
2873
- try {
2874
- const response = await fetch('/api/tabs/builder', {
2875
- method: 'POST',
2876
- headers: { 'Content-Type': 'application/json' },
2877
- body: JSON.stringify({})
2878
- });
2879
-
2880
- if (!response.ok) {
2881
- throw new Error(await response.text());
2882
- }
2883
-
2884
- const result = await response.json();
2885
-
2886
- // Add to local tabs and select it
2887
- const newTab = {
2888
- id: `builder-${result.id}`,
2889
- type: 'builder',
2890
- name: result.name,
2891
- port: result.port
2892
- };
2893
- tabs.push(newTab);
2894
- activeTabId = newTab.id;
2895
- renderTabs();
2896
- renderTabContent();
2897
- showToast(`Builder ${result.name} spawned`, 'success');
2898
- } catch (err) {
2899
- showToast('Failed to spawn builder: ' + err.message, 'error');
2900
- }
2901
- }
2902
-
2903
- // Spawn shell
2904
- async function spawnShell() {
2905
- try {
2906
- const response = await fetch('/api/tabs/shell', {
2907
- method: 'POST',
2908
- headers: { 'Content-Type': 'application/json' },
2909
- body: JSON.stringify({})
2910
- });
2911
-
2912
- if (!response.ok) {
2913
- throw new Error(await response.text());
2914
- }
2915
-
2916
- const result = await response.json();
2917
-
2918
- // Add to local tabs and select it
2919
- const newTab = {
2920
- id: `shell-${result.id}`,
2921
- type: 'shell',
2922
- name: result.name,
2923
- port: result.port,
2924
- utilId: result.id,
2925
- pendingLoad: true // Mark as pending to delay iframe
2926
- };
2927
- tabs.push(newTab);
2928
- activeTabId = newTab.id;
2929
- renderTabs();
2930
-
2931
- // Show loading state, then load iframe after delay
2932
- const content = document.getElementById('tab-content');
2933
- content.innerHTML = '<div class="empty-state"><p>Starting shell...</p></div>';
2934
-
2935
- setTimeout(() => {
2936
- delete newTab.pendingLoad;
2937
- currentTabPort = null; // Force re-render
2938
- renderTabContent();
2939
- }, 800);
2940
-
2941
- showToast('Shell spawned', 'success');
2942
- } catch (err) {
2943
- showToast('Failed to spawn shell: ' + err.message, 'error');
2944
- }
2945
- }
2946
-
2947
- // Refresh state from API
2948
- async function refresh() {
2949
- try {
2950
- const response = await fetch('/api/state');
2951
- if (!response.ok) throw new Error('Failed to fetch state');
2952
-
2953
- const newState = await response.json();
2954
- Object.assign(state, newState);
2955
-
2956
- buildTabsFromState();
2957
- renderArchitect();
2958
- renderTabs();
2959
- renderTabContent();
2960
- updateStatusBar();
2961
- } catch (err) {
2962
- console.error('Refresh error:', err);
2963
- }
2964
- }
2965
-
2966
- // Toast notifications
2967
- function showToast(message, type = 'info') {
2968
- const container = document.getElementById('toast-container');
2969
- const toast = document.createElement('div');
2970
- toast.className = `toast ${type}`;
2971
- toast.textContent = message;
2972
- container.appendChild(toast);
2973
-
2974
- setTimeout(() => {
2975
- toast.remove();
2976
- }, 3000);
2977
- }
2978
-
2979
- // Polling for state updates
2980
- let pollInterval = null;
2981
-
2982
- function startPolling() {
2983
- pollInterval = setInterval(refresh, 1000);
2984
- }
2985
-
2986
- function stopPolling() {
2987
- if (pollInterval) {
2988
- clearInterval(pollInterval);
2989
- pollInterval = null;
2990
- }
2991
- }
2992
-
2993
- // Keyboard shortcuts
2994
- document.addEventListener('keydown', (e) => {
2995
- // Escape to close dialogs and menus
2996
- if (e.key === 'Escape') {
2997
- hideFileDialog();
2998
- hideCloseDialog();
2999
- hideContextMenu();
3000
- hideOverflowMenu();
3001
- // Activity modal (Spec 0059)
3002
- const activityModal = document.getElementById('activity-modal');
3003
- if (activityModal && !activityModal.classList.contains('hidden')) {
3004
- closeActivityModal();
3005
- }
3006
- }
3007
-
3008
- // Enter in dialogs
3009
- if (e.key === 'Enter') {
3010
- if (!document.getElementById('file-dialog').classList.contains('hidden')) {
3011
- openFile();
3012
- }
3013
- }
3014
-
3015
- // Ctrl+Tab / Ctrl+Shift+Tab to switch tabs
3016
- if (e.ctrlKey && e.key === 'Tab') {
3017
- e.preventDefault();
3018
- if (tabs.length < 2) return;
3019
-
3020
- const currentIndex = tabs.findIndex(t => t.id === activeTabId);
3021
- let newIndex;
3022
-
3023
- if (e.shiftKey) {
3024
- newIndex = currentIndex <= 0 ? tabs.length - 1 : currentIndex - 1;
3025
- } else {
3026
- newIndex = currentIndex >= tabs.length - 1 ? 0 : currentIndex + 1;
3027
- }
3028
-
3029
- selectTab(tabs[newIndex].id);
3030
- }
3031
-
3032
- // Ctrl+W to close current tab
3033
- if (e.ctrlKey && e.key === 'w') {
3034
- e.preventDefault();
3035
- if (activeTabId) {
3036
- closeTab(activeTabId, e);
3037
- }
3038
- }
3039
- });
3040
-
3041
- // ============================================
3042
- // Projects Tab Functions (Spec 0045)
3043
- // ============================================
3044
-
3045
- // XSS-safe HTML escaping (used by escapeHtml above, same implementation)
3046
- function escapeProjectHtml(text) {
3047
- if (!text) return '';
3048
- const div = document.createElement('div');
3049
- div.textContent = String(text);
3050
- return div.innerHTML;
3051
- }
3052
-
3053
- // Simple DJB2 hash for change detection
3054
- function hashString(str) {
3055
- let hash = 5381;
3056
- for (let i = 0; i < str.length; i++) {
3057
- hash = ((hash << 5) + hash) + str.charCodeAt(i);
3058
- }
3059
- return hash >>> 0;
3060
- }
3061
-
3062
- // Parse a single project entry from YAML-like text
3063
- function parseProjectEntry(text) {
3064
- const project = {};
3065
- const lines = text.split('\n');
3066
-
3067
- for (const line of lines) {
3068
- // Match key: value or key: "value"
3069
- // Also handle "- id:" YAML list format
3070
- const match = line.match(/^\s*-?\s*(\w+):\s*(.*)$/);
3071
- if (!match) continue;
3072
-
3073
- const [, key, rawValue] = match;
3074
- // Remove quotes if present
3075
- let value = rawValue.trim();
3076
- if ((value.startsWith('"') && value.endsWith('"')) ||
3077
- (value.startsWith("'") && value.endsWith("'"))) {
3078
- value = value.slice(1, -1);
3079
- }
3080
-
3081
- // Handle nested files object
3082
- if (key === 'files') {
3083
- project.files = {};
3084
- continue;
3085
- }
3086
- if (key === 'spec' || key === 'plan' || key === 'review') {
3087
- if (!project.files) project.files = {};
3088
- project.files[key] = value === 'null' ? null : value;
3089
- continue;
3090
- }
3091
-
3092
- // Handle nested timestamps object
3093
- if (key === 'timestamps') {
3094
- project.timestamps = {};
3095
- continue;
3096
- }
3097
- const timestampFields = ['conceived_at', 'specified_at', 'planned_at',
3098
- 'implementing_at', 'implemented_at', 'committed_at', 'integrated_at'];
3099
- if (timestampFields.includes(key)) {
3100
- if (!project.timestamps) project.timestamps = {};
3101
- project.timestamps[key] = value === 'null' ? null : value;
3102
- continue;
3103
- }
3104
-
3105
- // Handle arrays (simple inline format)
3106
- if (key === 'dependencies' || key === 'tags' || key === 'ticks') {
3107
- if (value.startsWith('[') && value.endsWith(']')) {
3108
- const inner = value.slice(1, -1);
3109
- if (inner.trim() === '') {
3110
- project[key] = [];
3111
- } else {
3112
- project[key] = inner.split(',').map(s => s.trim().replace(/^["']|["']$/g, ''));
3113
- }
3114
- } else {
3115
- project[key] = [];
3116
- }
3117
- continue;
3118
- }
3119
-
3120
- // Regular string values
3121
- if (value !== 'null') {
3122
- project[key] = value;
3123
- }
3124
- }
3125
-
3126
- return project;
3127
- }
3128
-
3129
- // Validate that a project entry is valid
3130
- function isValidProject(project) {
3131
- // Must have id (4-digit string, not "NNNN")
3132
- if (!project.id || project.id === 'NNNN' || !/^\d{4}$/.test(project.id)) {
3133
- return false;
3134
- }
3135
-
3136
- // Must have status
3137
- const validStatuses = ['conceived', 'specified', 'planned', 'implementing',
3138
- 'implemented', 'committed', 'integrated', 'abandoned', 'on-hold'];
3139
- if (!project.status || !validStatuses.includes(project.status)) {
3140
- return false;
3141
- }
3142
-
3143
- // Must have title
3144
- if (!project.title) {
3145
- return false;
3146
- }
3147
-
3148
- // Filter out example entries
3149
- if (project.tags && project.tags.includes('example')) {
3150
- return false;
3151
- }
3152
-
3153
- return true;
3154
- }
3155
-
3156
- // Parse projectlist.md content into array of projects
3157
- function parseProjectlist(content) {
3158
- const projects = [];
3159
-
3160
- try {
3161
- // Extract YAML code blocks
3162
- const yamlBlockRegex = /```yaml\n([\s\S]*?)```/g;
3163
- let match;
3164
-
3165
- while ((match = yamlBlockRegex.exec(content)) !== null) {
3166
- const block = match[1];
3167
-
3168
- // Split by project entries (lines starting with " - id:")
3169
- // Handle both top-level and indented entries
3170
- const projectMatches = block.split(/\n(?=\s*- id:)/);
3171
-
3172
- for (const projectText of projectMatches) {
3173
- if (!projectText.trim() || !projectText.includes('id:')) continue;
3174
-
3175
- const project = parseProjectEntry(projectText);
3176
- if (isValidProject(project)) {
3177
- projects.push(project);
3178
- }
3179
- }
3180
- }
3181
- } catch (err) {
3182
- console.error('Error parsing projectlist:', err);
3183
- return [];
3184
- }
3185
-
3186
- return projects;
3187
- }
3188
-
3189
- // Render the welcome screen for new users
3190
- function renderWelcomeScreen() {
3191
- return `
3192
- <div class="projects-welcome">
3193
- <h2>Welcome to Codev</h2>
3194
- <p>Codev helps you build software with AI assistance. Projects flow through 7 stages from idea to production:</p>
3195
- <ol>
3196
- <li><strong>Conceived</strong> - Tell the architect what you want to build</li>
3197
- <li><strong>Specified</strong> - AI writes a spec, you approve it</li>
3198
- <li><strong>Planned</strong> - AI creates an implementation plan</li>
3199
- <li><strong>Implementing</strong> - Builder AI writes the code</li>
3200
- <li><strong>Implemented</strong> - Code complete, PR ready for review</li>
3201
- <li><strong>Committed</strong> - PR merged to main</li>
3202
- <li><strong>Integrated</strong> - Validated in production</li>
3203
- </ol>
3204
- <hr>
3205
- <p class="quick-tip">
3206
- <strong>Quick tip:</strong> Say "I want to build a [feature]" and the architect will guide you through the process.
3207
- </p>
3208
- </div>
3209
- `;
3210
- }
3211
-
3212
- // Render the error banner
3213
- function renderErrorBanner(message) {
3214
- return `
3215
- <div class="projects-error">
3216
- <span class="projects-error-message">${escapeProjectHtml(message)}</span>
3217
- <button onclick="reloadProjectlist()">Retry</button>
3218
- </div>
3219
- `;
3220
- }
3221
-
3222
- // Group projects by status for summary
3223
- function groupByStatus(projects, statuses) {
3224
- const groups = {};
3225
- for (const status of statuses) {
3226
- groups[status] = projects.filter(p => p.status === status);
3227
- }
3228
- return groups;
3229
- }
3230
-
3231
- // Render the status summary section
3232
- function renderStatusSummary(projects) {
3233
- const activeStatuses = ['conceived', 'specified', 'planned', 'implementing', 'implemented', 'committed'];
3234
- const active = projects.filter(p => activeStatuses.includes(p.status));
3235
- const completed = projects.filter(p => p.status === 'integrated');
3236
- const byStatus = groupByStatus(active, activeStatuses);
3237
-
3238
- const activeListItems = [];
3239
- for (const status of activeStatuses) {
3240
- const statusProjects = byStatus[status] || [];
3241
- if (statusProjects.length > 0) {
3242
- const names = statusProjects.slice(0, 3).map(p => `${p.id} ${p.title}`).join(', ');
3243
- const more = statusProjects.length > 3 ? ` (+${statusProjects.length - 3} more)` : '';
3244
- activeListItems.push(`<li>${statusProjects.length} ${status}: ${escapeProjectHtml(names)}${more}</li>`);
3245
- }
3246
- }
3247
-
3248
- return `
3249
- <div class="status-summary">
3250
- <div class="status-summary-header">
3251
- <span>Status Summary</span>
3252
- <button onclick="reloadProjectlist()" title="Reload">↻</button>
3253
- </div>
3254
- <div class="active-projects">
3255
- <span class="active-count">Active: ${active.length} project${active.length !== 1 ? 's' : ''}</span>
3256
- ${activeListItems.length > 0 ? `<ul class="active-list">${activeListItems.join('')}</ul>` : ''}
3257
- </div>
3258
- <div class="completed">Completed: ${completed.length} integrated</div>
3259
- </div>
3260
- `;
3261
- }
3262
-
3263
- // Get the lifecycle stages in order
3264
- const LIFECYCLE_STAGES = ['conceived', 'specified', 'planned', 'implementing', 'implemented', 'committed', 'integrated'];
3265
-
3266
- // Abbreviated column headers
3267
- const STAGE_HEADERS = {
3268
- 'conceived': "CONC'D",
3269
- 'specified': "SPEC'D",
3270
- 'planned': 'PLANNED',
3271
- 'implementing': 'IMPLING',
3272
- 'implemented': 'IMPLED',
3273
- 'committed': 'CMTD',
3274
- 'integrated': "INTGR'D"
3275
- };
3276
-
3277
- // Stage tooltips explaining purpose and exit criteria
3278
- const STAGE_TOOLTIPS = {
3279
- 'conceived': "CONCEIVED: Idea has been captured.\nExit: Human approves the specification.",
3280
- 'specified': "SPECIFIED: Human approved the spec.\nExit: Architect creates an implementation plan.",
3281
- 'planned': "PLANNED: Implementation plan is ready.\nExit: Architect spawns a Builder.",
3282
- 'implementing': "IMPLEMENTING: Builder is working on the code.\nExit: Builder creates a PR.",
3283
- 'implemented': "IMPLEMENTED: PR is ready for review.\nExit: Builder merges after Architect review.",
3284
- 'committed': "COMMITTED: PR has been merged.\nExit: Human validates in production.",
3285
- 'integrated': "INTEGRATED: Validated in production.\nThis is the goal state."
3286
- };
3287
-
3288
- // Get stage index (for comparison)
3289
- function getStageIndex(status) {
3290
- return LIFECYCLE_STAGES.indexOf(status);
3291
- }
3292
-
3293
- // Get the label and link for a stage cell
3294
- function getStageCellContent(project, stage) {
3295
- switch (stage) {
3296
- case 'specified':
3297
- if (project.files && project.files.spec) {
3298
- return { label: 'Spec', link: project.files.spec };
3299
- }
3300
- return { label: '', link: null };
3301
- case 'planned':
3302
- if (project.files && project.files.plan) {
3303
- return { label: 'Plan', link: project.files.plan };
3304
- }
3305
- return { label: '', link: null };
3306
- case 'implemented':
3307
- if (project.files && project.files.review) {
3308
- return { label: 'Revw', link: project.files.review };
3309
- }
3310
- return { label: '', link: null };
3311
- case 'committed':
3312
- // PR link from notes (format: "PR #N merged")
3313
- if (project.notes) {
3314
- const prMatch = project.notes.match(/PR\s*#?(\d+)/i);
3315
- if (prMatch) {
3316
- return { label: 'PR', link: `https://github.com/cluesmith/codev/pull/${prMatch[1]}`, external: true };
3317
- }
3318
- }
3319
- return { label: '', link: null };
3320
- default:
3321
- return { label: '', link: null };
3322
- }
3323
- }
3324
-
3325
- // Render a stage cell with appropriate styling
3326
- function renderStageCell(project, stage) {
3327
- const currentIndex = getStageIndex(project.status);
3328
- const stageIndex = getStageIndex(stage);
3329
-
3330
- let cellClass = 'stage-cell';
3331
- let content = '';
3332
- let ariaLabel = '';
3333
-
3334
- if (stageIndex < currentIndex) {
3335
- // Completed stage - green checkmark
3336
- ariaLabel = `${stage}: completed`;
3337
-
3338
- const cellContent = getStageCellContent(project, stage);
3339
- if (cellContent.label && cellContent.link) {
3340
- if (cellContent.external) {
3341
- content = `<span class="checkmark">✓</span> <a href="${cellContent.link}" target="_blank" rel="noopener">${cellContent.label}</a>`;
3342
- } else {
3343
- content = `<span class="checkmark">✓</span> <a href="#" onclick="openProjectFile('${cellContent.link}'); return false;">${cellContent.label}</a>`;
3344
- }
3345
- } else {
3346
- content = '<span class="checkmark">✓</span>';
3347
- }
3348
- } else if (stageIndex === currentIndex) {
3349
- // Current stage - hollow orange circle (or confetti if recently integrated)
3350
- if (stage === 'integrated' && isRecentlyIntegrated(project)) {
3351
- ariaLabel = `${stage}: recently completed!`;
3352
- content = '<span class="celebration">🎉</span>';
3353
- } else {
3354
- ariaLabel = `${stage}: in progress`;
3355
-
3356
- const cellContent = getStageCellContent(project, stage);
3357
- if (cellContent.label && cellContent.link) {
3358
- if (cellContent.external) {
3359
- content = `<span class="current-indicator"></span> <a href="${cellContent.link}" target="_blank" rel="noopener">${cellContent.label}</a>`;
3360
- } else {
3361
- content = `<span class="current-indicator"></span> <a href="#" onclick="openProjectFile('${cellContent.link}'); return false;">${cellContent.label}</a>`;
3362
- }
3363
- } else {
3364
- content = '<span class="current-indicator"></span>';
3365
- }
3366
- }
3367
- } else {
3368
- // Future stage - empty
3369
- ariaLabel = `${stage}: pending`;
3370
- }
3371
-
3372
- return `<td role="gridcell" class="${cellClass}" aria-label="${ariaLabel}">${content}</td>`;
3373
- }
3374
-
3375
- // Get URL for stage-specific artifact
3376
- function getStageLinkUrl(project, stage) {
3377
- if (!project.files) return null;
3378
-
3379
- switch (stage) {
3380
- case 'specified':
3381
- return project.files.spec || null;
3382
- case 'planned':
3383
- return project.files.plan || null;
3384
- case 'integrated':
3385
- return project.files.review || null;
3386
- default:
3387
- return null;
3388
- }
3389
- }
3390
-
3391
- // Open a project file in a new annotation tab
3392
- async function openProjectFile(path) {
3393
- try {
3394
- const response = await fetch('/api/tabs/file', {
3395
- method: 'POST',
3396
- headers: { 'Content-Type': 'application/json' },
3397
- body: JSON.stringify({ path })
3398
- });
3399
-
3400
- if (!response.ok) {
3401
- throw new Error(await response.text());
3402
- }
3403
-
3404
- await refresh();
3405
- showToast(`Opened ${path}`, 'success');
3406
- } catch (err) {
3407
- showToast('Failed to open file: ' + err.message, 'error');
3408
- }
3409
- }
3410
-
3411
- // Render a single project row
3412
- function renderProjectRow(project) {
3413
- const isExpanded = expandedProjectId === project.id;
3414
-
3415
- const row = `
3416
- <tr class="status-${project.status}"
3417
- role="row"
3418
- tabindex="0"
3419
- aria-expanded="${isExpanded}"
3420
- onkeydown="handleProjectRowKeydown(event, '${project.id}')">
3421
- <td role="gridcell">
3422
- <div class="project-cell clickable" onclick="toggleProjectDetails('${project.id}'); event.stopPropagation();">
3423
- <span class="project-id">${escapeProjectHtml(project.id)}</span>
3424
- <span class="project-title" title="${escapeProjectHtml(project.title)}">${escapeProjectHtml(project.title)}</span>
3425
- </div>
3426
- </td>
3427
- ${LIFECYCLE_STAGES.map(stage => renderStageCell(project, stage)).join('')}
3428
- </tr>
3429
- `;
3430
-
3431
- if (isExpanded) {
3432
- return row + renderProjectDetailsRow(project);
3433
- }
3434
- return row;
3435
- }
3436
-
3437
- // Render the details row when expanded
3438
- function renderProjectDetailsRow(project) {
3439
- const links = [];
3440
- if (project.files && project.files.review) {
3441
- links.push(`<a href="#" onclick="openProjectFile('${project.files.review}'); return false;">Review</a>`);
3442
- }
3443
-
3444
- const dependencies = project.dependencies && project.dependencies.length > 0
3445
- ? `<div class="project-dependencies">Dependencies: ${project.dependencies.map(d => escapeProjectHtml(d)).join(', ')}</div>`
3446
- : '';
3447
-
3448
- // Render TICKs if present
3449
- const ticks = project.ticks && project.ticks.length > 0
3450
- ? `<div class="project-ticks">TICKs: ${project.ticks.map(t => `<span class="tick-badge">TICK-${escapeProjectHtml(t)}</span>`).join(' ')}</div>`
3451
- : '';
3452
-
3453
- return `
3454
- <tr class="project-details-row" role="row">
3455
- <td colspan="8">
3456
- <div class="project-details-content">
3457
- <h3>${escapeProjectHtml(project.title)}</h3>
3458
- ${project.summary ? `<p>${escapeProjectHtml(project.summary)}</p>` : ''}
3459
- ${project.notes ? `<p class="notes">${escapeProjectHtml(project.notes)}</p>` : ''}
3460
- ${ticks}
3461
- ${links.length > 0 ? `<div class="project-details-links">${links.join('')}</div>` : ''}
3462
- ${dependencies}
3463
- </div>
3464
- </td>
3465
- </tr>
3466
- `;
3467
- }
3468
-
3469
- // Handle keyboard navigation on project rows
3470
- function handleProjectRowKeydown(event, projectId) {
3471
- if (event.key === 'Enter' || event.key === ' ') {
3472
- event.preventDefault();
3473
- toggleProjectDetails(projectId);
3474
- } else if (event.key === 'ArrowDown') {
3475
- event.preventDefault();
3476
- const currentRow = event.target.closest('tr');
3477
- let nextRow = currentRow.nextElementSibling;
3478
- // Skip details rows
3479
- while (nextRow && nextRow.classList.contains('project-details-row')) {
3480
- nextRow = nextRow.nextElementSibling;
3481
- }
3482
- if (nextRow) nextRow.focus();
3483
- } else if (event.key === 'ArrowUp') {
3484
- event.preventDefault();
3485
- const currentRow = event.target.closest('tr');
3486
- let prevRow = currentRow.previousElementSibling;
3487
- // Skip details rows
3488
- while (prevRow && prevRow.classList.contains('project-details-row')) {
3489
- prevRow = prevRow.previousElementSibling;
3490
- }
3491
- if (prevRow && prevRow.getAttribute('tabindex') === '0') prevRow.focus();
3492
- }
3493
- }
3494
-
3495
- // Toggle project details expansion
3496
- function toggleProjectDetails(projectId) {
3497
- if (expandedProjectId === projectId) {
3498
- expandedProjectId = null;
3499
- } else {
3500
- expandedProjectId = projectId;
3501
- }
3502
- // Re-render the projects tab to update expansion state
3503
- renderProjectsTabContent();
3504
- }
3505
-
3506
- // Render a table for a list of projects
3507
- function renderProjectTable(projectList) {
3508
- if (projectList.length === 0) {
3509
- return '<p style="color: var(--text-muted); text-align: center; padding: 20px;">No projects</p>';
3510
- }
3511
-
3512
- return `
3513
- <table class="kanban-grid" role="grid" aria-label="Project status grid">
3514
- <thead>
3515
- <tr role="row">
3516
- <th role="columnheader">Project</th>
3517
- ${LIFECYCLE_STAGES.map(stage => `<th role="columnheader" title="${STAGE_TOOLTIPS[stage]}">${STAGE_HEADERS[stage]}</th>`).join('')}
3518
- </tr>
3519
- </thead>
3520
- <tbody>
3521
- ${projectList.map(p => renderProjectRow(p)).join('')}
3522
- </tbody>
3523
- </table>
3524
- `;
3525
- }
3526
-
3527
- // Check if a project was integrated in the last 24 hours
3528
- function isRecentlyIntegrated(project) {
3529
- if (project.status !== 'integrated') return false;
3530
-
3531
- // Look in timestamps.integrated_at (new format)
3532
- const integratedAt = project.timestamps?.integrated_at;
3533
- if (!integratedAt) return false;
3534
-
3535
- const integratedDate = new Date(integratedAt);
3536
- if (isNaN(integratedDate.getTime())) return false;
3537
-
3538
- const now = new Date();
3539
- const hoursDiff = (now - integratedDate) / (1000 * 60 * 60);
3540
-
3541
- return hoursDiff <= 24;
3542
- }
3543
-
3544
- // Render the Kanban grid with Active/Inactive sections
3545
- function renderKanbanGrid(projects) {
3546
- // Separate active (conceived through committed) from inactive (integrated)
3547
- const activeStatuses = ['conceived', 'specified', 'planned', 'implementing', 'implemented', 'committed'];
3548
-
3549
- // Status order for sorting (higher index = further along)
3550
- const statusOrder = {
3551
- 'conceived': 0,
3552
- 'specified': 1,
3553
- 'planned': 2,
3554
- 'implementing': 3,
3555
- 'implemented': 4,
3556
- 'committed': 5,
3557
- 'integrated': 6
3558
- };
3559
-
3560
- // Include recently integrated projects in Active section
3561
- const activeProjects = projects.filter(p =>
3562
- activeStatuses.includes(p.status) || isRecentlyIntegrated(p)
3563
- );
3564
-
3565
- // Sort active projects by completion (most complete first)
3566
- activeProjects.sort((a, b) => {
3567
- const orderA = statusOrder[a.status] || 0;
3568
- const orderB = statusOrder[b.status] || 0;
3569
- // Higher status first (descending), then by ID (ascending) for tie-breaker
3570
- if (orderB !== orderA) return orderB - orderA;
3571
- return a.id.localeCompare(b.id);
3572
- });
3573
-
3574
- const inactiveProjects = projects.filter(p =>
3575
- p.status === 'integrated' && !isRecentlyIntegrated(p)
3576
- );
3577
-
3578
- let html = '';
3579
-
3580
- // Active section - expanded by default
3581
- if (activeProjects.length > 0 || inactiveProjects.length === 0) {
3582
- html += `
3583
- <details class="project-section" open>
3584
- <summary>Active <span class="section-count">(${activeProjects.length})</span></summary>
3585
- ${renderProjectTable(activeProjects)}
3586
- </details>
3587
- `;
3588
- }
3589
-
3590
- // Inactive section - collapsed by default
3591
- if (inactiveProjects.length > 0) {
3592
- html += `
3593
- <details class="project-section">
3594
- <summary>Completed <span class="section-count">(${inactiveProjects.length})</span></summary>
3595
- ${renderProjectTable(inactiveProjects)}
3596
- </details>
3597
- `;
3598
- }
3599
-
3600
- return html;
3601
- }
3602
-
3603
- // Render the terminal projects section (abandoned, on-hold)
3604
- function renderTerminalProjects(projects) {
3605
- const terminal = projects.filter(p => ['abandoned', 'on-hold'].includes(p.status));
3606
-
3607
- if (terminal.length === 0) return '';
3608
-
3609
- const items = terminal.map(p => {
3610
- const className = p.status === 'abandoned' ? 'project-abandoned' : 'project-on-hold';
3611
- const statusText = p.status === 'on-hold' ? ' (on-hold)' : '';
3612
- return `
3613
- <li>
3614
- <span class="${className}">
3615
- <span class="project-id">${escapeProjectHtml(p.id)}</span>
3616
- ${escapeProjectHtml(p.title)}${statusText}
3617
- </span>
3618
- </li>
3619
- `;
3620
- }).join('');
3621
-
3622
- return `
3623
- <details class="terminal-projects">
3624
- <summary>Terminal Projects (${terminal.length})</summary>
3625
- <ul>${items}</ul>
3626
- </details>
3627
- `;
3628
- }
3629
-
3630
- // ========================================
3631
- // Files Tab Functions (Spec 0055)
3632
- // ========================================
3633
-
3634
- // Load the file tree from the API
3635
- async function loadFilesTree() {
3636
- try {
3637
- const response = await fetch('/api/files');
3638
- if (!response.ok) {
3639
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
3640
- }
3641
- filesTreeData = await response.json();
3642
- filesTreeError = null;
3643
- filesTreeLoaded = true;
3644
- // Flatten tree for search (Spec 0058)
3645
- filesTreeFlat = flattenFilesTree(filesTreeData);
3646
- } catch (err) {
3647
- console.error('Failed to load files tree:', err);
3648
- filesTreeError = 'Could not load file tree: ' + err.message;
3649
- filesTreeData = [];
3650
- filesTreeFlat = [];
3651
- }
3652
- }
3653
-
3654
- // Flatten the file tree into a searchable array (Spec 0058)
3655
- function flattenFilesTree(nodes, result = []) {
3656
- for (const node of nodes) {
3657
- if (node.type === 'file') {
3658
- result.push({ name: node.name, path: node.path });
3659
- } else if (node.children) {
3660
- flattenFilesTree(node.children, result);
3661
- }
3662
- }
3663
- return result;
3664
- }
3665
-
3666
- // Search files with relevance sorting (Spec 0058)
3667
- function searchFiles(query) {
3668
- if (!query) return [];
3669
- const q = query.toLowerCase();
3670
-
3671
- const matches = filesTreeFlat.filter(f =>
3672
- f.path.toLowerCase().includes(q)
3673
- );
3674
-
3675
- // Sort by relevance: exact filename > filename prefix > filename contains > path
3676
- matches.sort((a, b) => {
3677
- const aName = a.name.toLowerCase();
3678
- const bName = b.name.toLowerCase();
3679
- const aPath = a.path.toLowerCase();
3680
- const bPath = b.path.toLowerCase();
3681
-
3682
- // Exact filename match first
3683
- if (aName === q && bName !== q) return -1;
3684
- if (bName === q && aName !== q) return 1;
3685
-
3686
- // Filename starts with query
3687
- if (aName.startsWith(q) && !bName.startsWith(q)) return -1;
3688
- if (bName.startsWith(q) && !aName.startsWith(q)) return 1;
3689
-
3690
- // Filename contains query
3691
- if (aName.includes(q) && !bName.includes(q)) return -1;
3692
- if (bName.includes(q) && !aName.includes(q)) return 1;
3693
-
3694
- // Alphabetical by path
3695
- return aPath.localeCompare(bPath);
3696
- });
3697
-
3698
- return matches.slice(0, 15);
3699
- }
3700
-
3701
- // Escape a string for use inside a JavaScript string literal in onclick handlers
3702
- // This handles quotes, backslashes, and other special characters
3703
- function escapeJsString(str) {
3704
- return str
3705
- .replace(/\\/g, '\\\\')
3706
- .replace(/'/g, "\\'")
3707
- .replace(/"/g, '\\"')
3708
- .replace(/\n/g, '\\n')
3709
- .replace(/\r/g, '\\r');
3710
- }
3711
-
3712
- // Render tree nodes recursively
3713
- function renderTreeNodes(nodes, depth) {
3714
- if (!nodes || nodes.length === 0) return '';
3715
-
3716
- return nodes.map(node => {
3717
- const indent = depth * 16;
3718
- const isExpanded = filesTreeExpanded.has(node.path);
3719
- // Use escapeJsString for onclick handlers (handles quotes correctly)
3720
- // Use escapeHtml for data attributes and display text (handles XSS)
3721
- const jsPath = escapeJsString(node.path);
3722
-
3723
- if (node.type === 'dir') {
3724
- const icon = isExpanded ? '▼' : '▶';
3725
- const childrenHtml = node.children && node.children.length > 0
3726
- ? `<div class="tree-children ${isExpanded ? '' : 'collapsed'}" data-path="${escapeHtml(node.path)}">${renderTreeNodes(node.children, depth + 1)}</div>`
3727
- : '';
3728
-
3729
- return `
3730
- <div class="tree-item" data-type="dir" data-path="${escapeHtml(node.path)}" style="padding-left: ${indent + 8}px;" onclick="toggleFolder('${jsPath}')">
3731
- <span class="tree-item-icon folder-toggle">${icon}</span>
3732
- <span class="tree-item-name">${escapeHtml(node.name)}</span>
3733
- </div>
3734
- ${childrenHtml}
3735
- `;
3736
- } else {
3737
- return `
3738
- <div class="tree-item" data-type="file" data-path="${escapeHtml(node.path)}" style="padding-left: ${indent + 8}px;" onclick="openFileFromTree('${jsPath}')">
3739
- <span class="tree-item-icon">${getFileIcon(node.name)}</span>
3740
- <span class="tree-item-name">${escapeHtml(node.name)}</span>
3741
- </div>
3742
- `;
3743
- }
3744
- }).join('');
3745
- }
3746
-
3747
- // Get file icon based on extension
3748
- function getFileIcon(filename) {
3749
- const ext = filename.split('.').pop().toLowerCase();
3750
- const iconMap = {
3751
- 'js': '📜',
3752
- 'ts': '📜',
3753
- 'jsx': '⚛️',
3754
- 'tsx': '⚛️',
3755
- 'json': '{}',
3756
- 'md': '📝',
3757
- 'html': '🌐',
3758
- 'css': '🎨',
3759
- 'py': '🐍',
3760
- 'sh': '⚙️',
3761
- 'bash': '⚙️',
3762
- 'yml': '⚙️',
3763
- 'yaml': '⚙️',
3764
- 'png': '🖼️',
3765
- 'jpg': '🖼️',
3766
- 'jpeg': '🖼️',
3767
- 'gif': '🖼️',
3768
- 'svg': '🖼️',
3769
- };
3770
- return iconMap[ext] || '📄';
3771
- }
3772
-
3773
- // Toggle folder expanded/collapsed state
3774
- function toggleFolder(path) {
3775
- if (filesTreeExpanded.has(path)) {
3776
- filesTreeExpanded.delete(path);
3777
- } else {
3778
- filesTreeExpanded.add(path);
3779
- }
3780
- rerenderFilesBrowser();
3781
- }
3782
-
3783
- // Re-render file browser in current context (dashboard or files tab)
3784
- function rerenderFilesBrowser() {
3785
- if (activeTabId === 'dashboard') {
3786
- // Re-render just the files content in dashboard
3787
- const filesContentEl = document.getElementById('dashboard-files-content');
3788
- if (filesContentEl) {
3789
- filesContentEl.innerHTML = filesSearchQuery
3790
- ? renderFilesSearchResults()
3791
- : renderDashboardFilesBrowserWithWrapper();
3792
- }
3793
- }
3794
- }
3795
-
3796
- // Wrapper for file browser that includes the list element ID (Spec 0058)
3797
- function renderDashboardFilesBrowserWithWrapper() {
3798
- return `<div class="dashboard-files-list" id="dashboard-files-list">${renderDashboardFilesBrowser()}</div>`;
3799
- }
3800
-
3801
- // ========================================
3802
- // File Search Functions (Spec 0058)
3803
- // ========================================
3804
-
3805
- // Debounced search input handler for Files column
3806
- function onFilesSearchInput(value) {
3807
- clearTimeout(filesSearchDebounceTimer);
3808
- filesSearchDebounceTimer = setTimeout(() => {
3809
- filesSearchQuery = value;
3810
- filesSearchResults = searchFiles(value);
3811
- filesSearchIndex = 0;
3812
- rerenderFilesSearch();
3813
- }, 100);
3814
- }
3815
-
3816
- // Clear files search and restore tree view
3817
- function clearFilesSearch() {
3818
- filesSearchQuery = '';
3819
- filesSearchResults = [];
3820
- filesSearchIndex = 0;
3821
- const input = document.getElementById('files-search-input');
3822
- if (input) {
3823
- input.value = '';
3824
- }
3825
- rerenderFilesSearch();
3826
- }
3827
-
3828
- // Re-render the files search area (results or tree)
3829
- function rerenderFilesSearch() {
3830
- const filesContentEl = document.getElementById('dashboard-files-content');
3831
- if (filesContentEl) {
3832
- filesContentEl.innerHTML = filesSearchQuery
3833
- ? renderFilesSearchResults()
3834
- : renderDashboardFilesBrowserWithWrapper();
3835
- }
3836
- // Update clear button visibility
3837
- const clearBtn = document.querySelector('.files-search-clear');
3838
- if (clearBtn) {
3839
- clearBtn.classList.toggle('hidden', !filesSearchQuery);
3840
- }
3841
- }
3842
-
3843
- // Render search results for Files column
3844
- function renderFilesSearchResults() {
3845
- if (!filesSearchResults.length) {
3846
- return '<div class="dashboard-empty-state">No files found</div>';
3847
- }
3848
-
3849
- return `<div class="files-search-results">${filesSearchResults.map((file, index) =>
3850
- renderSearchResult(file, index, index === filesSearchIndex, filesSearchQuery, 'files')
3851
- ).join('')}</div>`;
3852
- }
3853
-
3854
- // Highlight matching text in search results
3855
- function highlightMatch(text, query) {
3856
- if (!query) return escapeHtml(text);
3857
- const q = query.toLowerCase();
3858
- const t = text.toLowerCase();
3859
- const idx = t.indexOf(q);
3860
- if (idx === -1) return escapeHtml(text);
3861
-
3862
- return escapeHtml(text.substring(0, idx)) +
3863
- '<span class="files-search-highlight">' + escapeHtml(text.substring(idx, idx + query.length)) + '</span>' +
3864
- escapeHtml(text.substring(idx + query.length));
3865
- }
3866
-
3867
- // Render a single search result (shared by Files column and palette)
3868
- function renderSearchResult(file, index, isSelected, query, context) {
3869
- const classPrefix = context === 'palette' ? 'file-palette' : 'files-search';
3870
- const jsPath = escapeJsString(file.path);
3871
-
3872
- return `
3873
- <div class="${classPrefix}-result ${isSelected ? 'selected' : ''}"
3874
- data-index="${index}"
3875
- onclick="openFileFromSearch('${jsPath}', '${context}')">
3876
- <div class="${classPrefix}-result-name">${highlightMatch(file.name, query)}</div>
3877
- <div class="${classPrefix}-result-path">${highlightMatch(file.path, query)}</div>
3878
- </div>
3879
- `;
3880
- }
3881
-
3882
- // Keyboard handler for Files search input
3883
- function onFilesSearchKeydown(event) {
3884
- if (!filesSearchResults.length) {
3885
- if (event.key === 'Escape') {
3886
- clearFilesSearch();
3887
- event.target.blur();
3888
- }
3889
- return;
3890
- }
3891
-
3892
- if (event.key === 'ArrowDown') {
3893
- event.preventDefault();
3894
- filesSearchIndex = Math.min(filesSearchIndex + 1, filesSearchResults.length - 1);
3895
- rerenderFilesSearch();
3896
- scrollSelectedIntoView('files');
3897
- } else if (event.key === 'ArrowUp') {
3898
- event.preventDefault();
3899
- filesSearchIndex = Math.max(filesSearchIndex - 1, 0);
3900
- rerenderFilesSearch();
3901
- scrollSelectedIntoView('files');
3902
- } else if (event.key === 'Enter') {
3903
- event.preventDefault();
3904
- if (filesSearchResults[filesSearchIndex]) {
3905
- openFileFromSearch(filesSearchResults[filesSearchIndex].path, 'files');
3906
- }
3907
- } else if (event.key === 'Escape') {
3908
- clearFilesSearch();
3909
- event.target.blur();
3910
- }
3911
- }
3912
-
3913
- // Scroll selected result into view
3914
- function scrollSelectedIntoView(context) {
3915
- const selector = context === 'palette'
3916
- ? '.file-palette-result.selected'
3917
- : '.files-search-result.selected';
3918
- const selected = document.querySelector(selector);
3919
- if (selected) {
3920
- selected.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
3921
- }
3922
- }
3923
-
3924
- // Open file from search result (shared by Files column and palette)
3925
- function openFileFromSearch(filePath, context) {
3926
- // Check if file is already open
3927
- const existingTab = tabs.find(t => t.type === 'file' && t.path === filePath);
3928
- if (existingTab) {
3929
- selectTab(existingTab.id);
3930
- refreshFileTab(existingTab.id); // Refresh content in case file changed
3931
- } else {
3932
- openFileFromTree(filePath);
3933
- }
3934
-
3935
- // Clear search / close palette
3936
- if (context === 'palette') {
3937
- closePalette();
3938
- } else {
3939
- clearFilesSearch();
3940
- }
3941
- }
3942
-
3943
- // ========================================
3944
- // Cmd+P Palette Functions (Spec 0058)
3945
- // ========================================
3946
-
3947
- // Open the file search palette
3948
- function openPalette() {
3949
- paletteOpen = true;
3950
- paletteQuery = '';
3951
- paletteResults = [];
3952
- paletteIndex = 0;
3953
- document.getElementById('file-palette').classList.remove('hidden');
3954
- const input = document.getElementById('palette-input');
3955
- input.value = '';
3956
- input.focus();
3957
- rerenderPaletteResults();
3958
- }
3959
-
3960
- // Close the file search palette
3961
- function closePalette() {
3962
- paletteOpen = false;
3963
- paletteQuery = '';
3964
- paletteResults = [];
3965
- paletteIndex = 0;
3966
- document.getElementById('file-palette').classList.add('hidden');
3967
- }
3968
-
3969
- // Debounced palette input handler
3970
- function onPaletteInput(value) {
3971
- clearTimeout(paletteDebounceTimer);
3972
- paletteDebounceTimer = setTimeout(() => {
3973
- paletteQuery = value;
3974
- paletteResults = searchFiles(value);
3975
- paletteIndex = 0;
3976
- rerenderPaletteResults();
3977
- }, 100);
3978
- }
3979
-
3980
- // Re-render palette results
3981
- function rerenderPaletteResults() {
3982
- const resultsEl = document.getElementById('palette-results');
3983
- if (!resultsEl) return;
3984
-
3985
- if (!paletteQuery) {
3986
- resultsEl.innerHTML = '<div class="file-palette-empty">Type to search files...</div>';
3987
- return;
3988
- }
3989
-
3990
- if (!paletteResults.length) {
3991
- resultsEl.innerHTML = '<div class="file-palette-empty">No files found</div>';
3992
- return;
3993
- }
3994
-
3995
- resultsEl.innerHTML = paletteResults.map((file, index) =>
3996
- renderSearchResult(file, index, index === paletteIndex, paletteQuery, 'palette')
3997
- ).join('');
3998
- }
3999
-
4000
- // Keyboard handler for palette input
4001
- function onPaletteKeydown(event) {
4002
- if (event.key === 'Escape') {
4003
- closePalette();
4004
- return;
4005
- }
4006
-
4007
- if (!paletteResults.length) return;
4008
-
4009
- if (event.key === 'ArrowDown') {
4010
- event.preventDefault();
4011
- paletteIndex = Math.min(paletteIndex + 1, paletteResults.length - 1);
4012
- rerenderPaletteResults();
4013
- scrollSelectedIntoView('palette');
4014
- } else if (event.key === 'ArrowUp') {
4015
- event.preventDefault();
4016
- paletteIndex = Math.max(paletteIndex - 1, 0);
4017
- rerenderPaletteResults();
4018
- scrollSelectedIntoView('palette');
4019
- } else if (event.key === 'Enter') {
4020
- event.preventDefault();
4021
- if (paletteResults[paletteIndex]) {
4022
- openFileFromSearch(paletteResults[paletteIndex].path, 'palette');
4023
- }
4024
- }
4025
- }
4026
-
4027
- // Global keyboard handler for Cmd+P / Ctrl+P and Escape
4028
- document.addEventListener('keydown', (e) => {
4029
- // Global Escape handler for palette (works even if input loses focus)
4030
- if (e.key === 'Escape' && paletteOpen) {
4031
- closePalette();
4032
- return;
4033
- }
4034
-
4035
- // Cmd+P (macOS) or Ctrl+P (Windows/Linux)
4036
- if ((e.metaKey || e.ctrlKey) && e.key === 'p') {
4037
- // Skip if user is typing in an input/textarea (except our search inputs)
4038
- const active = document.activeElement;
4039
- const isOurInput = active?.id === 'palette-input' || active?.id === 'files-search-input';
4040
- const isEditable = active?.tagName === 'INPUT' || active?.tagName === 'TEXTAREA' || active?.isContentEditable;
4041
-
4042
- if (!isOurInput && isEditable) return; // Let native behavior happen
4043
-
4044
- e.preventDefault(); // Prevent browser Print dialog
4045
- if (paletteOpen) {
4046
- closePalette();
4047
- } else {
4048
- openPalette();
4049
- }
4050
- }
4051
- });
4052
-
4053
- // Collapse all folders
4054
- function collapseAllFolders() {
4055
- filesTreeExpanded.clear();
4056
- rerenderFilesBrowser();
4057
- }
4058
-
4059
- // Expand all folders
4060
- function expandAllFolders() {
4061
- function collectPaths(nodes) {
4062
- for (const node of nodes) {
4063
- if (node.type === 'dir') {
4064
- filesTreeExpanded.add(node.path);
4065
- if (node.children) {
4066
- collectPaths(node.children);
4067
- }
4068
- }
4069
- }
4070
- }
4071
- collectPaths(filesTreeData);
4072
- rerenderFilesBrowser();
4073
- }
4074
-
4075
- // Refresh files tree
4076
- async function refreshFilesTree() {
4077
- await loadFilesTree();
4078
- rerenderFilesBrowser();
4079
- showToast('Files refreshed', 'success');
4080
- }
4081
-
4082
- // Open file from tree click
4083
- async function openFileFromTree(filePath) {
4084
- try {
4085
- // Check if file is already open
4086
- const existingTab = tabs.find(t => t.type === 'file' && t.path === filePath);
4087
- if (existingTab) {
4088
- selectTab(existingTab.id);
4089
- refreshFileTab(existingTab.id); // Refresh content in case file changed
4090
- return;
4091
- }
4092
-
4093
- // Open the file via API
4094
- const response = await fetch('/api/tabs/file', {
4095
- method: 'POST',
4096
- headers: { 'Content-Type': 'application/json' },
4097
- body: JSON.stringify({ path: filePath })
4098
- });
4099
-
4100
- if (!response.ok) {
4101
- throw new Error(await response.text());
4102
- }
4103
-
4104
- // Refresh state and switch to the new tab
4105
- await refresh();
4106
-
4107
- // Find and select the new file tab
4108
- const newTab = tabs.find(t => t.type === 'file' && t.path === filePath);
4109
- if (newTab) {
4110
- selectTab(newTab.id);
4111
- }
4112
-
4113
- showToast(`Opened ${getFileName(filePath)}`, 'success');
4114
- } catch (err) {
4115
- showToast('Failed to open file: ' + err.message, 'error');
4116
- }
4117
- }
4118
-
4119
- // ========================================
4120
- // Projects Tab Functions (Spec 0045)
4121
- // ========================================
4122
-
4123
- // Render the info header with helpful links
4124
- function renderInfoHeader() {
4125
- return `
4126
- <div class="projects-info">
4127
- <h1 style="font-size: 20px; margin-bottom: 12px; color: var(--text-primary);">Agent Farm Dashboard</h1>
4128
- <p>Coordinate AI builders working on your codebase. The left panel shows the Architect terminal – tell it what you want to build. <strong>Tabs</strong> shows open terminals (Architect, Builders, utility shells). <strong>Files</strong> lets you browse and open project files. <strong>Projects</strong> tracks work as it moves from conception to integration.</p>
4129
- <p>Docs: <a href="#" onclick="openProjectFile('codev/resources/cheatsheet.md'); return false;">Cheatsheet</a> · <a href="#" onclick="openProjectFile('codev/resources/lifecycle.md'); return false;">Lifecycle</a> · <a href="#" onclick="openProjectFile('codev/resources/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> · <a href="https://discord.gg/mJ92DhDa6n" target="_blank">Discord</a></p>
4130
- </div>
4131
- `;
4132
- }
4133
-
4134
- // Render the dashboard tab content (internal - called after data is loaded)
4135
- function renderDashboardTabContent() {
4136
- const content = document.getElementById('tab-content');
4137
-
4138
- content.innerHTML = `
4139
- <div class="dashboard-container">
4140
- ${renderInfoHeader()}
4141
- <div class="dashboard-header">
4142
- <!-- Tabs Section -->
4143
- <div class="dashboard-section section-tabs ${sectionState.tabs ? '' : 'collapsed'}">
4144
- <div class="dashboard-section-header" onclick="toggleSection('tabs')">
4145
- <h3><span class="collapse-icon">▼</span> Tabs</h3>
4146
- <div class="header-actions" onclick="event.stopPropagation()">
4147
- <button onclick="spawnBuilder()" title="New Worktree">+ Worktree</button>
4148
- <button onclick="spawnShell()" title="New Shell">+ Shell</button>
4149
- </div>
4150
- </div>
4151
- <div class="dashboard-section-content">
4152
- <div class="dashboard-tabs-list" id="dashboard-tabs-list">
4153
- ${renderDashboardTabsList()}
4154
- </div>
4155
- </div>
4156
- </div>
4157
- <!-- Files Section -->
4158
- <div class="dashboard-section section-files ${sectionState.files ? '' : 'collapsed'}">
4159
- <div class="dashboard-section-header" onclick="toggleSection('files')">
4160
- <h3><span class="collapse-icon">▼</span> Files</h3>
4161
- <div class="header-actions" onclick="event.stopPropagation()">
4162
- <button onclick="refreshFilesTree()" title="Refresh">↻</button>
4163
- <button onclick="collapseAllFolders()" title="Collapse All">⊟</button>
4164
- <button onclick="expandAllFolders()" title="Expand All">⊞</button>
4165
- </div>
4166
- </div>
4167
- <div class="dashboard-section-content">
4168
- <div class="files-search-container" onclick="event.stopPropagation()">
4169
- <input type="text"
4170
- id="files-search-input"
4171
- class="files-search-input"
4172
- placeholder="Search files by name..."
4173
- oninput="onFilesSearchInput(this.value)"
4174
- onkeydown="onFilesSearchKeydown(event)"
4175
- value="${escapeHtml(filesSearchQuery)}" />
4176
- <button class="files-search-clear ${filesSearchQuery ? '' : 'hidden'}"
4177
- onclick="clearFilesSearch()"
4178
- title="Clear search">×</button>
4179
- </div>
4180
- <div id="dashboard-files-content">
4181
- ${filesSearchQuery ? renderFilesSearchResults() : renderDashboardFilesBrowserWithWrapper()}
4182
- </div>
4183
- </div>
4184
- </div>
4185
- </div>
4186
- <!-- Projects Section -->
4187
- <div class="dashboard-section section-projects ${sectionState.projects ? '' : 'collapsed'}">
4188
- <div class="dashboard-section-header" onclick="toggleSection('projects')">
4189
- <h3><span class="collapse-icon">▼</span> Projects</h3>
4190
- </div>
4191
- <div class="dashboard-section-content" id="dashboard-projects">
4192
- ${renderDashboardProjectsSection()}
4193
- </div>
4194
- </div>
4195
- </div>
4196
- `;
4197
- }
4198
-
4199
- // Render the tabs list for dashboard
4200
- function renderDashboardTabsList() {
4201
- // Filter to show terminal tabs only (not Dashboard/Files tabs)
4202
- const terminalTabs = tabs.filter(t => t.type !== 'dashboard' && t.type !== 'files');
4203
-
4204
- if (terminalTabs.length === 0) {
4205
- return '<div class="dashboard-empty-state">No tabs open</div>';
4206
- }
4207
-
4208
- return terminalTabs.map(tab => {
4209
- const isActive = tab.id === activeTabId;
4210
- const icon = getTabIcon(tab.type);
4211
- const statusIndicator = getDashboardStatusIndicator(tab);
4212
-
4213
- return `
4214
- <div class="dashboard-tab-item ${isActive ? 'active' : ''}" onclick="selectTab('${tab.id}')">
4215
- ${statusIndicator}
4216
- <span class="tab-icon">${icon}</span>
4217
- <span class="tab-name">${escapeHtml(tab.name)}</span>
4218
- </div>
4219
- `;
4220
- }).join('');
4221
- }
4222
-
4223
- // Get status indicator for dashboard tab list
4224
- function getDashboardStatusIndicator(tab) {
4225
- if (tab.type !== 'builder') return '';
4226
-
4227
- // Use builder status from state
4228
- const builderState = (state.builders || []).find(b => `builder-${b.id}` === tab.id);
4229
- if (!builderState) return '';
4230
-
4231
- const status = builderState.status;
4232
- if (['spawning', 'implementing'].includes(status)) {
4233
- return '<span class="dashboard-status-indicator dashboard-status-working" title="Working"></span>';
4234
- }
4235
- if (status === 'blocked') {
4236
- return '<span class="dashboard-status-indicator dashboard-status-blocked" title="Blocked"></span>';
4237
- }
4238
- if (['pr-ready', 'complete'].includes(status)) {
4239
- return '<span class="dashboard-status-indicator dashboard-status-idle" title="Idle"></span>';
4240
- }
4241
- return '';
4242
- }
4243
-
4244
- // Render compact file browser for dashboard
4245
- function renderDashboardFilesBrowser() {
4246
- if (filesTreeError) {
4247
- return `<div class="dashboard-empty-state">${escapeHtml(filesTreeError)}</div>`;
4248
- }
4249
-
4250
- if (!filesTreeLoaded || filesTreeData.length === 0) {
4251
- return '<div class="dashboard-empty-state">Loading files...</div>';
4252
- }
4253
-
4254
- return renderTreeNodes(filesTreeData, 0);
4255
- }
4256
-
4257
- // Render the projects section for dashboard
4258
- function renderDashboardProjectsSection() {
4259
- if (projectlistError) {
4260
- return renderErrorBanner(projectlistError);
4261
- }
4262
-
4263
- if (projectsData.length === 0) {
4264
- // No welcome screen - just a helpful message
4265
- return `
4266
- <div class="dashboard-empty-state" style="padding: 24px;">
4267
- No projects yet. Ask the Architect to create your first project.
4268
- </div>
4269
- `;
4270
- }
4271
-
4272
- // Render the existing project view
4273
- return `
4274
- ${renderKanbanGrid(projectsData)}
4275
- ${renderTerminalProjects(projectsData)}
4276
- `;
4277
- }
4278
-
4279
- // Create new utility shell (quick action button)
4280
- async function createNewShell() {
4281
- try {
4282
- const response = await fetch('/api/tabs/shell', { method: 'POST' });
4283
- const data = await response.json();
4284
- if (!data.success && data.error) {
4285
- showToast(data.error || 'Failed to create shell', 'error');
4286
- return;
4287
- }
4288
- await refresh();
4289
- if (data.id) {
4290
- selectTab(`shell-${data.id}`);
4291
- }
4292
- showToast('Shell created', 'success');
4293
- } catch (err) {
4294
- showToast('Network error: ' + err.message, 'error');
4295
- }
4296
- }
4297
-
4298
- // Create new worktree shell (quick action button)
4299
- async function createNewWorktreeShell() {
4300
- const branch = prompt('Branch name (leave empty for temp worktree):');
4301
- if (branch === null) return; // User cancelled
4302
-
4303
- try {
4304
- const response = await fetch('/api/tabs/shell', {
4305
- method: 'POST',
4306
- headers: { 'Content-Type': 'application/json' },
4307
- body: JSON.stringify({ worktree: true, branch: branch || undefined })
4308
- });
4309
- const data = await response.json();
4310
- if (!data.success && data.error) {
4311
- showToast(data.error || 'Failed to create worktree shell', 'error');
4312
- return;
4313
- }
4314
- await refresh();
4315
- // Auto-select the newly created tab (consistent with createNewShell behavior)
4316
- if (data.id) {
4317
- selectTab(`shell-${data.id}`);
4318
- }
4319
- showToast('Worktree shell created', 'success');
4320
- } catch (err) {
4321
- showToast('Network error: ' + err.message, 'error');
4322
- }
4323
- }
4324
-
4325
- // Render the dashboard tab (entry point - loads data first)
4326
- async function renderDashboardTab() {
4327
- const content = document.getElementById('tab-content');
4328
- content.innerHTML = '<div class="dashboard-container"><p style="color: var(--text-muted); padding: 16px;">Loading dashboard...</p></div>';
4329
-
4330
- // Load both projectlist and files tree in parallel
4331
- await Promise.all([
4332
- loadProjectlist(),
4333
- loadFilesTreeIfNeeded()
4334
- ]);
4335
-
4336
- renderDashboardTabContent();
4337
- checkStarterMode(); // Update polling state after initial load
4338
- }
4339
-
4340
- // Load files tree if not already loaded
4341
- async function loadFilesTreeIfNeeded() {
4342
- if (!filesTreeLoaded) {
4343
- await loadFilesTree();
4344
- }
4345
- }
4346
-
4347
- // Legacy function for backward compatibility (still used by polling)
4348
- function renderProjectsTabContent() {
4349
- // If dashboard tab is active, re-render dashboard instead
4350
- if (activeTabId === 'dashboard') {
4351
- renderDashboardTabContent();
4352
- }
4353
- }
4354
-
4355
- // Legacy function alias
4356
- async function renderProjectsTab() {
4357
- await renderDashboardTab();
4358
- }
4359
-
4360
- // Load projectlist.md from disk
4361
- async function loadProjectlist() {
4362
- try {
4363
- const response = await fetch('/file?path=codev/projectlist.md');
4364
-
4365
- if (!response.ok) {
4366
- if (response.status === 404) {
4367
- // File not found - show welcome screen
4368
- projectsData = [];
4369
- projectlistError = null;
4370
- return;
4371
- }
4372
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
4373
- }
4374
-
4375
- const text = await response.text();
4376
- const newHash = hashString(text);
4377
-
4378
- // Only re-parse if content changed
4379
- if (newHash !== projectlistHash) {
4380
- projectlistHash = newHash;
4381
- projectsData = parseProjectlist(text);
4382
- projectlistError = null;
4383
- }
4384
- } catch (err) {
4385
- console.error('Failed to load projectlist:', err);
4386
- projectlistError = 'Could not load projectlist.md: ' + err.message;
4387
- // Preserve last good state if available
4388
- if (projectsData.length === 0) {
4389
- projectsData = [];
4390
- }
4391
- }
4392
- }
4393
-
4394
- // Reload projectlist (manual refresh button)
4395
- async function reloadProjectlist() {
4396
- projectlistHash = null; // Force re-parse
4397
- await loadProjectlist();
4398
- renderProjectsTabContent();
4399
- checkStarterMode(); // Update polling state after reload
4400
- }
4401
-
4402
- // Poll projectlist for changes (every 5 seconds)
4403
- async function pollProjectlist() {
4404
- // Only poll if dashboard tab is active
4405
- if (activeTabId !== 'dashboard') return;
4406
-
4407
- try {
4408
- const response = await fetch('/file?path=codev/projectlist.md');
4409
- if (!response.ok) return;
4410
-
4411
- const text = await response.text();
4412
- const newHash = hashString(text);
4413
-
4414
- if (newHash !== projectlistHash) {
4415
- // Content changed - debounce to avoid reading mid-write
4416
- clearTimeout(projectlistDebounce);
4417
- projectlistDebounce = setTimeout(async () => {
4418
- projectlistHash = newHash;
4419
- projectsData = parseProjectlist(text);
4420
- projectlistError = null;
4421
- renderProjectsTabContent();
4422
- checkStarterMode(); // Update polling state after content change
4423
- }, 500);
4424
- }
4425
- } catch (err) {
4426
- // Silently ignore polling errors
4427
- }
4428
- }
4429
-
4430
- // Poll for projectlist.md creation when in starter mode (every 15 seconds)
4431
- let starterModePollingInterval = null;
4432
-
4433
- async function pollForProjectlistCreation() {
4434
- try {
4435
- const response = await fetch('/api/projectlist-exists');
4436
- if (!response.ok) return;
4437
-
4438
- const { exists } = await response.json();
4439
- if (exists) {
4440
- // projectlist.md was created - stop polling and reload
4441
- if (starterModePollingInterval) {
4442
- clearInterval(starterModePollingInterval);
4443
- starterModePollingInterval = null;
4444
- }
4445
- window.location.reload();
4446
- }
4447
- } catch (err) {
4448
- // Silently ignore polling errors
4449
- }
4450
- }
4451
-
4452
- // Check if we should start starter mode polling
4453
- function checkStarterMode() {
4454
- // We're in starter mode ONLY if:
4455
- // 1. projectsData is empty (no projects loaded)
4456
- // 2. No error occurred
4457
- // 3. projectlistHash is null (file was not found, not just empty)
4458
- // This prevents infinite reload loop when file exists but is empty
4459
- const isStarterMode = projectsData.length === 0 && !projectlistError && projectlistHash === null;
4460
-
4461
- if (isStarterMode && !starterModePollingInterval) {
4462
- // Start polling for projectlist.md creation
4463
- starterModePollingInterval = setInterval(pollForProjectlistCreation, 15000);
4464
- } else if (!isStarterMode && starterModePollingInterval) {
4465
- // Stop polling - file exists now (even if empty)
4466
- clearInterval(starterModePollingInterval);
4467
- starterModePollingInterval = null;
4468
- }
4469
- }
4470
-
4471
- // Start projectlist polling (separate from main state polling)
4472
- setInterval(pollProjectlist, 5000);
4473
-
4474
- // ========================================
4475
- // Activity Summary (Spec 0059)
4476
- // ========================================
4477
-
4478
- let activityData = null;
4479
-
4480
- // Show activity summary modal
4481
- async function showActivitySummary() {
4482
- // Check if activity tab already exists
4483
- let activityTab = tabs.find(t => t.type === 'activity');
4484
-
4485
- if (!activityTab) {
4486
- // Create new activity tab
4487
- activityTab = {
4488
- id: 'activity-today',
4489
- type: 'activity',
4490
- name: 'Today'
4491
- };
4492
- tabs.push(activityTab);
4493
- }
4494
-
4495
- // Switch to activity tab
4496
- activeTabId = activityTab.id;
4497
- currentTabType = null; // Force re-render
4498
- renderTabs();
4499
- renderTabContent();
4500
- }
4501
-
4502
- // Render the activity tab content
4503
- async function renderActivityTab() {
4504
- const content = document.getElementById('tab-content');
4505
-
4506
- // Show loading state
4507
- content.innerHTML = `
4508
- <div class="activity-tab-container">
4509
- <div class="activity-loading">
4510
- <span class="activity-spinner"></span>
4511
- Loading activity...
4512
- </div>
4513
- </div>
4514
- `;
4515
-
4516
- try {
4517
- const response = await fetch('/api/activity-summary');
4518
- if (!response.ok) {
4519
- throw new Error(await response.text());
4520
- }
4521
- activityData = await response.json();
4522
- renderActivityTabContent(activityData);
4523
- } catch (err) {
4524
- content.innerHTML = `
4525
- <div class="activity-tab-container">
4526
- <div class="activity-error">
4527
- Failed to load activity: ${escapeHtml(err.message)}
4528
- </div>
4529
- </div>
4530
- `;
4531
- }
4532
- }
4533
-
4534
- // Render activity tab content (similar to modal but in tab)
4535
- function renderActivityTabContent(data) {
4536
- const content = document.getElementById('tab-content');
4537
-
4538
- // Check for zero activity
4539
- if (data.commits.length === 0 && data.prs.length === 0 && data.builders.length === 0) {
4540
- content.innerHTML = `
4541
- <div class="activity-tab-container">
4542
- <div class="activity-empty">
4543
- <p>No activity recorded today</p>
4544
- <p style="font-size: 12px; margin-top: 8px;">Make some commits or create PRs to see your daily summary!</p>
4545
- </div>
4546
- </div>
4547
- `;
4548
- return;
4549
- }
4550
-
4551
- const hours = Math.floor(data.timeTracking.activeMinutes / 60);
4552
- const mins = data.timeTracking.activeMinutes % 60;
4553
- const uniqueBranches = new Set(data.commits.map(c => c.branch)).size;
4554
- const mergedPrs = data.prs.filter(p => p.state === 'MERGED').length;
4555
-
4556
- // Format time strings
4557
- const formatTime = (isoString) => {
4558
- if (!isoString) return '--';
4559
- const date = new Date(isoString);
4560
- return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
4561
- };
4562
-
4563
- let html = '<div class="activity-tab-container"><div class="activity-summary">';
4564
-
4565
- // AI Summary (if available)
4566
- if (data.aiSummary) {
4567
- html += `<div class="activity-ai-summary">${escapeHtml(data.aiSummary)}</div>`;
4568
- }
4569
-
4570
- // Activity section
4571
- html += `
4572
- <div class="activity-section">
4573
- <h4>Activity</h4>
4574
- <ul>
4575
- <li>${data.commits.length} commits across ${uniqueBranches} branch${uniqueBranches !== 1 ? 'es' : ''}</li>
4576
- <li>${data.files.length} files modified</li>
4577
- <li>${data.prs.length} PR${data.prs.length !== 1 ? 's' : ''} created${mergedPrs > 0 ? `, ${mergedPrs} merged` : ''}</li>
4578
- </ul>
4579
- </div>
4580
- `;
4581
-
4582
- // Projects section (if any status changes)
4583
- if (data.projectChanges && data.projectChanges.length > 0) {
4584
- html += `
4585
- <div class="activity-section">
4586
- <h4>Projects Touched</h4>
4587
- <ul>
4588
- ${data.projectChanges.map(p => `<li>${escapeHtml(p.id)}: ${escapeHtml(p.title)} (${escapeHtml(p.oldStatus)} → ${escapeHtml(p.newStatus)})</li>`).join('')}
4589
- </ul>
4590
- </div>
4591
- `;
4592
- }
4593
-
4594
- // Time section
4595
- html += `
4596
- <div class="activity-section">
4597
- <h4>Time</h4>
4598
- <p><span class="activity-time-value">~${hours}h ${mins}m</span> active time</p>
4599
- <p>First activity: ${formatTime(data.timeTracking.firstActivity)}</p>
4600
- <p>Last activity: ${formatTime(data.timeTracking.lastActivity)}</p>
4601
- </div>
4602
- `;
4603
-
4604
- // Copy button
4605
- html += `
4606
- <div class="activity-actions">
4607
- <button class="btn" onclick="copyActivityToClipboard()">Copy to Clipboard</button>
4608
- </div>
4609
- `;
4610
-
4611
- html += '</div></div>';
4612
- content.innerHTML = html;
4613
- }
4614
-
4615
- // Render activity summary content
4616
- function renderActivitySummary(data) {
4617
- const content = document.getElementById('activity-content');
4618
-
4619
- // Check for zero activity
4620
- if (data.commits.length === 0 && data.prs.length === 0 && data.builders.length === 0) {
4621
- content.innerHTML = `
4622
- <div class="activity-empty">
4623
- <p>No activity recorded today</p>
4624
- <p style="font-size: 12px; margin-top: 8px;">Make some commits or create PRs to see your daily summary!</p>
4625
- </div>
4626
- `;
4627
- return;
4628
- }
4629
-
4630
- const hours = Math.floor(data.timeTracking.activeMinutes / 60);
4631
- const mins = data.timeTracking.activeMinutes % 60;
4632
- const uniqueBranches = new Set(data.commits.map(c => c.branch)).size;
4633
- const mergedPrs = data.prs.filter(p => p.state === 'MERGED').length;
4634
-
4635
- // Format time strings
4636
- const formatTime = (isoString) => {
4637
- if (!isoString) return '--';
4638
- const date = new Date(isoString);
4639
- return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
4640
- };
4641
-
4642
- let html = '<div class="activity-summary">';
4643
-
4644
- // AI Summary (if available)
4645
- if (data.aiSummary) {
4646
- html += `<div class="activity-ai-summary">${escapeHtml(data.aiSummary)}</div>`;
4647
- }
4648
-
4649
- // Activity section
4650
- html += `
4651
- <div class="activity-section">
4652
- <h4>Activity</h4>
4653
- <ul>
4654
- <li>${data.commits.length} commits across ${uniqueBranches} branch${uniqueBranches !== 1 ? 'es' : ''}</li>
4655
- <li>${data.files.length} files modified</li>
4656
- <li>${data.prs.length} PR${data.prs.length !== 1 ? 's' : ''} created${mergedPrs > 0 ? `, ${mergedPrs} merged` : ''}</li>
4657
- </ul>
4658
- </div>
4659
- `;
4660
-
4661
- // Projects section (if any status changes)
4662
- if (data.projectChanges && data.projectChanges.length > 0) {
4663
- html += `
4664
- <div class="activity-section">
4665
- <h4>Projects Touched</h4>
4666
- <ul>
4667
- ${data.projectChanges.map(p => `<li>${escapeHtml(p.id)}: ${escapeHtml(p.title)} (${escapeHtml(p.oldStatus)} → ${escapeHtml(p.newStatus)})</li>`).join('')}
4668
- </ul>
4669
- </div>
4670
- `;
4671
- }
4672
-
4673
- // Time section
4674
- html += `
4675
- <div class="activity-section">
4676
- <h4>Time</h4>
4677
- <p><span class="activity-time-value">~${hours}h ${mins}m</span> active time</p>
4678
- <p>First activity: ${formatTime(data.timeTracking.firstActivity)}</p>
4679
- <p>Last activity: ${formatTime(data.timeTracking.lastActivity)}</p>
4680
- </div>
4681
- `;
4682
-
4683
- html += '</div>';
4684
- content.innerHTML = html;
4685
- }
4686
-
4687
- // Close activity modal
4688
- function closeActivityModal() {
4689
- document.getElementById('activity-modal').classList.add('hidden');
4690
- }
4691
-
4692
- // Copy activity summary to clipboard
4693
- function copyActivitySummary() {
4694
- if (!activityData) return;
4695
-
4696
- const hours = Math.floor(activityData.timeTracking.activeMinutes / 60);
4697
- const mins = activityData.timeTracking.activeMinutes % 60;
4698
- const uniqueBranches = new Set(activityData.commits.map(c => c.branch)).size;
4699
- const mergedPrs = activityData.prs.filter(p => p.state === 'MERGED').length;
4700
-
4701
- let markdown = `## Today's Summary\n\n`;
4702
-
4703
- if (activityData.aiSummary) {
4704
- markdown += `${activityData.aiSummary}\n\n`;
4705
- }
4706
-
4707
- markdown += `### Activity\n`;
4708
- markdown += `- ${activityData.commits.length} commits across ${uniqueBranches} branches\n`;
4709
- markdown += `- ${activityData.files.length} files modified\n`;
4710
- markdown += `- ${activityData.prs.length} PRs${mergedPrs > 0 ? ` (${mergedPrs} merged)` : ''}\n\n`;
4711
-
4712
- if (activityData.projectChanges && activityData.projectChanges.length > 0) {
4713
- markdown += `### Projects Touched\n`;
4714
- activityData.projectChanges.forEach(p => {
4715
- markdown += `- ${p.id}: ${p.title} (${p.oldStatus} → ${p.newStatus})\n`;
4716
- });
4717
- markdown += '\n';
4718
- }
4719
-
4720
- markdown += `### Time\n`;
4721
- markdown += `Active time: ~${hours}h ${mins}m\n`;
4722
-
4723
- navigator.clipboard.writeText(markdown).then(() => {
4724
- showToast('Copied to clipboard', 'success');
4725
- }).catch(() => {
4726
- showToast('Failed to copy', 'error');
4727
- });
4728
- }
4729
-
4730
- // Close activity modal when clicking backdrop
4731
- document.getElementById('activity-modal').addEventListener('click', (e) => {
4732
- if (e.target.id === 'activity-modal') {
4733
- closeActivityModal();
4734
- }
4735
- });
4736
-
4737
- // Initialize on load
4738
- init();
4739
- </script>
4740
- </body>
4741
- </html>