coxpit 5.5.0 → 5.6.0

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/cockpit.ts +229 -15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "5.5.0",
3
+ "version": "5.6.0",
4
4
  "description": "Self-hosted cockpit for running a fleet of AI coding agents across your own machines — parallel worktree runs, live board, compare & merge, web terminal, design capture.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cockpit.ts CHANGED
@@ -106,11 +106,43 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
106
106
  .empty h1{font-family:var(--mono);font-size:16px;margin:0 0 8px;color:var(--ink)}
107
107
  .empty p{color:var(--muted);font-size:13px;margin:0}
108
108
 
109
- .reqbar{display:flex;align-items:center;gap:10px;border-top:1px solid var(--line);background:var(--surface);padding:11px 14px;font-family:var(--mono)}
110
- .reqbar .ctx{font-size:11px;color:var(--brand-ink);background:var(--brand);border-radius:6px;padding:3px 9px;font-weight:600}
111
- .reqbar .caret{color:var(--brand)}
112
- .reqbar .ph{color:var(--faint);font-size:13px;flex:1}
113
- .reqbar .kbd{border:1px solid var(--line-hi);border-radius:5px;padding:1px 6px;color:var(--faint);font-size:11px}
109
+ .reqbar{display:flex;align-items:center;gap:9px;border-top:1px solid var(--line);background:var(--surface);padding:9px 12px;font-family:var(--mono)}
110
+ .modes{display:inline-flex;gap:2px;border:1px solid var(--line);border-radius:8px;padding:2px;background:var(--panel)}
111
+ .mode{font-size:11px;color:var(--muted);padding:4px 9px;border-radius:6px;cursor:pointer;background:none;border:none;font-family:var(--mono);white-space:nowrap}
112
+ .mode.on{background:var(--brand-dim);color:var(--ink);box-shadow:inset 0 0 0 1px rgba(78,201,176,.3)}
113
+ .reqbar select{font-family:var(--mono);font-size:11.5px;color:var(--ink);background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:5px 7px;max-width:150px}
114
+ .reqbar select:disabled{opacity:.35}
115
+ .reqbar .tgt{font-size:11px;color:var(--brand);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px}
116
+ .rlbl{display:inline-flex;align-items:center;gap:5px}
117
+ .rchk{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--muted);cursor:pointer;white-space:nowrap}
118
+ .reqinput{flex:1;min-width:80px;font-family:var(--mono);font-size:13px;color:var(--ink);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:8px 11px}
119
+ .reqinput:focus{outline:none;border-color:var(--line-hi);box-shadow:0 0 0 2px rgba(78,201,176,.18)}
120
+ .reqinput::placeholder{color:var(--faint)}
121
+ .reqgo{font-family:var(--mono);font-size:12px;font-weight:600;color:var(--brand-ink);background:var(--brand);border:none;border-radius:8px;padding:8px 13px;cursor:pointer}
122
+ .reqgo:disabled{opacity:.4;cursor:default}
123
+ .reqgo.bcast{background:var(--open);color:#0b0d12}
124
+ .reqgo.steer{background:var(--running);color:#04121e}
125
+ .toast{position:fixed;bottom:64px;left:50%;transform:translateX(-50%);background:var(--surface2);border:1px solid var(--line-hi);color:var(--ink);
126
+ font-family:var(--mono);font-size:12px;padding:8px 14px;border-radius:9px;opacity:0;transition:opacity .2s;pointer-events:none;z-index:40;max-width:80vw}
127
+ .toast.show{opacity:1}
128
+
129
+ /* ── Review (compare/merge) ── */
130
+ .review{position:absolute;inset:46px 0 0 0;background:var(--bg);display:none;flex-direction:column;overflow:hidden;z-index:20}
131
+ .review.on{display:flex}
132
+ .rv-head{display:flex;align-items:center;gap:10px;height:40px;padding:0 14px;border-bottom:1px solid var(--line);background:var(--panel);font-family:var(--mono);font-size:12px;color:var(--muted)}
133
+ .rv-head select{font-family:var(--mono);font-size:12px;color:var(--ink);background:var(--surface);border:1px solid var(--line);border-radius:7px;padding:5px 8px;max-width:340px}
134
+ .rv-cols{flex:1;display:flex;gap:1px;background:var(--line);overflow-x:auto;min-height:0}
135
+ .rv-col{flex:1 0 340px;min-width:300px;display:flex;flex-direction:column;background:var(--bg)}
136
+ .rv-col-h{display:flex;align-items:center;gap:8px;padding:9px 12px;border-bottom:1px solid var(--line);background:var(--surface);font-family:var(--mono);font-size:12px}
137
+ .rv-col-h .rid{color:var(--ink);font-weight:600}
138
+ .rv-col-h .stat{color:var(--faint);font-size:11px;margin-left:auto}
139
+ .rv-merge{font-family:var(--mono);font-size:10.5px;font-weight:600;color:var(--brand-ink);background:var(--brand);border:none;border-radius:6px;padding:4px 9px;cursor:pointer}
140
+ .rv-merge:disabled{opacity:.35;cursor:default;background:var(--line-hi);color:var(--faint)}
141
+ .rv-diff{flex:1;overflow:auto;padding:8px 10px;font-family:var(--mono);font-size:11.5px;line-height:1.5;white-space:pre;color:var(--muted)}
142
+ .rv-diff > span{display:block;min-height:1.2em}
143
+ .dl-file{color:var(--brand)} .dl-hunk{color:var(--open)} .dl-ctx{color:var(--muted)}
144
+ .dl-add{color:#7fdca0;background:rgba(88,179,104,.08)} .dl-del{color:#e58a92;background:rgba(226,91,103,.08)}
145
+ .rv-empty{flex:1;display:flex;align-items:center;justify-content:center;color:var(--faint);font-family:var(--mono);font-size:13px;text-align:center;padding:24px}
114
146
  </style>
115
147
  </head>
116
148
  <body>
@@ -118,13 +150,13 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
118
150
  <a class="brand" href="/"><img src="/brand/mark.png" alt="" /><span class="wm">coxpit</span></a>
119
151
  <span class="mach"><span class="dot"></span><span id="mach">local</span></span>
120
152
  <div class="vtabs">
121
- <button type="button" class="vtab on"><span class="g">⌗</span>Terminal</button>
122
- <button type="button" class="vtab" disabled title="Phase 3"><span class="g">⧉</span>Review</button>
123
- <button type="button" class="vtab" disabled title="Phase 3"><span class="g">▤</span>Docs</button>
153
+ <button type="button" class="vtab on" id="vtTerm"><span class="g">⌗</span>Terminal</button>
154
+ <button type="button" class="vtab" id="vtReview"><span class="g">⧉</span>Review</button>
155
+ <button type="button" class="vtab" disabled title="Docs = 보드"><span class="g">▤</span>Docs</button>
124
156
  </div>
125
157
  <div class="right">
126
158
  <span class="ws" id="ws"><span class="dot"></span><span id="wstext">connecting</span></span>
127
- <span class="wip">preview · Phase 2</span>
159
+ <span class="wip">preview · Phase 3</span>
128
160
  <a class="toggle" href="/">← Board</a>
129
161
  </div>
130
162
  </header>
@@ -152,13 +184,38 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
152
184
  </div>
153
185
  </div>
154
186
  <div class="reqbar">
155
- <span class="ctx">cockpit</span><span class="caret">›</span>
156
- <span class="ph">요청바 Phase 3 (repo 문맥에서 팬아웃)</span>
157
- <span class="kbd">⏎</span>
187
+ <div class="modes">
188
+ <button type="button" class="mode on" data-mode="new" title="새 태스크를 만들고 N개 에이전트로 팬아웃">⌗ New</button>
189
+ <button type="button" class="mode" data-mode="steer" title="포커스한 run 에 후속 지시">➤ Steer</button>
190
+ <button type="button" class="mode" data-mode="bcast" title="열린 모든 페인 터미널에 그대로 입력">⊞ Broadcast</button>
191
+ </div>
192
+ <span class="rlbl" id="newCtl">
193
+ <select id="reqRepo" title="대상 repo"></select>
194
+ <select id="reqAgent" title="에이전트"></select>
195
+ <select id="reqCount" title="팬아웃 개수">
196
+ <option value="1">×1</option><option value="2">×2</option><option value="3" selected>×3</option><option value="4">×4</option>
197
+ </select>
198
+ <label class="rchk" title="실제 CLI 실행 (기본=드라이런)"><input type="checkbox" id="reqReal" /> real</label>
199
+ </span>
200
+ <span class="tgt" id="reqTgt" style="display:none"></span>
201
+ <input class="reqinput" id="reqInput" placeholder="무엇을 만들까요? — 요청을 적고 ⏎ 로 3개 에이전트에 팬아웃" autocomplete="off" />
202
+ <button type="button" class="reqgo" id="reqGo">Run ⏎</button>
158
203
  </div>
159
204
  </main>
160
205
  </div>
161
206
 
207
+ <section class="review" id="review">
208
+ <div class="rv-head">
209
+ <span style="color:var(--brand)">⧉ Review</span>
210
+ <span>·</span>
211
+ <select id="rvTask" title="비교할 태스크"></select>
212
+ <span id="rvHint" style="margin-left:auto;color:var(--faint)">run diff 를 나란히 · 승자를 base 에 merge</span>
213
+ </div>
214
+ <div class="rv-cols" id="rvCols"></div>
215
+ </section>
216
+
217
+ <div class="toast" id="toast"></div>
218
+
162
219
  <script src="/vendor/xterm.js"></script>
163
220
  <script src="/vendor/addon-fit.js"></script>
164
221
  <script src="/vendor/addon-unicode11.js"></script>
@@ -168,23 +225,46 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
168
225
  var esc = function(s){ return String(s==null?'':s).replace(/[&<>"]/g, function(c){ return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'})[c]; }); };
169
226
  var $ = function(id){ return document.getElementById(id); };
170
227
 
228
+ function toast(msg){ var t=$('toast'); t.textContent=msg; t.classList.add('show'); clearTimeout(toast._h); toast._h=setTimeout(function(){ t.classList.remove('show'); }, 2600); }
229
+
171
230
  // ── fleet 상태 ──
172
- var fleet = { machines:[], repos:[], tasks:[], groups:[], runs:[] };
173
- var runById = {};
231
+ var fleet = { machines:[], repos:[], tasks:[], groups:[], runs:[], providers:[] };
232
+ var runById = {}, taskById = {}, repoById = {};
174
233
  var fold = {}; // 접힘 상태(repo/goal/task 노드)
175
234
  function isFold(k){ return fold[k]===true; }
235
+ function repoOfRun(runId){ var r=runById[runId]; var t=r&&taskById[r.taskId]; return t?t.repoId:null; }
176
236
 
177
237
  async function hydrate(){
178
238
  try{
179
239
  var d = await (await fetch('/api/fleet?view=all')).json();
180
- fleet = d; runById = {};
240
+ fleet = d; runById = {}; taskById = {}; repoById = {};
181
241
  (d.runs||[]).forEach(function(r){ runById[r.id]=r; });
242
+ (d.tasks||[]).forEach(function(t){ taskById[t.id]=t; });
243
+ (d.repos||[]).forEach(function(r){ repoById[r.id]=r; });
182
244
  if (d.machines && d.machines[0]) { $('mach').textContent = d.machines[0].slug; $('machName').textContent = d.machines[0].slug; }
183
245
  renderTree();
184
246
  syncPanes();
247
+ populateReq();
248
+ if (reviewOn) renderReviewPicker();
185
249
  }catch(e){ /* 재시도는 WS 재연결 or 다음 hydrate */ }
186
250
  }
187
251
 
252
+ // ── 요청바 셀렉트 채우기(선택 유지) ──
253
+ function fillSelect(sel, items, val, label, keep){
254
+ var cur = keep && sel.value; sel.innerHTML='';
255
+ items.forEach(function(it){ var o=document.createElement('option'); o.value=val(it); o.textContent=label(it); sel.appendChild(o); });
256
+ if (cur){ for (var i=0;i<sel.options.length;i++){ if (sel.options[i].value===cur){ sel.value=cur; break; } } }
257
+ }
258
+ function populateReq(){
259
+ var repos = fleet.repos||[];
260
+ fillSelect($('reqRepo'), repos, function(r){return String(r.id);}, function(r){return r.name;}, true);
261
+ // repo 미선택 상태면 포커스 페인의 repo 로 기본
262
+ if (focusId){ var rp = repoOfRun((panes.find(function(p){return p.id===focusId;})||{}).runId); if (rp!=null) $('reqRepo').value=String(rp); }
263
+ var provs = fleet.providers||[];
264
+ if (provs.length) fillSelect($('reqAgent'), provs, function(p){return p.id;}, function(p){return p.label||p.id;}, true);
265
+ else if (!$('reqAgent').options.length){ var o=document.createElement('option'); o.value='claude-code'; o.textContent='claude-code'; $('reqAgent').appendChild(o); }
266
+ }
267
+
188
268
  // ── 트리 렌더: Machine ▸ Repo ▸ Goal ▸ Task ▸ Run ──
189
269
  function renderTree(){
190
270
  var el = $('tree');
@@ -246,6 +326,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
246
326
  host.style.display = n ? 'grid' : 'none';
247
327
  $('paneCount').textContent = n + ' pane' + (n===1?'':'s');
248
328
  $('closeBtn').disabled = !focusId;
329
+ if (typeof reqMode!=='undefined' && reqMode==='bcast') setMode('bcast');
249
330
  if (!n) return;
250
331
  var cols = Math.ceil(Math.sqrt(n));
251
332
  var rows = Math.ceil(n / cols);
@@ -264,6 +345,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
264
345
  $('closeBtn').disabled = !focusId;
265
346
  var p = panes.find(function(x){return x.id===id;});
266
347
  if (p && p.term) try{ p.term.focus(); }catch(e){}
348
+ if (typeof reqMode!=='undefined' && reqMode!=='new') setMode(reqMode);
267
349
  }
268
350
  function openRunPane(runId){
269
351
  var existing = paneByRun[runId];
@@ -345,6 +427,137 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
345
427
  }
346
428
  $('closeBtn').addEventListener('click', function(){ if (focusId) closePane(focusId); });
347
429
 
430
+ // ── 요청바: New(팬아웃) / Steer / Broadcast ──
431
+ var reqMode = 'new';
432
+ function focusRun(){ if (!focusId) return null; var p = panes.find(function(x){return x.id===focusId;}); return p?p.runId:null; }
433
+ function setMode(m){
434
+ reqMode = m;
435
+ Array.prototype.forEach.call(document.querySelectorAll('.mode'), function(b){ b.classList.toggle('on', b.dataset.mode===m); });
436
+ var go=$('reqGo'), inp=$('reqInput');
437
+ $('newCtl').style.display = m==='new'?'inline-flex':'none';
438
+ $('reqTgt').style.display = m==='new'?'none':'inline';
439
+ go.className = 'reqgo' + (m==='bcast'?' bcast':m==='steer'?' steer':'');
440
+ if (m==='new'){ go.textContent='Run ⏎'; inp.placeholder='무엇을 만들까요? — 요청을 적고 ⏎ 로 팬아웃'; }
441
+ else if (m==='steer'){ var rid=focusRun(); go.textContent='Steer ⏎';
442
+ $('reqTgt').textContent = rid?('➤ r'+rid+' 에 후속 지시'):'포커스한 페인 없음';
443
+ inp.placeholder = rid?('r'+rid+' 에이전트에게 다음 지시…'):'왼쪽 트리에서 run 을 열어 포커스하세요'; }
444
+ else { go.textContent='Send ⏎'; var n=panes.length;
445
+ $('reqTgt').textContent = '⊞ 열린 페인 '+n+'개에 브로드캐스트';
446
+ inp.placeholder = n?('열린 '+n+'개 터미널에 그대로 전송 (엔터 포함)'):'열린 페인이 없습니다'; }
447
+ }
448
+ Array.prototype.forEach.call(document.querySelectorAll('.mode'), function(b){ b.addEventListener('click', function(){ setMode(b.dataset.mode); $('reqInput').focus(); }); });
449
+
450
+ var submitting = false;
451
+ async function submitReq(){
452
+ var inp = $('reqInput'); var text = inp.value.trim();
453
+ if (reqMode==='new'){
454
+ if (!text || submitting) return;
455
+ var repoId = Number($('reqRepo').value); if (!repoId){ toast('repo 를 먼저 등록/선택하세요'); return; }
456
+ submitting=true; $('reqGo').disabled=true;
457
+ try{
458
+ var title = text.length>72 ? text.slice(0,69)+'…' : text;
459
+ var tk = await (await fetch('/api/tasks',{method:'POST',headers:{'content-type':'application/json'},
460
+ body:JSON.stringify({repoId:repoId, title:title, prompt:text})})).json();
461
+ if (!tk || !tk.task){ toast('태스크 생성 실패'); return; }
462
+ var count = Number($('reqCount').value)||1;
463
+ var rr = await (await fetch('/api/tasks/'+tk.task.id+'/run',{method:'POST',headers:{'content-type':'application/json'},
464
+ body:JSON.stringify({agent:$('reqAgent').value, count:count, real:$('reqReal').checked})})).json();
465
+ var ids = (rr&&rr.runs||[]).map(function(x){return x.id;});
466
+ inp.value='';
467
+ await hydrate();
468
+ // 팬아웃 자동 타일: 생성된 run 을 전부 페인으로
469
+ ids.forEach(function(id){ openRunPane(id); });
470
+ toast(ids.length+'개 에이전트 팬아웃 · r'+ids.join(' r'));
471
+ }catch(e){ toast('요청 실패: '+e); }
472
+ finally{ submitting=false; $('reqGo').disabled=false; }
473
+ } else if (reqMode==='steer'){
474
+ var rid = focusRun(); if (!rid){ toast('포커스한 페인이 없습니다'); return; }
475
+ if (!text || submitting) return;
476
+ submitting=true; $('reqGo').disabled=true;
477
+ try{
478
+ var res = await fetch('/api/runs/'+rid+'/steer',{method:'POST',headers:{'content-type':'application/json'},
479
+ body:JSON.stringify({message:text})});
480
+ if (res.ok){ inp.value=''; toast('r'+rid+' 에 후속 지시 전송'); }
481
+ else { var j = await res.json().catch(function(){return{};}); toast('steer 불가: '+(j.error||res.status)); }
482
+ }catch(e){ toast('steer 실패: '+e); }
483
+ finally{ submitting=false; $('reqGo').disabled=false; }
484
+ } else { // broadcast
485
+ if (!panes.length){ toast('열린 페인이 없습니다'); return; }
486
+ var payload = text + '\\r';
487
+ var sent=0;
488
+ panes.forEach(function(p){ if (p.ws && p.ws.readyState===1){ p.ws.send(JSON.stringify({t:'i',d:payload})); sent++; } });
489
+ inp.value='';
490
+ toast('브로드캐스트 → '+sent+'개 페인');
491
+ }
492
+ }
493
+ $('reqGo').addEventListener('click', submitReq);
494
+ $('reqInput').addEventListener('keydown', function(e){ if (e.key==='Enter' && !e.isComposing){ e.preventDefault(); submitReq(); } });
495
+
496
+ // ── Review 탭: compare(diff 나란히) + merge 승자 ──
497
+ var reviewOn = false, rvTaskId = null;
498
+ function showTerminal(){ reviewOn=false; $('review').classList.remove('on'); $('vtReview').classList.remove('on'); $('vtTerm').classList.add('on'); }
499
+ function showReview(){ reviewOn=true; $('review').classList.add('on'); $('vtReview').classList.add('on'); $('vtTerm').classList.remove('on'); renderReviewPicker(); }
500
+ $('vtTerm').addEventListener('click', showTerminal);
501
+ $('vtReview').addEventListener('click', showReview);
502
+ function reviewableTasks(){
503
+ var byTask = {}; (fleet.runs||[]).forEach(function(r){ byTask[r.taskId]=(byTask[r.taskId]||0)+1; });
504
+ return (fleet.tasks||[]).filter(function(t){ return (byTask[t.id]||0)>=1; })
505
+ .sort(function(a,b){ return b.id-a.id; });
506
+ }
507
+ function renderReviewPicker(){
508
+ var tasks = reviewableTasks(); var sel=$('rvTask');
509
+ var cur = rvTaskId!=null ? String(rvTaskId) : (focusRun()!=null && runById[focusRun()] ? String(runById[focusRun()].taskId) : (tasks[0]?String(tasks[0].id):''));
510
+ sel.innerHTML='';
511
+ tasks.forEach(function(t){ var rp=repoById[t.repoId]; var o=document.createElement('option'); o.value=String(t.id);
512
+ o.textContent = (rp?rp.name+' · ':'')+t.title; sel.appendChild(o); });
513
+ if (!tasks.length){ $('rvCols').innerHTML='<div class="rv-empty">비교할 run 이 있는 태스크가 아직 없습니다. Terminal 에서 팬아웃해 보세요.</div>'; rvTaskId=null; return; }
514
+ if (cur){ sel.value=cur; } rvTaskId = Number(sel.value);
515
+ loadCompare(rvTaskId);
516
+ }
517
+ $('rvTask').addEventListener('change', function(){ rvTaskId=Number(this.value); loadCompare(rvTaskId); });
518
+ function diffHTML(text){
519
+ if (!text || !text.trim()) return '<span style="color:var(--faint)">no changes</span>';
520
+ return text.split('\\n').map(function(l){
521
+ var e = esc(l) || '&nbsp;';
522
+ if (l.indexOf('diff --git')===0 || l.indexOf('+++')===0 || l.indexOf('---')===0) return '<span class="dl-file">'+e+'</span>';
523
+ if (l.indexOf('@@')===0) return '<span class="dl-hunk">'+e+'</span>';
524
+ if (l.charAt(0)==='+') return '<span class="dl-add">'+e+'</span>';
525
+ if (l.charAt(0)==='-') return '<span class="dl-del">'+e+'</span>';
526
+ return '<span class="dl-ctx">'+e+'</span>';
527
+ }).join('');
528
+ }
529
+ async function loadCompare(taskId){
530
+ var host=$('rvCols'); host.innerHTML='<div class="rv-empty">불러오는 중…</div>';
531
+ try{
532
+ var d = await (await fetch('/api/tasks/'+taskId+'/compare')).json();
533
+ var rns = (d.runs||[]).sort(function(a,b){return a.id-b.id;});
534
+ if (!rns.length){ host.innerHTML='<div class="rv-empty">run 이 없습니다.</div>'; return; }
535
+ host.innerHTML='';
536
+ rns.forEach(function(r){
537
+ var col=document.createElement('div'); col.className='rv-col';
538
+ var st = typeof r.stat==='string' ? r.stat.split('\\n').pop().trim() : '';
539
+ var mergeable = (r.status==='done'||r.status==='open'||r.status==='merged') && r.filesChanged>0;
540
+ col.innerHTML = '<div class="rv-col-h"><span class="st '+esc(r.status)+'" style="width:7px;height:7px;border-radius:50%;display:inline-block"></span>'
541
+ + '<span class="rid">r'+r.id+'</span><span class="chip '+esc(r.status)+'">'+esc(r.status)+'</span>'
542
+ + '<span class="stat">'+esc(st)+'</span>'
543
+ + '<button class="rv-merge" data-merge="'+r.id+'"'+((r.status==='merged'||!mergeable)?' disabled':'')+'>'
544
+ + (r.status==='merged'?'merged':'merge ▸')+'</button></div>'
545
+ + '<div class="rv-diff">'+diffHTML(r.diff)+'</div>';
546
+ host.appendChild(col);
547
+ });
548
+ }catch(e){ host.innerHTML='<div class="rv-empty">compare 실패: '+esc(String(e))+'</div>'; }
549
+ }
550
+ $('rvCols').addEventListener('click', async function(e){
551
+ var b = e.target.closest('[data-merge]'); if (!b) return;
552
+ var rid = b.dataset.merge; b.disabled=true; b.textContent='merging…';
553
+ try{
554
+ var res = await fetch('/api/runs/'+rid+'/merge',{method:'POST'});
555
+ var j = await res.json().catch(function(){return{};});
556
+ if (res.ok){ toast('r'+rid+' merge 완료'); await hydrate(); if (rvTaskId!=null) loadCompare(rvTaskId); }
557
+ else { toast('merge 불가: '+(j.error||j.reason||res.status)); b.disabled=false; b.textContent='merge ▸'; }
558
+ }catch(err){ toast('merge 실패: '+err); b.disabled=false; b.textContent='merge ▸'; }
559
+ });
560
+
348
561
  // ── /ws 라이브 구독 → 트리·페인 갱신 ──
349
562
  function wsConnect(){
350
563
  var proto = location.protocol==='https:'?'wss':'ws';
@@ -356,6 +569,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
356
569
  var hydT=null;
357
570
  function scheduleHydrate(){ if (hydT) return; hydT = setTimeout(function(){ hydT=null; hydrate(); }, 400); }
358
571
 
572
+ setMode('new');
359
573
  hydrate();
360
574
  wsConnect();
361
575
  window.addEventListener('resize', function(){ panes.forEach(fitPane); });