@farazirfan/costar-server-executor 1.7.67 → 1.7.69

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.
@@ -0,0 +1,3261 @@
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>CoStar Dashboard</title>
7
+ <style>
8
+ /* ─── Reset & Base ─────────────────────────────── */
9
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
10
+
11
+ :root {
12
+ --bg-primary: #0f1117;
13
+ --bg-secondary: #161822;
14
+ --bg-card: #1c1e2e;
15
+ --bg-hover: #252840;
16
+ --border: #2a2d42;
17
+ --text-primary: #e4e6f0;
18
+ --text-secondary: #8b8fa8;
19
+ --text-muted: #5c6080;
20
+ --accent: #6366f1;
21
+ --accent-hover: #818cf8;
22
+ --accent-dim: rgba(99,102,241,.15);
23
+ --green: #22c55e;
24
+ --green-dim: rgba(34,197,94,.15);
25
+ --red: #ef4444;
26
+ --red-dim: rgba(239,68,68,.15);
27
+ --yellow: #eab308;
28
+ --yellow-dim: rgba(234,179,8,.15);
29
+ --blue: #3b82f6;
30
+ --blue-dim: rgba(59,130,246,.15);
31
+ --sidebar-w: 240px;
32
+ --header-h: 56px;
33
+ --radius: 8px;
34
+ --radius-lg: 12px;
35
+ --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif;
36
+ --mono: "SF Mono", "Fira Code", "Cascadia Code", monospace;
37
+ }
38
+
39
+ [data-theme="light"] {
40
+ --bg-primary: #f8f9fc;
41
+ --bg-secondary: #ffffff;
42
+ --bg-card: #ffffff;
43
+ --bg-hover: #f0f1f5;
44
+ --border: #e2e4eb;
45
+ --text-primary: #1a1c2e;
46
+ --text-secondary: #5c5f77;
47
+ --text-muted: #9496ad;
48
+ --accent: #6366f1;
49
+ --accent-hover: #4f46e5;
50
+ --accent-dim: rgba(99,102,241,.10);
51
+ --green: #16a34a;
52
+ --green-dim: rgba(22,163,74,.10);
53
+ --red: #dc2626;
54
+ --red-dim: rgba(220,38,38,.10);
55
+ --yellow: #ca8a04;
56
+ --yellow-dim: rgba(202,138,4,.10);
57
+ --blue: #2563eb;
58
+ --blue-dim: rgba(37,99,235,.10);
59
+ }
60
+
61
+ body {
62
+ font-family: var(--font);
63
+ background: var(--bg-primary);
64
+ color: var(--text-primary);
65
+ height: 100vh;
66
+ overflow: hidden;
67
+ }
68
+
69
+ /* ─── Layout ───────────────────────────────────── */
70
+ .app {
71
+ display: flex;
72
+ height: 100vh;
73
+ }
74
+
75
+ .sidebar {
76
+ width: var(--sidebar-w);
77
+ background: var(--bg-secondary);
78
+ border-right: 1px solid var(--border);
79
+ display: flex;
80
+ flex-direction: column;
81
+ flex-shrink: 0;
82
+ }
83
+
84
+ .sidebar-logo {
85
+ padding: 20px 20px 16px;
86
+ border-bottom: 1px solid var(--border);
87
+ display: flex;
88
+ align-items: center;
89
+ gap: 10px;
90
+ }
91
+
92
+ .sidebar-logo .logo-icon {
93
+ width: 32px;
94
+ height: 32px;
95
+ background: linear-gradient(135deg, var(--accent), #a78bfa);
96
+ border-radius: var(--radius);
97
+ display: flex;
98
+ align-items: center;
99
+ justify-content: center;
100
+ font-size: 16px;
101
+ font-weight: 700;
102
+ color: white;
103
+ }
104
+
105
+ .sidebar-logo h1 {
106
+ font-size: 18px;
107
+ font-weight: 700;
108
+ letter-spacing: -0.02em;
109
+ }
110
+
111
+ .sidebar-logo .version {
112
+ font-size: 11px;
113
+ color: var(--text-muted);
114
+ font-weight: 400;
115
+ margin-top: 2px;
116
+ }
117
+
118
+ .sidebar-logo .version span {
119
+ color: var(--accent);
120
+ font-family: var(--mono);
121
+ font-size: 10px;
122
+ }
123
+
124
+ .update-banner {
125
+ display: flex;
126
+ align-items: center;
127
+ justify-content: space-between;
128
+ background: var(--accent-dim);
129
+ border: 1px solid rgba(99,102,241,.3);
130
+ border-radius: var(--radius);
131
+ padding: 10px 14px;
132
+ margin-bottom: 16px;
133
+ }
134
+
135
+ .update-banner .update-info {
136
+ font-size: 13px;
137
+ color: var(--text-primary);
138
+ }
139
+
140
+ .update-banner .update-info small {
141
+ display: block;
142
+ color: var(--text-secondary);
143
+ font-size: 11px;
144
+ margin-top: 2px;
145
+ }
146
+
147
+ .update-btn {
148
+ background: var(--accent);
149
+ color: white;
150
+ border: none;
151
+ padding: 6px 14px;
152
+ border-radius: var(--radius);
153
+ font-size: 12px;
154
+ font-weight: 600;
155
+ cursor: pointer;
156
+ white-space: nowrap;
157
+ transition: background .15s;
158
+ }
159
+
160
+ .update-btn:hover { background: var(--accent-hover); }
161
+ .update-btn:disabled { opacity: .5; cursor: not-allowed; }
162
+
163
+ .sidebar-nav {
164
+ flex: 1;
165
+ padding: 12px 10px;
166
+ overflow-y: auto;
167
+ }
168
+
169
+ .nav-section {
170
+ margin-bottom: 20px;
171
+ }
172
+
173
+ .nav-section-label {
174
+ font-size: 11px;
175
+ text-transform: uppercase;
176
+ letter-spacing: 0.06em;
177
+ color: var(--text-muted);
178
+ padding: 4px 10px 8px;
179
+ font-weight: 600;
180
+ }
181
+
182
+ .nav-item {
183
+ display: flex;
184
+ align-items: center;
185
+ gap: 10px;
186
+ padding: 9px 12px;
187
+ border-radius: var(--radius);
188
+ cursor: pointer;
189
+ color: var(--text-secondary);
190
+ font-size: 14px;
191
+ font-weight: 500;
192
+ transition: all .15s;
193
+ border: none;
194
+ background: none;
195
+ width: 100%;
196
+ text-align: left;
197
+ }
198
+
199
+ .nav-item:hover {
200
+ background: var(--bg-hover);
201
+ color: var(--text-primary);
202
+ }
203
+
204
+ .nav-item.active {
205
+ background: var(--accent-dim);
206
+ color: var(--accent-hover);
207
+ }
208
+
209
+ .nav-item .icon {
210
+ font-size: 18px;
211
+ width: 22px;
212
+ text-align: center;
213
+ flex-shrink: 0;
214
+ }
215
+
216
+ .nav-item .badge {
217
+ margin-left: auto;
218
+ background: var(--accent-dim);
219
+ color: var(--accent);
220
+ font-size: 11px;
221
+ padding: 2px 7px;
222
+ border-radius: 10px;
223
+ font-weight: 600;
224
+ }
225
+
226
+ .sidebar-footer {
227
+ padding: 12px 16px;
228
+ border-top: 1px solid var(--border);
229
+ font-size: 12px;
230
+ color: var(--text-muted);
231
+ }
232
+
233
+ .status-dot {
234
+ display: inline-block;
235
+ width: 8px;
236
+ height: 8px;
237
+ border-radius: 50%;
238
+ background: var(--green);
239
+ margin-right: 6px;
240
+ animation: pulse 2s infinite;
241
+ }
242
+
243
+ @keyframes pulse {
244
+ 0%, 100% { opacity: 1; }
245
+ 50% { opacity: .5; }
246
+ }
247
+
248
+ /* ─── Main Content ─────────────────────────────── */
249
+ .main {
250
+ flex: 1;
251
+ display: flex;
252
+ flex-direction: column;
253
+ overflow: hidden;
254
+ }
255
+
256
+ .header {
257
+ height: var(--header-h);
258
+ border-bottom: 1px solid var(--border);
259
+ display: flex;
260
+ align-items: center;
261
+ padding: 0 24px;
262
+ gap: 16px;
263
+ flex-shrink: 0;
264
+ }
265
+
266
+ .header h2 {
267
+ font-size: 16px;
268
+ font-weight: 600;
269
+ }
270
+
271
+ .header .header-actions {
272
+ margin-left: auto;
273
+ display: flex;
274
+ gap: 8px;
275
+ }
276
+
277
+ .content {
278
+ flex: 1;
279
+ overflow-y: auto;
280
+ padding: 24px;
281
+ }
282
+
283
+ .page { display: none; }
284
+ .page.active { display: block; }
285
+
286
+ /* ─── Cards ────────────────────────────────────── */
287
+ .card {
288
+ background: var(--bg-card);
289
+ border: 1px solid var(--border);
290
+ border-radius: var(--radius-lg);
291
+ padding: 20px;
292
+ margin-bottom: 16px;
293
+ }
294
+
295
+ .card-header {
296
+ display: flex;
297
+ align-items: center;
298
+ justify-content: space-between;
299
+ margin-bottom: 16px;
300
+ }
301
+
302
+ .card-header h3 {
303
+ font-size: 15px;
304
+ font-weight: 600;
305
+ }
306
+
307
+ .stat-grid {
308
+ display: grid;
309
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
310
+ gap: 16px;
311
+ margin-bottom: 24px;
312
+ }
313
+
314
+ .stat-card {
315
+ background: var(--bg-card);
316
+ border: 1px solid var(--border);
317
+ border-radius: var(--radius-lg);
318
+ padding: 20px;
319
+ }
320
+
321
+ .stat-card .stat-label {
322
+ font-size: 12px;
323
+ color: var(--text-secondary);
324
+ text-transform: uppercase;
325
+ letter-spacing: 0.05em;
326
+ margin-bottom: 8px;
327
+ }
328
+
329
+ .stat-card .stat-value {
330
+ font-size: 28px;
331
+ font-weight: 700;
332
+ letter-spacing: -0.02em;
333
+ }
334
+
335
+ .stat-card .stat-sub {
336
+ font-size: 12px;
337
+ color: var(--text-muted);
338
+ margin-top: 4px;
339
+ }
340
+
341
+ /* ─── Buttons ──────────────────────────────────── */
342
+ .btn {
343
+ padding: 8px 16px;
344
+ border-radius: var(--radius);
345
+ border: 1px solid var(--border);
346
+ background: var(--bg-card);
347
+ color: var(--text-primary);
348
+ font-size: 13px;
349
+ font-weight: 500;
350
+ cursor: pointer;
351
+ transition: all .15s;
352
+ font-family: var(--font);
353
+ }
354
+
355
+ .btn:hover { background: var(--bg-hover); }
356
+
357
+ .btn-primary {
358
+ background: var(--accent);
359
+ border-color: var(--accent);
360
+ color: white;
361
+ }
362
+
363
+ .btn-primary:hover { background: var(--accent-hover); }
364
+
365
+ .btn-danger {
366
+ background: var(--red-dim);
367
+ border-color: transparent;
368
+ color: var(--red);
369
+ }
370
+
371
+ .btn-danger:hover { background: rgba(239,68,68,.25); }
372
+
373
+ .btn-sm { padding: 5px 10px; font-size: 12px; }
374
+
375
+ .btn-icon {
376
+ width: 32px;
377
+ height: 32px;
378
+ padding: 0;
379
+ display: flex;
380
+ align-items: center;
381
+ justify-content: center;
382
+ font-size: 16px;
383
+ }
384
+
385
+ /* ─── Tables ───────────────────────────────────── */
386
+ .table-wrap {
387
+ overflow-x: auto;
388
+ }
389
+
390
+ table {
391
+ width: 100%;
392
+ border-collapse: collapse;
393
+ font-size: 13px;
394
+ }
395
+
396
+ th {
397
+ text-align: left;
398
+ padding: 10px 12px;
399
+ color: var(--text-secondary);
400
+ font-size: 11px;
401
+ text-transform: uppercase;
402
+ letter-spacing: 0.06em;
403
+ font-weight: 600;
404
+ border-bottom: 1px solid var(--border);
405
+ }
406
+
407
+ td {
408
+ padding: 12px;
409
+ border-bottom: 1px solid var(--border);
410
+ vertical-align: middle;
411
+ }
412
+
413
+ tr:last-child td { border-bottom: none; }
414
+ tr:hover td { background: var(--bg-hover); }
415
+
416
+ /* ─── Tags / Badges ────────────────────────────── */
417
+ .tag {
418
+ display: inline-flex;
419
+ align-items: center;
420
+ gap: 4px;
421
+ padding: 3px 8px;
422
+ border-radius: 6px;
423
+ font-size: 11px;
424
+ font-weight: 600;
425
+ }
426
+
427
+ .tag-green { background: var(--green-dim); color: var(--green); }
428
+ .tag-red { background: var(--red-dim); color: var(--red); }
429
+ .tag-yellow { background: var(--yellow-dim); color: var(--yellow); }
430
+ .tag-blue { background: var(--blue-dim); color: var(--blue); }
431
+ .tag-muted { background: rgba(140,144,170,.1); color: var(--text-secondary); }
432
+
433
+ /* ─── Forms ────────────────────────────────────── */
434
+ input[type="text"], input[type="search"], textarea, select {
435
+ width: 100%;
436
+ padding: 9px 12px;
437
+ background: var(--bg-primary);
438
+ border: 1px solid var(--border);
439
+ border-radius: var(--radius);
440
+ color: var(--text-primary);
441
+ font-size: 13px;
442
+ font-family: var(--font);
443
+ outline: none;
444
+ transition: border-color .15s;
445
+ }
446
+
447
+ input:focus, textarea:focus, select:focus {
448
+ border-color: var(--accent);
449
+ }
450
+
451
+ textarea {
452
+ resize: vertical;
453
+ min-height: 100px;
454
+ font-family: var(--mono);
455
+ }
456
+
457
+ label {
458
+ display: block;
459
+ font-size: 12px;
460
+ font-weight: 600;
461
+ color: var(--text-secondary);
462
+ margin-bottom: 6px;
463
+ }
464
+
465
+ .form-group {
466
+ margin-bottom: 16px;
467
+ }
468
+
469
+ /* ─── Chat ─────────────────────────────────────── */
470
+ .chat-container {
471
+ display: flex;
472
+ flex-direction: column;
473
+ height: calc(100vh - var(--header-h) - 48px);
474
+ }
475
+
476
+ .chat-messages {
477
+ flex: 1;
478
+ overflow-y: auto;
479
+ padding: 16px 0;
480
+ }
481
+
482
+ .chat-msg {
483
+ display: flex;
484
+ gap: 12px;
485
+ padding: 12px 0;
486
+ }
487
+
488
+ .chat-msg .avatar {
489
+ width: 32px;
490
+ height: 32px;
491
+ border-radius: 50%;
492
+ display: flex;
493
+ align-items: center;
494
+ justify-content: center;
495
+ font-size: 14px;
496
+ flex-shrink: 0;
497
+ font-weight: 600;
498
+ }
499
+
500
+ .chat-msg.user .avatar {
501
+ background: var(--accent-dim);
502
+ color: var(--accent);
503
+ }
504
+
505
+ .chat-msg.assistant .avatar {
506
+ background: var(--green-dim);
507
+ color: var(--green);
508
+ }
509
+
510
+ .chat-msg .msg-content {
511
+ flex: 1;
512
+ line-height: 1.6;
513
+ font-size: 14px;
514
+ }
515
+
516
+ .chat-msg .msg-content .msg-role {
517
+ font-size: 12px;
518
+ font-weight: 600;
519
+ color: var(--text-secondary);
520
+ margin-bottom: 4px;
521
+ }
522
+
523
+ .chat-input-wrap {
524
+ display: flex;
525
+ gap: 8px;
526
+ padding-top: 16px;
527
+ border-top: 1px solid var(--border);
528
+ }
529
+
530
+ .chat-input-wrap input {
531
+ flex: 1;
532
+ }
533
+
534
+ /* ─── VNC Live Browser Panel ──────────────────── */
535
+ .vnc-panel {
536
+ display: none;
537
+ border-top: 1px solid var(--border);
538
+ background: var(--bg-secondary);
539
+ flex-shrink: 0;
540
+ overflow: hidden;
541
+ }
542
+ .vnc-panel.active { display: block; }
543
+
544
+ .vnc-panel-header {
545
+ display: flex;
546
+ align-items: center;
547
+ gap: 8px;
548
+ padding: 8px 12px;
549
+ cursor: pointer;
550
+ user-select: none;
551
+ transition: background 0.15s;
552
+ }
553
+ .vnc-panel-header:hover { background: var(--bg-hover); }
554
+
555
+ .vnc-panel-title {
556
+ font-size: 12px;
557
+ font-weight: 600;
558
+ color: var(--green);
559
+ font-family: var(--mono);
560
+ }
561
+ .vnc-panel-dot {
562
+ width: 8px;
563
+ height: 8px;
564
+ border-radius: 50%;
565
+ background: var(--green);
566
+ animation: vnc-pulse 2s infinite;
567
+ }
568
+ @keyframes vnc-pulse {
569
+ 0%, 100% { opacity: 1; }
570
+ 50% { opacity: 0.4; }
571
+ }
572
+ .vnc-panel-toggle {
573
+ font-size: 10px;
574
+ color: var(--text-muted);
575
+ transition: transform 0.2s;
576
+ }
577
+ .vnc-panel.collapsed .vnc-panel-toggle { transform: rotate(180deg); }
578
+
579
+ .vnc-panel-body {
580
+ height: 400px;
581
+ transition: height 0.3s ease;
582
+ }
583
+ .vnc-panel.collapsed .vnc-panel-body {
584
+ height: 0;
585
+ overflow: hidden;
586
+ }
587
+ .vnc-panel-body iframe {
588
+ width: 100%;
589
+ height: 100%;
590
+ border: none;
591
+ background: #000;
592
+ }
593
+ .vnc-panel-actions {
594
+ margin-left: auto;
595
+ display: flex;
596
+ gap: 6px;
597
+ align-items: center;
598
+ }
599
+ .vnc-open-btn {
600
+ font-size: 11px;
601
+ color: var(--text-muted);
602
+ background: none;
603
+ border: 1px solid var(--border);
604
+ border-radius: 4px;
605
+ padding: 2px 8px;
606
+ cursor: pointer;
607
+ font-family: var(--mono);
608
+ }
609
+ .vnc-open-btn:hover {
610
+ color: var(--text-primary);
611
+ border-color: var(--text-secondary);
612
+ }
613
+
614
+ /* ─── Chat Tool Cards ──────────────────────────── */
615
+ .tool-card {
616
+ background: var(--bg-secondary);
617
+ border: 1px solid var(--border);
618
+ border-radius: var(--radius);
619
+ margin: 8px 0;
620
+ font-size: 13px;
621
+ overflow: hidden;
622
+ }
623
+
624
+ .tool-card-header {
625
+ display: flex;
626
+ align-items: center;
627
+ gap: 8px;
628
+ padding: 8px 12px;
629
+ cursor: pointer;
630
+ user-select: none;
631
+ transition: background 0.15s;
632
+ }
633
+
634
+ .tool-card-header:hover {
635
+ background: var(--bg-hover);
636
+ }
637
+
638
+ .tool-icon {
639
+ font-size: 14px;
640
+ flex-shrink: 0;
641
+ }
642
+
643
+ .tool-icon.running {
644
+ animation: spin 1s linear infinite;
645
+ }
646
+
647
+ @keyframes spin {
648
+ from { transform: rotate(0deg); }
649
+ to { transform: rotate(360deg); }
650
+ }
651
+
652
+ .tool-name {
653
+ font-weight: 600;
654
+ color: var(--accent);
655
+ font-family: var(--mono);
656
+ font-size: 12px;
657
+ }
658
+
659
+ .tool-status {
660
+ margin-left: auto;
661
+ font-size: 11px;
662
+ font-family: var(--mono);
663
+ color: var(--text-muted);
664
+ }
665
+
666
+ .tool-status.success {
667
+ color: var(--green);
668
+ }
669
+
670
+ .tool-status.error {
671
+ color: var(--red);
672
+ }
673
+
674
+ .tool-chevron {
675
+ font-size: 10px;
676
+ color: var(--text-muted);
677
+ transition: transform 0.2s;
678
+ }
679
+
680
+ .tool-card.expanded .tool-chevron {
681
+ transform: rotate(90deg);
682
+ }
683
+
684
+ .tool-card-body {
685
+ display: none;
686
+ border-top: 1px solid var(--border);
687
+ }
688
+
689
+ .tool-card.expanded .tool-card-body {
690
+ display: block;
691
+ }
692
+
693
+ .tool-section {
694
+ padding: 8px 12px;
695
+ }
696
+
697
+ .tool-section + .tool-section {
698
+ border-top: 1px solid var(--border);
699
+ }
700
+
701
+ .tool-section-label {
702
+ font-size: 10px;
703
+ font-weight: 600;
704
+ text-transform: uppercase;
705
+ letter-spacing: 0.05em;
706
+ color: var(--text-muted);
707
+ margin-bottom: 4px;
708
+ }
709
+
710
+ .tool-section pre {
711
+ font-family: var(--mono);
712
+ font-size: 11px;
713
+ color: var(--text-secondary);
714
+ white-space: pre-wrap;
715
+ word-break: break-all;
716
+ max-height: 200px;
717
+ overflow-y: auto;
718
+ margin: 0;
719
+ line-height: 1.5;
720
+ }
721
+
722
+ /* Message flow — interleaved text segments and tool cards */
723
+ .msg-flow {
724
+ display: flex;
725
+ flex-direction: column;
726
+ }
727
+
728
+ .msg-text-segment:empty:not(.streaming-cursor) {
729
+ display: none;
730
+ }
731
+
732
+ /* Compaction banner — fixed above chat input */
733
+ .compaction-banner {
734
+ display: none;
735
+ align-items: center;
736
+ gap: 8px;
737
+ padding: 8px 12px;
738
+ margin: 0;
739
+ border-radius: var(--radius) var(--radius) 0 0;
740
+ background: rgba(99, 102, 241, 0.08);
741
+ border: 1px solid rgba(99, 102, 241, 0.2);
742
+ border-bottom: none;
743
+ font-size: 12px;
744
+ color: var(--accent);
745
+ font-family: var(--mono);
746
+ }
747
+
748
+ .compaction-banner.active { display: flex; }
749
+
750
+ .compaction-banner .compaction-icon {
751
+ animation: spin 1s linear infinite;
752
+ font-size: 14px;
753
+ }
754
+
755
+ .compaction-banner.compaction-done {
756
+ background: rgba(34, 197, 94, 0.08);
757
+ border-color: rgba(34, 197, 94, 0.2);
758
+ border-bottom: none;
759
+ color: var(--green);
760
+ }
761
+
762
+ .compaction-banner.compaction-done .compaction-icon {
763
+ animation: none;
764
+ }
765
+
766
+ .compaction-banner.compaction-error {
767
+ background: rgba(239, 68, 68, 0.08);
768
+ border-color: rgba(239, 68, 68, 0.2);
769
+ border-bottom: none;
770
+ color: var(--red);
771
+ }
772
+
773
+ .compaction-banner.compaction-error .compaction-icon {
774
+ animation: none;
775
+ }
776
+
777
+ /* Streaming cursor */
778
+ .streaming-cursor::after {
779
+ content: "";
780
+ display: inline-block;
781
+ width: 8px;
782
+ height: 16px;
783
+ background: var(--accent);
784
+ margin-left: 2px;
785
+ animation: blink 0.8s step-end infinite;
786
+ vertical-align: text-bottom;
787
+ }
788
+
789
+ @keyframes blink {
790
+ 50% { opacity: 0; }
791
+ }
792
+
793
+ /* Chat metadata bar */
794
+ .chat-meta {
795
+ display: flex;
796
+ gap: 12px;
797
+ padding: 6px 0;
798
+ font-size: 11px;
799
+ color: var(--text-muted);
800
+ font-family: var(--mono);
801
+ }
802
+
803
+ .chat-meta span {
804
+ display: flex;
805
+ align-items: center;
806
+ gap: 4px;
807
+ }
808
+
809
+ /* ─── Logs ─────────────────────────────────────── */
810
+ .log-toolbar {
811
+ display: flex;
812
+ gap: 8px;
813
+ align-items: center;
814
+ margin-bottom: 16px;
815
+ flex-wrap: wrap;
816
+ }
817
+
818
+ .log-toolbar select {
819
+ width: auto;
820
+ min-width: 130px;
821
+ }
822
+
823
+ .log-entries {
824
+ font-family: var(--mono);
825
+ font-size: 12px;
826
+ line-height: 1.8;
827
+ max-height: calc(100vh - 250px);
828
+ overflow-y: auto;
829
+ background: var(--bg-primary);
830
+ border: 1px solid var(--border);
831
+ border-radius: var(--radius);
832
+ padding: 12px;
833
+ }
834
+
835
+ .log-line {
836
+ display: flex;
837
+ gap: 8px;
838
+ padding: 2px 0;
839
+ white-space: nowrap;
840
+ }
841
+
842
+ .log-line:hover { background: var(--bg-hover); }
843
+
844
+ .log-line .log-time {
845
+ color: var(--text-muted);
846
+ flex-shrink: 0;
847
+ }
848
+
849
+ .log-line .log-level {
850
+ width: 50px;
851
+ flex-shrink: 0;
852
+ font-weight: 600;
853
+ text-transform: uppercase;
854
+ }
855
+
856
+ .log-line .log-level.info { color: var(--blue); }
857
+ .log-line .log-level.warn { color: var(--yellow); }
858
+ .log-line .log-level.error { color: var(--red); }
859
+ .log-line .log-level.debug { color: var(--text-muted); }
860
+
861
+ .log-line .log-comp {
862
+ color: var(--accent);
863
+ flex-shrink: 0;
864
+ min-width: 80px;
865
+ }
866
+
867
+ .log-line .log-msg {
868
+ color: var(--text-primary);
869
+ overflow: hidden;
870
+ text-overflow: ellipsis;
871
+ }
872
+
873
+ /* ─── Workspace Editor ──────────────────────────── */
874
+ .workspace-split {
875
+ display: grid;
876
+ grid-template-columns: 260px 1fr;
877
+ gap: 16px;
878
+ height: calc(100vh - var(--header-h) - 48px);
879
+ }
880
+
881
+ .workspace-list {
882
+ overflow-y: auto;
883
+ }
884
+
885
+ .file-item {
886
+ display: flex;
887
+ align-items: center;
888
+ gap: 8px;
889
+ padding: 10px 12px;
890
+ border-radius: var(--radius);
891
+ cursor: pointer;
892
+ color: var(--text-secondary);
893
+ font-size: 13px;
894
+ transition: all .15s;
895
+ }
896
+
897
+ .file-item:hover { background: var(--bg-hover); color: var(--text-primary); }
898
+ .file-item.active { background: var(--accent-dim); color: var(--accent-hover); }
899
+
900
+ .file-item .file-icon { font-size: 16px; }
901
+
902
+ .file-item .file-meta {
903
+ margin-left: auto;
904
+ font-size: 11px;
905
+ color: var(--text-muted);
906
+ }
907
+
908
+ .workspace-editor {
909
+ display: flex;
910
+ flex-direction: column;
911
+ }
912
+
913
+ .workspace-editor textarea {
914
+ flex: 1;
915
+ min-height: unset;
916
+ resize: none;
917
+ line-height: 1.7;
918
+ }
919
+
920
+ .editor-toolbar {
921
+ display: flex;
922
+ justify-content: space-between;
923
+ align-items: center;
924
+ padding: 8px 0;
925
+ }
926
+
927
+ /* ─── Settings ──────────────────────────────────── */
928
+ .config-list {
929
+ font-family: var(--mono);
930
+ font-size: 13px;
931
+ }
932
+
933
+ .config-row {
934
+ display: flex;
935
+ padding: 10px 0;
936
+ border-bottom: 1px solid var(--border);
937
+ }
938
+
939
+ .config-row:last-child { border-bottom: none; }
940
+
941
+ .config-key {
942
+ width: 280px;
943
+ color: var(--accent);
944
+ flex-shrink: 0;
945
+ }
946
+
947
+ .config-value {
948
+ color: var(--text-primary);
949
+ word-break: break-all;
950
+ }
951
+
952
+ /* ─── Empty states ──────────────────────────────── */
953
+ .empty-state {
954
+ text-align: center;
955
+ padding: 60px 20px;
956
+ color: var(--text-muted);
957
+ }
958
+
959
+ .empty-state .empty-icon {
960
+ font-size: 48px;
961
+ margin-bottom: 16px;
962
+ }
963
+
964
+ .empty-state p {
965
+ font-size: 14px;
966
+ max-width: 400px;
967
+ margin: 0 auto;
968
+ }
969
+
970
+ /* ─── Modal ────────────────────────────────────── */
971
+ .modal-overlay {
972
+ display: none;
973
+ position: fixed;
974
+ inset: 0;
975
+ background: rgba(0,0,0,.6);
976
+ z-index: 100;
977
+ align-items: center;
978
+ justify-content: center;
979
+ }
980
+
981
+ .modal-overlay.open { display: flex; }
982
+
983
+ .modal {
984
+ background: var(--bg-card);
985
+ border: 1px solid var(--border);
986
+ border-radius: var(--radius-lg);
987
+ width: 100%;
988
+ max-width: 520px;
989
+ padding: 24px;
990
+ max-height: 80vh;
991
+ overflow-y: auto;
992
+ }
993
+
994
+ .modal h3 {
995
+ font-size: 16px;
996
+ font-weight: 600;
997
+ margin-bottom: 20px;
998
+ }
999
+
1000
+ .modal-actions {
1001
+ display: flex;
1002
+ justify-content: flex-end;
1003
+ gap: 8px;
1004
+ margin-top: 20px;
1005
+ }
1006
+
1007
+ /* ─── File Tabs ────────────────────────────────── */
1008
+ .file-tabs {
1009
+ display: flex;
1010
+ gap: 4px;
1011
+ border-bottom: 1px solid var(--border);
1012
+ margin-bottom: 12px;
1013
+ overflow-x: auto;
1014
+ }
1015
+
1016
+ .file-tab {
1017
+ padding: 8px 14px;
1018
+ font-size: 12px;
1019
+ font-family: var(--mono);
1020
+ color: var(--text-secondary);
1021
+ background: transparent;
1022
+ border: none;
1023
+ border-bottom: 2px solid transparent;
1024
+ cursor: pointer;
1025
+ transition: all .15s;
1026
+ white-space: nowrap;
1027
+ }
1028
+
1029
+ .file-tab:hover {
1030
+ color: var(--text-primary);
1031
+ background: var(--bg-hover);
1032
+ }
1033
+
1034
+ .file-tab.active {
1035
+ color: var(--accent);
1036
+ border-bottom-color: var(--accent);
1037
+ }
1038
+
1039
+ .file-content {
1040
+ display: none;
1041
+ }
1042
+
1043
+ .file-content.active {
1044
+ display: block;
1045
+ }
1046
+
1047
+ /* ─── Scrollbar ─────────────────────────────────── */
1048
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
1049
+ ::-webkit-scrollbar-track { background: transparent; }
1050
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
1051
+ ::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
1052
+
1053
+ /* ─── Loading ───────────────────────────────────── */
1054
+ .spinner {
1055
+ width: 20px;
1056
+ height: 20px;
1057
+ border: 2px solid var(--border);
1058
+ border-top-color: var(--accent);
1059
+ border-radius: 50%;
1060
+ animation: spin .6s linear infinite;
1061
+ }
1062
+
1063
+ @keyframes spin { to { transform: rotate(360deg); } }
1064
+
1065
+ .loading-center {
1066
+ display: flex;
1067
+ align-items: center;
1068
+ justify-content: center;
1069
+ padding: 60px;
1070
+ }
1071
+
1072
+ /* ─── Skill card grid ───────────────────────────── */
1073
+ .skill-grid {
1074
+ display: grid;
1075
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
1076
+ gap: 16px;
1077
+ }
1078
+
1079
+ .skill-card {
1080
+ background: var(--bg-card);
1081
+ border: 1px solid var(--border);
1082
+ border-radius: var(--radius-lg);
1083
+ padding: 20px;
1084
+ cursor: pointer;
1085
+ transition: all .15s;
1086
+ }
1087
+
1088
+ .skill-card:hover { border-color: var(--accent); transform: translateY(-1px); }
1089
+
1090
+ .skill-card .skill-header {
1091
+ display: flex;
1092
+ align-items: center;
1093
+ gap: 10px;
1094
+ margin-bottom: 10px;
1095
+ }
1096
+
1097
+ .skill-card .skill-emoji { font-size: 24px; }
1098
+
1099
+ .skill-card .skill-name {
1100
+ font-size: 14px;
1101
+ font-weight: 600;
1102
+ }
1103
+
1104
+ .skill-card .skill-desc {
1105
+ font-size: 13px;
1106
+ color: var(--text-secondary);
1107
+ line-height: 1.5;
1108
+ display: -webkit-box;
1109
+ -webkit-line-clamp: 3;
1110
+ -webkit-box-orient: vertical;
1111
+ overflow: hidden;
1112
+ }
1113
+
1114
+ .skill-card .skill-footer {
1115
+ margin-top: 12px;
1116
+ display: flex;
1117
+ gap: 6px;
1118
+ }
1119
+
1120
+ /* ─── Theme Toggle ──────────────────────────────── */
1121
+ .theme-toggle {
1122
+ background: var(--bg-card);
1123
+ border: 1px solid var(--border);
1124
+ border-radius: var(--radius);
1125
+ color: var(--text-secondary);
1126
+ cursor: pointer;
1127
+ padding: 6px 10px;
1128
+ font-size: 16px;
1129
+ line-height: 1;
1130
+ transition: background .15s, color .15s;
1131
+ }
1132
+ .theme-toggle:hover {
1133
+ background: var(--bg-hover);
1134
+ color: var(--text-primary);
1135
+ }
1136
+
1137
+ /* ─── Light Mode Fixes ───────────────────────────── */
1138
+ [data-theme="light"] .sidebar {
1139
+ border-right: 1px solid var(--border);
1140
+ }
1141
+ [data-theme="light"] .card,
1142
+ [data-theme="light"] .stat-card,
1143
+ [data-theme="light"] .skill-card {
1144
+ box-shadow: 0 1px 3px rgba(0,0,0,.06);
1145
+ }
1146
+ [data-theme="light"] .modal-content {
1147
+ box-shadow: 0 8px 30px rgba(0,0,0,.12);
1148
+ }
1149
+ [data-theme="light"] .logo-icon {
1150
+ background: linear-gradient(135deg, var(--accent), #7c3aed);
1151
+ }
1152
+ [data-theme="light"] code,
1153
+ [data-theme="light"] .log-line {
1154
+ background: var(--bg-primary);
1155
+ }
1156
+
1157
+ /* ─── Responsive ────────────────────────────────── */
1158
+ @media (max-width: 768px) {
1159
+ .sidebar { display: none; }
1160
+ .stat-grid { grid-template-columns: 1fr 1fr; }
1161
+ .workspace-split { grid-template-columns: 1fr; }
1162
+ }
1163
+ </style>
1164
+ <script>
1165
+ // Apply saved theme immediately to prevent flash of wrong theme
1166
+ (function(){var t=localStorage.getItem('costar-theme');if(t)document.documentElement.setAttribute('data-theme',t);})();
1167
+ </script>
1168
+ </head>
1169
+ <body>
1170
+ <div class="app">
1171
+ <!-- Sidebar -->
1172
+ <aside class="sidebar">
1173
+ <div class="sidebar-logo">
1174
+ <div class="logo-icon">C</div>
1175
+ <div>
1176
+ <h1>CoStar</h1>
1177
+ <div class="version">Server Executor <span id="sidebar-version"></span></div>
1178
+ </div>
1179
+ </div>
1180
+
1181
+ <nav class="sidebar-nav">
1182
+ <div class="nav-section">
1183
+ <div class="nav-section-label">Overview</div>
1184
+ <button class="nav-item active" data-page="dashboard" onclick="navigate('dashboard')">
1185
+ <span class="icon">&#9881;</span> Dashboard
1186
+ </button>
1187
+ </div>
1188
+
1189
+ <div class="nav-section">
1190
+ <div class="nav-section-label">Agent</div>
1191
+ <button class="nav-item" data-page="chat" onclick="navigate('chat')">
1192
+ <span class="icon">&#128172;</span> Chat
1193
+ </button>
1194
+ <button class="nav-item" data-page="sessions" onclick="navigate('sessions')">
1195
+ <span class="icon">&#128203;</span> Sessions
1196
+ <span class="badge" id="sessions-badge" style="display:none">0</span>
1197
+ </button>
1198
+ </div>
1199
+
1200
+ <div class="nav-section">
1201
+ <div class="nav-section-label">Automation</div>
1202
+ <button class="nav-item" data-page="cron" onclick="navigate('cron')">
1203
+ <span class="icon">&#9200;</span> Cron Jobs
1204
+ <span class="badge" id="cron-badge">0</span>
1205
+ </button>
1206
+ </div>
1207
+
1208
+ <div class="nav-section">
1209
+ <div class="nav-section-label">Configuration</div>
1210
+ <button class="nav-item" data-page="skills" onclick="navigate('skills')">
1211
+ <span class="icon">&#9889;</span> Skills
1212
+ <span class="badge" id="skills-badge">0</span>
1213
+ </button>
1214
+ <button class="nav-item" data-page="workspace" onclick="navigate('workspace')">
1215
+ <span class="icon">&#128196;</span> Workspace
1216
+ </button>
1217
+ </div>
1218
+
1219
+ <div class="nav-section">
1220
+ <div class="nav-section-label">System</div>
1221
+ <button class="nav-item" data-page="logs" onclick="navigate('logs')">
1222
+ <span class="icon">&#128203;</span> Logs
1223
+ </button>
1224
+ <button class="nav-item" data-page="settings" onclick="navigate('settings')">
1225
+ <span class="icon">&#128295;</span> Settings
1226
+ </button>
1227
+ </div>
1228
+ </nav>
1229
+
1230
+ <div class="sidebar-footer">
1231
+ <span class="status-dot"></span> Agent running
1232
+ </div>
1233
+ </aside>
1234
+
1235
+ <!-- Main -->
1236
+ <main class="main">
1237
+ <div class="header">
1238
+ <h2 id="page-title">Dashboard</h2>
1239
+ <div class="header-actions" id="header-actions">
1240
+ <button class="theme-toggle" id="theme-toggle" onclick="toggleTheme()" title="Toggle light/dark mode">&#9790;</button>
1241
+ </div>
1242
+ </div>
1243
+
1244
+ <div class="content">
1245
+
1246
+ <!-- ═══ Dashboard Page ═══ -->
1247
+ <div class="page active" id="page-dashboard">
1248
+ <div id="update-banner-container"></div>
1249
+ <div class="stat-grid" id="stats-grid">
1250
+ <div class="stat-card">
1251
+ <div class="stat-label">Uptime</div>
1252
+ <div class="stat-value" id="stat-uptime">--</div>
1253
+ <div class="stat-sub" id="stat-platform">--</div>
1254
+ </div>
1255
+ <div class="stat-card">
1256
+ <div class="stat-label">Cron Jobs</div>
1257
+ <div class="stat-value" id="stat-cron">--</div>
1258
+ <div class="stat-sub" id="stat-cron-sub">--</div>
1259
+ </div>
1260
+ <div class="stat-card">
1261
+ <div class="stat-label">Skills</div>
1262
+ <div class="stat-value" id="stat-skills">--</div>
1263
+ <div class="stat-sub" id="stat-skills-sub">--</div>
1264
+ </div>
1265
+ <div class="stat-card">
1266
+ <div class="stat-label">Memory</div>
1267
+ <div class="stat-value" id="stat-memory">--</div>
1268
+ <div class="stat-sub" id="stat-memory-sub">--</div>
1269
+ </div>
1270
+ </div>
1271
+
1272
+ <div class="card">
1273
+ <div class="card-header">
1274
+ <h3>System Info</h3>
1275
+ <button class="btn btn-sm" onclick="loadDashboard()">Refresh</button>
1276
+ </div>
1277
+ <div class="config-list" id="system-info">
1278
+ <div class="loading-center"><div class="spinner"></div></div>
1279
+ </div>
1280
+ </div>
1281
+
1282
+ <!-- Session Management Card -->
1283
+ <div class="card">
1284
+ <div class="card-header">
1285
+ <h3>Agent Session</h3>
1286
+ <button class="btn btn-sm" onclick="loadSessionInfo()">Refresh</button>
1287
+ </div>
1288
+ <div class="config-list" id="session-info">
1289
+ <div class="loading-center"><div class="spinner"></div></div>
1290
+ </div>
1291
+ <div style="padding: 0 16px 16px; display: flex; gap: 10px; flex-wrap: wrap;">
1292
+ <button class="btn btn-sm btn-primary" id="compact-btn" onclick="triggerCompaction()">
1293
+ &#128230; Compact Session
1294
+ </button>
1295
+ <button class="btn btn-sm btn-danger" id="reset-session-btn" onclick="triggerResetSession()">
1296
+ &#128465; Reset Session
1297
+ </button>
1298
+ </div>
1299
+ </div>
1300
+ </div>
1301
+
1302
+ <!-- ═══ Chat Page ═══ -->
1303
+ <div class="page" id="page-chat">
1304
+ <div class="chat-container">
1305
+ <div class="chat-messages" id="chat-messages">
1306
+ <div class="empty-state">
1307
+ <div class="empty-icon">&#128172;</div>
1308
+ <p>Send a message to start chatting with your CoStar agent.</p>
1309
+ </div>
1310
+ </div>
1311
+ <!-- VNC Live Browser Panel -->
1312
+ <div class="vnc-panel" id="vnc-panel">
1313
+ <div class="vnc-panel-header" onclick="toggleVncPanel()">
1314
+ <span class="vnc-panel-dot"></span>
1315
+ <span class="vnc-panel-title">Browser &mdash; Live View</span>
1316
+ <div class="vnc-panel-actions">
1317
+ <button class="vnc-open-btn" onclick="event.stopPropagation(); openVncExternal()" title="Open in new tab">&#8599; Pop out</button>
1318
+ </div>
1319
+ <span class="vnc-panel-toggle">&#9660;</span>
1320
+ </div>
1321
+ <div class="vnc-panel-body" id="vnc-panel-body"></div>
1322
+ </div>
1323
+
1324
+ <div class="compaction-banner" id="compaction-banner">
1325
+ <span class="compaction-icon">&#9881;</span>
1326
+ <span class="compaction-text">Compacting conversation context...</span>
1327
+ </div>
1328
+ <div class="chat-input-wrap">
1329
+ <input type="text" id="chat-input" placeholder="Type a message..." onkeydown="if(event.key==='Enter') sendMessage()" />
1330
+ <button class="btn btn-primary" onclick="sendMessage()" id="chat-send-btn">Send</button>
1331
+ </div>
1332
+ </div>
1333
+ </div>
1334
+
1335
+ <!-- ═══ Sessions Page ═══ -->
1336
+ <div class="page" id="page-sessions">
1337
+ <!-- Session file list view -->
1338
+ <div class="card" id="sessions-list-view">
1339
+ <div class="card-header">
1340
+ <h3>Session Files</h3>
1341
+ <div style="display:flex;gap:8px;align-items:center">
1342
+ <span id="sessions-dir-path" style="font-size:11px;color:var(--text-muted);font-family:var(--mono);"></span>
1343
+ <button class="btn btn-sm" onclick="loadSessionFiles()">Refresh</button>
1344
+ </div>
1345
+ </div>
1346
+ <div class="table-wrap">
1347
+ <table>
1348
+ <thead>
1349
+ <tr>
1350
+ <th>File</th>
1351
+ <th>Size</th>
1352
+ <th>Last Modified</th>
1353
+ <th>Actions</th>
1354
+ </tr>
1355
+ </thead>
1356
+ <tbody id="sessions-table-body">
1357
+ <tr><td colspan="4"><div class="loading-center"><div class="spinner"></div></div></td></tr>
1358
+ </tbody>
1359
+ </table>
1360
+ </div>
1361
+ </div>
1362
+
1363
+ <!-- Session viewer (hidden by default) -->
1364
+ <div id="sessions-viewer" style="display:none">
1365
+ <div class="card" style="margin-bottom:0;">
1366
+ <div class="card-header">
1367
+ <div style="display:flex;align-items:center;gap:8px;">
1368
+ <button class="btn btn-sm" onclick="closeSessionViewer()">&#8592; Back</button>
1369
+ <h3 id="sessions-viewer-title" style="font-size:13px;font-family:var(--mono);margin:0;"></h3>
1370
+ </div>
1371
+ <div style="display:flex;gap:6px;align-items:center">
1372
+ <span id="sessions-viewer-meta" style="font-size:11px;color:var(--text-muted);"></span>
1373
+ <button class="btn btn-sm" id="sessions-tab-parsed" onclick="switchSessionView('parsed')" style="font-weight:600;">Conversation</button>
1374
+ <button class="btn btn-sm" id="sessions-tab-raw" onclick="switchSessionView('raw')">Raw JSON</button>
1375
+ </div>
1376
+ </div>
1377
+ </div>
1378
+
1379
+ <!-- Parsed conversation view -->
1380
+ <div id="sessions-parsed-view" style="max-height:70vh;overflow:auto;padding:8px 0;">
1381
+ <div id="sessions-parsed-content" style="display:flex;flex-direction:column;gap:2px;">
1382
+ <div class="loading-center"><div class="spinner"></div></div>
1383
+ </div>
1384
+ </div>
1385
+
1386
+ <!-- Raw JSON view -->
1387
+ <div id="sessions-raw-view" style="display:none;">
1388
+ <pre id="sessions-viewer-content" style="margin:0;padding:16px;background:var(--bg);border-radius:0 0 8px 8px;font-family:var(--mono);font-size:12px;line-height:1.5;max-height:70vh;overflow:auto;white-space:pre-wrap;word-break:break-all;color:var(--text-secondary);"></pre>
1389
+ </div>
1390
+ </div>
1391
+ </div>
1392
+
1393
+ <!-- ═══ Cron Page ═══ -->
1394
+ <div class="page" id="page-cron">
1395
+ <div class="card">
1396
+ <div class="card-header">
1397
+ <h3>Cron Jobs</h3>
1398
+ <div style="display:flex;gap:8px;">
1399
+ <button class="btn btn-sm" onclick="loadCronJobs()">Refresh</button>
1400
+ <button class="btn btn-sm btn-primary" onclick="openCronModal()">+ New Job</button>
1401
+ </div>
1402
+ </div>
1403
+ <div class="table-wrap">
1404
+ <table>
1405
+ <thead>
1406
+ <tr>
1407
+ <th>Name</th>
1408
+ <th>Schedule</th>
1409
+ <th>Status</th>
1410
+ <th>Last Run</th>
1411
+ <th>Next Run</th>
1412
+ <th>Actions</th>
1413
+ </tr>
1414
+ </thead>
1415
+ <tbody id="cron-table-body">
1416
+ <tr><td colspan="6"><div class="loading-center"><div class="spinner"></div></div></td></tr>
1417
+ </tbody>
1418
+ </table>
1419
+ </div>
1420
+ </div>
1421
+
1422
+ <div class="card">
1423
+ <div class="card-header"><h3>Scheduler Status</h3></div>
1424
+ <div class="config-list" id="cron-status-info">
1425
+ <div class="loading-center"><div class="spinner"></div></div>
1426
+ </div>
1427
+ </div>
1428
+ </div>
1429
+
1430
+ <!-- ═══ Skills Page ═══ -->
1431
+ <div class="page" id="page-skills">
1432
+ <div class="skill-grid" id="skills-grid">
1433
+ <div class="loading-center"><div class="spinner"></div></div>
1434
+ </div>
1435
+ </div>
1436
+
1437
+ <!-- ═══ Workspace Page ═══ -->
1438
+ <div class="page" id="page-workspace">
1439
+ <div class="workspace-split">
1440
+ <div class="card workspace-list" id="workspace-file-list">
1441
+ <div class="card-header"><h3>Files</h3></div>
1442
+ <div class="loading-center"><div class="spinner"></div></div>
1443
+ </div>
1444
+ <div class="workspace-editor">
1445
+ <div class="editor-toolbar">
1446
+ <span id="editor-filename" style="font-weight:600;font-size:14px;">No file selected</span>
1447
+ <div style="display:flex;gap:8px;">
1448
+ <button class="btn btn-sm btn-primary" id="save-file-btn" onclick="saveWorkspaceFile()" disabled>Save</button>
1449
+ </div>
1450
+ </div>
1451
+ <textarea id="workspace-editor-area" placeholder="Select a file to edit..." disabled></textarea>
1452
+ </div>
1453
+ </div>
1454
+ </div>
1455
+
1456
+ <!-- ═══ Logs Page ═══ -->
1457
+ <div class="page" id="page-logs">
1458
+ <div class="log-toolbar">
1459
+ <select id="log-component-filter" onchange="loadLogs()">
1460
+ <option value="">All Components</option>
1461
+ </select>
1462
+ <select id="log-level-filter" onchange="loadLogs()">
1463
+ <option value="">All Levels</option>
1464
+ <option value="info">Info</option>
1465
+ <option value="warn">Warn</option>
1466
+ <option value="error">Error</option>
1467
+ <option value="debug">Debug</option>
1468
+ </select>
1469
+ <button class="btn btn-sm" onclick="loadLogs()">Refresh</button>
1470
+ <label style="display:flex;align-items:center;gap:6px;margin-bottom:0;cursor:pointer;">
1471
+ <input type="checkbox" id="log-stream-toggle" onchange="toggleLogStream()" checked />
1472
+ <span style="font-size:13px;">Live</span>
1473
+ </label>
1474
+ <button class="btn btn-sm btn-danger" onclick="clearLogView()" style="margin-left:auto;">Clear View</button>
1475
+ </div>
1476
+ <div class="log-entries" id="log-entries">
1477
+ <div class="loading-center"><div class="spinner"></div></div>
1478
+ </div>
1479
+ </div>
1480
+
1481
+ <!-- ═══ Settings Page ═══ -->
1482
+ <div class="page" id="page-settings">
1483
+ <div class="card">
1484
+ <div class="card-header">
1485
+ <h3>Environment Variables</h3>
1486
+ <div style="display:flex;gap:8px;">
1487
+ <button class="btn btn-sm" onclick="loadEnvVariables()">Refresh</button>
1488
+ <button class="btn btn-sm btn-primary" onclick="openEnvModal()">+ Add Variable</button>
1489
+ </div>
1490
+ </div>
1491
+ <div class="table-wrap">
1492
+ <table>
1493
+ <thead>
1494
+ <tr>
1495
+ <th>Key</th>
1496
+ <th>Value</th>
1497
+ <th>Actions</th>
1498
+ </tr>
1499
+ </thead>
1500
+ <tbody id="env-table-body">
1501
+ <tr><td colspan="3"><div class="loading-center"><div class="spinner"></div></div></td></tr>
1502
+ </tbody>
1503
+ </table>
1504
+ </div>
1505
+ </div>
1506
+
1507
+ <div class="card">
1508
+ <div class="card-header">
1509
+ <h3>Configuration</h3>
1510
+ <button class="btn btn-sm" onclick="loadSettings()">Refresh</button>
1511
+ </div>
1512
+ <div class="config-list" id="settings-config">
1513
+ <div class="loading-center"><div class="spinner"></div></div>
1514
+ </div>
1515
+ </div>
1516
+
1517
+ <div class="card">
1518
+ <div class="card-header"><h3>Paths</h3></div>
1519
+ <div class="config-list" id="settings-paths"></div>
1520
+ </div>
1521
+ </div>
1522
+ </div>
1523
+ </main>
1524
+ </div>
1525
+
1526
+ <!-- Cron Modal -->
1527
+ <div class="modal-overlay" id="cron-modal">
1528
+ <div class="modal">
1529
+ <h3 id="cron-modal-title">New Cron Job</h3>
1530
+ <div class="form-group">
1531
+ <label>Name</label>
1532
+ <input type="text" id="cron-name" placeholder="e.g. daily-news-summary" />
1533
+ </div>
1534
+ <div class="form-group">
1535
+ <label>Task / Instruction</label>
1536
+ <textarea id="cron-task" placeholder="What should the agent do?"></textarea>
1537
+ </div>
1538
+ <div class="form-group">
1539
+ <label>Schedule Type</label>
1540
+ <select id="cron-schedule-type" onchange="toggleCronScheduleFields()">
1541
+ <option value="every">Every (interval)</option>
1542
+ <option value="cron">Cron Expression</option>
1543
+ <option value="at">At (one-time)</option>
1544
+ </select>
1545
+ </div>
1546
+ <div class="form-group" id="cron-every-group">
1547
+ <label>Interval</label>
1548
+ <input type="text" id="cron-every" placeholder="e.g. 1h, 30m, 6h" />
1549
+ </div>
1550
+ <div class="form-group" id="cron-expression-group" style="display:none;">
1551
+ <label>Cron Expression</label>
1552
+ <input type="text" id="cron-expression" placeholder="e.g. 0 9 * * *" />
1553
+ </div>
1554
+ <div class="form-group" id="cron-at-group" style="display:none;">
1555
+ <label>Run At (ISO datetime)</label>
1556
+ <input type="text" id="cron-at" placeholder="e.g. 2026-02-07T09:00:00Z" />
1557
+ </div>
1558
+ <div class="modal-actions">
1559
+ <button class="btn" onclick="closeCronModal()">Cancel</button>
1560
+ <button class="btn btn-primary" onclick="saveCronJob()">Save</button>
1561
+ </div>
1562
+ </div>
1563
+ </div>
1564
+
1565
+ <!-- Skill Detail Modal -->
1566
+ <div class="modal-overlay" id="skill-modal">
1567
+ <div class="modal" style="max-width:800px;">
1568
+ <h3 id="skill-modal-title">Skill Details</h3>
1569
+ <div id="skill-modal-content"></div>
1570
+ <div class="modal-actions">
1571
+ <button class="btn" onclick="closeSkillModal()">Close</button>
1572
+ </div>
1573
+ </div>
1574
+ </div>
1575
+
1576
+ <!-- Environment Variable Modal -->
1577
+ <div class="modal-overlay" id="env-modal">
1578
+ <div class="modal">
1579
+ <h3 id="env-modal-title">Add Environment Variable</h3>
1580
+ <div class="form-group">
1581
+ <label>Key (UPPERCASE_WITH_UNDERSCORES)</label>
1582
+ <input type="text" id="env-key" placeholder="e.g. MY_API_KEY" style="text-transform:uppercase;" />
1583
+ </div>
1584
+ <div class="form-group">
1585
+ <label>Value</label>
1586
+ <input type="text" id="env-value" placeholder="Enter value..." />
1587
+ </div>
1588
+ <div class="modal-actions">
1589
+ <button class="btn" onclick="closeEnvModal()">Cancel</button>
1590
+ <button class="btn btn-primary" onclick="saveEnvVariable()">Save</button>
1591
+ </div>
1592
+ </div>
1593
+ </div>
1594
+
1595
+ <script>
1596
+ /* ═══════════════════════════════════════════════
1597
+ * CoStar Dashboard - Client-side JS
1598
+ * ═══════════════════════════════════════════════ */
1599
+
1600
+ const API = ''; // same origin
1601
+ let currentPage = 'dashboard';
1602
+ let chatSessionId = null;
1603
+ let logEventSource = null;
1604
+ let currentWorkspaceFile = null;
1605
+
1606
+ // ─── Theme ────────────────────────────────────────
1607
+ function initTheme() {
1608
+ const saved = localStorage.getItem('costar-theme');
1609
+ const theme = saved || 'dark';
1610
+ document.documentElement.setAttribute('data-theme', theme);
1611
+ updateThemeIcon(theme);
1612
+ }
1613
+ function toggleTheme() {
1614
+ const current = document.documentElement.getAttribute('data-theme') || 'dark';
1615
+ const next = current === 'dark' ? 'light' : 'dark';
1616
+ document.documentElement.setAttribute('data-theme', next);
1617
+ localStorage.setItem('costar-theme', next);
1618
+ updateThemeIcon(next);
1619
+ }
1620
+ function updateThemeIcon(theme) {
1621
+ const btn = document.getElementById('theme-toggle');
1622
+ if (btn) btn.innerHTML = theme === 'dark' ? '&#9788;' : '&#9790;';
1623
+ }
1624
+ initTheme();
1625
+
1626
+ // ─── Navigation ──────────────────────────────────
1627
+ function navigate(page) {
1628
+ currentPage = page;
1629
+
1630
+ // Update nav
1631
+ document.querySelectorAll('.nav-item').forEach(el => {
1632
+ el.classList.toggle('active', el.dataset.page === page);
1633
+ });
1634
+
1635
+ // Update pages
1636
+ document.querySelectorAll('.page').forEach(el => {
1637
+ el.classList.toggle('active', el.id === `page-${page}`);
1638
+ });
1639
+
1640
+ // Update title
1641
+ const titles = {
1642
+ dashboard: 'Dashboard',
1643
+ chat: 'Chat',
1644
+ sessions: 'Sessions',
1645
+ cron: 'Cron Jobs',
1646
+ skills: 'Skills',
1647
+ workspace: 'Workspace',
1648
+ logs: 'Logs',
1649
+ settings: 'Settings',
1650
+ };
1651
+ document.getElementById('page-title').textContent = titles[page] || page;
1652
+
1653
+ // Load page data
1654
+ switch (page) {
1655
+ case 'dashboard': loadDashboard(); break;
1656
+ case 'chat': initChat(); break;
1657
+ case 'sessions': loadSessionFiles(); startSessionPolling(); break;
1658
+ case 'cron': loadCronJobs(); loadCronStatus(); break;
1659
+ case 'skills': loadSkills(); break;
1660
+ case 'workspace': loadWorkspaceFiles(); break;
1661
+ case 'logs': loadLogs(); startLogStream(); break;
1662
+ case 'settings': loadSettings(); break;
1663
+ }
1664
+ }
1665
+
1666
+ // ─── API helper ──────────────────────────────────
1667
+ async function api(path, opts = {}) {
1668
+ const res = await fetch(API + path, {
1669
+ headers: { 'Content-Type': 'application/json', ...opts.headers },
1670
+ ...opts,
1671
+ });
1672
+ if (!res.ok) {
1673
+ const err = await res.json().catch(() => ({ error: res.statusText }));
1674
+ throw new Error(err.error || res.statusText);
1675
+ }
1676
+ return res.json();
1677
+ }
1678
+
1679
+ // ─── Dashboard ───────────────────────────────────
1680
+ async function loadDashboard() {
1681
+ try {
1682
+ const [status, versionInfo] = await Promise.all([
1683
+ api('/api/status'),
1684
+ api('/api/version').catch(() => null),
1685
+ ]);
1686
+ const a = status.agent;
1687
+
1688
+ // Show version in sidebar
1689
+ if (versionInfo?.version) {
1690
+ document.getElementById('sidebar-version').textContent = `v${versionInfo.version}`;
1691
+ }
1692
+
1693
+ // Update banner — check for newer version on npm
1694
+ renderUpdateBanner(versionInfo);
1695
+
1696
+ // Stats
1697
+ const uptime = formatUptime(a.uptime);
1698
+ document.getElementById('stat-uptime').textContent = uptime;
1699
+ document.getElementById('stat-platform').textContent = `${a.platform} / ${a.arch} / ${a.nodeVersion}`;
1700
+
1701
+ const cronStatus = status.cron;
1702
+ document.getElementById('stat-cron').textContent = cronStatus.jobsExecuted || '0';
1703
+ document.getElementById('stat-cron-sub').textContent = cronStatus.isRunning ? 'Scheduler running' : 'Scheduler stopped';
1704
+
1705
+ document.getElementById('stat-skills').textContent = status.skills.eligible;
1706
+ document.getElementById('stat-skills-sub').textContent = `${status.skills.total} total loaded`;
1707
+
1708
+ const heapMB = (a.memoryUsage.heapUsed / 1024 / 1024).toFixed(0);
1709
+ const heapTotalMB = (a.memoryUsage.heapTotal / 1024 / 1024).toFixed(0);
1710
+ document.getElementById('stat-memory').textContent = `${heapMB} MB`;
1711
+ document.getElementById('stat-memory-sub').textContent = `${heapTotalMB} MB heap total`;
1712
+
1713
+ // System info
1714
+ const info = document.getElementById('system-info');
1715
+ info.innerHTML = [
1716
+ row('Version', versionInfo?.version ? `v${versionInfo.version}` : '--'),
1717
+ row('Auto-Update', versionInfo?.autoUpdateEnabled ? 'Enabled' : 'Disabled'),
1718
+ row('User ID', a.userId),
1719
+ row('Workspace', a.workspaceDir),
1720
+ row('Node', a.nodeVersion),
1721
+ row('Platform', `${a.platform} ${a.arch}`),
1722
+ row('Heartbeat', status.heartbeat.enabled ? 'Enabled' : 'Disabled'),
1723
+ row('Cron Scheduler', cronStatus.isRunning ? 'Running' : 'Stopped'),
1724
+ row('Cron Last Check', cronStatus.lastCheckAt ? new Date(cronStatus.lastCheckAt).toLocaleString() : 'Never'),
1725
+ ].join('');
1726
+ // Load session info alongside dashboard
1727
+ loadSessionInfo();
1728
+ } catch (err) {
1729
+ document.getElementById('system-info').innerHTML = `<div style="color:var(--red);padding:12px;">Error: ${esc(err.message)}</div>`;
1730
+ }
1731
+ }
1732
+
1733
+ // ─── Agent Session Management ─────────────────────
1734
+ async function loadSessionInfo() {
1735
+ try {
1736
+ const info = await api('/api/agent/session');
1737
+ const el = document.getElementById('session-info');
1738
+ if (!el) return;
1739
+
1740
+ el.innerHTML = [
1741
+ row('Session ID', info.sessionId),
1742
+ row('Session File', info.exists ? 'Exists' : 'Not created yet'),
1743
+ row('File Size', info.exists ? `${info.sizeKB} KB (${info.sizeBytes.toLocaleString()} bytes)` : '--'),
1744
+ row('Conversation Length', `${info.conversationLength} messages`),
1745
+ row('Status', info.isBusy ? '⏳ Busy' : '✅ Idle'),
1746
+ ].join('');
1747
+
1748
+ // Disable buttons if busy
1749
+ const compactBtn = document.getElementById('compact-btn');
1750
+ const resetBtn = document.getElementById('reset-session-btn');
1751
+ if (compactBtn) compactBtn.disabled = info.isBusy;
1752
+ if (resetBtn) resetBtn.disabled = info.isBusy;
1753
+ } catch (err) {
1754
+ const el = document.getElementById('session-info');
1755
+ if (el) el.innerHTML = `<div style="color:var(--red);padding:12px;">Error: ${esc(err.message)}</div>`;
1756
+ }
1757
+ }
1758
+
1759
+ async function triggerCompaction() {
1760
+ const btn = document.getElementById('compact-btn');
1761
+ if (!btn) return;
1762
+
1763
+ if (!confirm('Compact the agent session? This will summarize older messages to reduce context size.')) return;
1764
+
1765
+ btn.disabled = true;
1766
+ const origText = btn.innerHTML;
1767
+ btn.innerHTML = '&#9203; Compacting...';
1768
+
1769
+ try {
1770
+ const result = await api('/api/agent/compact', {
1771
+ method: 'POST',
1772
+ headers: { 'Content-Type': 'application/json' },
1773
+ });
1774
+
1775
+ if (result.compacted) {
1776
+ const before = result.result?.tokensBefore?.toLocaleString() ?? '?';
1777
+ const after = result.result?.tokensAfter?.toLocaleString() ?? '?';
1778
+ alert(`Compaction successful!\n\nTokens: ${before} → ${after}`);
1779
+ } else {
1780
+ alert(`Compaction did not reduce size: ${result.reason || 'Unknown reason'}`);
1781
+ }
1782
+
1783
+ // Refresh session info
1784
+ await loadSessionInfo();
1785
+ } catch (err) {
1786
+ alert(`Compaction failed: ${err.message}`);
1787
+ } finally {
1788
+ btn.disabled = false;
1789
+ btn.innerHTML = origText;
1790
+ }
1791
+ }
1792
+
1793
+ async function triggerResetSession() {
1794
+ const btn = document.getElementById('reset-session-btn');
1795
+ if (!btn) return;
1796
+
1797
+ if (!confirm('⚠️ Reset the agent session?\n\nThis will delete the conversation history. The agent will start fresh on the next turn.\n\nThis action cannot be undone.')) return;
1798
+
1799
+ btn.disabled = true;
1800
+ const origText = btn.innerHTML;
1801
+ btn.innerHTML = '&#9203; Resetting...';
1802
+
1803
+ try {
1804
+ const result = await api('/api/agent/reset-session', {
1805
+ method: 'POST',
1806
+ headers: { 'Content-Type': 'application/json' },
1807
+ });
1808
+
1809
+ if (result.success) {
1810
+ alert('Session reset successfully. The agent will start fresh on the next turn.');
1811
+ } else {
1812
+ alert(`Reset failed: ${result.error || 'Unknown error'}`);
1813
+ }
1814
+
1815
+ // Refresh session info
1816
+ await loadSessionInfo();
1817
+ } catch (err) {
1818
+ alert(`Reset failed: ${err.message}`);
1819
+ } finally {
1820
+ btn.disabled = false;
1821
+ btn.innerHTML = origText;
1822
+ }
1823
+ }
1824
+
1825
+ // ─── Update Banner ────────────────────────────────
1826
+ async function renderUpdateBanner(versionInfo) {
1827
+ const container = document.getElementById('update-banner-container');
1828
+ if (!container) return;
1829
+
1830
+ // Always show version + manual update button
1831
+ if (!versionInfo) {
1832
+ container.innerHTML = '';
1833
+ return;
1834
+ }
1835
+
1836
+ const v = esc(versionInfo.version || 'unknown');
1837
+ const autoUpdate = versionInfo.autoUpdateEnabled;
1838
+ const updating = versionInfo.updateInProgress;
1839
+
1840
+ container.innerHTML = `
1841
+ <div class="update-banner">
1842
+ <div class="update-info">
1843
+ Running <strong>v${v}</strong>
1844
+ <small>${autoUpdate ? 'Auto-update enabled — checks every 24h' : 'Auto-update disabled'}</small>
1845
+ </div>
1846
+ <button class="update-btn" id="manual-update-btn" onclick="triggerManualUpdate()" ${updating ? 'disabled' : ''}>
1847
+ ${updating ? 'Updating...' : 'Update Now'}
1848
+ </button>
1849
+ </div>
1850
+ `;
1851
+ }
1852
+
1853
+ async function triggerManualUpdate() {
1854
+ const btn = document.getElementById('manual-update-btn');
1855
+ if (!btn) return;
1856
+
1857
+ btn.disabled = true;
1858
+ btn.textContent = 'Updating...';
1859
+
1860
+ try {
1861
+ const result = await api('/api/update', {
1862
+ method: 'POST',
1863
+ headers: { 'Content-Type': 'application/json' },
1864
+ body: JSON.stringify({ version: 'latest' }),
1865
+ });
1866
+ btn.textContent = 'Restarting...';
1867
+ // The server will restart, so the page will lose connection
1868
+ setTimeout(() => {
1869
+ btn.textContent = 'Reconnecting...';
1870
+ // Try to reload after a delay to reconnect to the new version
1871
+ setTimeout(() => location.reload(), 8000);
1872
+ }, 5000);
1873
+ } catch (err) {
1874
+ btn.disabled = false;
1875
+ btn.textContent = 'Update Failed — Retry';
1876
+ console.error('Update failed:', err);
1877
+ }
1878
+ }
1879
+
1880
+ function row(key, value) {
1881
+ return `<div class="config-row"><span class="config-key">${esc(key)}</span><span class="config-value">${esc(String(value || '--'))}</span></div>`;
1882
+ }
1883
+
1884
+ function formatUptime(seconds) {
1885
+ const d = Math.floor(seconds / 86400);
1886
+ const h = Math.floor((seconds % 86400) / 3600);
1887
+ const m = Math.floor((seconds % 3600) / 60);
1888
+ if (d > 0) return `${d}d ${h}h`;
1889
+ if (h > 0) return `${h}h ${m}m`;
1890
+ return `${m}m`;
1891
+ }
1892
+
1893
+ // ─── VNC Live Browser Panel ─────────────────────
1894
+ let vncUrl = null;
1895
+ let vncPanelCollapsed = false;
1896
+
1897
+ /** Build VNC URL using the current page's hostname (not 127.0.0.1) so it works on remote sandboxes. */
1898
+ function buildVncUrl(port) {
1899
+ const host = window.location.hostname || '127.0.0.1';
1900
+ return 'http://' + host + ':' + port + '/vnc.html?autoconnect=1&resize=remote';
1901
+ }
1902
+
1903
+ function showVncPanel(url) {
1904
+ vncUrl = url;
1905
+ const panel = document.getElementById('vnc-panel');
1906
+ const body = document.getElementById('vnc-panel-body');
1907
+ if (!panel || !body) return;
1908
+ if (!body.querySelector('iframe') || body.querySelector('iframe').src !== url) {
1909
+ body.innerHTML = '<iframe src="' + esc(url) + '" allow="clipboard-read; clipboard-write"></iframe>';
1910
+ }
1911
+ panel.classList.add('active');
1912
+ panel.classList.remove('collapsed');
1913
+ vncPanelCollapsed = false;
1914
+ scrollChat();
1915
+ }
1916
+
1917
+ function hideVncPanel() {
1918
+ vncUrl = null;
1919
+ const panel = document.getElementById('vnc-panel');
1920
+ const body = document.getElementById('vnc-panel-body');
1921
+ if (!panel) return;
1922
+ panel.classList.remove('active');
1923
+ if (body) body.innerHTML = '';
1924
+ }
1925
+
1926
+ function toggleVncPanel() {
1927
+ const panel = document.getElementById('vnc-panel');
1928
+ if (!panel) return;
1929
+ vncPanelCollapsed = !vncPanelCollapsed;
1930
+ panel.classList.toggle('collapsed', vncPanelCollapsed);
1931
+ }
1932
+
1933
+ function openVncExternal() {
1934
+ if (vncUrl) window.open(vncUrl, '_blank', 'width=1280,height=900');
1935
+ }
1936
+
1937
+ async function checkBrowserVnc() {
1938
+ try {
1939
+ const data = await api('/api/browser/vnc');
1940
+ if (data.vncPort) {
1941
+ showVncPanel(buildVncUrl(data.vncPort));
1942
+ } else if (data.vncUrl) {
1943
+ showVncPanel(data.vncUrl);
1944
+ }
1945
+ } catch { /* ignore */ }
1946
+ }
1947
+
1948
+ // ─── Chat (Real-time Streaming) ─────────────────
1949
+ let chatStreaming = false;
1950
+
1951
+ async function initChat() {
1952
+ if (!chatSessionId) {
1953
+ try {
1954
+ const session = await api('/api/sessions', { method: 'POST' });
1955
+ chatSessionId = session.id;
1956
+ } catch (err) {
1957
+ console.error('Failed to create chat session:', err);
1958
+ }
1959
+ }
1960
+ checkBrowserVnc();
1961
+ }
1962
+
1963
+ async function sendMessage() {
1964
+ const input = document.getElementById('chat-input');
1965
+ const msg = input.value.trim();
1966
+ if (!msg || chatStreaming) return;
1967
+
1968
+ if (!chatSessionId) await initChat();
1969
+
1970
+ input.value = '';
1971
+ const messagesEl = document.getElementById('chat-messages');
1972
+
1973
+ // Clear empty state
1974
+ const emptyState = messagesEl.querySelector('.empty-state');
1975
+ if (emptyState) emptyState.remove();
1976
+
1977
+ // Add user message
1978
+ messagesEl.insertAdjacentHTML('beforeend', chatMsgHTML('user', msg));
1979
+ scrollChat();
1980
+
1981
+ // Create streaming assistant message container
1982
+ // Single flow container — text and tool cards interleave in arrival order
1983
+ const streamId = 'stream-' + Date.now();
1984
+ messagesEl.insertAdjacentHTML('beforeend', `
1985
+ <div id="${streamId}" class="chat-msg assistant">
1986
+ <div class="avatar">C</div>
1987
+ <div class="msg-content">
1988
+ <div class="msg-role">CoStar</div>
1989
+ <div id="${streamId}-flow" class="msg-flow"></div>
1990
+ <div id="${streamId}-meta" class="chat-meta" style="display:none;"></div>
1991
+ </div>
1992
+ </div>
1993
+ `);
1994
+ scrollChat();
1995
+
1996
+ // Disable send
1997
+ const sendBtn = document.getElementById('chat-send-btn');
1998
+ sendBtn.disabled = true;
1999
+ sendBtn.textContent = 'Thinking...';
2000
+ chatStreaming = true;
2001
+
2002
+ try {
2003
+ await streamChat(chatSessionId, msg, streamId);
2004
+ } catch (err) {
2005
+ const flowEl = document.getElementById(`${streamId}-flow`);
2006
+ if (flowEl) {
2007
+ flowEl.querySelectorAll('.streaming-cursor').forEach(el => el.classList.remove('streaming-cursor'));
2008
+ flowEl.insertAdjacentHTML('beforeend',
2009
+ `<div style="color:var(--red);margin-top:8px;">Error: ${esc(err.message)}</div>`);
2010
+ }
2011
+ } finally {
2012
+ sendBtn.disabled = false;
2013
+ sendBtn.textContent = 'Send';
2014
+ chatStreaming = false;
2015
+ // Remove any remaining streaming cursors
2016
+ const flowEl = document.getElementById(`${streamId}-flow`);
2017
+ if (flowEl) flowEl.querySelectorAll('.streaming-cursor').forEach(el => el.classList.remove('streaming-cursor'));
2018
+ }
2019
+ }
2020
+
2021
+ function streamChat(sessionId, message, streamId) {
2022
+ return new Promise((resolve, reject) => {
2023
+ const flowEl = document.getElementById(`${streamId}-flow`);
2024
+ const metaEl = document.getElementById(`${streamId}-meta`);
2025
+ let fullText = '';
2026
+ let toolCount = 0;
2027
+
2028
+ // Track the current text span — a new one is created after each tool card
2029
+ let currentTextSpan = null;
2030
+
2031
+ function ensureTextSpan() {
2032
+ if (!currentTextSpan) {
2033
+ currentTextSpan = document.createElement('div');
2034
+ currentTextSpan.className = 'msg-text-segment streaming-cursor';
2035
+ flowEl.appendChild(currentTextSpan);
2036
+ }
2037
+ return currentTextSpan;
2038
+ }
2039
+
2040
+ // Create initial text span
2041
+ ensureTextSpan();
2042
+
2043
+ // Use fetch + ReadableStream for POST-based SSE
2044
+ fetch(`/api/sessions/${sessionId}/messages/stream`, {
2045
+ method: 'POST',
2046
+ headers: { 'Content-Type': 'application/json' },
2047
+ body: JSON.stringify({ message }),
2048
+ }).then(response => {
2049
+ if (!response.ok) {
2050
+ throw new Error(`HTTP ${response.status}`);
2051
+ }
2052
+
2053
+ const reader = response.body.getReader();
2054
+ const decoder = new TextDecoder();
2055
+ let buffer = '';
2056
+
2057
+ function processChunk(chunk) {
2058
+ buffer += chunk;
2059
+ const lines = buffer.split('\n');
2060
+ buffer = lines.pop() || '';
2061
+
2062
+ let eventType = '';
2063
+ let eventData = '';
2064
+
2065
+ for (const line of lines) {
2066
+ if (line.startsWith('event: ')) {
2067
+ eventType = line.slice(7).trim();
2068
+ } else if (line.startsWith('data: ')) {
2069
+ eventData = line.slice(6);
2070
+ if (eventType && eventData) {
2071
+ handleSSEEvent(eventType, eventData, flowEl, metaEl, streamId, {
2072
+ fullText: () => fullText,
2073
+ setFullText: (t) => { fullText = t; },
2074
+ toolCount: () => toolCount,
2075
+ incToolCount: () => { toolCount++; },
2076
+ ensureTextSpan,
2077
+ breakTextSpan: () => {
2078
+ // Remove cursor from current span and start a new one after the next tool card
2079
+ if (currentTextSpan) currentTextSpan.classList.remove('streaming-cursor');
2080
+ currentTextSpan = null;
2081
+ },
2082
+ });
2083
+ eventType = '';
2084
+ eventData = '';
2085
+ }
2086
+ } else if (line === '') {
2087
+ eventType = '';
2088
+ eventData = '';
2089
+ }
2090
+ }
2091
+ }
2092
+
2093
+ function pump() {
2094
+ return reader.read().then(({ done, value }) => {
2095
+ if (done) {
2096
+ if (buffer.trim()) processChunk('\n');
2097
+ resolve();
2098
+ return;
2099
+ }
2100
+ processChunk(decoder.decode(value, { stream: true }));
2101
+ return pump();
2102
+ });
2103
+ }
2104
+
2105
+ return pump();
2106
+ }).catch(reject);
2107
+ });
2108
+ }
2109
+
2110
+ /**
2111
+ * Tracks per-text-segment accumulated text so we can re-render each segment
2112
+ * independently when new deltas arrive (without re-parsing the entire response).
2113
+ */
2114
+ const segmentTexts = new Map(); // segmentEl → accumulated text for that segment
2115
+
2116
+ function handleSSEEvent(type, dataStr, flowEl, metaEl, streamId, state) {
2117
+ let data;
2118
+ try { data = JSON.parse(dataStr); } catch { return; }
2119
+
2120
+ switch (type) {
2121
+ case 'connected':
2122
+ break;
2123
+
2124
+ case 'text_delta':
2125
+ if (data.text && flowEl) {
2126
+ state.setFullText(state.fullText() + data.text);
2127
+ const span = state.ensureTextSpan();
2128
+ const prev = segmentTexts.get(span) || '';
2129
+ const updated = prev + data.text;
2130
+ segmentTexts.set(span, updated);
2131
+ span.innerHTML = formatMessage(updated);
2132
+ scrollChat();
2133
+ }
2134
+ break;
2135
+
2136
+ case 'tool_start':
2137
+ if (flowEl) {
2138
+ // Break the current text span so the tool card appears after existing text
2139
+ state.breakTextSpan();
2140
+ state.incToolCount();
2141
+ const cardId = `${streamId}-tool-${data.toolCallId || state.toolCount()}`;
2142
+ const argsStr = data.args ? JSON.stringify(data.args, null, 2) : '{}';
2143
+
2144
+ flowEl.insertAdjacentHTML('beforeend', `
2145
+ <div class="tool-card" id="${cardId}" onclick="toggleToolCard('${cardId}')">
2146
+ <div class="tool-card-header">
2147
+ <span class="tool-icon running">&#9881;</span>
2148
+ <span class="tool-name">${esc(data.name || 'tool')}</span>
2149
+ <span class="tool-status">running...</span>
2150
+ <span class="tool-chevron">&#9654;</span>
2151
+ </div>
2152
+ <div class="tool-card-body">
2153
+ <div class="tool-section">
2154
+ <div class="tool-section-label">Arguments</div>
2155
+ <pre>${esc(argsStr)}</pre>
2156
+ </div>
2157
+ <div class="tool-section" id="${cardId}-result" style="display:none;">
2158
+ <div class="tool-section-label">Result</div>
2159
+ <pre></pre>
2160
+ </div>
2161
+ </div>
2162
+ </div>
2163
+ `);
2164
+
2165
+ // Start a new text span after the tool card for subsequent text
2166
+ state.ensureTextSpan();
2167
+ scrollChat();
2168
+
2169
+ // Update send button with tool count
2170
+ const sendBtn = document.getElementById('chat-send-btn');
2171
+ sendBtn.textContent = `Tool ${state.toolCount()}...`;
2172
+ }
2173
+ break;
2174
+
2175
+ case 'tool_end': {
2176
+ const cardId = `${streamId}-tool-${data.toolCallId || state.toolCount()}`;
2177
+ const card = document.getElementById(cardId);
2178
+ if (card) {
2179
+ // Update icon — stop spinning
2180
+ const icon = card.querySelector('.tool-icon');
2181
+ if (icon) {
2182
+ icon.classList.remove('running');
2183
+ icon.innerHTML = data.isError ? '&#10060;' : '&#9989;';
2184
+ }
2185
+
2186
+ // Update status
2187
+ const status = card.querySelector('.tool-status');
2188
+ if (status) {
2189
+ const durationStr = data.durationMs ? ` (${formatDurationShort(data.durationMs)})` : '';
2190
+ status.textContent = data.isError ? `error${durationStr}` : `done${durationStr}`;
2191
+ status.className = `tool-status ${data.isError ? 'error' : 'success'}`;
2192
+ }
2193
+
2194
+ // Show result
2195
+ const resultSection = document.getElementById(`${cardId}-result`);
2196
+ if (resultSection && data.result) {
2197
+ resultSection.style.display = '';
2198
+ resultSection.querySelector('pre').textContent = data.result;
2199
+ }
2200
+ }
2201
+ scrollChat();
2202
+ break;
2203
+ }
2204
+
2205
+ case 'compaction_start': {
2206
+ const cb = document.getElementById('compaction-banner');
2207
+ if (cb) {
2208
+ cb.className = 'compaction-banner active';
2209
+ cb.querySelector('.compaction-icon').innerHTML = '&#9881;';
2210
+ cb.querySelector('.compaction-text').textContent = 'Compacting conversation context...';
2211
+ }
2212
+ break;
2213
+ }
2214
+
2215
+ case 'compaction_end': {
2216
+ const cb = document.getElementById('compaction-banner');
2217
+ if (cb) {
2218
+ const icon = cb.querySelector('.compaction-icon');
2219
+ const text = cb.querySelector('.compaction-text');
2220
+ if (data.error) {
2221
+ icon.innerHTML = '&#9888;';
2222
+ cb.className = 'compaction-banner active compaction-error';
2223
+ text.textContent = `Compaction failed: ${esc(data.error)}`;
2224
+ } else {
2225
+ icon.innerHTML = '&#10003;';
2226
+ cb.className = 'compaction-banner active compaction-done';
2227
+ const detail = data.tokensBefore && data.tokensAfter
2228
+ ? ` (${data.tokensBefore.toLocaleString()} \u2192 ${data.tokensAfter.toLocaleString()} tokens)`
2229
+ : '';
2230
+ text.textContent = `Context compacted${detail}`;
2231
+ }
2232
+ // Auto-hide after 5s on success
2233
+ if (!data.error) {
2234
+ setTimeout(() => { cb.classList.remove('active'); }, 5000);
2235
+ }
2236
+ }
2237
+ break;
2238
+ }
2239
+
2240
+ case 'browser_vnc':
2241
+ if (data.port) {
2242
+ showVncPanel(buildVncUrl(data.port));
2243
+ } else if (data.url) {
2244
+ showVncPanel(data.url);
2245
+ } else {
2246
+ hideVncPanel();
2247
+ }
2248
+ break;
2249
+
2250
+ case 'done': {
2251
+ // Remove streaming cursor from all text segments
2252
+ if (flowEl) {
2253
+ flowEl.querySelectorAll('.streaming-cursor').forEach(el => el.classList.remove('streaming-cursor'));
2254
+ }
2255
+ if (metaEl && (data.toolCalls > 0 || data.inputTokens > 0)) {
2256
+ metaEl.style.display = '';
2257
+ let metaHTML = '';
2258
+ if (data.toolCalls > 0) metaHTML += `<span>&#128295; ${data.toolCalls} tool${data.toolCalls > 1 ? 's' : ''}</span>`;
2259
+ if (data.inputTokens > 0) metaHTML += `<span>&#8594; ${data.inputTokens.toLocaleString()} in</span>`;
2260
+ if (data.outputTokens > 0) metaHTML += `<span>&#8592; ${data.outputTokens.toLocaleString()} out</span>`;
2261
+ if (data.compactionCount > 0) metaHTML += `<span>&#128230; ${data.compactionCount} compaction${data.compactionCount > 1 ? 's' : ''}</span>`;
2262
+ metaEl.innerHTML = metaHTML;
2263
+ }
2264
+ break;
2265
+ }
2266
+
2267
+ case 'error': {
2268
+ const span = state.ensureTextSpan();
2269
+ span.classList.remove('streaming-cursor');
2270
+ const errorMsg = data.message || 'Unknown error';
2271
+ const currentText = segmentTexts.get(span) || '';
2272
+ span.innerHTML = (currentText ? formatMessage(currentText) + '<br><br>' : '') +
2273
+ `<span style="color:var(--red);">Error: ${esc(errorMsg)}</span>`;
2274
+ break;
2275
+ }
2276
+ }
2277
+ }
2278
+
2279
+ function toggleToolCard(cardId) {
2280
+ const card = document.getElementById(cardId);
2281
+ if (card) card.classList.toggle('expanded');
2282
+ }
2283
+
2284
+ function formatDurationShort(ms) {
2285
+ if (ms < 1000) return `${ms}ms`;
2286
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
2287
+ return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
2288
+ }
2289
+
2290
+ function formatMessage(text) {
2291
+ if (!text) return '';
2292
+ // Simple markdown-ish formatting
2293
+ let html = esc(text);
2294
+ // Code blocks: ```...```
2295
+ html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre style="background:var(--bg-primary);border:1px solid var(--border);border-radius:var(--radius);padding:10px;margin:8px 0;overflow-x:auto;font-family:var(--mono);font-size:12px;">$2</pre>');
2296
+ // Inline code: `...`
2297
+ html = html.replace(/`([^`]+)`/g, '<code style="background:var(--bg-primary);padding:2px 6px;border-radius:4px;font-family:var(--mono);font-size:12px;">$1</code>');
2298
+ // Bold: **...**
2299
+ html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
2300
+ // Newlines
2301
+ html = html.replace(/\n/g, '<br>');
2302
+ return html;
2303
+ }
2304
+
2305
+ function scrollChat() {
2306
+ const el = document.getElementById('chat-messages');
2307
+ if (el) el.scrollTop = el.scrollHeight;
2308
+ }
2309
+
2310
+ function chatMsgHTML(role, content) {
2311
+ const avatar = role === 'user' ? 'U' : 'C';
2312
+ const label = role === 'user' ? 'You' : 'CoStar';
2313
+ const formattedContent = role === 'user' ? esc(content) : formatMessage(content);
2314
+ return `<div class="chat-msg ${role}"><div class="avatar">${avatar}</div><div class="msg-content"><div class="msg-role">${label}</div><div>${formattedContent}</div></div></div>`;
2315
+ }
2316
+
2317
+ // ─── Cron ────────────────────────────────────────
2318
+ async function loadCronJobs() {
2319
+ try {
2320
+ const jobs = await api('/api/cron/jobs');
2321
+ const tbody = document.getElementById('cron-table-body');
2322
+
2323
+ if (!Array.isArray(jobs) || jobs.length === 0) {
2324
+ tbody.innerHTML = `<tr><td colspan="6"><div class="empty-state"><div class="empty-icon">&#9200;</div><p>No cron jobs configured yet.</p></div></td></tr>`;
2325
+ document.getElementById('cron-badge').textContent = '0';
2326
+ return;
2327
+ }
2328
+
2329
+ document.getElementById('cron-badge').textContent = jobs.length;
2330
+
2331
+ tbody.innerHTML = jobs.map(job => {
2332
+ const schedule = job.cron_expression || (job.every ? `every ${job.every}` : job.at ? `at ${job.at}` : '--');
2333
+ const statusTag = job.status === 'active'
2334
+ ? '<span class="tag tag-green">Active</span>'
2335
+ : '<span class="tag tag-muted">Paused</span>';
2336
+ const lastRun = job.last_run_at ? new Date(job.last_run_at).toLocaleString() : '--';
2337
+ const nextRun = job.next_run_at ? new Date(job.next_run_at).toLocaleString() : '--';
2338
+
2339
+ return `<tr>
2340
+ <td><strong>${esc(job.name || job.id)}</strong><br><span style="font-size:11px;color:var(--text-muted);">${esc((job.task || '').substring(0, 80))}</span></td>
2341
+ <td><code style="font-size:12px;">${esc(schedule)}</code></td>
2342
+ <td>${statusTag}</td>
2343
+ <td style="font-size:12px;">${lastRun}</td>
2344
+ <td style="font-size:12px;">${nextRun}</td>
2345
+ <td>
2346
+ <button class="btn btn-sm" onclick="triggerCronJob('${job.id}')">Run</button>
2347
+ <button class="btn btn-sm btn-danger" onclick="deleteCronJob('${job.id}')">Delete</button>
2348
+ </td>
2349
+ </tr>`;
2350
+ }).join('');
2351
+ } catch (err) {
2352
+ document.getElementById('cron-table-body').innerHTML = `<tr><td colspan="6" style="color:var(--red);padding:16px;">Error: ${esc(err.message)}</td></tr>`;
2353
+ }
2354
+ }
2355
+
2356
+ async function loadCronStatus() {
2357
+ try {
2358
+ const status = await api('/api/cron/status');
2359
+ const el = document.getElementById('cron-status-info');
2360
+ el.innerHTML = [
2361
+ row('Running', status.isRunning ? 'Yes' : 'No'),
2362
+ row('Executing', status.isExecuting ? 'Yes' : 'No'),
2363
+ row('Last Check', status.lastCheckAt ? new Date(status.lastCheckAt).toLocaleString() : 'Never'),
2364
+ row('Jobs Executed', status.jobsExecuted),
2365
+ ].join('');
2366
+ } catch (err) {
2367
+ document.getElementById('cron-status-info').innerHTML = `<div style="color:var(--red)">Error: ${esc(err.message)}</div>`;
2368
+ }
2369
+ }
2370
+
2371
+ async function triggerCronJob(id) {
2372
+ try {
2373
+ await api(`/api/cron/jobs/${id}/run`, { method: 'POST' });
2374
+ loadCronJobs();
2375
+ } catch (err) {
2376
+ alert('Failed: ' + err.message);
2377
+ }
2378
+ }
2379
+
2380
+ async function deleteCronJob(id) {
2381
+ if (!confirm('Delete this cron job?')) return;
2382
+ try {
2383
+ await api(`/api/cron/jobs/${id}`, { method: 'DELETE' });
2384
+ loadCronJobs();
2385
+ } catch (err) {
2386
+ alert('Failed: ' + err.message);
2387
+ }
2388
+ }
2389
+
2390
+ function openCronModal() {
2391
+ document.getElementById('cron-modal').classList.add('open');
2392
+ document.getElementById('cron-modal-title').textContent = 'New Cron Job';
2393
+ document.getElementById('cron-name').value = '';
2394
+ document.getElementById('cron-task').value = '';
2395
+ document.getElementById('cron-schedule-type').value = 'every';
2396
+ document.getElementById('cron-every').value = '';
2397
+ document.getElementById('cron-expression').value = '';
2398
+ document.getElementById('cron-at').value = '';
2399
+ toggleCronScheduleFields();
2400
+ }
2401
+
2402
+ function closeCronModal() {
2403
+ document.getElementById('cron-modal').classList.remove('open');
2404
+ }
2405
+
2406
+ function toggleCronScheduleFields() {
2407
+ const type = document.getElementById('cron-schedule-type').value;
2408
+ document.getElementById('cron-every-group').style.display = type === 'every' ? '' : 'none';
2409
+ document.getElementById('cron-expression-group').style.display = type === 'cron' ? '' : 'none';
2410
+ document.getElementById('cron-at-group').style.display = type === 'at' ? '' : 'none';
2411
+ }
2412
+
2413
+ async function saveCronJob() {
2414
+ const name = document.getElementById('cron-name').value.trim();
2415
+ const task = document.getElementById('cron-task').value.trim();
2416
+ const type = document.getElementById('cron-schedule-type').value;
2417
+
2418
+ if (!name || !task) { alert('Name and task are required.'); return; }
2419
+
2420
+ const body = { name, task };
2421
+
2422
+ if (type === 'every') body.every = document.getElementById('cron-every').value.trim();
2423
+ else if (type === 'cron') body.cron_expression = document.getElementById('cron-expression').value.trim();
2424
+ else if (type === 'at') body.at = document.getElementById('cron-at').value.trim();
2425
+
2426
+ try {
2427
+ await api('/api/cron/jobs', { method: 'POST', body: JSON.stringify(body) });
2428
+ closeCronModal();
2429
+ loadCronJobs();
2430
+ } catch (err) {
2431
+ alert('Failed: ' + err.message);
2432
+ }
2433
+ }
2434
+
2435
+ // ─── Skills ──────────────────────────────────────
2436
+ async function loadSkills() {
2437
+ try {
2438
+ const data = await api('/api/skills');
2439
+ const grid = document.getElementById('skills-grid');
2440
+
2441
+ document.getElementById('skills-badge').textContent = data.eligible;
2442
+
2443
+ if (!data.skills || data.skills.length === 0) {
2444
+ grid.innerHTML = `<div class="empty-state"><div class="empty-icon">&#9889;</div><p>No skills loaded. Add skills to your workspace or managed directory.</p></div>`;
2445
+ return;
2446
+ }
2447
+
2448
+ grid.innerHTML = data.skills.map(skill => {
2449
+ const eligibleTag = skill.eligible
2450
+ ? '<span class="tag tag-green">Eligible</span>'
2451
+ : '<span class="tag tag-muted">Inactive</span>';
2452
+ const sourceTag = `<span class="tag tag-blue">${esc(skill.source || 'unknown')}</span>`;
2453
+
2454
+ return `<div class="skill-card" onclick="showSkillDetail('${esc(skill.name)}')">
2455
+ <div class="skill-header">
2456
+ <span class="skill-emoji">${skill.emoji || '&#128230;'}</span>
2457
+ <span class="skill-name">${esc(skill.name)}</span>
2458
+ </div>
2459
+ <div class="skill-desc">${esc(skill.description || 'No description')}</div>
2460
+ <div class="skill-footer">${eligibleTag} ${sourceTag}</div>
2461
+ </div>`;
2462
+ }).join('');
2463
+ } catch (err) {
2464
+ document.getElementById('skills-grid').innerHTML = `<div style="color:var(--red);padding:16px;">Error: ${esc(err.message)}</div>`;
2465
+ }
2466
+ }
2467
+
2468
+ async function showSkillDetail(name) {
2469
+ try {
2470
+ const skill = await api(`/api/skills/${encodeURIComponent(name)}`);
2471
+ document.getElementById('skill-modal-title').textContent = `${skill.emoji || ''} ${skill.name}`;
2472
+
2473
+ let html = `
2474
+ <div style="margin-bottom:16px;">
2475
+ <p style="color:var(--text-secondary);line-height:1.6;">${esc(skill.description || 'No description')}</p>
2476
+ </div>
2477
+ <div class="config-list" style="margin-bottom:16px;">
2478
+ ${row('Source', skill.source)}
2479
+ ${row('Eligible', skill.eligible ? 'Yes' : 'No')}
2480
+ ${row('Base Directory', skill.baseDir)}
2481
+ ${skill.homepage ? row('Homepage', skill.homepage) : ''}
2482
+ </div>
2483
+ `;
2484
+
2485
+ // Show file tabs if there are multiple files
2486
+ if (skill.files && skill.files.length > 0) {
2487
+ html += `<div style="margin-top:16px;">
2488
+ <label style="margin-bottom:8px;">Files</label>
2489
+ <div class="file-tabs" id="skill-file-tabs">`;
2490
+
2491
+ // Create tabs for each file
2492
+ skill.files.forEach((file, idx) => {
2493
+ const isActive = idx === 0 ? 'active' : '';
2494
+ html += `<button class="file-tab ${isActive}" onclick="switchSkillFileTab(${idx})">${esc(file.name)}</button>`;
2495
+ });
2496
+
2497
+ html += `</div>`;
2498
+
2499
+ // Create content for each file
2500
+ html += `<div id="skill-file-contents">`;
2501
+
2502
+ skill.files.forEach((file, idx) => {
2503
+ const isActive = idx === 0 ? 'active' : '';
2504
+ const sizeKB = (file.size / 1024).toFixed(2);
2505
+ html += `
2506
+ <div class="file-content ${isActive}" id="skill-file-${idx}">
2507
+ <div style="margin-bottom:8px;font-size:11px;color:var(--text-muted);font-family:var(--mono);">
2508
+ ${esc(file.path)} • ${sizeKB} KB
2509
+ </div>
2510
+ <div style="background:var(--bg-primary);border:1px solid var(--border);border-radius:var(--radius);padding:12px;max-height:400px;overflow-y:auto;">
2511
+ <pre style="font-family:var(--mono);font-size:12px;white-space:pre-wrap;color:var(--text-secondary);margin:0;">${esc(file.content)}</pre>
2512
+ </div>
2513
+ </div>
2514
+ `;
2515
+ });
2516
+
2517
+ html += `</div></div>`;
2518
+ } else {
2519
+ // Fallback to showing just the main content if no files array
2520
+ if (skill.content) {
2521
+ html += `<div style="margin-top:16px;">
2522
+ <label>Content</label>
2523
+ <div style="background:var(--bg-primary);border:1px solid var(--border);border-radius:var(--radius);padding:12px;max-height:300px;overflow-y:auto;">
2524
+ <pre style="font-family:var(--mono);font-size:12px;white-space:pre-wrap;color:var(--text-secondary);">${esc(skill.content)}</pre>
2525
+ </div>
2526
+ </div>`;
2527
+ }
2528
+ }
2529
+
2530
+ document.getElementById('skill-modal-content').innerHTML = html;
2531
+ document.getElementById('skill-modal').classList.add('open');
2532
+ } catch (err) {
2533
+ alert('Failed to load skill: ' + err.message);
2534
+ }
2535
+ }
2536
+
2537
+ function switchSkillFileTab(index) {
2538
+ // Update tab active states
2539
+ const tabs = document.querySelectorAll('.file-tab');
2540
+ tabs.forEach((tab, idx) => {
2541
+ tab.classList.toggle('active', idx === index);
2542
+ });
2543
+
2544
+ // Update content active states
2545
+ const contents = document.querySelectorAll('.file-content');
2546
+ contents.forEach((content, idx) => {
2547
+ content.classList.toggle('active', idx === index);
2548
+ });
2549
+ }
2550
+
2551
+ function closeSkillModal() {
2552
+ document.getElementById('skill-modal').classList.remove('open');
2553
+ }
2554
+
2555
+ // ─── Workspace ───────────────────────────────────
2556
+ async function loadWorkspaceFiles() {
2557
+ try {
2558
+ const data = await api('/api/workspace/files');
2559
+ const list = document.getElementById('workspace-file-list');
2560
+
2561
+ let html = '<div class="card-header"><h3>Files</h3></div>';
2562
+
2563
+ if (!data.files || data.files.length === 0) {
2564
+ html += '<div style="padding:20px;color:var(--text-muted);font-size:13px;">No workspace files found.</div>';
2565
+ } else {
2566
+ html += data.files.map(f => {
2567
+ const sizeKB = (f.size / 1024).toFixed(1);
2568
+ return `<div class="file-item ${currentWorkspaceFile === f.name ? 'active' : ''}" onclick="openWorkspaceFile('${esc(f.name)}')">
2569
+ <span class="file-icon">&#128196;</span>
2570
+ <span>${esc(f.name)}</span>
2571
+ <span class="file-meta">${sizeKB}KB</span>
2572
+ </div>`;
2573
+ }).join('');
2574
+ }
2575
+
2576
+ list.innerHTML = html;
2577
+ } catch (err) {
2578
+ document.getElementById('workspace-file-list').innerHTML = `<div style="color:var(--red);padding:16px;">Error: ${esc(err.message)}</div>`;
2579
+ }
2580
+ }
2581
+
2582
+ async function openWorkspaceFile(name) {
2583
+ try {
2584
+ currentWorkspaceFile = name;
2585
+ const data = await api(`/api/workspace/files/${encodeURIComponent(name)}`);
2586
+
2587
+ document.getElementById('editor-filename').textContent = name;
2588
+ const editor = document.getElementById('workspace-editor-area');
2589
+ editor.value = data.content;
2590
+ editor.disabled = false;
2591
+ document.getElementById('save-file-btn').disabled = false;
2592
+
2593
+ // Update active state in list
2594
+ document.querySelectorAll('.file-item').forEach(el => {
2595
+ el.classList.toggle('active', el.textContent.includes(name));
2596
+ });
2597
+ } catch (err) {
2598
+ alert('Failed to open file: ' + err.message);
2599
+ }
2600
+ }
2601
+
2602
+ async function saveWorkspaceFile() {
2603
+ if (!currentWorkspaceFile) return;
2604
+
2605
+ const content = document.getElementById('workspace-editor-area').value;
2606
+
2607
+ try {
2608
+ await api(`/api/workspace/files/${encodeURIComponent(currentWorkspaceFile)}`, {
2609
+ method: 'PUT',
2610
+ body: JSON.stringify({ content }),
2611
+ });
2612
+
2613
+ const btn = document.getElementById('save-file-btn');
2614
+ btn.textContent = 'Saved!';
2615
+ setTimeout(() => { btn.textContent = 'Save'; }, 1500);
2616
+ } catch (err) {
2617
+ alert('Failed to save: ' + err.message);
2618
+ }
2619
+ }
2620
+
2621
+ // ─── Session Files ────────────────────────────────
2622
+ let currentSessionContent = '';
2623
+ let currentSessionFilename = null;
2624
+ let sessionRefreshInterval = null;
2625
+ let sessionViewMode = 'parsed'; // 'parsed' or 'raw'
2626
+
2627
+ function startSessionPolling() {
2628
+ stopSessionPolling();
2629
+ sessionRefreshInterval = setInterval(() => {
2630
+ if (currentPage !== 'sessions') { stopSessionPolling(); return; }
2631
+ if (currentSessionFilename) {
2632
+ refreshSessionViewer();
2633
+ } else {
2634
+ loadSessionFiles();
2635
+ }
2636
+ }, 3000);
2637
+ }
2638
+
2639
+ function stopSessionPolling() {
2640
+ if (sessionRefreshInterval) {
2641
+ clearInterval(sessionRefreshInterval);
2642
+ sessionRefreshInterval = null;
2643
+ }
2644
+ }
2645
+
2646
+ async function refreshSessionViewer() {
2647
+ if (!currentSessionFilename) return;
2648
+ try {
2649
+ if (sessionViewMode === 'parsed') {
2650
+ const data = await api('/api/sessions/files/' + encodeURIComponent(currentSessionFilename) + '/parsed');
2651
+ const container = document.getElementById('sessions-parsed-view');
2652
+ const wasAtBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 50;
2653
+ renderParsedSession(data);
2654
+ if (wasAtBottom) container.scrollTop = container.scrollHeight;
2655
+ document.getElementById('sessions-viewer-meta').textContent = `${data.totalTurns} turns \u00b7 ${data.totalEntries} entries`;
2656
+ } else {
2657
+ const res = await fetch(API + '/api/sessions/files/' + encodeURIComponent(currentSessionFilename));
2658
+ const raw = await res.text();
2659
+ if (raw === currentSessionContent) return;
2660
+ currentSessionContent = raw;
2661
+ const el = document.getElementById('sessions-viewer-content');
2662
+ const wasAtBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 50;
2663
+ el.textContent = raw;
2664
+ if (wasAtBottom) el.scrollTop = el.scrollHeight;
2665
+ }
2666
+ } catch { /* ignore refresh errors */ }
2667
+ }
2668
+
2669
+ async function loadSessionFiles() {
2670
+ try {
2671
+ const data = await api('/api/sessions/files');
2672
+ const files = data.files || [];
2673
+ const tbody = document.getElementById('sessions-table-body');
2674
+
2675
+ const dirEl = document.getElementById('sessions-dir-path');
2676
+ if (data.directory) dirEl.textContent = data.directory;
2677
+
2678
+ const badge = document.getElementById('sessions-badge');
2679
+ if (badge) {
2680
+ badge.textContent = files.length;
2681
+ badge.style.display = files.length > 0 ? '' : 'none';
2682
+ }
2683
+
2684
+ if (files.length === 0) {
2685
+ tbody.innerHTML = '<tr><td colspan="4"><div class="empty-state"><div class="empty-icon">&#128203;</div><p>No session files found.</p></div></td></tr>';
2686
+ return;
2687
+ }
2688
+
2689
+ tbody.innerHTML = files.map(f => {
2690
+ const modified = new Date(f.modifiedAt);
2691
+ const timeStr = modified.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
2692
+ const dateStr = modified.toLocaleDateString([], { month: 'short', day: 'numeric' });
2693
+ const sizeStr = f.sizeKB > 1024 ? (f.sizeKB / 1024).toFixed(1) + ' MB' : f.sizeKB + ' KB';
2694
+ const shortName = f.filename.length > 50 ? f.filename.slice(0, 24) + '...' + f.filename.slice(-20) : f.filename;
2695
+ return `<tr style="cursor:pointer;" onclick="openSessionFile('${escHtml(f.filename)}')">
2696
+ <td><code style="font-size:12px;color:var(--accent);" title="${escHtml(f.filename)}">${escHtml(shortName)}</code></td>
2697
+ <td style="font-family:var(--mono);font-size:12px;white-space:nowrap;">${sizeStr}</td>
2698
+ <td style="font-family:var(--mono);font-size:12px;white-space:nowrap;" title="${modified.toISOString()}">${dateStr} ${timeStr}</td>
2699
+ <td><button class="btn btn-sm" onclick="event.stopPropagation();openSessionFile('${escHtml(f.filename)}')" style="font-size:11px;padding:2px 8px;">View</button></td>
2700
+ </tr>`;
2701
+ }).join('');
2702
+ } catch (err) {
2703
+ document.getElementById('sessions-table-body').innerHTML = `<tr><td colspan="4" style="color:var(--red);padding:16px;">Error: ${escHtml(err.message)}</td></tr>`;
2704
+ }
2705
+ }
2706
+
2707
+ async function openSessionFile(filename) {
2708
+ currentSessionFilename = filename;
2709
+ sessionViewMode = 'parsed';
2710
+ document.getElementById('sessions-list-view').style.display = 'none';
2711
+ document.getElementById('sessions-viewer').style.display = '';
2712
+ document.getElementById('sessions-viewer-title').textContent = filename;
2713
+ document.getElementById('sessions-viewer-meta').textContent = 'Loading...';
2714
+ updateSessionViewTabs();
2715
+
2716
+ // Load parsed view by default
2717
+ try {
2718
+ const data = await api('/api/sessions/files/' + encodeURIComponent(filename) + '/parsed');
2719
+ const ctxInfo = data.hasCompaction ? ` \u00b7 ${data.turnsInContext} in context \u00b7 ${data.turnsCompactedAway} compacted` : '';
2720
+ document.getElementById('sessions-viewer-meta').textContent = `${data.totalTurns} turns \u00b7 ${data.totalEntries} entries${ctxInfo}`;
2721
+ renderParsedSession(data);
2722
+ // Scroll to bottom
2723
+ const container = document.getElementById('sessions-parsed-view');
2724
+ container.scrollTop = container.scrollHeight;
2725
+ } catch (err) {
2726
+ document.getElementById('sessions-parsed-content').innerHTML = `<div style="color:var(--red);padding:16px;">Error: ${escHtml(err.message)}</div>`;
2727
+ }
2728
+ }
2729
+
2730
+ function switchSessionView(mode) {
2731
+ sessionViewMode = mode;
2732
+ updateSessionViewTabs();
2733
+ if (mode === 'raw' && currentSessionFilename) {
2734
+ loadRawSessionView();
2735
+ }
2736
+ }
2737
+
2738
+ function updateSessionViewTabs() {
2739
+ document.getElementById('sessions-tab-parsed').style.fontWeight = sessionViewMode === 'parsed' ? '600' : '400';
2740
+ document.getElementById('sessions-tab-raw').style.fontWeight = sessionViewMode === 'raw' ? '600' : '400';
2741
+ document.getElementById('sessions-parsed-view').style.display = sessionViewMode === 'parsed' ? '' : 'none';
2742
+ document.getElementById('sessions-raw-view').style.display = sessionViewMode === 'raw' ? '' : 'none';
2743
+ }
2744
+
2745
+ async function loadRawSessionView() {
2746
+ try {
2747
+ const el = document.getElementById('sessions-viewer-content');
2748
+ el.textContent = 'Loading...';
2749
+ const res = await fetch(API + '/api/sessions/files/' + encodeURIComponent(currentSessionFilename));
2750
+ const raw = await res.text();
2751
+ currentSessionContent = raw;
2752
+ // Show raw JSONL lines, each pretty-printed
2753
+ const lines = raw.split('\n').filter(l => l.trim());
2754
+ el.textContent = lines.map(line => {
2755
+ try { return JSON.stringify(JSON.parse(line), null, 2); } catch { return line; }
2756
+ }).join('\n---\n');
2757
+ el.scrollTop = el.scrollHeight;
2758
+ } catch (err) {
2759
+ document.getElementById('sessions-viewer-content').textContent = 'Error: ' + err.message;
2760
+ }
2761
+ }
2762
+
2763
+ function renderParsedSession(data) {
2764
+ const el = document.getElementById('sessions-parsed-content');
2765
+ let html = '';
2766
+
2767
+ // Session header — single line
2768
+ if (data.session) {
2769
+ html += `<div style="padding:3px 8px;font-size:10px;color:var(--text-muted);font-family:var(--mono);border-bottom:1px solid var(--border);">
2770
+ ${escHtml(data.session.id || '').slice(0,8)}.. | v${data.session.version || '?'} | ${escHtml(data.session.cwd || '')} | ${new Date(data.session.timestamp || '').toLocaleString()}
2771
+ </div>`;
2772
+ }
2773
+
2774
+ let shownContextBoundary = false;
2775
+ let lastWasOutOfContext = false;
2776
+
2777
+ const turns = data.turns || [];
2778
+ for (let i = 0; i < turns.length; i++) {
2779
+ const turn = turns[i];
2780
+ const time = new Date(turn.timestamp);
2781
+ const timeStr = time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
2782
+ const dateStr = time.toLocaleDateString([], { month: 'short', day: 'numeric' });
2783
+
2784
+ // Context boundary
2785
+ if (!turn.inContext && data.hasCompaction) lastWasOutOfContext = true;
2786
+ if (turn.inContext && lastWasOutOfContext && !shownContextBoundary) {
2787
+ shownContextBoundary = true;
2788
+ if (data.compactions && data.compactions.length > 0) {
2789
+ const c = data.compactions[data.compactions.length - 1];
2790
+ html += `<div style="padding:4px 8px;background:#a855f712;border-left:2px solid #a855f7;margin:2px 0;font-size:10px;">
2791
+ <span style="color:#a855f7;font-weight:600;">COMPACTION</span>
2792
+ <span style="color:var(--text-muted);"> ${new Date(c.timestamp).toLocaleString()}${c.tokensBefore ? ' \u2014 ' + c.tokensBefore.toLocaleString() + ' tok' : ''}</span>
2793
+ <div style="color:var(--text-secondary);margin-top:2px;line-height:1.3;white-space:pre-wrap;max-height:80px;overflow:hidden;">${escHtml(c.summary || '').slice(0, 500)}${(c.summary || '').length > 500 ? '...' : ''}</div>
2794
+ </div>`;
2795
+ }
2796
+ html += `<div style="display:flex;align-items:center;gap:6px;margin:2px 0;">
2797
+ <div style="flex:1;height:1px;background:#10b98140;"></div>
2798
+ <span style="font-size:9px;color:#10b981;font-weight:700;letter-spacing:0.5px;">ACTIVE CONTEXT</span>
2799
+ <div style="flex:1;height:1px;background:#10b98140;"></div>
2800
+ </div>`;
2801
+ }
2802
+
2803
+ const dim = !turn.inContext && data.hasCompaction ? 'opacity:0.35;' : '';
2804
+ const hbBadge = turn.isHeartbeat ? '<span style="color:#f59e0b;font-size:8px;font-weight:700;margin-left:3px;">HB</span>' : '';
2805
+ const compBadge = !turn.inContext && data.hasCompaction ? '<span style="color:#ef4444;font-size:8px;margin-left:3px;">OLD</span>' : '';
2806
+
2807
+ // Native server tool calls summary
2808
+ const toolStr = turn.toolCalls && turn.toolCalls.length > 0
2809
+ ? turn.toolCalls.map(tc => tc.name).join(', ')
2810
+ : '';
2811
+
2812
+ // Tokens / cost
2813
+ const metaRight = [];
2814
+ if (turn.model) metaRight.push(escHtml(turn.model).replace('claude-', '').replace('-20250514',''));
2815
+ if (turn.usage) metaRight.push((turn.usage.totalTokens || 0).toLocaleString() + 't');
2816
+ if (turn.usage?.cost) metaRight.push('$' + turn.usage.cost.toFixed(3));
2817
+
2818
+ // Single compact turn block
2819
+ html += `<div style="border-left:2px solid var(--accent);padding:3px 8px;${dim}">`;
2820
+
2821
+ // Header line: #N time USER message-preview
2822
+ const userPreview = escHtml(turn.userMessage).replace(/\n/g, ' ').slice(0, 120);
2823
+ html += `<div style="display:flex;justify-content:space-between;align-items:baseline;gap:4px;">
2824
+ <div style="font-size:10px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
2825
+ <span style="color:var(--text-muted);font-family:var(--mono);">#${turn.turnNumber}</span>
2826
+ <span style="color:var(--text-muted);font-family:var(--mono);margin:0 2px;">${dateStr} ${timeStr}</span>${hbBadge}${compBadge}
2827
+ <span style="color:var(--accent);font-weight:600;margin-left:3px;">USER</span>
2828
+ <span style="color:var(--text-primary);margin-left:4px;">${userPreview}</span>
2829
+ </div>
2830
+ <div style="font-size:9px;color:var(--text-muted);white-space:nowrap;flex-shrink:0;">${metaRight.join(' \u00b7 ')}</div>
2831
+ </div>`;
2832
+
2833
+ // Native server tool calls — collapsed single line
2834
+ if (toolStr) {
2835
+ html += `<div style="font-size:9px;color:#3b82f6;font-family:var(--mono);padding:1px 0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
2836
+ \u2514 ${turn.toolCalls.length} tool${turn.toolCalls.length > 1 ? 's' : ''}: ${escHtml(toolStr).slice(0, 100)}
2837
+ </div>`;
2838
+ }
2839
+
2840
+ // Assistant messages — render each entry separately
2841
+ if (turn.assistantMessages && turn.assistantMessages.length > 0) {
2842
+ for (const aMsg of turn.assistantMessages) {
2843
+ const trimmed = aMsg.trim();
2844
+ if (!trimmed) continue;
2845
+ // Detect [label → result] tool entries from client session sync
2846
+ const toolMatch = trimmed.match(/^\[(.+?)\s*→\s*([\s\S]*)\]$/);
2847
+ if (toolMatch) {
2848
+ const tLabel = escHtml(toolMatch[1]);
2849
+ const tResult = escHtml(toolMatch[2]).replace(/\n/g, ' ').slice(0, 150);
2850
+ html += `<div style="font-size:9px;color:#3b82f6;font-family:var(--mono);padding:1px 0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
2851
+ \u2514 <span style="font-weight:600;">${tLabel}</span> <span style="color:var(--text-muted);">→ ${tResult}${toolMatch[2].length > 150 ? '...' : ''}</span>
2852
+ </div>`;
2853
+ } else {
2854
+ // Regular assistant text
2855
+ const aPreview = escHtml(trimmed).replace(/\n/g, ' ').slice(0, 200);
2856
+ html += `<div style="font-size:10px;padding:1px 0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
2857
+ <span style="color:#10b981;font-weight:600;">ASST</span>
2858
+ <span style="color:var(--text-secondary);margin-left:4px;">${aPreview}${trimmed.length > 200 ? '...' : ''}</span>
2859
+ </div>`;
2860
+ }
2861
+ }
2862
+ }
2863
+
2864
+ html += `</div>`;
2865
+ }
2866
+
2867
+ if (turns.length === 0) {
2868
+ html += '<div style="text-align:center;color:var(--text-muted);padding:20px;font-size:12px;">No conversation turns found.</div>';
2869
+ }
2870
+
2871
+ el.innerHTML = html;
2872
+ }
2873
+
2874
+ function closeSessionViewer() {
2875
+ document.getElementById('sessions-viewer').style.display = 'none';
2876
+ document.getElementById('sessions-list-view').style.display = '';
2877
+ currentSessionContent = '';
2878
+ currentSessionFilename = null;
2879
+ }
2880
+
2881
+ function copySessionContent() {
2882
+ if (!currentSessionFilename) return;
2883
+ fetch(API + '/api/sessions/files/' + encodeURIComponent(currentSessionFilename))
2884
+ .then(r => r.text())
2885
+ .then(raw => {
2886
+ navigator.clipboard.writeText(raw).then(() => {
2887
+ const btn = event.target;
2888
+ const orig = btn.textContent;
2889
+ btn.textContent = 'Copied!';
2890
+ setTimeout(() => btn.textContent = orig, 1500);
2891
+ });
2892
+ });
2893
+ }
2894
+
2895
+ function escHtml(str) {
2896
+ if (!str) return '';
2897
+ return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
2898
+ }
2899
+
2900
+ // ─── Logs ────────────────────────────────────────
2901
+ async function loadLogs() {
2902
+ try {
2903
+ const component = document.getElementById('log-component-filter').value;
2904
+ const level = document.getElementById('log-level-filter').value;
2905
+ const params = new URLSearchParams({ limit: '200' });
2906
+ if (component) params.set('component', component);
2907
+ if (level) params.set('level', level);
2908
+
2909
+ const data = await api(`/api/logs?${params}`);
2910
+
2911
+ // Update component filter options
2912
+ const compFilter = document.getElementById('log-component-filter');
2913
+ const currentComp = compFilter.value;
2914
+ if (data.components && data.components.length > 0) {
2915
+ const opts = ['<option value="">All Components</option>'];
2916
+ for (const c of data.components) {
2917
+ opts.push(`<option value="${esc(c)}" ${c === currentComp ? 'selected' : ''}>${esc(c)}</option>`);
2918
+ }
2919
+ compFilter.innerHTML = opts.join('');
2920
+ }
2921
+
2922
+ renderLogEntries(data.entries || []);
2923
+ } catch (err) {
2924
+ document.getElementById('log-entries').innerHTML = `<div style="color:var(--red);padding:12px;">Error: ${esc(err.message)}</div>`;
2925
+ }
2926
+ }
2927
+
2928
+ function renderLogEntries(entries) {
2929
+ const el = document.getElementById('log-entries');
2930
+
2931
+ if (entries.length === 0) {
2932
+ el.innerHTML = '<div style="padding:20px;color:var(--text-muted);text-align:center;">No log entries.</div>';
2933
+ return;
2934
+ }
2935
+
2936
+ el.innerHTML = entries.map(e => {
2937
+ const time = new Date(e.timestamp).toLocaleTimeString();
2938
+ return `<div class="log-line">
2939
+ <span class="log-time">${time}</span>
2940
+ <span class="log-level ${e.level}">${e.level}</span>
2941
+ <span class="log-comp">${esc(e.component)}</span>
2942
+ <span class="log-msg">${esc(e.message)}</span>
2943
+ </div>`;
2944
+ }).join('');
2945
+ }
2946
+
2947
+ function startLogStream() {
2948
+ if (logEventSource) return;
2949
+ const toggle = document.getElementById('log-stream-toggle');
2950
+ if (!toggle.checked) return;
2951
+
2952
+ logEventSource = new EventSource('/api/logs/stream');
2953
+
2954
+ logEventSource.onmessage = (event) => {
2955
+ try {
2956
+ const entry = JSON.parse(event.data);
2957
+ if (entry.type === 'connected') return;
2958
+
2959
+ // Apply filters
2960
+ const compFilter = document.getElementById('log-component-filter').value;
2961
+ const levelFilter = document.getElementById('log-level-filter').value;
2962
+ if (compFilter && entry.component !== compFilter) return;
2963
+ if (levelFilter && entry.level !== levelFilter) return;
2964
+
2965
+ const el = document.getElementById('log-entries');
2966
+ const emptyMsg = el.querySelector('div[style*="text-align"]');
2967
+ if (emptyMsg) emptyMsg.remove();
2968
+
2969
+ const time = new Date(entry.timestamp).toLocaleTimeString();
2970
+ const html = `<div class="log-line">
2971
+ <span class="log-time">${time}</span>
2972
+ <span class="log-level ${entry.level}">${entry.level}</span>
2973
+ <span class="log-comp">${esc(entry.component)}</span>
2974
+ <span class="log-msg">${esc(entry.message)}</span>
2975
+ </div>`;
2976
+
2977
+ // Prepend (newest first)
2978
+ el.insertAdjacentHTML('afterbegin', html);
2979
+
2980
+ // Limit DOM entries
2981
+ const lines = el.querySelectorAll('.log-line');
2982
+ if (lines.length > 500) {
2983
+ for (let i = 500; i < lines.length; i++) lines[i].remove();
2984
+ }
2985
+ } catch { /* ignore */ }
2986
+ };
2987
+
2988
+ logEventSource.onerror = () => {
2989
+ // Will auto-reconnect
2990
+ };
2991
+ }
2992
+
2993
+ function stopLogStream() {
2994
+ if (logEventSource) {
2995
+ logEventSource.close();
2996
+ logEventSource = null;
2997
+ }
2998
+ }
2999
+
3000
+ function toggleLogStream() {
3001
+ const toggle = document.getElementById('log-stream-toggle');
3002
+ if (toggle.checked) {
3003
+ startLogStream();
3004
+ } else {
3005
+ stopLogStream();
3006
+ }
3007
+ }
3008
+
3009
+ function clearLogView() {
3010
+ document.getElementById('log-entries').innerHTML = '<div style="padding:20px;color:var(--text-muted);text-align:center;">Log view cleared.</div>';
3011
+ }
3012
+
3013
+ // ─── Settings ────────────────────────────────────
3014
+ async function loadSettings() {
3015
+ try {
3016
+ const data = await api('/api/config');
3017
+
3018
+ const configEl = document.getElementById('settings-config');
3019
+ const entries = Object.entries(data.config || {});
3020
+
3021
+ if (entries.length === 0) {
3022
+ configEl.innerHTML = '<div style="padding:20px;color:var(--text-muted);">No configuration found. Run <code>costar setup</code> to configure.</div>';
3023
+ } else {
3024
+ configEl.innerHTML = entries.map(([k, v]) => row(k, v)).join('');
3025
+ }
3026
+
3027
+ const pathsEl = document.getElementById('settings-paths');
3028
+ pathsEl.innerHTML = [
3029
+ row('Config Directory', data.configDir),
3030
+ row('Logs Directory', data.logsDir),
3031
+ ].join('');
3032
+ } catch (err) {
3033
+ document.getElementById('settings-config').innerHTML = `<div style="color:var(--red);padding:12px;">Error: ${esc(err.message)}</div>`;
3034
+ }
3035
+
3036
+ // Also load env variables
3037
+ loadEnvVariables();
3038
+ }
3039
+
3040
+ // ─── Environment Variables ───────────────────────
3041
+ let envVariables = [];
3042
+
3043
+ async function loadEnvVariables() {
3044
+ try {
3045
+ const data = await api('/api/env');
3046
+ envVariables = data.variables || [];
3047
+ const tbody = document.getElementById('env-table-body');
3048
+
3049
+ if (envVariables.length === 0) {
3050
+ tbody.innerHTML = `<tr><td colspan="3"><div class="empty-state"><div class="empty-icon">&#128295;</div><p>No environment variables configured.</p></div></td></tr>`;
3051
+ return;
3052
+ }
3053
+
3054
+ const platformVars = envVariables.filter(e => e.source === 'platform');
3055
+ const userVars = envVariables.filter(e => e.source !== 'platform');
3056
+
3057
+ let html = '';
3058
+
3059
+ // Platform-managed keys section (compact, read-only)
3060
+ if (platformVars.length > 0) {
3061
+ html += `<tr><td colspan="3" style="padding:8px 12px;background:var(--bg);border-bottom:1px solid var(--border);">
3062
+ <span style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted);">Platform Managed</span>
3063
+ <span style="font-size:10px;color:var(--text-muted);margin-left:6px;">(read-only)</span>
3064
+ </td></tr>`;
3065
+ html += platformVars.map((env, idx) => {
3066
+ const realIdx = envVariables.indexOf(env);
3067
+ const valueDisplay = env.masked
3068
+ ? `<span style="font-family:var(--mono);font-size:12px;color:var(--text-muted);" id="env-value-${realIdx}">${esc(env.value)}</span>
3069
+ <button class="btn btn-sm" onclick="revealEnvValue('${esc(env.key)}', ${realIdx})" id="env-reveal-${realIdx}" style="margin-left:4px;font-size:11px;padding:2px 6px;">Reveal</button>`
3070
+ : `<span style="font-family:var(--mono);font-size:12px;">${esc(env.value)}</span>`;
3071
+ return `<tr style="opacity:0.85;">
3072
+ <td style="padding:4px 12px;"><code style="font-size:12px;color:var(--accent);">${esc(env.key)}</code></td>
3073
+ <td style="padding:4px 12px;">${valueDisplay}</td>
3074
+ <td style="padding:4px 12px;"><span style="font-size:10px;color:var(--text-muted);background:var(--bg);padding:2px 6px;border-radius:4px;">managed</span></td>
3075
+ </tr>`;
3076
+ }).join('');
3077
+ }
3078
+
3079
+ // User-editable keys section
3080
+ if (userVars.length > 0) {
3081
+ if (platformVars.length > 0) {
3082
+ html += `<tr><td colspan="3" style="padding:8px 12px;background:var(--bg);border-bottom:1px solid var(--border);">
3083
+ <span style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted);">User Configured</span>
3084
+ </td></tr>`;
3085
+ }
3086
+ html += userVars.map((env, idx) => {
3087
+ const realIdx = envVariables.indexOf(env);
3088
+ const valueDisplay = env.masked
3089
+ ? `<span style="font-family:var(--mono);font-size:12px;color:var(--text-muted);" id="env-value-${realIdx}">${esc(env.value)}</span>
3090
+ <button class="btn btn-sm" onclick="revealEnvValue('${esc(env.key)}', ${realIdx})" id="env-reveal-${realIdx}" style="margin-left:4px;font-size:11px;padding:2px 6px;">Reveal</button>`
3091
+ : `<span style="font-family:var(--mono);font-size:12px;">${esc(env.value)}</span>`;
3092
+ return `<tr>
3093
+ <td style="padding:4px 12px;"><code style="font-size:12px;color:var(--accent);">${esc(env.key)}</code></td>
3094
+ <td style="padding:4px 12px;">${valueDisplay}</td>
3095
+ <td style="padding:4px 12px;">
3096
+ <button class="btn btn-sm" onclick="editEnvVariable('${esc(env.key)}')" style="font-size:11px;padding:2px 6px;">Edit</button>
3097
+ <button class="btn btn-sm btn-danger" onclick="deleteEnvVariable('${esc(env.key)}')" style="font-size:11px;padding:2px 6px;">Delete</button>
3098
+ </td>
3099
+ </tr>`;
3100
+ }).join('');
3101
+ }
3102
+
3103
+ tbody.innerHTML = html;
3104
+ } catch (err) {
3105
+ document.getElementById('env-table-body').innerHTML = `<tr><td colspan="3" style="color:var(--red);padding:16px;">Error: ${esc(err.message)}</td></tr>`;
3106
+ }
3107
+ }
3108
+
3109
+ async function revealEnvValue(key, idx) {
3110
+ try {
3111
+ const btn = document.getElementById(`env-reveal-${idx}`);
3112
+ const valueEl = document.getElementById(`env-value-${idx}`);
3113
+
3114
+ if (!btn || !valueEl) return;
3115
+
3116
+ // If already revealed, hide it again
3117
+ if (btn.textContent.includes('Hide')) {
3118
+ const maskedVar = envVariables[idx];
3119
+ valueEl.textContent = maskedVar.value;
3120
+ btn.innerHTML = '&#128065; Reveal';
3121
+ return;
3122
+ }
3123
+
3124
+ btn.disabled = true;
3125
+ btn.textContent = 'Loading...';
3126
+
3127
+ const data = await api(`/api/env/${encodeURIComponent(key)}`);
3128
+ valueEl.textContent = data.value;
3129
+ btn.disabled = false;
3130
+ btn.innerHTML = '&#128065; Hide';
3131
+
3132
+ // Auto-hide after 10 seconds
3133
+ setTimeout(() => {
3134
+ if (btn.textContent.includes('Hide')) {
3135
+ const maskedVar = envVariables[idx];
3136
+ valueEl.textContent = maskedVar.value;
3137
+ btn.innerHTML = '&#128065; Reveal';
3138
+ }
3139
+ }, 10000);
3140
+ } catch (err) {
3141
+ alert('Failed to reveal value: ' + err.message);
3142
+ const btn = document.getElementById(`env-reveal-${idx}`);
3143
+ if (btn) {
3144
+ btn.disabled = false;
3145
+ btn.innerHTML = '&#128065; Reveal';
3146
+ }
3147
+ }
3148
+ }
3149
+
3150
+ function openEnvModal() {
3151
+ document.getElementById('env-modal').classList.add('open');
3152
+ document.getElementById('env-modal-title').textContent = 'Add Environment Variable';
3153
+ document.getElementById('env-key').value = '';
3154
+ document.getElementById('env-key').disabled = false;
3155
+ document.getElementById('env-value').value = '';
3156
+ document.getElementById('env-key').dataset.editMode = 'false';
3157
+ }
3158
+
3159
+ function closeEnvModal() {
3160
+ document.getElementById('env-modal').classList.remove('open');
3161
+ }
3162
+
3163
+ function editEnvVariable(key) {
3164
+ const envVar = envVariables.find(e => e.key === key);
3165
+ if (!envVar) return;
3166
+
3167
+ document.getElementById('env-modal').classList.add('open');
3168
+ document.getElementById('env-modal-title').textContent = 'Edit Environment Variable';
3169
+ document.getElementById('env-key').value = key;
3170
+ document.getElementById('env-key').disabled = true; // Can't change key when editing
3171
+ document.getElementById('env-value').value = ''; // Don't pre-fill for security
3172
+ document.getElementById('env-value').placeholder = 'Enter new value...';
3173
+ document.getElementById('env-key').dataset.editMode = 'true';
3174
+ }
3175
+
3176
+ async function saveEnvVariable() {
3177
+ const keyInput = document.getElementById('env-key');
3178
+ const valueInput = document.getElementById('env-value');
3179
+ const key = keyInput.value.trim().toUpperCase();
3180
+ const value = valueInput.value.trim();
3181
+ const isEdit = keyInput.dataset.editMode === 'true';
3182
+
3183
+ if (!key) {
3184
+ alert('Key is required.');
3185
+ return;
3186
+ }
3187
+
3188
+ if (!value) {
3189
+ alert('Value is required.');
3190
+ return;
3191
+ }
3192
+
3193
+ // Validate key format
3194
+ if (!/^[A-Z0-9_]+$/.test(key)) {
3195
+ alert('Key must contain only uppercase letters, numbers, and underscores.');
3196
+ return;
3197
+ }
3198
+
3199
+ try {
3200
+ if (isEdit) {
3201
+ await api(`/api/env/${encodeURIComponent(key)}`, {
3202
+ method: 'PUT',
3203
+ body: JSON.stringify({ value }),
3204
+ });
3205
+ } else {
3206
+ await api('/api/env', {
3207
+ method: 'POST',
3208
+ body: JSON.stringify({ key, value }),
3209
+ });
3210
+ }
3211
+
3212
+ closeEnvModal();
3213
+ loadEnvVariables();
3214
+ } catch (err) {
3215
+ alert('Failed to save: ' + err.message);
3216
+ }
3217
+ }
3218
+
3219
+ async function deleteEnvVariable(key) {
3220
+ if (!confirm(`Delete environment variable "${key}"?\n\nThis will remove it from ~/.costar/.env and the current process.`)) {
3221
+ return;
3222
+ }
3223
+
3224
+ try {
3225
+ await api(`/api/env/${encodeURIComponent(key)}`, { method: 'DELETE' });
3226
+ loadEnvVariables();
3227
+ } catch (err) {
3228
+ alert('Failed to delete: ' + err.message);
3229
+ }
3230
+ }
3231
+
3232
+ // ─── Utilities ───────────────────────────────────
3233
+ function esc(str) {
3234
+ if (!str) return '';
3235
+ const div = document.createElement('div');
3236
+ div.textContent = String(str);
3237
+ return div.innerHTML;
3238
+ }
3239
+
3240
+ // ─── Init ────────────────────────────────────────
3241
+ document.addEventListener('DOMContentLoaded', () => {
3242
+ loadDashboard();
3243
+ });
3244
+
3245
+ // Close modals on overlay click
3246
+ document.querySelectorAll('.modal-overlay').forEach(overlay => {
3247
+ overlay.addEventListener('click', (e) => {
3248
+ if (e.target === overlay) overlay.classList.remove('open');
3249
+ });
3250
+ });
3251
+
3252
+ // Keyboard shortcut: Escape to close modals
3253
+ document.addEventListener('keydown', (e) => {
3254
+ if (e.key === 'Escape') {
3255
+ document.querySelectorAll('.modal-overlay.open').forEach(m => m.classList.remove('open'));
3256
+ }
3257
+ });
3258
+
3259
+ </script>
3260
+ </body>
3261
+ </html>