coxpit 5.11.0 → 5.12.1
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/LICENSE.md +717 -0
- package/README.md +5 -1
- package/package.json +2 -2
- package/src/cockpit.ts +339 -144
- package/src/server.ts +14 -0
- package/LICENSE +0 -21
package/src/cockpit.ts
CHANGED
|
@@ -74,36 +74,63 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
74
74
|
.tree-sep{height:1px;background:var(--line);margin:8px 4px}
|
|
75
75
|
.railfoot{margin-top:auto;padding:8px;color:var(--faint);font-size:11px;border-top:1px solid var(--line)}
|
|
76
76
|
|
|
77
|
-
/* ──
|
|
78
|
-
.stage{display:flex;flex-direction:column;min-width:0;background:var(--bg)}
|
|
79
|
-
.
|
|
80
|
-
.
|
|
81
|
-
.
|
|
82
|
-
.
|
|
83
|
-
.
|
|
84
|
-
.
|
|
85
|
-
.
|
|
86
|
-
.
|
|
87
|
-
.
|
|
88
|
-
.
|
|
89
|
-
.
|
|
90
|
-
.
|
|
77
|
+
/* ── 탭 바 + 분할 트리 페인 ── */
|
|
78
|
+
.stage{display:flex;flex-direction:column;min-width:0;background:var(--bg);position:relative}
|
|
79
|
+
.tabbar{display:flex;align-items:stretch;height:38px;border-bottom:1px solid var(--line);background:var(--panel);font-family:var(--mono)}
|
|
80
|
+
.tabs{flex:1;display:flex;align-items:stretch;overflow-x:auto;overflow-y:hidden}
|
|
81
|
+
.tab{display:inline-flex;align-items:center;gap:7px;padding:0 10px 0 12px;border-right:1px solid var(--line);font-size:12px;color:var(--muted);cursor:pointer;white-space:nowrap;max-width:220px;user-select:none;flex:none}
|
|
82
|
+
.tab:hover{background:var(--surface)}
|
|
83
|
+
.tab.shown{color:var(--ink);background:var(--bg);box-shadow:inset 0 -2px 0 var(--brand)}
|
|
84
|
+
.tab.drag{opacity:.4}
|
|
85
|
+
.tab .st{flex:none}
|
|
86
|
+
.tab .nm{overflow:hidden;text-overflow:ellipsis}
|
|
87
|
+
.tab .ren{font:inherit;color:var(--ink);background:var(--panel);border:1px solid var(--brand);border-radius:4px;padding:0 4px;width:120px;outline:none}
|
|
88
|
+
.tab .x{color:var(--faint);font-size:13px;padding:0 2px;border:none;background:none;cursor:pointer;line-height:1}
|
|
89
|
+
.tab .x:hover{color:var(--failed)}
|
|
90
|
+
.tabctl{display:flex;align-items:center;gap:6px;padding:0 10px;border-left:1px solid var(--line)}
|
|
91
|
+
.tc-btn{background:none;border:1px solid var(--line);border-radius:6px;color:var(--muted);font-size:11px;padding:3px 8px;cursor:pointer;font-family:var(--mono)}
|
|
92
|
+
.tc-btn:hover:not([disabled]){color:var(--ink);border-color:var(--line-hi)}
|
|
93
|
+
.tc-btn[disabled]{opacity:.4;cursor:default}
|
|
94
|
+
.tc-btn.session{color:var(--brand);border-color:rgba(78,201,176,.35)}
|
|
95
|
+
.tc-btn.session:hover:not([disabled]){background:var(--brand-dim);border-color:var(--brand)}
|
|
96
|
+
|
|
97
|
+
.panes{flex:1;display:none;min-height:0;min-width:0} /* 초기 숨김 — 탭 있을 때만 flex */
|
|
98
|
+
.node{display:flex;min-width:0;min-height:0;flex:1 1 0}
|
|
99
|
+
.node.row{flex-direction:row} .node.col{flex-direction:column}
|
|
100
|
+
.gutter{flex:none;background:var(--line);z-index:2}
|
|
101
|
+
.gutter.row{width:5px;cursor:col-resize} .gutter.col{height:5px;cursor:row-resize}
|
|
102
|
+
.gutter:hover,.gutter.drag{background:var(--brand)}
|
|
103
|
+
.leaf{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden;flex:1 1 0;background:var(--bg)}
|
|
104
|
+
/* 포커스 표시는 헤더 배경으로만 — 전체 inset 링은 터미널 안쪽 우/하단에 방해되는 선을 만든다 */
|
|
105
|
+
.leaf.drop{box-shadow:inset 0 0 0 2px var(--brand)}
|
|
106
|
+
.leaf-h{display:flex;align-items:center;gap:8px;height:26px;padding:0 6px 0 10px;background:var(--surface);border-bottom:1px solid var(--line);font-family:var(--mono);font-size:11px;color:var(--muted);flex:none;cursor:pointer}
|
|
107
|
+
.leaf.focus .leaf-h{background:var(--brand-dim)}
|
|
108
|
+
.leaf-h .nm{flex:1;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:var(--ink)}
|
|
109
|
+
.leaf-h .x{color:var(--faint);border:none;background:none;cursor:pointer;font-size:13px}
|
|
110
|
+
.leaf-h .x:hover{color:var(--failed)}
|
|
111
|
+
.chip{font-size:9px;text-transform:uppercase;letter-spacing:.04em;padding:1px 6px;border-radius:999px}
|
|
91
112
|
.chip.running{color:var(--running);background:rgba(85,167,224,.14)}
|
|
92
113
|
.chip.done,.chip.merged{color:var(--done);background:rgba(88,179,104,.14)}
|
|
93
114
|
.chip.blocked,.chip.preparing,.chip.pending,.chip.starting{color:var(--blocked);background:rgba(214,162,73,.16)}
|
|
94
115
|
.chip.failed,.chip.error{color:var(--failed);background:rgba(226,91,103,.14)}
|
|
95
116
|
.chip.open{color:var(--open);background:rgba(127,156,245,.14)}
|
|
96
117
|
.chip.stopped{color:var(--stopped);background:rgba(181,139,224,.14)}
|
|
97
|
-
.pane-h .title{flex:1;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:var(--faint)}
|
|
98
|
-
.pane-h .x{color:var(--faint);border:none;background:none;cursor:pointer;font-size:13px;padding:0 3px}
|
|
99
|
-
.pane-h .x:hover{color:var(--failed)}
|
|
100
118
|
.vbadge{font-size:9px;font-weight:600;letter-spacing:.03em;padding:1px 6px;border-radius:999px;white-space:nowrap}
|
|
101
119
|
.vbadge.pass{color:var(--done);background:rgba(88,179,104,.16)}
|
|
102
120
|
.vbadge.fail{color:var(--failed);background:rgba(226,91,103,.16)}
|
|
103
121
|
.vbadge.running{color:var(--blocked);background:rgba(214,162,73,.16)}
|
|
104
122
|
.vbadge.error{color:var(--failed);background:rgba(226,91,103,.12)}
|
|
105
|
-
.
|
|
106
|
-
.
|
|
123
|
+
.leaf-body{flex:1;min-height:0;min-width:0;display:flex}
|
|
124
|
+
.leaf-body.drop{box-shadow:inset 0 0 0 2px var(--brand);background:var(--brand-dim)}
|
|
125
|
+
.leaf-empty{flex:1;display:flex;align-items:center;justify-content:center;color:var(--faint);font-family:var(--mono);font-size:11.5px;text-align:center;padding:12px}
|
|
126
|
+
.term-host{flex:1;min-height:0;min-width:0;padding:4px 2px 2px 8px}
|
|
127
|
+
.term-host .xterm{height:100%}
|
|
128
|
+
/* xterm 스크롤바 — 기본 밝은 트랙이 우측에 하얀 세로선처럼 보임. 어둡게/투명 처리 */
|
|
129
|
+
.term-host .xterm-viewport{scrollbar-width:thin;scrollbar-color:var(--line-hi) transparent;background-color:transparent!important}
|
|
130
|
+
.term-host .xterm-viewport::-webkit-scrollbar{width:8px}
|
|
131
|
+
.term-host .xterm-viewport::-webkit-scrollbar-track{background:transparent}
|
|
132
|
+
.term-host .xterm-viewport::-webkit-scrollbar-thumb{background:var(--line-hi);border-radius:4px}
|
|
133
|
+
.term-host .xterm-viewport::-webkit-scrollbar-thumb:hover{background:var(--faint)}
|
|
107
134
|
|
|
108
135
|
.empty{flex:1;display:flex;align-items:center;justify-content:center;text-align:center;padding:24px}
|
|
109
136
|
.empty .card{max-width:440px}
|
|
@@ -152,6 +179,9 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
152
179
|
.pick-f .go{margin-left:auto;font-family:var(--mono);font-size:12px;font-weight:600;color:var(--brand-ink);background:var(--brand);border:none;border-radius:8px;padding:9px 15px;cursor:pointer}
|
|
153
180
|
.pick-f .home{font-family:var(--mono);font-size:11px;color:var(--muted);background:none;border:1px solid var(--line);border-radius:7px;padding:7px 11px;cursor:pointer}
|
|
154
181
|
.pick-f .home:hover{color:var(--ink);border-color:var(--line-hi)}
|
|
182
|
+
.pick-name{flex:1;font-family:var(--mono);font-size:12px;color:var(--ink);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:7px 10px}
|
|
183
|
+
.pick-name:focus{outline:none;border-color:var(--brand)}
|
|
184
|
+
.pick-name::placeholder{color:var(--faint)}
|
|
155
185
|
|
|
156
186
|
.lbl .lnk{color:var(--brand);cursor:pointer;font-size:10px;letter-spacing:0;text-transform:none}
|
|
157
187
|
.tnode.session{padding-left:20px;cursor:pointer} .tnode.session:hover{background:var(--surface)}
|
|
@@ -211,23 +241,25 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
211
241
|
<aside class="rail">
|
|
212
242
|
<div class="lbl"><span>Workspace</span><span id="machName" style="color:var(--faint)">local</span></div>
|
|
213
243
|
<div id="tree"></div>
|
|
214
|
-
<div class="railfoot">클릭한 run
|
|
244
|
+
<div class="railfoot">클릭한 run·세션은 <b>탭</b>으로 열립니다 · split 으로 페인을 나란히 배치</div>
|
|
215
245
|
</aside>
|
|
216
246
|
|
|
217
247
|
<main class="stage">
|
|
218
|
-
<div class="
|
|
219
|
-
<
|
|
220
|
-
<
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
248
|
+
<div class="tabbar">
|
|
249
|
+
<div class="tabs" id="tabs"></div>
|
|
250
|
+
<div class="tabctl">
|
|
251
|
+
<button class="tc-btn" id="splitRow" title="세로 분할 — 포커스 페인을 좌우로" disabled>▐ split</button>
|
|
252
|
+
<button class="tc-btn" id="splitCol" title="가로 분할 — 포커스 페인을 상하로" disabled>▬ split</button>
|
|
253
|
+
<button class="tc-btn session" id="sessionBtn" title="자유 세션(폴더 지정 터미널) 열기">+ Session</button>
|
|
254
|
+
<button class="tc-btn" id="closeBtn" title="포커스 페인 닫기(탭은 유지)" disabled>× pane</button>
|
|
255
|
+
</div>
|
|
224
256
|
</div>
|
|
225
257
|
<div class="panes" id="panes"></div>
|
|
226
258
|
<div class="empty" id="empty">
|
|
227
259
|
<div class="card">
|
|
228
260
|
<div class="glyph">⌗ ⌗ ⌗</div>
|
|
229
261
|
<h1>여기서 작업을 시작하세요</h1>
|
|
230
|
-
<p>직접 몰고 갈 <b>작업 세션</b>(자유 터미널)을 열거나, 아래 요청바로 에이전트를 팬아웃하세요. 트리의 <b>run</b> 을 클릭해도
|
|
262
|
+
<p>직접 몰고 갈 <b>작업 세션</b>(자유 터미널)을 열거나, 아래 요청바로 에이전트를 팬아웃하세요. 트리의 <b>run</b> 을 클릭해도 <b>탭</b>으로 열립니다.</p>
|
|
231
263
|
<button class="cta" id="sessionCta">+ 새 작업 세션 열기</button>
|
|
232
264
|
<div class="hint">세션 = <b>지정한 폴더</b>의 tmux 셸(특정 프로젝트에 소속되지 않음). 그 안에서 <code>claude</code> 를 띄워 “이 프로젝트 구현해줘” 처럼 직접 지시할 수 있습니다.</div>
|
|
233
265
|
</div>
|
|
@@ -275,7 +307,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
275
307
|
<div class="pick-list" id="pickList"></div>
|
|
276
308
|
<div class="pick-f">
|
|
277
309
|
<button class="home" id="pickHome" title="홈으로">⌂ home</button>
|
|
278
|
-
<
|
|
310
|
+
<input class="pick-name" id="pickName" placeholder="세션 이름 (선택 — 비우면 폴더명)" autocomplete="off" />
|
|
279
311
|
<button class="go" id="pickGo">여기서 열기</button>
|
|
280
312
|
</div>
|
|
281
313
|
</div>
|
|
@@ -328,7 +360,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
328
360
|
var repos = (fleet.repos||[]).filter(function(r){ return r.kind!=='sessions'; });
|
|
329
361
|
fillSelect($('reqRepo'), repos, function(r){return String(r.id);}, function(r){return r.name;}, true);
|
|
330
362
|
// repo 미선택 상태면 포커스 페인의 repo 로 기본
|
|
331
|
-
if (
|
|
363
|
+
var fr=(typeof focusedRunId==='function')?focusedRunId():null; if (fr!=null){ var rp = repoOfRun(fr); if (rp!=null) $('reqRepo').value=String(rp); }
|
|
332
364
|
var provs = fleet.providers||[];
|
|
333
365
|
if (provs.length) fillSelect($('reqAgent'), provs, function(p){return p.id;}, function(p){return p.label||p.id;}, true);
|
|
334
366
|
else if (!$('reqAgent').options.length){ var o=document.createElement('option'); o.value='claude-code'; o.textContent='claude-code'; $('reqAgent').appendChild(o); }
|
|
@@ -353,7 +385,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
353
385
|
html += '<div class="lbl"><span>Sessions</span><span class="lnk" data-newsession="1">+ 새 세션</span></div>';
|
|
354
386
|
if (sessRuns.length){
|
|
355
387
|
sessRuns.forEach(function(s){
|
|
356
|
-
var r=s.run; var open =
|
|
388
|
+
var r=s.run; var open = tabs[r.id] ? ' open' : '';
|
|
357
389
|
html += '<div class="tnode session'+open+'" data-run="'+r.id+'"><span class="st '+esc(r.status)+'"></span>'
|
|
358
390
|
+ '<span class="n">'+esc(s.title||'session')+'</span><span class="p">'+esc((r.worktreePath||'').replace(/^.*\\/([^/]+\\/[^/]+)$/,'…/$1'))+'</span></div>';
|
|
359
391
|
});
|
|
@@ -389,7 +421,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
389
421
|
var s = '<div class="tnode task" data-fold="'+tk+'"><span class="car">'+(rns.length?(isFold(tk)?'▸':'▾'):' ')+'</span>'
|
|
390
422
|
+ '<span class="n">'+esc(t.title)+'</span></div>';
|
|
391
423
|
if (!isFold(tk)) rns.sort(function(a,b){return a.id-b.id;}).forEach(function(r){
|
|
392
|
-
var open =
|
|
424
|
+
var open = tabs[r.id] ? ' open' : '';
|
|
393
425
|
s += '<div class="tnode run'+open+'" data-run="'+r.id+'"><span class="st '+esc(r.status)+'"></span>'
|
|
394
426
|
+ '<span class="n">r'+r.id+' · '+esc(r.status)+'</span></div>';
|
|
395
427
|
});
|
|
@@ -402,128 +434,290 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
402
434
|
var fn = e.target.closest('[data-fold]');
|
|
403
435
|
if (fn){ var k = fn.dataset.fold; fold[k] = !isFold(k); renderTree(); }
|
|
404
436
|
});
|
|
437
|
+
// 트리 세션 행 더블클릭 → 이름 변경
|
|
438
|
+
$('tree').addEventListener('dblclick', function(e){
|
|
439
|
+
var s = e.target.closest('.tnode.session[data-run]'); if(!s) return;
|
|
440
|
+
var runId=+s.dataset.run; var r=runById[runId]; if(!r) return;
|
|
441
|
+
var cur=(taskById[r.taskId]||{}).title||''; var v=prompt('세션 이름', cur);
|
|
442
|
+
if(v!=null && v.trim()) renameTask(r.taskId, v.trim());
|
|
443
|
+
});
|
|
405
444
|
|
|
406
|
-
// ──
|
|
407
|
-
|
|
408
|
-
var
|
|
409
|
-
var
|
|
410
|
-
var
|
|
411
|
-
var
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
function setFocus(id){
|
|
433
|
-
focusId = id;
|
|
434
|
-
panes.forEach(function(p){ p.el.classList.toggle('focus', p.id===id); });
|
|
435
|
-
$('closeBtn').disabled = !focusId;
|
|
436
|
-
var p = panes.find(function(x){return x.id===id;});
|
|
437
|
-
if (p && p.term) try{ p.term.focus(); }catch(e){}
|
|
438
|
-
if (typeof reqMode!=='undefined' && reqMode!=='new') setMode(reqMode);
|
|
445
|
+
// ── 탭 + 분할 트리 페인 ──
|
|
446
|
+
// 탭 = 열린 세션/run. 탭이 xterm·WS 를 소유(안 보여도 살아있음). 슬롯(leaf)에 배치돼 표시된다.
|
|
447
|
+
var tabs = {}; // runId -> { runId, name, term, fit, ws, retry, closing, ro, host }
|
|
448
|
+
var tabOrder = []; // 탭 바 순서(runId)
|
|
449
|
+
var layout = { leaf:true, id:'L0', tab:null }; // 분할 트리 루트
|
|
450
|
+
var focusLeaf = 'L0';
|
|
451
|
+
var leafSeq = 1;
|
|
452
|
+
var MAX_LEAVES = 6;
|
|
453
|
+
var dragRunId = null;
|
|
454
|
+
|
|
455
|
+
function newLeafId(){ return 'L'+(leafSeq++); }
|
|
456
|
+
function eachLeaf(node, fn){ if(node.leaf){ fn(node); } else { eachLeaf(node.a,fn); eachLeaf(node.b,fn); } }
|
|
457
|
+
function findLeaf(id, node){ node=node||layout; if(node.leaf) return node.id===id?node:null; return findLeaf(id,node.a)||findLeaf(id,node.b); }
|
|
458
|
+
function findSplit(id, node){ node=node||layout; if(node.leaf) return null; if(node.id===id) return node; return findSplit(id,node.a)||findSplit(id,node.b); }
|
|
459
|
+
function leafOfTab(runId){ var f=null; eachLeaf(layout,function(l){ if(l.tab===runId) f=l; }); return f; }
|
|
460
|
+
function firstLeaf(){ var f=null; eachLeaf(layout,function(l){ if(!f) f=l; }); return f; }
|
|
461
|
+
function countLeaves(){ var n=0; eachLeaf(layout,function(){n++;}); return n; }
|
|
462
|
+
function focusedRunId(){ var l=findLeaf(focusLeaf); return l?l.tab:null; }
|
|
463
|
+
function focusRun(){ return focusedRunId(); } // 기존 호출부(steer/review) 호환
|
|
464
|
+
function copyInto(dst,src){ Object.keys(dst).forEach(function(k){delete dst[k];}); Object.keys(src).forEach(function(k){dst[k]=src[k];}); }
|
|
465
|
+
|
|
466
|
+
function tabName(runId){
|
|
467
|
+
var r=runById[runId]; if(!r) return 'r'+runId;
|
|
468
|
+
var task=taskById[r.taskId]; var rp=task&&repoById[task.repoId];
|
|
469
|
+
if (rp && rp.kind==='sessions') return (task&&task.title)||('session r'+runId);
|
|
470
|
+
return 'r'+runId;
|
|
439
471
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
if (
|
|
443
|
-
|
|
444
|
-
var
|
|
445
|
-
var p = { id: ++paneSeq, runId: runId, retry: 0, closing: false };
|
|
446
|
-
var el = document.createElement('div'); el.className = 'pane'; p.el = el;
|
|
447
|
-
el.innerHTML = '<div class="pane-h"><span class="rid">r'+runId+'</span>'
|
|
448
|
-
+ '<span class="chip '+esc(r?r.status:'')+'" data-role="chip">'+esc(r?r.status:'')+'</span>'
|
|
449
|
-
+ '<span data-role="vslot">'+vbadge(r&&r.verifyStatus)+'</span>'
|
|
450
|
-
+ '<span class="title" data-role="title">'+esc(r&&r.tmuxWindow||'terminal')+'</span>'
|
|
451
|
-
+ '<button class="x" title="close">×</button></div>'
|
|
452
|
-
+ '<div class="pane-term"></div>';
|
|
453
|
-
$('panes').appendChild(el);
|
|
454
|
-
el.querySelector('.pane-h').addEventListener('click', function(ev){
|
|
455
|
-
if (ev.target.closest('.x')) return; setFocus(p.id);
|
|
456
|
-
});
|
|
457
|
-
el.querySelector('.x').addEventListener('click', function(){ closePane(p.id); });
|
|
458
|
-
// xterm
|
|
459
|
-
var term = new window.Terminal({
|
|
472
|
+
|
|
473
|
+
function ensureTab(runId){
|
|
474
|
+
if (tabs[runId]) return tabs[runId];
|
|
475
|
+
var host=document.createElement('div'); host.className='term-host';
|
|
476
|
+
var term=new window.Terminal({
|
|
460
477
|
fontFamily: "ui-monospace, 'SF Mono', Menlo, Monaco, 'Apple SD Gothic Neo', 'Noto Sans KR', monospace",
|
|
461
478
|
fontSize: 12, cursorBlink: true, allowProposedApi: true, scrollback: 4000,
|
|
462
479
|
theme: { background:'#0b0d12', foreground:'#dee4ec', cursor:'#4ec9b0', selectionBackground:'rgba(78,201,176,.25)', black:'#1c212c', brightBlack:'#5c6675' },
|
|
463
480
|
});
|
|
464
|
-
|
|
465
|
-
p.fit = new window.FitAddon.FitAddon(); term.loadAddon(p.fit);
|
|
481
|
+
var fit=new window.FitAddon.FitAddon(); term.loadAddon(fit);
|
|
466
482
|
try{ term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion='11'; }catch(e){}
|
|
467
|
-
term.open(
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
function connectPane(p){
|
|
477
|
-
if (p.closing || !p.term) return;
|
|
478
|
-
try{ p.fit.fit(); }catch(e){}
|
|
483
|
+
// term.open 은 host 가 DOM 에 붙은 뒤(attachHosts) 최초 1회 — detached 에서 open 하면 렌더러가 안 뜬다.
|
|
484
|
+
var t={ runId:runId, name:tabName(runId), term:term, fit:fit, ws:null, retry:0, closing:false, host:host, ro:null, opened:false, connected:false };
|
|
485
|
+
term.onData(function(d){ if(t.ws&&t.ws.readyState===1) t.ws.send(JSON.stringify({t:'i',d:d})); });
|
|
486
|
+
tabs[runId]=t; tabOrder.push(runId);
|
|
487
|
+
return t; // open·connect 는 attachHosts 에서(Phase 2 순서: open → connect)
|
|
488
|
+
}
|
|
489
|
+
function connectTab(t){
|
|
490
|
+
if (t.closing || !t.term) return;
|
|
491
|
+
try{ t.fit.fit(); }catch(e){}
|
|
479
492
|
var proto = location.protocol==='https:'?'wss':'ws';
|
|
480
|
-
var sock = new WebSocket(proto+'://'+location.host+'/ws/term/'+
|
|
481
|
-
|
|
482
|
-
sock.onopen = function(){ if (sock!==
|
|
493
|
+
var sock = new WebSocket(proto+'://'+location.host+'/ws/term/'+t.runId+'?cols='+t.term.cols+'&rows='+t.term.rows);
|
|
494
|
+
t.ws = sock;
|
|
495
|
+
sock.onopen = function(){ if (sock!==t.ws){ try{sock.close();}catch(e){} return; } t.retry=0; };
|
|
483
496
|
sock.onmessage = function(m){
|
|
484
|
-
if (sock!==
|
|
497
|
+
if (sock!==t.ws || !t.term) return;
|
|
485
498
|
try{ var d = JSON.parse(m.data);
|
|
486
|
-
if (d.t==='o')
|
|
487
|
-
else if (d.t==='err')
|
|
488
|
-
else if (d.t==='exit')
|
|
499
|
+
if (d.t==='o') t.term.write(d.d);
|
|
500
|
+
else if (d.t==='err') t.term.write('\\r\\n\\x1b[31m'+d.d+'\\x1b[0m\\r\\n');
|
|
501
|
+
else if (d.t==='exit') t.term.write('\\r\\n\\x1b[90m[session ended — 재연결 시 소생]\\x1b[0m\\r\\n');
|
|
489
502
|
}catch(e){}
|
|
490
503
|
};
|
|
491
504
|
sock.onclose = function(){
|
|
492
|
-
if (sock!==
|
|
493
|
-
var delay = Math.min(8000, 800 * Math.pow(2,
|
|
494
|
-
setTimeout(function(){
|
|
505
|
+
if (sock!==t.ws || t.closing) return;
|
|
506
|
+
var delay = Math.min(8000, 800 * Math.pow(2, t.retry++));
|
|
507
|
+
setTimeout(function(){ connectTab(t); }, delay);
|
|
495
508
|
};
|
|
496
509
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
510
|
+
|
|
511
|
+
function fitTab(t){ if(!t||!t.fit||!t.term) return; try{ t.fit.fit(); if(t.ws&&t.ws.readyState===1) t.ws.send(JSON.stringify({t:'r',cols:t.term.cols,rows:t.term.rows})); }catch(e){} }
|
|
512
|
+
function fitAllVisible(){ eachLeaf(layout,function(l){ if(l.tab!=null && tabs[l.tab] && tabs[l.tab].host.isConnected) fitTab(tabs[l.tab]); }); }
|
|
513
|
+
|
|
514
|
+
// ── 탭 바 렌더(터미널 없음 — 언제든 안전) ──
|
|
515
|
+
function renderTabs(){
|
|
516
|
+
var shown={}; eachLeaf(layout,function(l){ if(l.tab!=null) shown[l.tab]=true; });
|
|
517
|
+
var html='';
|
|
518
|
+
tabOrder.forEach(function(runId){
|
|
519
|
+
var t=tabs[runId]; if(!t) return; var r=runById[runId];
|
|
520
|
+
html += '<div class="tab'+(shown[runId]?' shown':'')+'" draggable="true" data-tab="'+runId+'" title="더블클릭=이름변경 · 드래그=페인에 배치">'
|
|
521
|
+
+ '<span class="st '+esc(r?r.status:'')+'"></span>'
|
|
522
|
+
+ '<span class="nm">'+esc(t.name)+'</span>'
|
|
523
|
+
+ '<button class="x" title="탭 닫기(터미널 종료)">×</button></div>';
|
|
524
|
+
});
|
|
525
|
+
$('tabs').innerHTML = html;
|
|
526
|
+
}
|
|
527
|
+
function buildNode(node){
|
|
528
|
+
if (node.leaf){
|
|
529
|
+
var leaf=document.createElement('div'); leaf.className='leaf'+(node.id===focusLeaf?' focus':''); leaf.dataset.leaf=node.id;
|
|
530
|
+
var t=node.tab!=null?tabs[node.tab]:null; var r=node.tab!=null?runById[node.tab]:null;
|
|
531
|
+
var head=document.createElement('div'); head.className='leaf-h'; head.dataset.leafhead=node.id;
|
|
532
|
+
head.innerHTML = t
|
|
533
|
+
? '<span class="st '+esc(r?r.status:'')+'"></span><span class="nm">'+esc(t.name)+'</span>'
|
|
534
|
+
+ '<span data-role="chip" class="chip '+esc(r?r.status:'')+'">'+esc(r?r.status:'')+'</span>'
|
|
535
|
+
+ '<span data-role="vslot">'+vbadge(r&&r.verifyStatus)+'</span>'
|
|
536
|
+
+ '<button class="x" title="이 페인 닫기(탭은 유지)">×</button>'
|
|
537
|
+
: '<span class="nm" style="color:var(--faint)">빈 페인</span><button class="x" title="이 페인 닫기">×</button>';
|
|
538
|
+
var body=document.createElement('div'); body.className='leaf-body'; body.dataset.leafbody=node.id;
|
|
539
|
+
if (!t){ var em=document.createElement('div'); em.className='leaf-empty'; em.textContent='탭을 여기로 드래그하거나 탭을 클릭하세요'; body.appendChild(em); }
|
|
540
|
+
leaf.appendChild(head); leaf.appendChild(body); return leaf;
|
|
541
|
+
}
|
|
542
|
+
var el=document.createElement('div'); el.className='node '+(node.dir==='col'?'col':'row'); el.dataset.split=node.id;
|
|
543
|
+
var a=buildNode(node.a), b=buildNode(node.b);
|
|
544
|
+
a.style.flexGrow=String(node.ratio); a.style.flexBasis='0';
|
|
545
|
+
b.style.flexGrow=String(1-node.ratio); b.style.flexBasis='0';
|
|
546
|
+
var g=document.createElement('div'); g.className='gutter '+(node.dir==='col'?'col':'row'); g.dataset.gutter=node.id;
|
|
547
|
+
el.appendChild(a); el.appendChild(g); el.appendChild(b); return el;
|
|
548
|
+
}
|
|
549
|
+
// host 를 DOM 에 붙이고, 최초 1회 open → connect(Phase 2 순서). fit 은 render 의 rAF 에서.
|
|
550
|
+
function attachHosts(){ eachLeaf(layout,function(l){ if(l.tab==null||!tabs[l.tab]) return; var t=tabs[l.tab]; var body=$('panes').querySelector('[data-leafbody="'+l.id+'"]'); if(!body) return; body.appendChild(t.host);
|
|
551
|
+
if(!t.opened){ try{ t.term.open(t.host); t.opened=true; }catch(e){} }
|
|
552
|
+
if(!t.connected){ t.connected=true; connectTab(t); }
|
|
553
|
+
}); }
|
|
554
|
+
function updateControls(){
|
|
555
|
+
var has=tabOrder.length>0; var canSplit=has && countLeaves()<MAX_LEAVES;
|
|
556
|
+
$('splitRow').disabled=!canSplit; $('splitCol').disabled=!canSplit; $('closeBtn').disabled=!has;
|
|
557
|
+
}
|
|
558
|
+
// 구조 변경 시 전체 재구성(탭 열기·닫기·분할·드롭·리사이즈완료)
|
|
559
|
+
function render(){
|
|
560
|
+
var n=tabOrder.length;
|
|
561
|
+
$('empty').style.display = n?'none':'flex';
|
|
562
|
+
$('panes').style.display = n?'flex':'none';
|
|
563
|
+
renderTabs();
|
|
564
|
+
var host=$('panes'); host.innerHTML='';
|
|
565
|
+
if (n){ host.appendChild(buildNode(layout)); attachHosts(); }
|
|
566
|
+
updateControls();
|
|
567
|
+
if (typeof reqMode!=='undefined') setMode(reqMode);
|
|
568
|
+
requestAnimationFrame(fitAllVisible);
|
|
569
|
+
setTimeout(fitAllVisible, 60); // 레이아웃 확정 후 재핏(초기 0-size 보정)
|
|
570
|
+
}
|
|
571
|
+
function setLeafFocus(id){
|
|
572
|
+
focusLeaf=id;
|
|
573
|
+
Array.prototype.forEach.call($('panes').querySelectorAll('.leaf'),function(el){ el.classList.toggle('focus', el.dataset.leaf===id); });
|
|
574
|
+
var rid=focusedRunId(); if(rid!=null && tabs[rid]) try{ tabs[rid].term.focus(); }catch(e){}
|
|
575
|
+
updateControls();
|
|
576
|
+
if (typeof reqMode!=='undefined' && reqMode!=='new') setMode(reqMode);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// 탭 열기 = 포커스 슬롯에 표시(강제 분할 없음). 기존 호출부(openRunPane) 호환.
|
|
580
|
+
function openTab(runId){
|
|
581
|
+
ensureTab(runId);
|
|
582
|
+
var l=leafOfTab(runId);
|
|
583
|
+
if (l){ setLeafFocus(l.id); return; }
|
|
584
|
+
var f=findLeaf(focusLeaf) || firstLeaf();
|
|
585
|
+
if (!f){ layout={leaf:true,id:'L0',tab:runId}; focusLeaf='L0'; }
|
|
586
|
+
else { f.tab=runId; focusLeaf=f.id; }
|
|
587
|
+
render(); renderTree();
|
|
588
|
+
}
|
|
589
|
+
function openRunPane(runId){ return openTab(runId); }
|
|
590
|
+
|
|
591
|
+
function splitFocused(dir){
|
|
592
|
+
if (!tabOrder.length) return;
|
|
593
|
+
if (countLeaves()>=MAX_LEAVES){ toast('페인 최대 '+MAX_LEAVES+'개'); return; }
|
|
594
|
+
var l=findLeaf(focusLeaf) || firstLeaf(); if(!l) return;
|
|
595
|
+
var keep=l.tab; var aId=l.id, bId=newLeafId();
|
|
596
|
+
delete l.tab; delete l.leaf; l.id=newLeafId(); l.split=true; l.dir=dir; l.ratio=0.5;
|
|
597
|
+
l.a={leaf:true,id:aId,tab:keep}; l.b={leaf:true,id:bId,tab:null};
|
|
598
|
+
focusLeaf=bId; render(); renderTree();
|
|
599
|
+
}
|
|
600
|
+
function closeSlot(id){
|
|
601
|
+
if (countLeaves()<=1){ var only=findLeaf(id)||firstLeaf(); if(only) only.tab=null; if(only) focusLeaf=only.id; render(); renderTree(); return; }
|
|
602
|
+
(function walk(node){
|
|
603
|
+
if(node.leaf) return false;
|
|
604
|
+
if(node.a.leaf && node.a.id===id){ copyInto(node,node.b); return true; }
|
|
605
|
+
if(node.b.leaf && node.b.id===id){ copyInto(node,node.a); return true; }
|
|
606
|
+
return walk(node.a)||walk(node.b);
|
|
607
|
+
})(layout);
|
|
608
|
+
if(!findLeaf(focusLeaf)){ var f=firstLeaf(); focusLeaf=f?f.id:'L0'; }
|
|
609
|
+
render(); renderTree();
|
|
610
|
+
}
|
|
611
|
+
function closeTab(runId){
|
|
612
|
+
var t=tabs[runId]; if(!t) return; t.closing=true;
|
|
613
|
+
try{ if(t.ws) t.ws.close(); }catch(e){}
|
|
614
|
+
try{ if(t.ro) t.ro.disconnect(); }catch(e){}
|
|
615
|
+
try{ t.term.dispose(); }catch(e){}
|
|
616
|
+
eachLeaf(layout,function(l){ if(l.tab===runId) l.tab=null; });
|
|
617
|
+
delete tabs[runId]; tabOrder=tabOrder.filter(function(x){return x!==runId;});
|
|
618
|
+
render(); renderTree();
|
|
619
|
+
}
|
|
620
|
+
// 팬아웃 — N개 탭을 만들고 자동 타일 배치(비교용)
|
|
621
|
+
function buildTiled(ids,dir){
|
|
622
|
+
if(ids.length===1) return {leaf:true,id:newLeafId(),tab:ids[0]};
|
|
623
|
+
var mid=Math.ceil(ids.length/2);
|
|
624
|
+
return {split:true,id:newLeafId(),dir:dir,ratio:mid/ids.length,
|
|
625
|
+
a:buildTiled(ids.slice(0,mid),dir==='row'?'col':'row'),
|
|
626
|
+
b:buildTiled(ids.slice(mid),dir==='row'?'col':'row')};
|
|
627
|
+
}
|
|
628
|
+
function tileTabs(ids){
|
|
629
|
+
ids=(ids||[]).filter(function(x){return x!=null;}); if(!ids.length) return;
|
|
630
|
+
ids.forEach(ensureTab);
|
|
631
|
+
layout=buildTiled(ids,'row'); var f=firstLeaf(); focusLeaf=f?f.id:'L0';
|
|
632
|
+
render(); renderTree();
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// fleet 갱신 시 라벨·상태만 갱신(페인 DOM 재구성 X — 터미널 유지). 사라진 run 탭 정리.
|
|
512
636
|
function syncPanes(){
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
if
|
|
518
|
-
|
|
637
|
+
tabOrder.slice().forEach(function(runId){ if(!runById[runId]) closeTab(runId); });
|
|
638
|
+
tabOrder.forEach(function(runId){ var t=tabs[runId]; if(t) t.name=tabName(runId); });
|
|
639
|
+
renderTabs();
|
|
640
|
+
eachLeaf(layout,function(l){
|
|
641
|
+
if(l.tab==null) return; var r=runById[l.tab]; if(!r) return;
|
|
642
|
+
var head=$('panes').querySelector('[data-leafhead="'+l.id+'"]'); if(!head) return;
|
|
643
|
+
var nm=head.querySelector('.nm'); if(nm && tabs[l.tab]) nm.textContent=tabs[l.tab].name;
|
|
644
|
+
var st=head.querySelector('.st'); if(st) st.className='st '+r.status;
|
|
645
|
+
var chip=head.querySelector('[data-role=chip]'); if(chip){ chip.className='chip '+r.status; chip.textContent=r.status; }
|
|
646
|
+
var vslot=head.querySelector('[data-role=vslot]'); if(vslot) vslot.innerHTML=vbadge(r.verifyStatus);
|
|
519
647
|
});
|
|
520
648
|
}
|
|
521
|
-
|
|
649
|
+
|
|
650
|
+
// 인라인 이름 변경(탭 더블클릭)
|
|
651
|
+
function startRename(runId, tabEl){
|
|
652
|
+
var r=runById[runId]; var t=tabs[runId]; if(!r||!t) return;
|
|
653
|
+
var nm=tabEl.querySelector('.nm'); if(!nm) return;
|
|
654
|
+
var input=document.createElement('input'); input.className='ren'; input.value=t.name;
|
|
655
|
+
nm.replaceWith(input); input.focus(); input.select();
|
|
656
|
+
var done=false;
|
|
657
|
+
function finish(commit){ if(done) return; done=true;
|
|
658
|
+
var val=input.value.trim();
|
|
659
|
+
var span=document.createElement('span'); span.className='nm'; span.textContent=(commit&&val)?val:t.name;
|
|
660
|
+
input.replaceWith(span);
|
|
661
|
+
if(commit && val && val!==t.name) renameTask(r.taskId, val);
|
|
662
|
+
}
|
|
663
|
+
input.addEventListener('keydown', function(e){ e.stopPropagation(); if(e.key==='Enter'){ e.preventDefault(); finish(true); } else if(e.key==='Escape'){ finish(false); } });
|
|
664
|
+
input.addEventListener('blur', function(){ finish(true); });
|
|
665
|
+
input.addEventListener('click', function(e){ e.stopPropagation(); });
|
|
666
|
+
}
|
|
667
|
+
async function renameTask(taskId, title){
|
|
668
|
+
try{
|
|
669
|
+
var res=await fetch('/api/tasks/'+taskId,{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({title:title})});
|
|
670
|
+
if(res.ok){ if(taskById[taskId]) taskById[taskId].title=title; toast('이름 변경 · '+title); await hydrate(); }
|
|
671
|
+
else { var j=await res.json().catch(function(){return{};}); toast('이름 변경 실패: '+(j.error||res.status)); }
|
|
672
|
+
}catch(e){ toast('이름 변경 실패: '+e); }
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// ── 탭 바 이벤트 ──
|
|
676
|
+
$('tabs').addEventListener('click', function(e){
|
|
677
|
+
var tabEl=e.target.closest('[data-tab]'); if(!tabEl) return; var runId=+tabEl.dataset.tab;
|
|
678
|
+
if (e.target.closest('.x')){ closeTab(runId); return; }
|
|
679
|
+
openTab(runId);
|
|
680
|
+
});
|
|
681
|
+
$('tabs').addEventListener('dblclick', function(e){ var tabEl=e.target.closest('[data-tab]'); if(tabEl) startRename(+tabEl.dataset.tab, tabEl); });
|
|
682
|
+
$('tabs').addEventListener('dragstart', function(e){ var tabEl=e.target.closest('[data-tab]'); if(!tabEl) return; dragRunId=+tabEl.dataset.tab; tabEl.classList.add('drag'); try{ e.dataTransfer.effectAllowed='move'; e.dataTransfer.setData('text/plain', String(dragRunId)); }catch(_){} });
|
|
683
|
+
$('tabs').addEventListener('dragend', function(e){ var tabEl=e.target.closest('[data-tab]'); if(tabEl) tabEl.classList.remove('drag'); dragRunId=null; });
|
|
684
|
+
|
|
685
|
+
// ── 페인(슬롯) 이벤트: 포커스·닫기·드롭·리사이즈 ──
|
|
686
|
+
$('panes').addEventListener('click', function(e){
|
|
687
|
+
var leaf=e.target.closest('[data-leaf]'); if(!leaf) return;
|
|
688
|
+
if (e.target.closest('.leaf-h .x')){ closeSlot(leaf.dataset.leaf); return; }
|
|
689
|
+
setLeafFocus(leaf.dataset.leaf);
|
|
690
|
+
});
|
|
691
|
+
$('panes').addEventListener('dragover', function(e){ var body=e.target.closest('[data-leafbody]'); if(body && dragRunId!=null){ e.preventDefault(); body.classList.add('drop'); } });
|
|
692
|
+
$('panes').addEventListener('dragleave', function(e){ var body=e.target.closest('[data-leafbody]'); if(body) body.classList.remove('drop'); });
|
|
693
|
+
$('panes').addEventListener('drop', function(e){
|
|
694
|
+
var body=e.target.closest('[data-leafbody]'); if(!body || dragRunId==null) return; e.preventDefault(); body.classList.remove('drop');
|
|
695
|
+
var l=findLeaf(body.dataset.leafbody); if(!l) return;
|
|
696
|
+
var rid=dragRunId; eachLeaf(layout,function(x){ if(x.tab===rid) x.tab=null; });
|
|
697
|
+
ensureTab(rid); l.tab=rid; focusLeaf=l.id; render(); renderTree();
|
|
698
|
+
});
|
|
699
|
+
$('panes').addEventListener('mousedown', function(e){
|
|
700
|
+
var g=e.target.closest('[data-gutter]'); if(!g) return; e.preventDefault();
|
|
701
|
+
var node=findSplit(g.dataset.gutter); if(!node) return;
|
|
702
|
+
var container=g.parentElement; var rect=container.getBoundingClientRect(); var horiz=node.dir==='row';
|
|
703
|
+
g.classList.add('drag');
|
|
704
|
+
function mv(ev){
|
|
705
|
+
var pos=horiz?(ev.clientX-rect.left)/rect.width:(ev.clientY-rect.top)/rect.height;
|
|
706
|
+
node.ratio=Math.max(0.1,Math.min(0.9,pos));
|
|
707
|
+
var a=container.children[0], b=container.children[2];
|
|
708
|
+
if(a&&b){ a.style.flexGrow=String(node.ratio); b.style.flexGrow=String(1-node.ratio); }
|
|
709
|
+
}
|
|
710
|
+
function up(){ document.removeEventListener('mousemove',mv); document.removeEventListener('mouseup',up); g.classList.remove('drag'); fitAllVisible(); }
|
|
711
|
+
document.addEventListener('mousemove',mv); document.addEventListener('mouseup',up);
|
|
712
|
+
});
|
|
713
|
+
$('splitRow').addEventListener('click', function(){ splitFocused('row'); });
|
|
714
|
+
$('splitCol').addEventListener('click', function(){ splitFocused('col'); });
|
|
715
|
+
$('closeBtn').addEventListener('click', function(){ if(focusLeaf) closeSlot(focusLeaf); });
|
|
522
716
|
|
|
523
717
|
// ── 자유 세션 — 폴더를 지정해 tmux 셸(프로젝트 비소속). ──
|
|
524
718
|
var pickPathCur = '';
|
|
525
719
|
function machineSlug(){ return (fleet.machines && fleet.machines[0] && fleet.machines[0].slug) || 'local'; }
|
|
526
|
-
function openSession(){ $('pickModal').classList.add('on'); browseTo(''); }
|
|
720
|
+
function openSession(){ $('pickModal').classList.add('on'); $('pickName').value=''; browseTo(''); }
|
|
527
721
|
function closePicker(){ $('pickModal').classList.remove('on'); }
|
|
528
722
|
async function browseTo(p){
|
|
529
723
|
try{
|
|
@@ -549,20 +743,21 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
549
743
|
if (openingSession || !pickPathCur) return;
|
|
550
744
|
openingSession=true; $('pickGo').disabled=true;
|
|
551
745
|
try{
|
|
746
|
+
var nm = $('pickName').value.trim();
|
|
552
747
|
var res = await fetch('/api/session',{method:'POST',headers:{'content-type':'application/json'},
|
|
553
|
-
body:JSON.stringify({machineSlug:machineSlug(), path:pickPathCur})});
|
|
748
|
+
body:JSON.stringify({machineSlug:machineSlug(), path:pickPathCur, title:nm})});
|
|
554
749
|
var j = await res.json().catch(function(){return{};});
|
|
555
|
-
if (res.ok && j.runId){ closePicker(); await hydrate(); openRunPane(j.runId); toast('세션 열림 · '+pickPathCur); }
|
|
750
|
+
if (res.ok && j.runId){ closePicker(); await hydrate(); openRunPane(j.runId); toast('세션 열림 · '+(nm||pickPathCur)); }
|
|
556
751
|
else toast('세션 실패: '+(j.detail||j.error||res.status));
|
|
557
752
|
}catch(e){ toast('세션 실패: '+e); }
|
|
558
753
|
finally{ openingSession=false; $('pickGo').disabled=false; }
|
|
559
754
|
});
|
|
755
|
+
$('pickName').addEventListener('keydown', function(e){ if(e.key==='Enter'){ e.preventDefault(); $('pickGo').click(); } });
|
|
560
756
|
$('sessionBtn').addEventListener('click', openSession);
|
|
561
757
|
$('sessionCta').addEventListener('click', openSession);
|
|
562
758
|
|
|
563
759
|
// ── 요청바: New(팬아웃) / Steer / Broadcast ──
|
|
564
760
|
var reqMode = 'new';
|
|
565
|
-
function focusRun(){ if (!focusId) return null; var p = panes.find(function(x){return x.id===focusId;}); return p?p.runId:null; }
|
|
566
761
|
function setMode(m){
|
|
567
762
|
reqMode = m;
|
|
568
763
|
Array.prototype.forEach.call(document.querySelectorAll('.mode'), function(b){ b.classList.toggle('on', b.dataset.mode===m); });
|
|
@@ -574,9 +769,9 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
574
769
|
else if (m==='steer'){ var rid=focusRun(); go.textContent='Steer ⏎';
|
|
575
770
|
$('reqTgt').textContent = rid?('➤ r'+rid+' 에 후속 지시'):'포커스한 페인 없음';
|
|
576
771
|
inp.placeholder = rid?('r'+rid+' 에이전트에게 다음 지시…'):'왼쪽 트리에서 run 을 열어 포커스하세요'; }
|
|
577
|
-
else { go.textContent='Send ⏎'; var n=
|
|
578
|
-
$('reqTgt').textContent = '⊞ 열린
|
|
579
|
-
inp.placeholder = n?('열린 '+n+'개 터미널에 그대로 전송 (엔터 포함)'):'열린
|
|
772
|
+
else { go.textContent='Send ⏎'; var n=tabOrder.length;
|
|
773
|
+
$('reqTgt').textContent = '⊞ 열린 탭 '+n+'개에 브로드캐스트';
|
|
774
|
+
inp.placeholder = n?('열린 '+n+'개 터미널에 그대로 전송 (엔터 포함)'):'열린 탭이 없습니다'; }
|
|
580
775
|
}
|
|
581
776
|
Array.prototype.forEach.call(document.querySelectorAll('.mode'), function(b){ b.addEventListener('click', function(){ setMode(b.dataset.mode); $('reqInput').focus(); }); });
|
|
582
777
|
|
|
@@ -598,8 +793,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
598
793
|
var ids = (rr&&rr.runs||[]).map(function(x){return x.id;});
|
|
599
794
|
inp.value='';
|
|
600
795
|
await hydrate();
|
|
601
|
-
//
|
|
602
|
-
ids
|
|
796
|
+
// 팬아웃은 비교가 목적 — N개 탭을 만들고 자동 타일 배치
|
|
797
|
+
tileTabs(ids);
|
|
603
798
|
toast(ids.length+'개 에이전트 팬아웃 · r'+ids.join(' r'));
|
|
604
799
|
}catch(e){ toast('요청 실패: '+e); }
|
|
605
800
|
finally{ submitting=false; $('reqGo').disabled=false; }
|
|
@@ -615,12 +810,12 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
615
810
|
}catch(e){ toast('steer 실패: '+e); }
|
|
616
811
|
finally{ submitting=false; $('reqGo').disabled=false; }
|
|
617
812
|
} else { // broadcast
|
|
618
|
-
if (!
|
|
813
|
+
if (!tabOrder.length){ toast('열린 탭이 없습니다'); return; }
|
|
619
814
|
var payload = text + '\\r';
|
|
620
815
|
var sent=0;
|
|
621
|
-
|
|
816
|
+
tabOrder.forEach(function(rid){ var t=tabs[rid]; if (t && t.ws && t.ws.readyState===1){ t.ws.send(JSON.stringify({t:'i',d:payload})); sent++; } });
|
|
622
817
|
inp.value='';
|
|
623
|
-
toast('브로드캐스트 → '+sent+'개
|
|
818
|
+
toast('브로드캐스트 → '+sent+'개 탭');
|
|
624
819
|
}
|
|
625
820
|
}
|
|
626
821
|
$('reqGo').addEventListener('click', submitReq);
|
|
@@ -749,10 +944,10 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
749
944
|
function scheduleHydrate(){ if (hydT) return; hydT = setTimeout(function(){ hydT=null; hydrate(); }, 400); }
|
|
750
945
|
|
|
751
946
|
setMode('new');
|
|
752
|
-
|
|
947
|
+
render(); // 초기 빈 상태 정합(탭 없음 → empty)
|
|
753
948
|
hydrate();
|
|
754
949
|
wsConnect();
|
|
755
|
-
window.addEventListener('resize',
|
|
950
|
+
window.addEventListener('resize', fitAllVisible);
|
|
756
951
|
</script>
|
|
757
952
|
</body>
|
|
758
953
|
</html>`;
|