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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-agent-ui",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Live canvas UI for cc-agent jobs — infinite canvas, streaming output, file browser",
5
5
  "type": "module",
6
6
  "repository": {
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
  }