cc-agent-ui 0.2.2 → 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 +227 -0
- package/server.js +50 -0
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -328,7 +328,9 @@ body {
|
|
|
328
328
|
border-bottom: 1px solid var(--border);
|
|
329
329
|
background: rgba(0,0,0,0.2);
|
|
330
330
|
flex-shrink: 0;
|
|
331
|
+
cursor: pointer;
|
|
331
332
|
}
|
|
333
|
+
.card-hdr:hover { background: rgba(255,255,255,0.04); }
|
|
332
334
|
|
|
333
335
|
.card-num {
|
|
334
336
|
font-size: 10px;
|
|
@@ -541,6 +543,99 @@ body {
|
|
|
541
543
|
border-radius: 4px;
|
|
542
544
|
}
|
|
543
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
|
+
|
|
544
639
|
/* Clickable file paths in terminal */
|
|
545
640
|
.fp-link {
|
|
546
641
|
color: var(--orange);
|
|
@@ -618,6 +713,18 @@ body {
|
|
|
618
713
|
|
|
619
714
|
</div>
|
|
620
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
|
+
|
|
621
728
|
<!-- File Browser Panel -->
|
|
622
729
|
<div id="filebrowser">
|
|
623
730
|
<div id="fb-topbar">
|
|
@@ -996,6 +1103,7 @@ function addJob(job, lines) {
|
|
|
996
1103
|
jobs[job.id] = { job: { ...job }, card, logEl, n: jobCounter };
|
|
997
1104
|
if (lines.length) appendLines(logEl, lines, false);
|
|
998
1105
|
updateColHeader(key);
|
|
1106
|
+
wireCardClick(card, job.id);
|
|
999
1107
|
}
|
|
1000
1108
|
|
|
1001
1109
|
// ── Handle job update ──────────────────────────────────────────────────────
|
|
@@ -1041,6 +1149,13 @@ function handleJobUpdate(data) {
|
|
|
1041
1149
|
const dot = $(`si-${job.id}`)?.querySelector('.ji-status');
|
|
1042
1150
|
if (dot) { dot.className = `ji-status ${status}`; }
|
|
1043
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
|
+
|
|
1044
1159
|
updateColHeader(repoKey(job));
|
|
1045
1160
|
updateCounts();
|
|
1046
1161
|
applyFilter();
|
|
@@ -1060,6 +1175,10 @@ function handleOutput(data) {
|
|
|
1060
1175
|
const entry = jobs[data.id];
|
|
1061
1176
|
if (!entry) return;
|
|
1062
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
|
+
}
|
|
1063
1182
|
}
|
|
1064
1183
|
|
|
1065
1184
|
// ── WebSocket ──────────────────────────────────────────────────────────────
|
|
@@ -1083,6 +1202,114 @@ function connect() {
|
|
|
1083
1202
|
};
|
|
1084
1203
|
}
|
|
1085
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
|
+
|
|
1086
1313
|
// ── File Browser ───────────────────────────────────────────────────────────
|
|
1087
1314
|
const fb = $('filebrowser');
|
|
1088
1315
|
const fbBody = $('fb-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
|
}
|