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.
- package/package.json +1 -1
- package/src/cockpit.ts +229 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "5.
|
|
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:
|
|
110
|
-
.
|
|
111
|
-
.
|
|
112
|
-
.
|
|
113
|
-
.reqbar .
|
|
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"
|
|
123
|
-
<button type="button" class="vtab" disabled title="
|
|
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
|
|
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
|
-
<
|
|
156
|
-
|
|
157
|
-
|
|
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 ({'&':'&','<':'<','>':'>','"':'"'})[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) || ' ';
|
|
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); });
|