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