cc-agent-ui 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/public/index.html +449 -20
- package/server.js +110 -3
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -296,26 +296,23 @@ body {
|
|
|
296
296
|
}
|
|
297
297
|
|
|
298
298
|
/* status colors */
|
|
299
|
-
|
|
299
|
+
|
|
300
300
|
.tcard.done { border-color: #1e2d1e; box-shadow: 0 0 16px var(--green-glow); }
|
|
301
301
|
.tcard.failed { border-color: #2d1e20; box-shadow: 0 0 16px var(--red-glow); }
|
|
302
302
|
.tcard.pending_approval { border-color: #2d2a1e; box-shadow: 0 0 16px var(--yellow-glow); }
|
|
303
303
|
.tcard.cloning { border-color: #1e2233; box-shadow: 0 0 16px rgba(97,175,239,0.1); }
|
|
304
304
|
|
|
305
|
-
|
|
306
|
-
.
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
opacity: 0.4;
|
|
312
|
-
animation: scanline 3s linear infinite;
|
|
313
|
-
pointer-events: none;
|
|
314
|
-
z-index: 5;
|
|
305
|
+
@keyframes border-race {
|
|
306
|
+
0% { box-shadow: 0 -1px 4px 0px #60a5fa, inset 0 0 0 1px #1e3a8a, 0 0 10px rgba(59,130,246,0.12); }
|
|
307
|
+
25% { box-shadow: 2px 0 4px 0px #60a5fa, inset 0 0 0 1px #1e3a8a, 0 0 10px rgba(59,130,246,0.12); }
|
|
308
|
+
50% { box-shadow: 0 2px 4px 0px #60a5fa, inset 0 0 0 1px #1e3a8a, 0 0 10px rgba(59,130,246,0.12); }
|
|
309
|
+
75% { box-shadow: -2px 0 4px 0px #60a5fa, inset 0 0 0 1px #1e3a8a, 0 0 10px rgba(59,130,246,0.12); }
|
|
310
|
+
100% { box-shadow: 0 -1px 4px 0px #60a5fa, inset 0 0 0 1px #1e3a8a, 0 0 10px rgba(59,130,246,0.12); }
|
|
315
311
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
312
|
+
|
|
313
|
+
.tcard.running {
|
|
314
|
+
border-color: #1e3a8a;
|
|
315
|
+
animation: border-race 2s linear infinite;
|
|
319
316
|
}
|
|
320
317
|
|
|
321
318
|
/* ── Card Header ── */
|
|
@@ -328,7 +325,9 @@ body {
|
|
|
328
325
|
border-bottom: 1px solid var(--border);
|
|
329
326
|
background: rgba(0,0,0,0.2);
|
|
330
327
|
flex-shrink: 0;
|
|
328
|
+
cursor: pointer;
|
|
331
329
|
}
|
|
330
|
+
.card-hdr:hover { background: rgba(255,255,255,0.04); }
|
|
332
331
|
|
|
333
332
|
.card-num {
|
|
334
333
|
font-size: 10px;
|
|
@@ -388,6 +387,7 @@ body {
|
|
|
388
387
|
}
|
|
389
388
|
.card-repo-icon { color: var(--dimmer); }
|
|
390
389
|
.card-branch { color: var(--purple); margin-left: auto; flex-shrink:0; }
|
|
390
|
+
.card-docker-badge { color: #38bdf8; font-size:9px; background: rgba(56,189,248,.12); border:1px solid rgba(56,189,248,.3); border-radius:3px; padding:1px 5px; flex-shrink:0; }
|
|
391
391
|
|
|
392
392
|
/* ── Terminal body ── */
|
|
393
393
|
.card-term {
|
|
@@ -407,11 +407,11 @@ body {
|
|
|
407
407
|
|
|
408
408
|
/* Output line coloring */
|
|
409
409
|
.tl { white-space: pre-wrap; word-break: break-all; }
|
|
410
|
-
.tl-sys { color:
|
|
410
|
+
.tl-sys { color: #7a8099; } /* [cc-agent] meta lines */
|
|
411
411
|
.tl-ok { color: var(--green); } /* success */
|
|
412
412
|
.tl-err { color: var(--red); } /* errors */
|
|
413
413
|
.tl-warn { color: var(--yellow); } /* warnings */
|
|
414
|
-
.tl-tool { color: var(--blue); }
|
|
414
|
+
.tl-tool { color: var(--blue); opacity: 0.8; font-size: 10px; } /* tool calls */
|
|
415
415
|
.tl-file { color: var(--orange); } /* file operations */
|
|
416
416
|
.tl-head { color: var(--cyan); font-weight:600; } /* bold headings */
|
|
417
417
|
.tl-dim { color: var(--dim); } /* faded */
|
|
@@ -541,6 +541,99 @@ body {
|
|
|
541
541
|
border-radius: 4px;
|
|
542
542
|
}
|
|
543
543
|
|
|
544
|
+
/* ── Job Detail Panel ────────────────────────────────────────────────────── */
|
|
545
|
+
#jobpanel {
|
|
546
|
+
position: fixed;
|
|
547
|
+
top: 40px; right: 0; bottom: 0;
|
|
548
|
+
width: 600px;
|
|
549
|
+
background: var(--sidebar-bg);
|
|
550
|
+
border-left: 1px solid var(--border-hi);
|
|
551
|
+
display: flex;
|
|
552
|
+
flex-direction: column;
|
|
553
|
+
z-index: 600;
|
|
554
|
+
transform: translateX(100%);
|
|
555
|
+
transition: transform 0.22s cubic-bezier(.4,0,.2,1);
|
|
556
|
+
}
|
|
557
|
+
#jobpanel.open { transform: translateX(0); }
|
|
558
|
+
#jobpanel.open ~ #filebrowser { right: 600px; }
|
|
559
|
+
|
|
560
|
+
#jp-topbar {
|
|
561
|
+
display: flex; align-items: center;
|
|
562
|
+
padding: 0 14px; height: 44px;
|
|
563
|
+
border-bottom: 1px solid var(--border);
|
|
564
|
+
gap: 10px; flex-shrink: 0;
|
|
565
|
+
}
|
|
566
|
+
#jp-title {
|
|
567
|
+
flex: 1; font-size: 11px; font-weight: 600;
|
|
568
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
569
|
+
}
|
|
570
|
+
#jp-close {
|
|
571
|
+
background: transparent; border: none;
|
|
572
|
+
color: var(--dim); font-size: 16px; cursor: pointer;
|
|
573
|
+
padding: 0 4px; line-height: 1; font-family: var(--font);
|
|
574
|
+
}
|
|
575
|
+
#jp-close:hover { color: var(--text); }
|
|
576
|
+
|
|
577
|
+
#jp-meta {
|
|
578
|
+
padding: 10px 14px;
|
|
579
|
+
border-bottom: 1px solid var(--border);
|
|
580
|
+
display: flex; flex-wrap: wrap; gap: 8px;
|
|
581
|
+
flex-shrink: 0;
|
|
582
|
+
}
|
|
583
|
+
.jp-badge {
|
|
584
|
+
font-size: 9px; padding: 3px 8px; border-radius: 3px;
|
|
585
|
+
text-transform: uppercase; letter-spacing: 0.08em; font-weight: 600;
|
|
586
|
+
}
|
|
587
|
+
.jp-meta-row {
|
|
588
|
+
width: 100%; font-size: 10px; color: var(--dim);
|
|
589
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
590
|
+
}
|
|
591
|
+
.jp-meta-row span { color: var(--text); }
|
|
592
|
+
|
|
593
|
+
#jp-actions {
|
|
594
|
+
padding: 10px 14px;
|
|
595
|
+
border-bottom: 1px solid var(--border);
|
|
596
|
+
display: flex; gap: 8px; flex-shrink: 0;
|
|
597
|
+
flex-wrap: wrap;
|
|
598
|
+
}
|
|
599
|
+
.jp-btn {
|
|
600
|
+
font-family: var(--font); font-size: 10px;
|
|
601
|
+
padding: 5px 14px; border-radius: 4px; cursor: pointer;
|
|
602
|
+
border: 1px solid var(--border); background: transparent;
|
|
603
|
+
color: var(--text); transition: all 0.15s;
|
|
604
|
+
font-weight: 600; letter-spacing: 0.04em;
|
|
605
|
+
}
|
|
606
|
+
.jp-btn:hover { border-color: var(--border-hi); background: rgba(255,255,255,0.04); }
|
|
607
|
+
.jp-btn.approve { border-color: rgba(152,195,121,0.5); color: var(--green); }
|
|
608
|
+
.jp-btn.approve:hover { background: rgba(152,195,121,0.12); }
|
|
609
|
+
.jp-btn.cancel { border-color: rgba(224,108,117,0.5); color: var(--red); }
|
|
610
|
+
.jp-btn.cancel:hover { background: rgba(224,108,117,0.12); }
|
|
611
|
+
.jp-btn.wake { border-color: rgba(229,192,123,0.5); color: var(--yellow); }
|
|
612
|
+
.jp-btn.wake:hover { background: rgba(229,192,123,0.12); }
|
|
613
|
+
.jp-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
|
614
|
+
|
|
615
|
+
#jp-log {
|
|
616
|
+
flex: 1; overflow-y: auto;
|
|
617
|
+
padding: 10px 14px;
|
|
618
|
+
font-size: 11px; line-height: 1.65;
|
|
619
|
+
scrollbar-width: thin;
|
|
620
|
+
scrollbar-color: var(--border) transparent;
|
|
621
|
+
background: var(--card-body);
|
|
622
|
+
}
|
|
623
|
+
#jp-log::-webkit-scrollbar { width: 4px; }
|
|
624
|
+
#jp-log::-webkit-scrollbar-thumb { background: var(--border-hi); border-radius: 2px; }
|
|
625
|
+
|
|
626
|
+
#jp-task {
|
|
627
|
+
padding: 10px 14px;
|
|
628
|
+
border-bottom: 1px solid var(--border);
|
|
629
|
+
font-size: 10px; color: var(--dim);
|
|
630
|
+
max-height: 100px; overflow-y: auto;
|
|
631
|
+
flex-shrink: 0;
|
|
632
|
+
white-space: pre-wrap; word-break: break-word;
|
|
633
|
+
line-height: 1.6;
|
|
634
|
+
}
|
|
635
|
+
#jp-task strong { color: var(--text); display: block; margin-bottom: 4px; font-size: 9px; text-transform: uppercase; letter-spacing: 0.08em; }
|
|
636
|
+
|
|
544
637
|
/* Clickable file paths in terminal */
|
|
545
638
|
.fp-link {
|
|
546
639
|
color: var(--orange);
|
|
@@ -554,6 +647,49 @@ body {
|
|
|
554
647
|
text-decoration-color: var(--orange);
|
|
555
648
|
}
|
|
556
649
|
|
|
650
|
+
/* ── Message bar (job panel) ── */
|
|
651
|
+
#jp-msg-bar {
|
|
652
|
+
border-top: 1px solid var(--border);
|
|
653
|
+
padding: 8px 14px;
|
|
654
|
+
display: flex;
|
|
655
|
+
gap: 8px;
|
|
656
|
+
flex-shrink: 0;
|
|
657
|
+
background: rgba(0,0,0,0.15);
|
|
658
|
+
}
|
|
659
|
+
#jp-msg-bar.hidden { display: none; }
|
|
660
|
+
#jp-msg-input {
|
|
661
|
+
flex: 1;
|
|
662
|
+
background: rgba(0,0,0,0.4);
|
|
663
|
+
border: 1px solid var(--border-hi);
|
|
664
|
+
border-radius: 4px;
|
|
665
|
+
color: var(--text);
|
|
666
|
+
font-family: var(--font);
|
|
667
|
+
font-size: 11px;
|
|
668
|
+
padding: 5px 10px;
|
|
669
|
+
outline: none;
|
|
670
|
+
transition: border-color 0.15s;
|
|
671
|
+
}
|
|
672
|
+
#jp-msg-input::placeholder { color: var(--dim); }
|
|
673
|
+
#jp-msg-input:focus { border-color: var(--cyan); }
|
|
674
|
+
#jp-msg-send {
|
|
675
|
+
font-family: var(--font); font-size: 10px;
|
|
676
|
+
padding: 5px 14px; border-radius: 4px; cursor: pointer;
|
|
677
|
+
border: 1px solid rgba(86,182,194,0.4);
|
|
678
|
+
background: transparent; color: var(--cyan); font-weight: 600;
|
|
679
|
+
transition: all 0.15s; white-space: nowrap;
|
|
680
|
+
}
|
|
681
|
+
#jp-msg-send:hover { background: rgba(86,182,194,0.12); border-color: var(--cyan); }
|
|
682
|
+
#jp-msg-send:disabled { opacity: 0.35; cursor: not-allowed; }
|
|
683
|
+
|
|
684
|
+
/* ── Working indicator on empty running terminal ── */
|
|
685
|
+
.term-working {
|
|
686
|
+
padding: 12px 0 4px;
|
|
687
|
+
display: flex; align-items: center; gap: 8px;
|
|
688
|
+
color: var(--dim); font-size: 10px;
|
|
689
|
+
}
|
|
690
|
+
.term-spinner { animation: spin 1.2s linear infinite; display: inline-block; }
|
|
691
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
692
|
+
|
|
557
693
|
/* ── Empty state ── */
|
|
558
694
|
#empty-state {
|
|
559
695
|
position: absolute;
|
|
@@ -567,10 +703,41 @@ body {
|
|
|
567
703
|
#empty-state .e-icon { font-size: 48px; color: var(--dimmer); margin-bottom: 4px; }
|
|
568
704
|
#empty-state .e-title { font-size: 16px; font-weight:700; color: var(--dim); }
|
|
569
705
|
#empty-state .e-sub { font-size: 11px; color: var(--dimmer); }
|
|
706
|
+
/* ── Loading overlay ──────────────────────────────────────────────────── */
|
|
707
|
+
#loading-overlay {
|
|
708
|
+
position: fixed;
|
|
709
|
+
inset: 0;
|
|
710
|
+
background: var(--bg);
|
|
711
|
+
display: flex;
|
|
712
|
+
flex-direction: column;
|
|
713
|
+
align-items: center;
|
|
714
|
+
justify-content: center;
|
|
715
|
+
gap: 16px;
|
|
716
|
+
z-index: 9999;
|
|
717
|
+
}
|
|
718
|
+
#loading-spinner {
|
|
719
|
+
font-size: 24px;
|
|
720
|
+
color: var(--cyan);
|
|
721
|
+
text-shadow: 0 0 12px var(--cyan);
|
|
722
|
+
font-family: var(--font);
|
|
723
|
+
line-height: 1;
|
|
724
|
+
}
|
|
725
|
+
#loading-text {
|
|
726
|
+
font-size: 12px;
|
|
727
|
+
color: var(--dim);
|
|
728
|
+
letter-spacing: 0.08em;
|
|
729
|
+
font-family: var(--font);
|
|
730
|
+
}
|
|
570
731
|
</style>
|
|
571
732
|
</head>
|
|
572
733
|
<body>
|
|
573
734
|
|
|
735
|
+
<!-- Loading overlay -->
|
|
736
|
+
<div id="loading-overlay">
|
|
737
|
+
<div id="loading-spinner">⣾</div>
|
|
738
|
+
<div id="loading-text">Loading agents…</div>
|
|
739
|
+
</div>
|
|
740
|
+
|
|
574
741
|
<!-- Top bar -->
|
|
575
742
|
<div id="topbar">
|
|
576
743
|
<div id="logo">
|
|
@@ -618,6 +785,22 @@ body {
|
|
|
618
785
|
|
|
619
786
|
</div>
|
|
620
787
|
|
|
788
|
+
<!-- Job Detail Panel -->
|
|
789
|
+
<div id="jobpanel">
|
|
790
|
+
<div id="jp-topbar">
|
|
791
|
+
<span id="jp-title">—</span>
|
|
792
|
+
<button id="jp-close" onclick="jpClose()">✕</button>
|
|
793
|
+
</div>
|
|
794
|
+
<div id="jp-meta"></div>
|
|
795
|
+
<div id="jp-task"></div>
|
|
796
|
+
<div id="jp-actions"></div>
|
|
797
|
+
<div id="jp-log"></div>
|
|
798
|
+
<div id="jp-msg-bar" class="hidden">
|
|
799
|
+
<input id="jp-msg-input" type="text" placeholder="Send message to Claude…" autocomplete="off">
|
|
800
|
+
<button id="jp-msg-send" onclick="jpSendMessage()">↵ Send</button>
|
|
801
|
+
</div>
|
|
802
|
+
</div>
|
|
803
|
+
|
|
621
804
|
<!-- File Browser Panel -->
|
|
622
805
|
<div id="filebrowser">
|
|
623
806
|
<div id="fb-topbar">
|
|
@@ -713,29 +896,51 @@ viewport.addEventListener('touchmove', e => {
|
|
|
713
896
|
}, { passive: false });
|
|
714
897
|
|
|
715
898
|
// ── Log line colorizer ─────────────────────────────────────────────────────
|
|
899
|
+
const TOOL_ICONS = {
|
|
900
|
+
Read:'📖', Edit:'✏️', Write:'💾', Bash:'$', Glob:'🔍', Grep:'🔎',
|
|
901
|
+
WebFetch:'🌐', WebSearch:'🌐', TodoWrite:'✅', Agent:'🤖',
|
|
902
|
+
MultiEdit:'✏️', NotebookEdit:'📓', Dispatch:'⚡',
|
|
903
|
+
};
|
|
904
|
+
|
|
716
905
|
function classifyLine(raw) {
|
|
717
906
|
const s = raw.replace(/\x1b\[[0-9;]*m/g, '').trim();
|
|
718
907
|
if (!s) return null;
|
|
908
|
+
if (s.startsWith('[tool] ')) return 'tl tl-tool';
|
|
909
|
+
if (s.startsWith('[you] ')) return 'tl tl-head';
|
|
719
910
|
if (s.startsWith('[cc-agent]')) return 'tl tl-sys';
|
|
720
911
|
if (/✓|✅|PASS|passed|merged|pushed|success/i.test(s)) return 'tl tl-ok';
|
|
721
912
|
if (/✗|❌|error|Error|FAIL|failed|exception/i.test(s)) return 'tl tl-err';
|
|
722
913
|
if (/⚠|warn|Warning/i.test(s)) return 'tl tl-warn';
|
|
723
|
-
if (/^(Read|Edit|Write|Bash|Glob|Grep|WebFetch|TodoWrite|Agent)\b/.test(s)) return 'tl tl-tool';
|
|
914
|
+
if (/^(Read|Edit|Write|Bash|Glob|Grep|WebFetch|WebSearch|TodoWrite|Agent)\b/.test(s)) return 'tl tl-tool';
|
|
724
915
|
if (/\.(ts|js|tsx|jsx|py|go|rs|md|json|yaml|sh)\b/.test(s)) return 'tl tl-file';
|
|
725
916
|
if (/^#{1,3}\s|^\*\*/.test(s)) return 'tl tl-head';
|
|
726
917
|
if (/^[\-–—>·]/.test(s)) return 'tl tl-dim';
|
|
727
918
|
return 'tl';
|
|
728
919
|
}
|
|
729
920
|
|
|
921
|
+
function formatLine(raw) {
|
|
922
|
+
const s = raw.replace(/\x1b\[[0-9;]*m/g, '');
|
|
923
|
+
// Enrich [tool] lines with icon
|
|
924
|
+
const toolMatch = s.match(/^\[tool\] (\w+)(.*)/);
|
|
925
|
+
if (toolMatch) {
|
|
926
|
+
const icon = TOOL_ICONS[toolMatch[1]] || '⚙';
|
|
927
|
+
return `${icon} ${toolMatch[1]}${toolMatch[2]}`;
|
|
928
|
+
}
|
|
929
|
+
return s;
|
|
930
|
+
}
|
|
931
|
+
|
|
730
932
|
function appendLines(logEl, lines, isNew) {
|
|
933
|
+
// Remove working indicator if present
|
|
934
|
+
const working = logEl.querySelector('.term-working');
|
|
935
|
+
if (working && lines.some(l => l.trim())) working.remove();
|
|
936
|
+
|
|
731
937
|
const frag = document.createDocumentFragment();
|
|
732
938
|
for (const raw of lines) {
|
|
733
939
|
const cls = classifyLine(raw);
|
|
734
940
|
if (!cls) continue;
|
|
735
941
|
const d = document.createElement('div');
|
|
736
942
|
d.className = isNew ? cls + ' tl-new' : cls;
|
|
737
|
-
const clean = raw
|
|
738
|
-
// Linkify file paths
|
|
943
|
+
const clean = formatLine(raw);
|
|
739
944
|
d.innerHTML = linkifyPaths(clean);
|
|
740
945
|
if (isNew) setTimeout(() => d.classList.remove('tl-new'), 1500);
|
|
741
946
|
frag.appendChild(d);
|
|
@@ -745,6 +950,18 @@ function appendLines(logEl, lines, isNew) {
|
|
|
745
950
|
logEl.scrollTop = logEl.scrollHeight;
|
|
746
951
|
}
|
|
747
952
|
|
|
953
|
+
function setWorkingIndicator(logEl, show) {
|
|
954
|
+
const existing = logEl.querySelector('.term-working');
|
|
955
|
+
if (show && !existing && logEl.children.length === 0) {
|
|
956
|
+
const d = document.createElement('div');
|
|
957
|
+
d.className = 'term-working';
|
|
958
|
+
d.innerHTML = '<span class="term-spinner">⟳</span> Claude is working…';
|
|
959
|
+
logEl.appendChild(d);
|
|
960
|
+
} else if (!show && existing) {
|
|
961
|
+
existing.remove();
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
748
965
|
// ── Card factory ───────────────────────────────────────────────────────────
|
|
749
966
|
function shortRepo(url) {
|
|
750
967
|
if (!url) return 'local';
|
|
@@ -795,6 +1012,7 @@ function makeCard(job, n) {
|
|
|
795
1012
|
<span class="card-repo-icon">⎇</span>
|
|
796
1013
|
<span>${escHtml(repoStr)}</span>
|
|
797
1014
|
${job.branch ? `<span class="card-branch">${escHtml(job.branch)}</span>` : ''}
|
|
1015
|
+
${job.dockerIsolation ? `<span class="card-docker-badge" title="Running in Docker container">🐳 isolated</span>` : ''}
|
|
798
1016
|
</div>
|
|
799
1017
|
<div class="card-term"></div>
|
|
800
1018
|
<div class="card-foot">
|
|
@@ -819,6 +1037,7 @@ function makeSidebarItem(job) {
|
|
|
819
1037
|
const item = document.createElement('div');
|
|
820
1038
|
item.className = `job-item`;
|
|
821
1039
|
item.id = `si-${job.id}`;
|
|
1040
|
+
item.dataset.startedAt = job.startedAt || '';
|
|
822
1041
|
const status = job.status || 'unknown';
|
|
823
1042
|
|
|
824
1043
|
item.innerHTML = `
|
|
@@ -982,6 +1201,7 @@ function handleSnapshot(data) {
|
|
|
982
1201
|
updateCounts();
|
|
983
1202
|
applyFilter();
|
|
984
1203
|
if (Object.keys(jobs).length > 0) emptyState.style.display = 'none';
|
|
1204
|
+
if (typeof hideLoader === 'function') hideLoader();
|
|
985
1205
|
}
|
|
986
1206
|
|
|
987
1207
|
function addJob(job, lines) {
|
|
@@ -992,10 +1212,17 @@ function addJob(job, lines) {
|
|
|
992
1212
|
getOrCreateCol(key);
|
|
993
1213
|
insertCardInColumn(key, card, job);
|
|
994
1214
|
const sidebarItem = makeSidebarItem(job);
|
|
995
|
-
|
|
1215
|
+
const jobTime = new Date(job.startedAt || 0).getTime();
|
|
1216
|
+
const after = Array.from(jobList.children).find(el =>
|
|
1217
|
+
new Date(el.dataset.startedAt || 0).getTime() < jobTime
|
|
1218
|
+
);
|
|
1219
|
+
if (after) jobList.insertBefore(sidebarItem, after);
|
|
1220
|
+
else jobList.appendChild(sidebarItem);
|
|
996
1221
|
jobs[job.id] = { job: { ...job }, card, logEl, n: jobCounter };
|
|
997
1222
|
if (lines.length) appendLines(logEl, lines, false);
|
|
1223
|
+
else if (job.status === 'running' || job.status === 'cloning') setWorkingIndicator(logEl, true);
|
|
998
1224
|
updateColHeader(key);
|
|
1225
|
+
wireCardClick(card, job.id);
|
|
999
1226
|
}
|
|
1000
1227
|
|
|
1001
1228
|
// ── Handle job update ──────────────────────────────────────────────────────
|
|
@@ -1016,6 +1243,10 @@ function handleJobUpdate(data) {
|
|
|
1016
1243
|
entry.job = { ...entry.job, ...job };
|
|
1017
1244
|
const status = job.status;
|
|
1018
1245
|
|
|
1246
|
+
// Working indicator
|
|
1247
|
+
if (status === 'running' || status === 'cloning') setWorkingIndicator(entry.logEl, true);
|
|
1248
|
+
else setWorkingIndicator(entry.logEl, false);
|
|
1249
|
+
|
|
1019
1250
|
// Update card class
|
|
1020
1251
|
entry.card.className = `tcard ${status}`;
|
|
1021
1252
|
|
|
@@ -1041,6 +1272,16 @@ function handleJobUpdate(data) {
|
|
|
1041
1272
|
const dot = $(`si-${job.id}`)?.querySelector('.ji-status');
|
|
1042
1273
|
if (dot) { dot.className = `ji-status ${status}`; }
|
|
1043
1274
|
|
|
1275
|
+
// Refresh job panel if this job is open
|
|
1276
|
+
if (jpCurrentId === job.id) {
|
|
1277
|
+
jpMeta.querySelector('.jp-badge').className = `jp-badge badge-${status}`;
|
|
1278
|
+
jpMeta.querySelector('.jp-badge').textContent = status.replace('_', ' ');
|
|
1279
|
+
renderActions(status);
|
|
1280
|
+
const msgBar = $('jp-msg-bar');
|
|
1281
|
+
if (status === 'running') msgBar.classList.remove('hidden');
|
|
1282
|
+
else msgBar.classList.add('hidden');
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1044
1285
|
updateColHeader(repoKey(job));
|
|
1045
1286
|
updateCounts();
|
|
1046
1287
|
applyFilter();
|
|
@@ -1060,6 +1301,10 @@ function handleOutput(data) {
|
|
|
1060
1301
|
const entry = jobs[data.id];
|
|
1061
1302
|
if (!entry) return;
|
|
1062
1303
|
appendLines(entry.logEl, data.lines, true);
|
|
1304
|
+
// Mirror to job panel if open
|
|
1305
|
+
if (jpCurrentId === data.id) {
|
|
1306
|
+
appendLines(jpLog, data.lines, true);
|
|
1307
|
+
}
|
|
1063
1308
|
}
|
|
1064
1309
|
|
|
1065
1310
|
// ── WebSocket ──────────────────────────────────────────────────────────────
|
|
@@ -1083,6 +1328,164 @@ function connect() {
|
|
|
1083
1328
|
};
|
|
1084
1329
|
}
|
|
1085
1330
|
|
|
1331
|
+
// ── Job Detail Panel ───────────────────────────────────────────────────────
|
|
1332
|
+
const jp = $('jobpanel');
|
|
1333
|
+
const jpLog = $('jp-log');
|
|
1334
|
+
const jpTitle = $('jp-title');
|
|
1335
|
+
const jpMeta = $('jp-meta');
|
|
1336
|
+
const jpTask = $('jp-task');
|
|
1337
|
+
const jpActions = $('jp-actions');
|
|
1338
|
+
let jpCurrentId = null;
|
|
1339
|
+
|
|
1340
|
+
function jpClose() {
|
|
1341
|
+
jp.classList.remove('open');
|
|
1342
|
+
jpCurrentId = null;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
async function jpSendMessage() {
|
|
1346
|
+
const input = $('jp-msg-input');
|
|
1347
|
+
const btn = $('jp-msg-send');
|
|
1348
|
+
const text = input.value.trim();
|
|
1349
|
+
if (!text || !jpCurrentId) return;
|
|
1350
|
+
input.value = '';
|
|
1351
|
+
btn.disabled = true;
|
|
1352
|
+
try {
|
|
1353
|
+
const r = await fetch('/api/job/action', {
|
|
1354
|
+
method: 'POST',
|
|
1355
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1356
|
+
body: JSON.stringify({ id: jpCurrentId, action: 'message', message: text }),
|
|
1357
|
+
});
|
|
1358
|
+
const data = await r.json();
|
|
1359
|
+
const d = document.createElement('div');
|
|
1360
|
+
d.className = 'tl tl-head';
|
|
1361
|
+
d.textContent = `[you] ${text}`;
|
|
1362
|
+
jpLog.appendChild(d);
|
|
1363
|
+
jpLog.scrollTop = jpLog.scrollHeight;
|
|
1364
|
+
// Also echo to card terminal
|
|
1365
|
+
const entry = jobs[jpCurrentId];
|
|
1366
|
+
if (entry?.logEl) appendLines(entry.logEl, [`[you] ${text}`], true);
|
|
1367
|
+
} catch (e) {
|
|
1368
|
+
const d = document.createElement('div');
|
|
1369
|
+
d.className = 'tl tl-err';
|
|
1370
|
+
d.textContent = `[ui] Error: ${e.message}`;
|
|
1371
|
+
jpLog.appendChild(d);
|
|
1372
|
+
} finally {
|
|
1373
|
+
btn.disabled = false;
|
|
1374
|
+
input.focus();
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// Enter key submits message
|
|
1379
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
1380
|
+
$('jp-msg-input').addEventListener('keydown', e => {
|
|
1381
|
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); jpSendMessage(); }
|
|
1382
|
+
});
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
async function jpOpen(id) {
|
|
1386
|
+
const entry = jobs[id];
|
|
1387
|
+
if (!entry) return;
|
|
1388
|
+
jpCurrentId = id;
|
|
1389
|
+
jp.classList.add('open');
|
|
1390
|
+
|
|
1391
|
+
const job = entry.job;
|
|
1392
|
+
const status = job.status || 'unknown';
|
|
1393
|
+
|
|
1394
|
+
jpTitle.textContent = shortTask(job.task);
|
|
1395
|
+
jpTask.innerHTML = `<strong>Task</strong>${escHtml(job.task || '(no task)')}`;
|
|
1396
|
+
|
|
1397
|
+
// Meta
|
|
1398
|
+
jpMeta.innerHTML = `
|
|
1399
|
+
<span class="jp-badge badge-${status}">${status.replace('_',' ')}</span>
|
|
1400
|
+
<span class="jp-meta-row">repo <span>${escHtml(shortRepo(job.repoUrl) || 'local')}</span>
|
|
1401
|
+
${job.branch ? ` · branch <span>${escHtml(job.branch)}</span>` : ''}
|
|
1402
|
+
${job.dockerIsolation ? ` · <span style="color:#38bdf8">🐳 docker isolated</span>` : ''}
|
|
1403
|
+
</span>
|
|
1404
|
+
<span class="jp-meta-row">id <span>${escHtml(job.id || id)}</span></span>
|
|
1405
|
+
<span class="jp-meta-row">started <span>${job.startedAt ? new Date(job.startedAt).toLocaleString() : '—'}</span></span>
|
|
1406
|
+
`;
|
|
1407
|
+
|
|
1408
|
+
// Actions
|
|
1409
|
+
renderActions(status);
|
|
1410
|
+
|
|
1411
|
+
// Show/hide message bar
|
|
1412
|
+
const msgBar = $('jp-msg-bar');
|
|
1413
|
+
if (status === 'running') {
|
|
1414
|
+
msgBar.classList.remove('hidden');
|
|
1415
|
+
setTimeout(() => $('jp-msg-input').focus(), 50);
|
|
1416
|
+
} else {
|
|
1417
|
+
msgBar.classList.add('hidden');
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// Load full log
|
|
1421
|
+
jpLog.innerHTML = '<div style="color:var(--dim);padding:4px">loading…</div>';
|
|
1422
|
+
try {
|
|
1423
|
+
const r = await fetch(`/api/job/output?id=${encodeURIComponent(id)}`);
|
|
1424
|
+
const data = await r.json();
|
|
1425
|
+
jpLog.innerHTML = '';
|
|
1426
|
+
appendLines(jpLog, data.lines || [], false);
|
|
1427
|
+
jpLog.scrollTop = jpLog.scrollHeight;
|
|
1428
|
+
} catch (e) {
|
|
1429
|
+
jpLog.innerHTML = `<div style="color:var(--red)">${escHtml(e.message)}</div>`;
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
function renderActions(status) {
|
|
1434
|
+
jpActions.innerHTML = '';
|
|
1435
|
+
const add = (label, cls, action, extra = {}) => {
|
|
1436
|
+
const b = document.createElement('button');
|
|
1437
|
+
b.className = `jp-btn ${cls}`;
|
|
1438
|
+
b.textContent = label;
|
|
1439
|
+
if (extra.disabled) b.disabled = true;
|
|
1440
|
+
b.addEventListener('click', () => jobAction(jpCurrentId, action));
|
|
1441
|
+
jpActions.appendChild(b);
|
|
1442
|
+
};
|
|
1443
|
+
|
|
1444
|
+
if (status === 'pending_approval') add('✓ Approve', 'approve', 'approve');
|
|
1445
|
+
if (['running','cloning','pending_approval'].includes(status)) add('✕ Cancel', 'cancel', 'cancel');
|
|
1446
|
+
if (status === 'failed' || status === 'cancelled') add('⟳ Wake', 'wake', 'wake');
|
|
1447
|
+
if (jpActions.children.length === 0) {
|
|
1448
|
+
jpActions.innerHTML = `<span style="font-size:10px;color:var(--dim)">no actions available for ${status} jobs</span>`;
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
async function jobAction(id, action) {
|
|
1453
|
+
if (!id) return;
|
|
1454
|
+
const btns = jpActions.querySelectorAll('.jp-btn');
|
|
1455
|
+
btns.forEach(b => b.disabled = true);
|
|
1456
|
+
try {
|
|
1457
|
+
const r = await fetch('/api/job/action', {
|
|
1458
|
+
method: 'POST',
|
|
1459
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1460
|
+
body: JSON.stringify({ id, action }),
|
|
1461
|
+
});
|
|
1462
|
+
const data = await r.json();
|
|
1463
|
+
if (data.ok) {
|
|
1464
|
+
// Append confirmation to panel log
|
|
1465
|
+
const d = document.createElement('div');
|
|
1466
|
+
d.className = 'tl tl-ok';
|
|
1467
|
+
d.textContent = `[ui] Action "${action}" sent`;
|
|
1468
|
+
jpLog.appendChild(d);
|
|
1469
|
+
jpLog.scrollTop = jpLog.scrollHeight;
|
|
1470
|
+
}
|
|
1471
|
+
} catch (e) {
|
|
1472
|
+
const d = document.createElement('div');
|
|
1473
|
+
d.className = 'tl tl-err';
|
|
1474
|
+
d.textContent = `[ui] Error: ${e.message}`;
|
|
1475
|
+
jpLog.appendChild(d);
|
|
1476
|
+
} finally {
|
|
1477
|
+
btns.forEach(b => b.disabled = false);
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// Wire card clicks to open job panel
|
|
1482
|
+
function wireCardClick(card, id) {
|
|
1483
|
+
card.querySelector('.card-hdr').addEventListener('click', (e) => {
|
|
1484
|
+
if (e.target.closest('.fp-link')) return; // don't interfere with file links
|
|
1485
|
+
jpOpen(id);
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1086
1489
|
// ── File Browser ───────────────────────────────────────────────────────────
|
|
1087
1490
|
const fb = $('filebrowser');
|
|
1088
1491
|
const fbBody = $('fb-body');
|
|
@@ -1220,6 +1623,32 @@ setInterval(() => {
|
|
|
1220
1623
|
}
|
|
1221
1624
|
}, 30000);
|
|
1222
1625
|
|
|
1626
|
+
// ── Loading overlay ────────────────────────────────────────────────────────
|
|
1627
|
+
(function() {
|
|
1628
|
+
const overlay = document.getElementById('loading-overlay');
|
|
1629
|
+
const spinnerEl = document.getElementById('loading-spinner');
|
|
1630
|
+
const textEl = document.getElementById('loading-text');
|
|
1631
|
+
const frames = ['⣾','⣽','⣻','⢿','⡿','⣟','⣯','⣷'];
|
|
1632
|
+
let frame = 0;
|
|
1633
|
+
const spinInterval = setInterval(() => {
|
|
1634
|
+
spinnerEl.textContent = frames[frame++ % frames.length];
|
|
1635
|
+
}, 100);
|
|
1636
|
+
|
|
1637
|
+
let snapshotReceived = false;
|
|
1638
|
+
const fallbackTimer = setTimeout(() => {
|
|
1639
|
+
if (!snapshotReceived) {
|
|
1640
|
+
textEl.textContent = 'Reconnecting…';
|
|
1641
|
+
}
|
|
1642
|
+
}, 8000);
|
|
1643
|
+
|
|
1644
|
+
window.hideLoader = function() {
|
|
1645
|
+
snapshotReceived = true;
|
|
1646
|
+
clearInterval(spinInterval);
|
|
1647
|
+
clearTimeout(fallbackTimer);
|
|
1648
|
+
overlay.style.display = 'none';
|
|
1649
|
+
};
|
|
1650
|
+
})();
|
|
1651
|
+
|
|
1223
1652
|
connect();
|
|
1224
1653
|
</script>
|
|
1225
1654
|
</body>
|
package/server.js
CHANGED
|
@@ -195,6 +195,63 @@ const server = http.createServer((req, res) => {
|
|
|
195
195
|
res.writeHead(404); res.end(e.message);
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
} else if (url.pathname === '/api/job/output') {
|
|
199
|
+
// Full output for a job
|
|
200
|
+
const id = url.searchParams.get('id');
|
|
201
|
+
if (!id) { res.writeHead(400); res.end('missing id'); return; }
|
|
202
|
+
(async () => {
|
|
203
|
+
try {
|
|
204
|
+
const lines = await getOutputTail(id, 5000);
|
|
205
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
206
|
+
res.end(JSON.stringify({ lines }));
|
|
207
|
+
} catch (e) { res.writeHead(500); res.end(e.message); }
|
|
208
|
+
})();
|
|
209
|
+
|
|
210
|
+
} else if (url.pathname === '/api/job/action' && req.method === 'POST') {
|
|
211
|
+
// Job actions: approve, cancel, wake
|
|
212
|
+
let body = '';
|
|
213
|
+
req.on('data', d => body += d);
|
|
214
|
+
req.on('end', async () => {
|
|
215
|
+
try {
|
|
216
|
+
const { id, action, message } = JSON.parse(body);
|
|
217
|
+
if (!id || !action) { res.writeHead(400); res.end('missing id/action'); return; }
|
|
218
|
+
const jobRaw = await redis.get(`cca:job:${id}`);
|
|
219
|
+
const job = parseJob(jobRaw);
|
|
220
|
+
if (!job) { res.writeHead(404); res.end('job not found'); return; }
|
|
221
|
+
|
|
222
|
+
if (action === 'approve') {
|
|
223
|
+
// Mark approved in Redis — cc-agent MCP must be called separately to actually start it
|
|
224
|
+
// (cc-agent's approval is in-memory; this sets a flag for reference and for GitHub issue polling)
|
|
225
|
+
const updated = { ...job, approvedAt: new Date().toISOString(), approved: true };
|
|
226
|
+
await redis.set(`cca:job:${id}`, JSON.stringify(updated));
|
|
227
|
+
await redis.rPush(`cca:job:${id}:output`, '[cc-agent-ui] Approved by UI — use MCP approve_job to start');
|
|
228
|
+
broadcast({ type: 'job_output', id, lines: ['[cc-agent-ui] Approved by UI — use MCP approve_job to start'] });
|
|
229
|
+
} else if (action === 'cancel') {
|
|
230
|
+
const updated = { ...job, status: 'cancelled', cancelledAt: new Date().toISOString() };
|
|
231
|
+
await redis.set(`cca:job:${id}`, JSON.stringify(updated));
|
|
232
|
+
broadcast({ type: 'job_update', job: updated });
|
|
233
|
+
} else if (action === 'wake') {
|
|
234
|
+
const updated = { ...job, status: 'running', wakedAt: new Date().toISOString() };
|
|
235
|
+
await redis.set(`cca:job:${id}`, JSON.stringify(updated));
|
|
236
|
+
broadcast({ type: 'job_update', job: updated });
|
|
237
|
+
} else if (action === 'message') {
|
|
238
|
+
if (message) {
|
|
239
|
+
// Queue for cc-agent to pick up (future: cc-agent polls cca:job:{id}:input)
|
|
240
|
+
await redis.rPush(`cca:job:${id}:input`, message);
|
|
241
|
+
// Echo to output so it's visible in terminal immediately
|
|
242
|
+
const line = `[you] ${message}`;
|
|
243
|
+
await redis.rPush(`cca:job:${id}:output`, line);
|
|
244
|
+
broadcast({ type: 'job_output', id, lines: [line] });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
249
|
+
res.end(JSON.stringify({ ok: true, action, id }));
|
|
250
|
+
} catch (e) {
|
|
251
|
+
res.writeHead(500); res.end(e.message);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
198
255
|
} else {
|
|
199
256
|
res.writeHead(404); res.end();
|
|
200
257
|
}
|
|
@@ -214,6 +271,25 @@ wss.on('connection', async ws => {
|
|
|
214
271
|
ws.on('close', () => { clients.delete(ws); });
|
|
215
272
|
});
|
|
216
273
|
|
|
274
|
+
// ── Tool call synthesis from recentTools diff ──────────────────────────────
|
|
275
|
+
const toolTrack = {}; // id → last recentTools array
|
|
276
|
+
|
|
277
|
+
function diffTools(prevArr, currArr) {
|
|
278
|
+
if (!currArr?.length) return [];
|
|
279
|
+
if (!prevArr?.length) return currArr.slice(-3); // first snapshot: emit up to 3
|
|
280
|
+
if (JSON.stringify(prevArr) === JSON.stringify(currArr)) return [];
|
|
281
|
+
// Find how many NEW items appeared at the tail of currArr relative to prevArr.
|
|
282
|
+
// Strategy: find the longest suffix of prevArr that matches a prefix of the new tail.
|
|
283
|
+
for (let overlap = Math.min(prevArr.length, currArr.length); overlap >= 0; overlap--) {
|
|
284
|
+
const prevSuffix = prevArr.slice(prevArr.length - overlap);
|
|
285
|
+
const currPrefix = currArr.slice(0, overlap);
|
|
286
|
+
if (JSON.stringify(prevSuffix) === JSON.stringify(currPrefix)) {
|
|
287
|
+
return currArr.slice(overlap); // these are genuinely new
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return currArr.slice(-Math.min(3, currArr.length)); // fallback: last 3
|
|
291
|
+
}
|
|
292
|
+
|
|
217
293
|
// ── Polling: job status changes ────────────────────────────────────────────
|
|
218
294
|
setInterval(async () => {
|
|
219
295
|
try {
|
|
@@ -227,6 +303,7 @@ setInterval(async () => {
|
|
|
227
303
|
if (!prev) {
|
|
228
304
|
// New job
|
|
229
305
|
jobCache[job.id] = job;
|
|
306
|
+
toolTrack[job.id] = job.recentTools || [];
|
|
230
307
|
const lines = await getOutputTail(job.id, 50);
|
|
231
308
|
broadcast({ type: 'job_new', job: { ...job, lines } });
|
|
232
309
|
} else if (prev.status !== job.status) {
|
|
@@ -235,6 +312,22 @@ setInterval(async () => {
|
|
|
235
312
|
} else {
|
|
236
313
|
jobCache[job.id] = { ...prev, ...job };
|
|
237
314
|
}
|
|
315
|
+
// Detect new tool calls via recentTools diff
|
|
316
|
+
const activeStatuses = new Set(['running', 'cloning']);
|
|
317
|
+
if (activeStatuses.has(job.status)) {
|
|
318
|
+
const prevTools = toolTrack[job.id] || [];
|
|
319
|
+
const currTools = job.recentTools || [];
|
|
320
|
+
const newTools = diffTools(prevTools, currTools);
|
|
321
|
+
toolTrack[job.id] = currTools;
|
|
322
|
+
if (newTools.length) {
|
|
323
|
+
const lines = newTools.map(t => `[tool] ${t}`);
|
|
324
|
+
broadcast({ type: 'job_output', id: job.id, lines });
|
|
325
|
+
// Also write to Redis output so it persists
|
|
326
|
+
const pipeline = redis.multi();
|
|
327
|
+
for (const l of lines) pipeline.rPush(`cca:job:${job.id}:output`, l);
|
|
328
|
+
pipeline.exec().catch(() => {});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
238
331
|
}
|
|
239
332
|
}
|
|
240
333
|
} catch (e) {
|
|
@@ -242,11 +335,21 @@ setInterval(async () => {
|
|
|
242
335
|
}
|
|
243
336
|
}, 2500);
|
|
244
337
|
|
|
245
|
-
// ── Polling: output for active jobs
|
|
338
|
+
// ── Polling: output for active + recently-finished jobs ───────────────────
|
|
339
|
+
const recentlyFinished = new Map(); // id → finishedTimestamp
|
|
246
340
|
setInterval(async () => {
|
|
341
|
+
const now = Date.now();
|
|
247
342
|
const activeStatuses = new Set(['running', 'cloning', 'pending_approval']);
|
|
248
|
-
|
|
249
|
-
|
|
343
|
+
// Include recently finished jobs for 15s to catch tail output
|
|
344
|
+
const toPoll = Object.values(jobCache).filter(j =>
|
|
345
|
+
activeStatuses.has(j.status) ||
|
|
346
|
+
(recentlyFinished.has(j.id) && now - recentlyFinished.get(j.id) < 15000)
|
|
347
|
+
);
|
|
348
|
+
for (const job of toPoll) {
|
|
349
|
+
// Track when jobs finish
|
|
350
|
+
if (!activeStatuses.has(job.status) && !recentlyFinished.has(job.id)) {
|
|
351
|
+
recentlyFinished.set(job.id, now);
|
|
352
|
+
}
|
|
250
353
|
try {
|
|
251
354
|
const lines = await pollNewOutput(job.id);
|
|
252
355
|
if (lines.length > 0) {
|
|
@@ -254,6 +357,10 @@ setInterval(async () => {
|
|
|
254
357
|
}
|
|
255
358
|
} catch {}
|
|
256
359
|
}
|
|
360
|
+
// Clean up old entries
|
|
361
|
+
for (const [id, ts] of recentlyFinished) {
|
|
362
|
+
if (now - ts > 30000) recentlyFinished.delete(id);
|
|
363
|
+
}
|
|
257
364
|
}, 900);
|
|
258
365
|
|
|
259
366
|
// ── Start ──────────────────────────────────────────────────────────────────
|