cc-agent-ui 0.2.3 → 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 +222 -20
- package/server.js +65 -8
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 ── */
|
|
@@ -390,6 +387,7 @@ body {
|
|
|
390
387
|
}
|
|
391
388
|
.card-repo-icon { color: var(--dimmer); }
|
|
392
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; }
|
|
393
391
|
|
|
394
392
|
/* ── Terminal body ── */
|
|
395
393
|
.card-term {
|
|
@@ -409,11 +407,11 @@ body {
|
|
|
409
407
|
|
|
410
408
|
/* Output line coloring */
|
|
411
409
|
.tl { white-space: pre-wrap; word-break: break-all; }
|
|
412
|
-
.tl-sys { color:
|
|
410
|
+
.tl-sys { color: #7a8099; } /* [cc-agent] meta lines */
|
|
413
411
|
.tl-ok { color: var(--green); } /* success */
|
|
414
412
|
.tl-err { color: var(--red); } /* errors */
|
|
415
413
|
.tl-warn { color: var(--yellow); } /* warnings */
|
|
416
|
-
.tl-tool { color: var(--blue); }
|
|
414
|
+
.tl-tool { color: var(--blue); opacity: 0.8; font-size: 10px; } /* tool calls */
|
|
417
415
|
.tl-file { color: var(--orange); } /* file operations */
|
|
418
416
|
.tl-head { color: var(--cyan); font-weight:600; } /* bold headings */
|
|
419
417
|
.tl-dim { color: var(--dim); } /* faded */
|
|
@@ -649,6 +647,49 @@ body {
|
|
|
649
647
|
text-decoration-color: var(--orange);
|
|
650
648
|
}
|
|
651
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
|
+
|
|
652
693
|
/* ── Empty state ── */
|
|
653
694
|
#empty-state {
|
|
654
695
|
position: absolute;
|
|
@@ -662,10 +703,41 @@ body {
|
|
|
662
703
|
#empty-state .e-icon { font-size: 48px; color: var(--dimmer); margin-bottom: 4px; }
|
|
663
704
|
#empty-state .e-title { font-size: 16px; font-weight:700; color: var(--dim); }
|
|
664
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
|
+
}
|
|
665
731
|
</style>
|
|
666
732
|
</head>
|
|
667
733
|
<body>
|
|
668
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
|
+
|
|
669
741
|
<!-- Top bar -->
|
|
670
742
|
<div id="topbar">
|
|
671
743
|
<div id="logo">
|
|
@@ -723,6 +795,10 @@ body {
|
|
|
723
795
|
<div id="jp-task"></div>
|
|
724
796
|
<div id="jp-actions"></div>
|
|
725
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>
|
|
726
802
|
</div>
|
|
727
803
|
|
|
728
804
|
<!-- File Browser Panel -->
|
|
@@ -820,29 +896,51 @@ viewport.addEventListener('touchmove', e => {
|
|
|
820
896
|
}, { passive: false });
|
|
821
897
|
|
|
822
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
|
+
|
|
823
905
|
function classifyLine(raw) {
|
|
824
906
|
const s = raw.replace(/\x1b\[[0-9;]*m/g, '').trim();
|
|
825
907
|
if (!s) return null;
|
|
908
|
+
if (s.startsWith('[tool] ')) return 'tl tl-tool';
|
|
909
|
+
if (s.startsWith('[you] ')) return 'tl tl-head';
|
|
826
910
|
if (s.startsWith('[cc-agent]')) return 'tl tl-sys';
|
|
827
911
|
if (/✓|✅|PASS|passed|merged|pushed|success/i.test(s)) return 'tl tl-ok';
|
|
828
912
|
if (/✗|❌|error|Error|FAIL|failed|exception/i.test(s)) return 'tl tl-err';
|
|
829
913
|
if (/⚠|warn|Warning/i.test(s)) return 'tl tl-warn';
|
|
830
|
-
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';
|
|
831
915
|
if (/\.(ts|js|tsx|jsx|py|go|rs|md|json|yaml|sh)\b/.test(s)) return 'tl tl-file';
|
|
832
916
|
if (/^#{1,3}\s|^\*\*/.test(s)) return 'tl tl-head';
|
|
833
917
|
if (/^[\-–—>·]/.test(s)) return 'tl tl-dim';
|
|
834
918
|
return 'tl';
|
|
835
919
|
}
|
|
836
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
|
+
|
|
837
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
|
+
|
|
838
937
|
const frag = document.createDocumentFragment();
|
|
839
938
|
for (const raw of lines) {
|
|
840
939
|
const cls = classifyLine(raw);
|
|
841
940
|
if (!cls) continue;
|
|
842
941
|
const d = document.createElement('div');
|
|
843
942
|
d.className = isNew ? cls + ' tl-new' : cls;
|
|
844
|
-
const clean = raw
|
|
845
|
-
// Linkify file paths
|
|
943
|
+
const clean = formatLine(raw);
|
|
846
944
|
d.innerHTML = linkifyPaths(clean);
|
|
847
945
|
if (isNew) setTimeout(() => d.classList.remove('tl-new'), 1500);
|
|
848
946
|
frag.appendChild(d);
|
|
@@ -852,6 +950,18 @@ function appendLines(logEl, lines, isNew) {
|
|
|
852
950
|
logEl.scrollTop = logEl.scrollHeight;
|
|
853
951
|
}
|
|
854
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
|
+
|
|
855
965
|
// ── Card factory ───────────────────────────────────────────────────────────
|
|
856
966
|
function shortRepo(url) {
|
|
857
967
|
if (!url) return 'local';
|
|
@@ -902,6 +1012,7 @@ function makeCard(job, n) {
|
|
|
902
1012
|
<span class="card-repo-icon">⎇</span>
|
|
903
1013
|
<span>${escHtml(repoStr)}</span>
|
|
904
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>` : ''}
|
|
905
1016
|
</div>
|
|
906
1017
|
<div class="card-term"></div>
|
|
907
1018
|
<div class="card-foot">
|
|
@@ -926,6 +1037,7 @@ function makeSidebarItem(job) {
|
|
|
926
1037
|
const item = document.createElement('div');
|
|
927
1038
|
item.className = `job-item`;
|
|
928
1039
|
item.id = `si-${job.id}`;
|
|
1040
|
+
item.dataset.startedAt = job.startedAt || '';
|
|
929
1041
|
const status = job.status || 'unknown';
|
|
930
1042
|
|
|
931
1043
|
item.innerHTML = `
|
|
@@ -1089,6 +1201,7 @@ function handleSnapshot(data) {
|
|
|
1089
1201
|
updateCounts();
|
|
1090
1202
|
applyFilter();
|
|
1091
1203
|
if (Object.keys(jobs).length > 0) emptyState.style.display = 'none';
|
|
1204
|
+
if (typeof hideLoader === 'function') hideLoader();
|
|
1092
1205
|
}
|
|
1093
1206
|
|
|
1094
1207
|
function addJob(job, lines) {
|
|
@@ -1099,9 +1212,15 @@ function addJob(job, lines) {
|
|
|
1099
1212
|
getOrCreateCol(key);
|
|
1100
1213
|
insertCardInColumn(key, card, job);
|
|
1101
1214
|
const sidebarItem = makeSidebarItem(job);
|
|
1102
|
-
|
|
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);
|
|
1103
1221
|
jobs[job.id] = { job: { ...job }, card, logEl, n: jobCounter };
|
|
1104
1222
|
if (lines.length) appendLines(logEl, lines, false);
|
|
1223
|
+
else if (job.status === 'running' || job.status === 'cloning') setWorkingIndicator(logEl, true);
|
|
1105
1224
|
updateColHeader(key);
|
|
1106
1225
|
wireCardClick(card, job.id);
|
|
1107
1226
|
}
|
|
@@ -1124,6 +1243,10 @@ function handleJobUpdate(data) {
|
|
|
1124
1243
|
entry.job = { ...entry.job, ...job };
|
|
1125
1244
|
const status = job.status;
|
|
1126
1245
|
|
|
1246
|
+
// Working indicator
|
|
1247
|
+
if (status === 'running' || status === 'cloning') setWorkingIndicator(entry.logEl, true);
|
|
1248
|
+
else setWorkingIndicator(entry.logEl, false);
|
|
1249
|
+
|
|
1127
1250
|
// Update card class
|
|
1128
1251
|
entry.card.className = `tcard ${status}`;
|
|
1129
1252
|
|
|
@@ -1154,6 +1277,9 @@ function handleJobUpdate(data) {
|
|
|
1154
1277
|
jpMeta.querySelector('.jp-badge').className = `jp-badge badge-${status}`;
|
|
1155
1278
|
jpMeta.querySelector('.jp-badge').textContent = status.replace('_', ' ');
|
|
1156
1279
|
renderActions(status);
|
|
1280
|
+
const msgBar = $('jp-msg-bar');
|
|
1281
|
+
if (status === 'running') msgBar.classList.remove('hidden');
|
|
1282
|
+
else msgBar.classList.add('hidden');
|
|
1157
1283
|
}
|
|
1158
1284
|
|
|
1159
1285
|
updateColHeader(repoKey(job));
|
|
@@ -1216,6 +1342,46 @@ function jpClose() {
|
|
|
1216
1342
|
jpCurrentId = null;
|
|
1217
1343
|
}
|
|
1218
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
|
+
|
|
1219
1385
|
async function jpOpen(id) {
|
|
1220
1386
|
const entry = jobs[id];
|
|
1221
1387
|
if (!entry) return;
|
|
@@ -1233,6 +1399,7 @@ async function jpOpen(id) {
|
|
|
1233
1399
|
<span class="jp-badge badge-${status}">${status.replace('_',' ')}</span>
|
|
1234
1400
|
<span class="jp-meta-row">repo <span>${escHtml(shortRepo(job.repoUrl) || 'local')}</span>
|
|
1235
1401
|
${job.branch ? ` · branch <span>${escHtml(job.branch)}</span>` : ''}
|
|
1402
|
+
${job.dockerIsolation ? ` · <span style="color:#38bdf8">🐳 docker isolated</span>` : ''}
|
|
1236
1403
|
</span>
|
|
1237
1404
|
<span class="jp-meta-row">id <span>${escHtml(job.id || id)}</span></span>
|
|
1238
1405
|
<span class="jp-meta-row">started <span>${job.startedAt ? new Date(job.startedAt).toLocaleString() : '—'}</span></span>
|
|
@@ -1241,6 +1408,15 @@ async function jpOpen(id) {
|
|
|
1241
1408
|
// Actions
|
|
1242
1409
|
renderActions(status);
|
|
1243
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
|
+
|
|
1244
1420
|
// Load full log
|
|
1245
1421
|
jpLog.innerHTML = '<div style="color:var(--dim);padding:4px">loading…</div>';
|
|
1246
1422
|
try {
|
|
@@ -1447,6 +1623,32 @@ setInterval(() => {
|
|
|
1447
1623
|
}
|
|
1448
1624
|
}, 30000);
|
|
1449
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
|
+
|
|
1450
1652
|
connect();
|
|
1451
1653
|
</script>
|
|
1452
1654
|
</body>
|
package/server.js
CHANGED
|
@@ -220,11 +220,12 @@ const server = http.createServer((req, res) => {
|
|
|
220
220
|
if (!job) { res.writeHead(404); res.end('job not found'); return; }
|
|
221
221
|
|
|
222
222
|
if (action === 'approve') {
|
|
223
|
-
//
|
|
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)
|
|
224
225
|
const updated = { ...job, approvedAt: new Date().toISOString(), approved: true };
|
|
225
226
|
await redis.set(`cca:job:${id}`, JSON.stringify(updated));
|
|
226
|
-
|
|
227
|
-
|
|
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'] });
|
|
228
229
|
} else if (action === 'cancel') {
|
|
229
230
|
const updated = { ...job, status: 'cancelled', cancelledAt: new Date().toISOString() };
|
|
230
231
|
await redis.set(`cca:job:${id}`, JSON.stringify(updated));
|
|
@@ -234,8 +235,14 @@ const server = http.createServer((req, res) => {
|
|
|
234
235
|
await redis.set(`cca:job:${id}`, JSON.stringify(updated));
|
|
235
236
|
broadcast({ type: 'job_update', job: updated });
|
|
236
237
|
} else if (action === 'message') {
|
|
237
|
-
|
|
238
|
-
|
|
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
|
+
}
|
|
239
246
|
}
|
|
240
247
|
|
|
241
248
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -264,6 +271,25 @@ wss.on('connection', async ws => {
|
|
|
264
271
|
ws.on('close', () => { clients.delete(ws); });
|
|
265
272
|
});
|
|
266
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
|
+
|
|
267
293
|
// ── Polling: job status changes ────────────────────────────────────────────
|
|
268
294
|
setInterval(async () => {
|
|
269
295
|
try {
|
|
@@ -277,6 +303,7 @@ setInterval(async () => {
|
|
|
277
303
|
if (!prev) {
|
|
278
304
|
// New job
|
|
279
305
|
jobCache[job.id] = job;
|
|
306
|
+
toolTrack[job.id] = job.recentTools || [];
|
|
280
307
|
const lines = await getOutputTail(job.id, 50);
|
|
281
308
|
broadcast({ type: 'job_new', job: { ...job, lines } });
|
|
282
309
|
} else if (prev.status !== job.status) {
|
|
@@ -285,6 +312,22 @@ setInterval(async () => {
|
|
|
285
312
|
} else {
|
|
286
313
|
jobCache[job.id] = { ...prev, ...job };
|
|
287
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
|
+
}
|
|
288
331
|
}
|
|
289
332
|
}
|
|
290
333
|
} catch (e) {
|
|
@@ -292,11 +335,21 @@ setInterval(async () => {
|
|
|
292
335
|
}
|
|
293
336
|
}, 2500);
|
|
294
337
|
|
|
295
|
-
// ── Polling: output for active jobs
|
|
338
|
+
// ── Polling: output for active + recently-finished jobs ───────────────────
|
|
339
|
+
const recentlyFinished = new Map(); // id → finishedTimestamp
|
|
296
340
|
setInterval(async () => {
|
|
341
|
+
const now = Date.now();
|
|
297
342
|
const activeStatuses = new Set(['running', 'cloning', 'pending_approval']);
|
|
298
|
-
|
|
299
|
-
|
|
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
|
+
}
|
|
300
353
|
try {
|
|
301
354
|
const lines = await pollNewOutput(job.id);
|
|
302
355
|
if (lines.length > 0) {
|
|
@@ -304,6 +357,10 @@ setInterval(async () => {
|
|
|
304
357
|
}
|
|
305
358
|
} catch {}
|
|
306
359
|
}
|
|
360
|
+
// Clean up old entries
|
|
361
|
+
for (const [id, ts] of recentlyFinished) {
|
|
362
|
+
if (now - ts > 30000) recentlyFinished.delete(id);
|
|
363
|
+
}
|
|
307
364
|
}, 900);
|
|
308
365
|
|
|
309
366
|
// ── Start ──────────────────────────────────────────────────────────────────
|