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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/public/index.html +449 -20
  3. package/server.js +110 -3
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.4",
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
@@ -296,26 +296,23 @@ body {
296
296
  }
297
297
 
298
298
  /* status colors */
299
- .tcard.running { border-color: #2a3545; box-shadow: 0 0 24px var(--cyan-glow); }
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
- /* scan line on running */
306
- .tcard.running::before {
307
- content: '';
308
- position: absolute;
309
- left: 0; right: 0; height: 1px;
310
- background: linear-gradient(90deg, transparent 0%, var(--cyan) 50%, transparent 100%);
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
- @keyframes scanline {
317
- 0% { top: 0; }
318
- 100% { top: 100%; }
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: var(--dim); } /* [cc-agent] meta lines */
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); } /* tool calls */
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.replace(/\x1b\[[0-9;]*m/g, '');
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
- jobList.prepend(sidebarItem);
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
- const active = Object.values(jobCache).filter(j => activeStatuses.has(j.status));
249
- for (const job of active) {
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 ──────────────────────────────────────────────────────────────────