cc-agent-ui 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/public/index.html +354 -33
- package/server.js +50 -0
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -240,13 +240,48 @@ body {
|
|
|
240
240
|
position: absolute;
|
|
241
241
|
top: 0; left: 0;
|
|
242
242
|
transform-origin: 0 0;
|
|
243
|
-
display:
|
|
244
|
-
|
|
245
|
-
|
|
243
|
+
display: flex;
|
|
244
|
+
flex-direction: row;
|
|
245
|
+
align-items: flex-start;
|
|
246
|
+
gap: 18px;
|
|
246
247
|
padding: 24px;
|
|
247
248
|
width: max-content;
|
|
248
249
|
}
|
|
249
250
|
|
|
251
|
+
/* ── Repo Column ──────────────────────────────────────────────────────── */
|
|
252
|
+
.repo-col {
|
|
253
|
+
display: flex;
|
|
254
|
+
flex-direction: column;
|
|
255
|
+
gap: 10px;
|
|
256
|
+
width: 460px;
|
|
257
|
+
flex-shrink: 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.col-header {
|
|
261
|
+
padding: 8px 12px;
|
|
262
|
+
background: rgba(0,0,0,0.35);
|
|
263
|
+
border: 1px solid var(--border);
|
|
264
|
+
border-radius: 5px;
|
|
265
|
+
display: flex;
|
|
266
|
+
align-items: center;
|
|
267
|
+
gap: 8px;
|
|
268
|
+
flex-shrink: 0;
|
|
269
|
+
}
|
|
270
|
+
.col-repo-icon { color: var(--dimmer); font-size: 12px; }
|
|
271
|
+
.col-repo-name {
|
|
272
|
+
font-size: 11px; font-weight: 600; color: var(--blue);
|
|
273
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
274
|
+
flex: 1;
|
|
275
|
+
}
|
|
276
|
+
.col-counts { font-size: 9px; color: var(--dim); flex-shrink: 0; }
|
|
277
|
+
.col-counts.live { color: var(--cyan); }
|
|
278
|
+
|
|
279
|
+
.col-cards {
|
|
280
|
+
display: flex;
|
|
281
|
+
flex-direction: column;
|
|
282
|
+
gap: 10px;
|
|
283
|
+
}
|
|
284
|
+
|
|
250
285
|
/* ── Terminal Card ────────────────────────────────────────────────────── */
|
|
251
286
|
.tcard {
|
|
252
287
|
width: 460px;
|
|
@@ -293,7 +328,9 @@ body {
|
|
|
293
328
|
border-bottom: 1px solid var(--border);
|
|
294
329
|
background: rgba(0,0,0,0.2);
|
|
295
330
|
flex-shrink: 0;
|
|
331
|
+
cursor: pointer;
|
|
296
332
|
}
|
|
333
|
+
.card-hdr:hover { background: rgba(255,255,255,0.04); }
|
|
297
334
|
|
|
298
335
|
.card-num {
|
|
299
336
|
font-size: 10px;
|
|
@@ -506,6 +543,99 @@ body {
|
|
|
506
543
|
border-radius: 4px;
|
|
507
544
|
}
|
|
508
545
|
|
|
546
|
+
/* ── Job Detail Panel ────────────────────────────────────────────────────── */
|
|
547
|
+
#jobpanel {
|
|
548
|
+
position: fixed;
|
|
549
|
+
top: 40px; right: 0; bottom: 0;
|
|
550
|
+
width: 600px;
|
|
551
|
+
background: var(--sidebar-bg);
|
|
552
|
+
border-left: 1px solid var(--border-hi);
|
|
553
|
+
display: flex;
|
|
554
|
+
flex-direction: column;
|
|
555
|
+
z-index: 600;
|
|
556
|
+
transform: translateX(100%);
|
|
557
|
+
transition: transform 0.22s cubic-bezier(.4,0,.2,1);
|
|
558
|
+
}
|
|
559
|
+
#jobpanel.open { transform: translateX(0); }
|
|
560
|
+
#jobpanel.open ~ #filebrowser { right: 600px; }
|
|
561
|
+
|
|
562
|
+
#jp-topbar {
|
|
563
|
+
display: flex; align-items: center;
|
|
564
|
+
padding: 0 14px; height: 44px;
|
|
565
|
+
border-bottom: 1px solid var(--border);
|
|
566
|
+
gap: 10px; flex-shrink: 0;
|
|
567
|
+
}
|
|
568
|
+
#jp-title {
|
|
569
|
+
flex: 1; font-size: 11px; font-weight: 600;
|
|
570
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
571
|
+
}
|
|
572
|
+
#jp-close {
|
|
573
|
+
background: transparent; border: none;
|
|
574
|
+
color: var(--dim); font-size: 16px; cursor: pointer;
|
|
575
|
+
padding: 0 4px; line-height: 1; font-family: var(--font);
|
|
576
|
+
}
|
|
577
|
+
#jp-close:hover { color: var(--text); }
|
|
578
|
+
|
|
579
|
+
#jp-meta {
|
|
580
|
+
padding: 10px 14px;
|
|
581
|
+
border-bottom: 1px solid var(--border);
|
|
582
|
+
display: flex; flex-wrap: wrap; gap: 8px;
|
|
583
|
+
flex-shrink: 0;
|
|
584
|
+
}
|
|
585
|
+
.jp-badge {
|
|
586
|
+
font-size: 9px; padding: 3px 8px; border-radius: 3px;
|
|
587
|
+
text-transform: uppercase; letter-spacing: 0.08em; font-weight: 600;
|
|
588
|
+
}
|
|
589
|
+
.jp-meta-row {
|
|
590
|
+
width: 100%; font-size: 10px; color: var(--dim);
|
|
591
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
592
|
+
}
|
|
593
|
+
.jp-meta-row span { color: var(--text); }
|
|
594
|
+
|
|
595
|
+
#jp-actions {
|
|
596
|
+
padding: 10px 14px;
|
|
597
|
+
border-bottom: 1px solid var(--border);
|
|
598
|
+
display: flex; gap: 8px; flex-shrink: 0;
|
|
599
|
+
flex-wrap: wrap;
|
|
600
|
+
}
|
|
601
|
+
.jp-btn {
|
|
602
|
+
font-family: var(--font); font-size: 10px;
|
|
603
|
+
padding: 5px 14px; border-radius: 4px; cursor: pointer;
|
|
604
|
+
border: 1px solid var(--border); background: transparent;
|
|
605
|
+
color: var(--text); transition: all 0.15s;
|
|
606
|
+
font-weight: 600; letter-spacing: 0.04em;
|
|
607
|
+
}
|
|
608
|
+
.jp-btn:hover { border-color: var(--border-hi); background: rgba(255,255,255,0.04); }
|
|
609
|
+
.jp-btn.approve { border-color: rgba(152,195,121,0.5); color: var(--green); }
|
|
610
|
+
.jp-btn.approve:hover { background: rgba(152,195,121,0.12); }
|
|
611
|
+
.jp-btn.cancel { border-color: rgba(224,108,117,0.5); color: var(--red); }
|
|
612
|
+
.jp-btn.cancel:hover { background: rgba(224,108,117,0.12); }
|
|
613
|
+
.jp-btn.wake { border-color: rgba(229,192,123,0.5); color: var(--yellow); }
|
|
614
|
+
.jp-btn.wake:hover { background: rgba(229,192,123,0.12); }
|
|
615
|
+
.jp-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
|
616
|
+
|
|
617
|
+
#jp-log {
|
|
618
|
+
flex: 1; overflow-y: auto;
|
|
619
|
+
padding: 10px 14px;
|
|
620
|
+
font-size: 11px; line-height: 1.65;
|
|
621
|
+
scrollbar-width: thin;
|
|
622
|
+
scrollbar-color: var(--border) transparent;
|
|
623
|
+
background: var(--card-body);
|
|
624
|
+
}
|
|
625
|
+
#jp-log::-webkit-scrollbar { width: 4px; }
|
|
626
|
+
#jp-log::-webkit-scrollbar-thumb { background: var(--border-hi); border-radius: 2px; }
|
|
627
|
+
|
|
628
|
+
#jp-task {
|
|
629
|
+
padding: 10px 14px;
|
|
630
|
+
border-bottom: 1px solid var(--border);
|
|
631
|
+
font-size: 10px; color: var(--dim);
|
|
632
|
+
max-height: 100px; overflow-y: auto;
|
|
633
|
+
flex-shrink: 0;
|
|
634
|
+
white-space: pre-wrap; word-break: break-word;
|
|
635
|
+
line-height: 1.6;
|
|
636
|
+
}
|
|
637
|
+
#jp-task strong { color: var(--text); display: block; margin-bottom: 4px; font-size: 9px; text-transform: uppercase; letter-spacing: 0.08em; }
|
|
638
|
+
|
|
509
639
|
/* Clickable file paths in terminal */
|
|
510
640
|
.fp-link {
|
|
511
641
|
color: var(--orange);
|
|
@@ -583,6 +713,18 @@ body {
|
|
|
583
713
|
|
|
584
714
|
</div>
|
|
585
715
|
|
|
716
|
+
<!-- Job Detail Panel -->
|
|
717
|
+
<div id="jobpanel">
|
|
718
|
+
<div id="jp-topbar">
|
|
719
|
+
<span id="jp-title">—</span>
|
|
720
|
+
<button id="jp-close" onclick="jpClose()">✕</button>
|
|
721
|
+
</div>
|
|
722
|
+
<div id="jp-meta"></div>
|
|
723
|
+
<div id="jp-task"></div>
|
|
724
|
+
<div id="jp-actions"></div>
|
|
725
|
+
<div id="jp-log"></div>
|
|
726
|
+
</div>
|
|
727
|
+
|
|
586
728
|
<!-- File Browser Panel -->
|
|
587
729
|
<div id="filebrowser">
|
|
588
730
|
<div id="fb-topbar">
|
|
@@ -595,7 +737,8 @@ body {
|
|
|
595
737
|
|
|
596
738
|
<script>
|
|
597
739
|
// ── State ──────────────────────────────────────────────────────────────────
|
|
598
|
-
const jobs = {}; // id → { job, card, logEl,
|
|
740
|
+
const jobs = {}; // id → { job, card, logEl, n }
|
|
741
|
+
const cols = {}; // repoKey → { el, cardsEl }
|
|
599
742
|
let filterMode = 'all';
|
|
600
743
|
let jobCounter = 0;
|
|
601
744
|
let scale = 1, ox = 40, oy = 40;
|
|
@@ -816,14 +959,14 @@ function focusCard(id) {
|
|
|
816
959
|
applyFilter();
|
|
817
960
|
}
|
|
818
961
|
|
|
819
|
-
//
|
|
820
|
-
|
|
821
|
-
|
|
962
|
+
// Walk offsetParent chain up to inner for accurate canvas coordinates
|
|
963
|
+
let cx = 0, cy = 0, el = card;
|
|
964
|
+
while (el && el !== inner) { cx += el.offsetLeft; cy += el.offsetTop; el = el.offsetParent; }
|
|
822
965
|
const cardW = card.offsetWidth || 460;
|
|
823
966
|
const cardH = card.offsetHeight || 370;
|
|
824
967
|
const vw = viewport.clientWidth, vh = viewport.clientHeight;
|
|
825
|
-
ox = vw/2 - (
|
|
826
|
-
oy = vh/2 - (
|
|
968
|
+
ox = vw/2 - (cx + cardW/2) * scale;
|
|
969
|
+
oy = vh/2 - (cy + cardH/2) * scale;
|
|
827
970
|
applyTransform();
|
|
828
971
|
|
|
829
972
|
// Scroll terminal to bottom
|
|
@@ -856,6 +999,11 @@ function applyFilter() {
|
|
|
856
999
|
if (item) item.style.display = show ? '' : 'none';
|
|
857
1000
|
if (card) card.style.display = show ? '' : 'none';
|
|
858
1001
|
}
|
|
1002
|
+
// Hide columns with no visible cards
|
|
1003
|
+
for (const [key, col] of Object.entries(cols)) {
|
|
1004
|
+
const hasVisible = [...col.cardsEl.children].some(c => c.style.display !== 'none');
|
|
1005
|
+
col.el.style.display = hasVisible ? '' : 'none';
|
|
1006
|
+
}
|
|
859
1007
|
}
|
|
860
1008
|
|
|
861
1009
|
document.querySelectorAll('.fbtn').forEach(btn => {
|
|
@@ -867,34 +1015,95 @@ document.querySelectorAll('.fbtn').forEach(btn => {
|
|
|
867
1015
|
});
|
|
868
1016
|
});
|
|
869
1017
|
|
|
870
|
-
// ──
|
|
871
|
-
function
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
1018
|
+
// ── Column helpers ─────────────────────────────────────────────────────────
|
|
1019
|
+
function repoKey(job) {
|
|
1020
|
+
return shortRepo(job.repoUrl) || 'local';
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function getOrCreateCol(key) {
|
|
1024
|
+
if (cols[key]) return cols[key];
|
|
1025
|
+
const el = document.createElement('div');
|
|
1026
|
+
el.className = 'repo-col';
|
|
1027
|
+
el.id = `col-${key.replace(/[^a-z0-9]/gi, '-')}`;
|
|
1028
|
+
el.innerHTML = `
|
|
1029
|
+
<div class="col-header">
|
|
1030
|
+
<span class="col-repo-icon">⎇</span>
|
|
1031
|
+
<span class="col-repo-name" title="${escHtml(key)}">${escHtml(key)}</span>
|
|
1032
|
+
<span class="col-counts"></span>
|
|
1033
|
+
</div>
|
|
1034
|
+
<div class="col-cards"></div>
|
|
1035
|
+
`;
|
|
1036
|
+
cols[key] = { el, cardsEl: el.querySelector('.col-cards') };
|
|
1037
|
+
inner.appendChild(el);
|
|
1038
|
+
return cols[key];
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function insertCardInColumn(key, card, job) {
|
|
1042
|
+
const { cardsEl } = cols[key];
|
|
1043
|
+
const t = new Date(job.startedAt || 0).getTime();
|
|
1044
|
+
for (const sib of cardsEl.children) {
|
|
1045
|
+
const sibJob = jobs[sib.id.replace('card-', '')]?.job;
|
|
1046
|
+
if (t >= new Date(sibJob?.startedAt || 0).getTime()) {
|
|
1047
|
+
cardsEl.insertBefore(card, sib);
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
cardsEl.appendChild(card);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function updateColHeader(key) {
|
|
1055
|
+
const col = cols[key];
|
|
1056
|
+
if (!col) return;
|
|
1057
|
+
const colJobs = Object.values(jobs).filter(e => repoKey(e.job) === key);
|
|
1058
|
+
const running = colJobs.filter(e => ['running','cloning','pending_approval'].includes(e.job.status)).length;
|
|
1059
|
+
const el = col.el.querySelector('.col-counts');
|
|
1060
|
+
if (!el) return;
|
|
1061
|
+
if (running > 0) {
|
|
1062
|
+
el.textContent = `${running} live · ${colJobs.length}`;
|
|
1063
|
+
el.className = 'col-counts live';
|
|
1064
|
+
} else {
|
|
1065
|
+
el.textContent = `${colJobs.length}`;
|
|
1066
|
+
el.className = 'col-counts';
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function sortColumns() {
|
|
1071
|
+
// Order columns by their most recent job's startedAt, descending
|
|
1072
|
+
const keys = Object.keys(cols).sort((a, b) => {
|
|
1073
|
+
const latest = key => Math.max(0, ...Object.values(jobs)
|
|
1074
|
+
.filter(e => repoKey(e.job) === key)
|
|
1075
|
+
.map(e => new Date(e.job.startedAt || 0).getTime()));
|
|
1076
|
+
return latest(b) - latest(a);
|
|
877
1077
|
});
|
|
1078
|
+
for (const key of keys) inner.appendChild(cols[key].el);
|
|
1079
|
+
}
|
|
878
1080
|
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
1081
|
+
// ── Handle snapshot (initial load) ────────────────────────────────────────
|
|
1082
|
+
function handleSnapshot(data) {
|
|
1083
|
+
// Sort purely by time descending — columns handle repo grouping visually
|
|
1084
|
+
const sorted = [...data.jobs].sort((a, b) =>
|
|
1085
|
+
new Date(b.startedAt||0) - new Date(a.startedAt||0)
|
|
1086
|
+
);
|
|
1087
|
+
for (const job of sorted) addJob(job, job.lines || []);
|
|
1088
|
+
sortColumns();
|
|
882
1089
|
updateCounts();
|
|
883
1090
|
applyFilter();
|
|
884
|
-
if (Object.keys(jobs).length > 0)
|
|
885
|
-
emptyState.style.display = 'none';
|
|
886
|
-
}
|
|
1091
|
+
if (Object.keys(jobs).length > 0) emptyState.style.display = 'none';
|
|
887
1092
|
}
|
|
888
1093
|
|
|
889
1094
|
function addJob(job, lines) {
|
|
890
|
-
if (jobs[job.id]) return;
|
|
1095
|
+
if (jobs[job.id]) return;
|
|
891
1096
|
jobCounter++;
|
|
892
1097
|
const { card, logEl } = makeCard(job, jobCounter);
|
|
893
|
-
|
|
1098
|
+
const key = repoKey(job);
|
|
1099
|
+
getOrCreateCol(key);
|
|
1100
|
+
insertCardInColumn(key, card, job);
|
|
894
1101
|
const sidebarItem = makeSidebarItem(job);
|
|
895
1102
|
jobList.prepend(sidebarItem);
|
|
896
1103
|
jobs[job.id] = { job: { ...job }, card, logEl, n: jobCounter };
|
|
897
1104
|
if (lines.length) appendLines(logEl, lines, false);
|
|
1105
|
+
updateColHeader(key);
|
|
1106
|
+
wireCardClick(card, job.id);
|
|
898
1107
|
}
|
|
899
1108
|
|
|
900
1109
|
// ── Handle job update ──────────────────────────────────────────────────────
|
|
@@ -940,6 +1149,14 @@ function handleJobUpdate(data) {
|
|
|
940
1149
|
const dot = $(`si-${job.id}`)?.querySelector('.ji-status');
|
|
941
1150
|
if (dot) { dot.className = `ji-status ${status}`; }
|
|
942
1151
|
|
|
1152
|
+
// Refresh job panel if this job is open
|
|
1153
|
+
if (jpCurrentId === job.id) {
|
|
1154
|
+
jpMeta.querySelector('.jp-badge').className = `jp-badge badge-${status}`;
|
|
1155
|
+
jpMeta.querySelector('.jp-badge').textContent = status.replace('_', ' ');
|
|
1156
|
+
renderActions(status);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
updateColHeader(repoKey(job));
|
|
943
1160
|
updateCounts();
|
|
944
1161
|
applyFilter();
|
|
945
1162
|
}
|
|
@@ -947,6 +1164,7 @@ function handleJobUpdate(data) {
|
|
|
947
1164
|
// ── Handle job_new ─────────────────────────────────────────────────────────
|
|
948
1165
|
function handleJobNew(data) {
|
|
949
1166
|
addJob(data.job, data.job.lines || []);
|
|
1167
|
+
sortColumns();
|
|
950
1168
|
emptyState.style.display = 'none';
|
|
951
1169
|
updateCounts();
|
|
952
1170
|
applyFilter();
|
|
@@ -957,6 +1175,10 @@ function handleOutput(data) {
|
|
|
957
1175
|
const entry = jobs[data.id];
|
|
958
1176
|
if (!entry) return;
|
|
959
1177
|
appendLines(entry.logEl, data.lines, true);
|
|
1178
|
+
// Mirror to job panel if open
|
|
1179
|
+
if (jpCurrentId === data.id) {
|
|
1180
|
+
appendLines(jpLog, data.lines, true);
|
|
1181
|
+
}
|
|
960
1182
|
}
|
|
961
1183
|
|
|
962
1184
|
// ── WebSocket ──────────────────────────────────────────────────────────────
|
|
@@ -980,6 +1202,114 @@ function connect() {
|
|
|
980
1202
|
};
|
|
981
1203
|
}
|
|
982
1204
|
|
|
1205
|
+
// ── Job Detail Panel ───────────────────────────────────────────────────────
|
|
1206
|
+
const jp = $('jobpanel');
|
|
1207
|
+
const jpLog = $('jp-log');
|
|
1208
|
+
const jpTitle = $('jp-title');
|
|
1209
|
+
const jpMeta = $('jp-meta');
|
|
1210
|
+
const jpTask = $('jp-task');
|
|
1211
|
+
const jpActions = $('jp-actions');
|
|
1212
|
+
let jpCurrentId = null;
|
|
1213
|
+
|
|
1214
|
+
function jpClose() {
|
|
1215
|
+
jp.classList.remove('open');
|
|
1216
|
+
jpCurrentId = null;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
async function jpOpen(id) {
|
|
1220
|
+
const entry = jobs[id];
|
|
1221
|
+
if (!entry) return;
|
|
1222
|
+
jpCurrentId = id;
|
|
1223
|
+
jp.classList.add('open');
|
|
1224
|
+
|
|
1225
|
+
const job = entry.job;
|
|
1226
|
+
const status = job.status || 'unknown';
|
|
1227
|
+
|
|
1228
|
+
jpTitle.textContent = shortTask(job.task);
|
|
1229
|
+
jpTask.innerHTML = `<strong>Task</strong>${escHtml(job.task || '(no task)')}`;
|
|
1230
|
+
|
|
1231
|
+
// Meta
|
|
1232
|
+
jpMeta.innerHTML = `
|
|
1233
|
+
<span class="jp-badge badge-${status}">${status.replace('_',' ')}</span>
|
|
1234
|
+
<span class="jp-meta-row">repo <span>${escHtml(shortRepo(job.repoUrl) || 'local')}</span>
|
|
1235
|
+
${job.branch ? ` · branch <span>${escHtml(job.branch)}</span>` : ''}
|
|
1236
|
+
</span>
|
|
1237
|
+
<span class="jp-meta-row">id <span>${escHtml(job.id || id)}</span></span>
|
|
1238
|
+
<span class="jp-meta-row">started <span>${job.startedAt ? new Date(job.startedAt).toLocaleString() : '—'}</span></span>
|
|
1239
|
+
`;
|
|
1240
|
+
|
|
1241
|
+
// Actions
|
|
1242
|
+
renderActions(status);
|
|
1243
|
+
|
|
1244
|
+
// Load full log
|
|
1245
|
+
jpLog.innerHTML = '<div style="color:var(--dim);padding:4px">loading…</div>';
|
|
1246
|
+
try {
|
|
1247
|
+
const r = await fetch(`/api/job/output?id=${encodeURIComponent(id)}`);
|
|
1248
|
+
const data = await r.json();
|
|
1249
|
+
jpLog.innerHTML = '';
|
|
1250
|
+
appendLines(jpLog, data.lines || [], false);
|
|
1251
|
+
jpLog.scrollTop = jpLog.scrollHeight;
|
|
1252
|
+
} catch (e) {
|
|
1253
|
+
jpLog.innerHTML = `<div style="color:var(--red)">${escHtml(e.message)}</div>`;
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function renderActions(status) {
|
|
1258
|
+
jpActions.innerHTML = '';
|
|
1259
|
+
const add = (label, cls, action, extra = {}) => {
|
|
1260
|
+
const b = document.createElement('button');
|
|
1261
|
+
b.className = `jp-btn ${cls}`;
|
|
1262
|
+
b.textContent = label;
|
|
1263
|
+
if (extra.disabled) b.disabled = true;
|
|
1264
|
+
b.addEventListener('click', () => jobAction(jpCurrentId, action));
|
|
1265
|
+
jpActions.appendChild(b);
|
|
1266
|
+
};
|
|
1267
|
+
|
|
1268
|
+
if (status === 'pending_approval') add('✓ Approve', 'approve', 'approve');
|
|
1269
|
+
if (['running','cloning','pending_approval'].includes(status)) add('✕ Cancel', 'cancel', 'cancel');
|
|
1270
|
+
if (status === 'failed' || status === 'cancelled') add('⟳ Wake', 'wake', 'wake');
|
|
1271
|
+
if (jpActions.children.length === 0) {
|
|
1272
|
+
jpActions.innerHTML = `<span style="font-size:10px;color:var(--dim)">no actions available for ${status} jobs</span>`;
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
async function jobAction(id, action) {
|
|
1277
|
+
if (!id) return;
|
|
1278
|
+
const btns = jpActions.querySelectorAll('.jp-btn');
|
|
1279
|
+
btns.forEach(b => b.disabled = true);
|
|
1280
|
+
try {
|
|
1281
|
+
const r = await fetch('/api/job/action', {
|
|
1282
|
+
method: 'POST',
|
|
1283
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1284
|
+
body: JSON.stringify({ id, action }),
|
|
1285
|
+
});
|
|
1286
|
+
const data = await r.json();
|
|
1287
|
+
if (data.ok) {
|
|
1288
|
+
// Append confirmation to panel log
|
|
1289
|
+
const d = document.createElement('div');
|
|
1290
|
+
d.className = 'tl tl-ok';
|
|
1291
|
+
d.textContent = `[ui] Action "${action}" sent`;
|
|
1292
|
+
jpLog.appendChild(d);
|
|
1293
|
+
jpLog.scrollTop = jpLog.scrollHeight;
|
|
1294
|
+
}
|
|
1295
|
+
} catch (e) {
|
|
1296
|
+
const d = document.createElement('div');
|
|
1297
|
+
d.className = 'tl tl-err';
|
|
1298
|
+
d.textContent = `[ui] Error: ${e.message}`;
|
|
1299
|
+
jpLog.appendChild(d);
|
|
1300
|
+
} finally {
|
|
1301
|
+
btns.forEach(b => b.disabled = false);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// Wire card clicks to open job panel
|
|
1306
|
+
function wireCardClick(card, id) {
|
|
1307
|
+
card.querySelector('.card-hdr').addEventListener('click', (e) => {
|
|
1308
|
+
if (e.target.closest('.fp-link')) return; // don't interfere with file links
|
|
1309
|
+
jpOpen(id);
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
|
|
983
1313
|
// ── File Browser ───────────────────────────────────────────────────────────
|
|
984
1314
|
const fb = $('filebrowser');
|
|
985
1315
|
const fbBody = $('fb-body');
|
|
@@ -1117,15 +1447,6 @@ setInterval(() => {
|
|
|
1117
1447
|
}
|
|
1118
1448
|
}, 30000);
|
|
1119
1449
|
|
|
1120
|
-
// Auto-layout columns based on viewport width
|
|
1121
|
-
function updateCols() {
|
|
1122
|
-
const vw = viewport.clientWidth;
|
|
1123
|
-
const cols = Math.max(1, Math.floor((vw - 40) / 474));
|
|
1124
|
-
inner.style.setProperty('--cols', cols);
|
|
1125
|
-
}
|
|
1126
|
-
updateCols();
|
|
1127
|
-
new ResizeObserver(updateCols).observe(viewport);
|
|
1128
|
-
|
|
1129
1450
|
connect();
|
|
1130
1451
|
</script>
|
|
1131
1452
|
</body>
|
package/server.js
CHANGED
|
@@ -195,6 +195,56 @@ 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
|
+
// Set approval flag — cc-agent polls for this
|
|
224
|
+
const updated = { ...job, approvedAt: new Date().toISOString(), approved: true };
|
|
225
|
+
await redis.set(`cca:job:${id}`, JSON.stringify(updated));
|
|
226
|
+
// Also push approval to output list so agent sees it
|
|
227
|
+
await redis.rPush(`cca:job:${id}:output`, '[cc-agent-ui] Job approved by user');
|
|
228
|
+
} else if (action === 'cancel') {
|
|
229
|
+
const updated = { ...job, status: 'cancelled', cancelledAt: new Date().toISOString() };
|
|
230
|
+
await redis.set(`cca:job:${id}`, JSON.stringify(updated));
|
|
231
|
+
broadcast({ type: 'job_update', job: updated });
|
|
232
|
+
} else if (action === 'wake') {
|
|
233
|
+
const updated = { ...job, status: 'running', wakedAt: new Date().toISOString() };
|
|
234
|
+
await redis.set(`cca:job:${id}`, JSON.stringify(updated));
|
|
235
|
+
broadcast({ type: 'job_update', job: updated });
|
|
236
|
+
} else if (action === 'message') {
|
|
237
|
+
// Send a message into the job's input channel
|
|
238
|
+
if (message) await redis.rPush(`cca:job:${id}:input`, message);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
242
|
+
res.end(JSON.stringify({ ok: true, action, id }));
|
|
243
|
+
} catch (e) {
|
|
244
|
+
res.writeHead(500); res.end(e.message);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
198
248
|
} else {
|
|
199
249
|
res.writeHead(404); res.end();
|
|
200
250
|
}
|