coxpit 5.10.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}
@@ -133,6 +154,34 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
133
154
  .reqgo:disabled{opacity:.4;cursor:default}
134
155
  .reqgo.bcast{background:var(--open);color:#0b0d12}
135
156
  .reqgo.steer{background:var(--running);color:#04121e}
157
+ /* ── folder picker (자유 세션 폴더 지정) ── */
158
+ .modal{position:fixed;inset:0;background:rgba(4,6,10,.6);display:none;align-items:center;justify-content:center;z-index:60}
159
+ .modal.on{display:flex}
160
+ .pick{width:min(560px,92vw);max-height:76vh;display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--line-hi);border-radius:14px;overflow:hidden;font-family:var(--mono)}
161
+ .pick-h{display:flex;align-items:center;gap:10px;padding:13px 15px;border-bottom:1px solid var(--line)}
162
+ .pick-h .t{font-size:13px;color:var(--ink);font-weight:600}
163
+ .pick-h .x{margin-left:auto;background:none;border:none;color:var(--faint);font-size:16px;cursor:pointer}
164
+ .pick-h .x:hover{color:var(--ink)}
165
+ .pick-path{padding:8px 15px;font-size:11.5px;color:var(--brand);border-bottom:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;direction:rtl;text-align:left}
166
+ .pick-list{flex:1;overflow:auto;padding:6px}
167
+ .pick-row{display:flex;align-items:center;gap:9px;padding:7px 10px;border-radius:7px;cursor:pointer;font-size:12.5px;color:var(--muted)}
168
+ .pick-row:hover{background:var(--surface2);color:var(--ink)}
169
+ .pick-row .ic{width:14px;text-align:center;color:var(--faint)}
170
+ .pick-row.up .ic{color:var(--muted)}
171
+ .pick-row .rp{margin-left:auto;font-size:9px;color:var(--brand);border:1px solid rgba(78,201,176,.3);border-radius:999px;padding:0 6px}
172
+ .pick-f{display:flex;align-items:center;gap:10px;padding:12px 15px;border-top:1px solid var(--line)}
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}
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}
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)}
179
+
180
+ .lbl .lnk{color:var(--brand);cursor:pointer;font-size:10px;letter-spacing:0;text-transform:none}
181
+ .tnode.session{padding-left:20px;cursor:pointer} .tnode.session:hover{background:var(--surface)}
182
+ .tnode.session.open{background:var(--brand-dim);color:var(--ink);box-shadow:inset 0 0 0 1px rgba(78,201,176,.22)}
183
+ .tnode.session .p{color:var(--faint);font-size:10.5px;overflow:hidden;text-overflow:ellipsis}
184
+
136
185
  .toast{position:fixed;bottom:64px;left:50%;transform:translateX(-50%);background:var(--surface2);border:1px solid var(--line-hi);color:var(--ink);
137
186
  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}
138
187
  .toast.show{opacity:1}
@@ -186,25 +235,27 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
186
235
  <aside class="rail">
187
236
  <div class="lbl"><span>Workspace</span><span id="machName" style="color:var(--faint)">local</span></div>
188
237
  <div id="tree"></div>
189
- <div class="railfoot">클릭한 run 오른쪽 페인에서 열립니다 · 분할(⊞)로 여러 에이전트를 나란히</div>
238
+ <div class="railfoot">클릭한 run·세션은 <b>탭</b>으로 열립니다 · split 으로 페인을 나란히 배치</div>
190
239
  </aside>
191
240
 
192
241
  <main class="stage">
193
- <div class="panebar">
194
- <span id="paneCount">0 panes</span>
195
- <span style="color:var(--faint)">· run 을 열수록 자동 분할(타일)</span>
196
- <span class="grow"></span>
197
- <button class="pb-btn session" id="sessionBtn" title="에이전트에 묶인 자유 작업 세션(터미널) 열기">+ Session</button>
198
- <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>
199
250
  </div>
200
251
  <div class="panes" id="panes"></div>
201
252
  <div class="empty" id="empty">
202
253
  <div class="card">
203
254
  <div class="glyph">⌗ ⌗ ⌗</div>
204
255
  <h1>여기서 작업을 시작하세요</h1>
205
- <p>직접 몰고 갈 <b>작업 세션</b>(자유 터미널)을 열거나, 아래 요청바로 에이전트를 팬아웃하세요. 트리의 <b>run</b> 을 클릭해도 터미널이 페인으로 열립니다.</p>
256
+ <p>직접 몰고 갈 <b>작업 세션</b>(자유 터미널)을 열거나, 아래 요청바로 에이전트를 팬아웃하세요. 트리의 <b>run</b> 을 클릭해도 <b>탭</b>으로 열립니다.</p>
206
257
  <button class="cta" id="sessionCta">+ 새 작업 세션 열기</button>
207
- <div class="hint">세션 = repo <b>최상위 체크아웃</b>의 tmux 셸(격리 폴더 아님, 전체를 보고 관리). 그 안에서 <code>claude</code> 를 띄워 “이 프로젝트 구현해줘” 처럼 직접 지시할 수 있습니다.</div>
258
+ <div class="hint">세션 = <b>지정한 폴더</b>의 tmux 셸(특정 프로젝트에 소속되지 않음). 그 안에서 <code>claude</code> 를 띄워 “이 프로젝트 구현해줘” 처럼 직접 지시할 수 있습니다.</div>
208
259
  </div>
209
260
  </div>
210
261
  <div class="reqbar">
@@ -243,6 +294,19 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
243
294
  <div class="rv-cols" id="rvCols"></div>
244
295
  </section>
245
296
 
297
+ <div class="modal" id="pickModal">
298
+ <div class="pick">
299
+ <div class="pick-h"><span class="t">세션 폴더 지정</span><button class="x" id="pickClose" title="닫기">×</button></div>
300
+ <div class="pick-path" id="pickPath">…</div>
301
+ <div class="pick-list" id="pickList"></div>
302
+ <div class="pick-f">
303
+ <button class="home" id="pickHome" title="홈으로">⌂ home</button>
304
+ <input class="pick-name" id="pickName" placeholder="세션 이름 (선택 — 비우면 폴더명)" autocomplete="off" />
305
+ <button class="go" id="pickGo">여기서 열기</button>
306
+ </div>
307
+ </div>
308
+ </div>
309
+
246
310
  <div class="toast" id="toast"></div>
247
311
 
248
312
  <script src="/vendor/xterm.js"></script>
@@ -287,10 +351,10 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
287
351
  if (cur){ for (var i=0;i<sel.options.length;i++){ if (sel.options[i].value===cur){ sel.value=cur; break; } } }
288
352
  }
289
353
  function populateReq(){
290
- var repos = fleet.repos||[];
354
+ var repos = (fleet.repos||[]).filter(function(r){ return r.kind!=='sessions'; });
291
355
  fillSelect($('reqRepo'), repos, function(r){return String(r.id);}, function(r){return r.name;}, true);
292
356
  // repo 미선택 상태면 포커스 페인의 repo 로 기본
293
- 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); }
294
358
  var provs = fleet.providers||[];
295
359
  if (provs.length) fillSelect($('reqAgent'), provs, function(p){return p.id;}, function(p){return p.label||p.id;}, true);
296
360
  else if (!$('reqAgent').options.length){ var o=document.createElement('option'); o.value='claude-code'; o.textContent='claude-code'; $('reqAgent').appendChild(o); }
@@ -303,9 +367,29 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
303
367
  var groupById = {}; groups.forEach(function(g){ groupById[g.id]=g; });
304
368
  var tasksByRepo = {}; tasks.forEach(function(t){ (tasksByRepo[t.repoId]=tasksByRepo[t.repoId]||[]).push(t); });
305
369
  var runsByTask = {}; runs.forEach(function(r){ (runsByTask[r.taskId]=runsByTask[r.taskId]||[]).push(r); });
306
- if (!repos.length){ el.innerHTML = '<div class="tnode empty">등록된 repo 없습니다 보드에서 추가하세요.</div>'; return; }
370
+ // 세션 버킷(kind='sessions') 분리 프로젝트 트리와 별개 SESSIONS 섹션
371
+ var sessionRepoIds = {}; repos.forEach(function(r){ if (r.kind==='sessions') sessionRepoIds[r.id]=true; });
372
+ var realRepos = repos.filter(function(r){ return r.kind!=='sessions'; });
373
+ var sessRuns = [];
374
+ tasks.forEach(function(t){ if (sessionRepoIds[t.repoId]) (runsByTask[t.id]||[]).forEach(function(r){ sessRuns.push({run:r, title:t.title}); }); });
375
+ sessRuns.sort(function(a,b){ return b.run.id-a.run.id; });
376
+
307
377
  var html = '';
308
- repos.forEach(function(repo){
378
+ // ── SESSIONS (자유 세션) ──
379
+ html += '<div class="lbl"><span>Sessions</span><span class="lnk" data-newsession="1">+ 새 세션</span></div>';
380
+ if (sessRuns.length){
381
+ sessRuns.forEach(function(s){
382
+ var r=s.run; var open = tabs[r.id] ? ' open' : '';
383
+ html += '<div class="tnode session'+open+'" data-run="'+r.id+'"><span class="st '+esc(r.status)+'"></span>'
384
+ + '<span class="n">'+esc(s.title||'session')+'</span><span class="p">'+esc((r.worktreePath||'').replace(/^.*\\/([^/]+\\/[^/]+)$/,'…/$1'))+'</span></div>';
385
+ });
386
+ } else {
387
+ html += '<div class="tnode empty" style="padding-left:14px">열린 세션 없음 — + Session 으로 폴더 지정</div>';
388
+ }
389
+ html += '<div class="tree-sep"></div>';
390
+ html += '<div class="lbl"><span>Projects</span></div>';
391
+ if (!realRepos.length){ html += '<div class="tnode empty">등록된 repo 가 없습니다 — 보드에서 추가하세요.</div>'; }
392
+ realRepos.forEach(function(repo){
309
393
  var rk = 'repo'+repo.id;
310
394
  var rTasks = (tasksByRepo[repo.id]||[]).filter(function(t){ return t.status!=='closed'; });
311
395
  var runCount = rTasks.reduce(function(n,t){ return n+((runsByTask[t.id]||[]).length); }, 0);
@@ -331,163 +415,343 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
331
415
  var s = '<div class="tnode task" data-fold="'+tk+'"><span class="car">'+(rns.length?(isFold(tk)?'▸':'▾'):' ')+'</span>'
332
416
  + '<span class="n">'+esc(t.title)+'</span></div>';
333
417
  if (!isFold(tk)) rns.sort(function(a,b){return a.id-b.id;}).forEach(function(r){
334
- var open = paneByRun[r.id] ? ' open' : '';
418
+ var open = tabs[r.id] ? ' open' : '';
335
419
  s += '<div class="tnode run'+open+'" data-run="'+r.id+'"><span class="st '+esc(r.status)+'"></span>'
336
420
  + '<span class="n">r'+r.id+' · '+esc(r.status)+'</span></div>';
337
421
  });
338
422
  return s;
339
423
  }
340
424
  $('tree').addEventListener('click', function(e){
341
- var run = e.target.closest('.tnode.run');
425
+ if (e.target.closest('[data-newsession]')){ openSession(); return; }
426
+ var run = e.target.closest('[data-run]');
342
427
  if (run){ openRunPane(+run.dataset.run); return; }
343
428
  var fn = e.target.closest('[data-fold]');
344
429
  if (fn){ var k = fn.dataset.fold; fold[k] = !isFold(k); renderTree(); }
345
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
+ });
346
438
 
347
- // ── 페인 그리드(창분할, 오토타일) ──
348
- var panes = []; // { id, runId, el, term, fit, ws, retry, closing, ro }
349
- var paneByRun = {}; // runId -> pane
350
- var focusId = null;
351
- var paneSeq = 0;
352
- var MAX_PANES = 8;
353
-
354
- function layoutPanes(){
355
- var host = $('panes'); var n = panes.length;
356
- $('empty').style.display = n ? 'none' : 'flex';
357
- host.style.display = n ? 'grid' : 'none';
358
- $('paneCount').textContent = n + ' pane' + (n===1?'':'s');
359
- $('closeBtn').disabled = !focusId;
360
- if (typeof reqMode!=='undefined' && reqMode==='bcast') setMode('bcast');
361
- if (!n) return;
362
- var cols = Math.ceil(Math.sqrt(n));
363
- var rows = Math.ceil(n / cols);
364
- host.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
365
- host.style.gridTemplateRows = 'repeat(' + rows + ', 1fr)';
366
- // 레이아웃 터미널 리핏
367
- requestAnimationFrame(function(){ panes.forEach(fitPane); });
368
- }
369
- function fitPane(p){
370
- if (!p.fit || !p.term) return;
371
- 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){}
372
- }
373
- function setFocus(id){
374
- focusId = id;
375
- panes.forEach(function(p){ p.el.classList.toggle('focus', p.id===id); });
376
- $('closeBtn').disabled = !focusId;
377
- var p = panes.find(function(x){return x.id===id;});
378
- if (p && p.term) try{ p.term.focus(); }catch(e){}
379
- 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;
380
465
  }
381
- function openRunPane(runId){
382
- var existing = paneByRun[runId];
383
- if (existing){ setFocus(existing.id); return; }
384
- if (panes.length >= MAX_PANES){ return; }
385
- var r = runById[runId];
386
- var p = { id: ++paneSeq, runId: runId, retry: 0, closing: false };
387
- var el = document.createElement('div'); el.className = 'pane'; p.el = el;
388
- el.innerHTML = '<div class="pane-h"><span class="rid">r'+runId+'</span>'
389
- + '<span class="chip '+esc(r?r.status:'')+'" data-role="chip">'+esc(r?r.status:'')+'</span>'
390
- + '<span data-role="vslot">'+vbadge(r&&r.verifyStatus)+'</span>'
391
- + '<span class="title" data-role="title">'+esc(r&&r.tmuxWindow||'terminal')+'</span>'
392
- + '<button class="x" title="close">×</button></div>'
393
- + '<div class="pane-term"></div>';
394
- $('panes').appendChild(el);
395
- el.querySelector('.pane-h').addEventListener('click', function(ev){
396
- if (ev.target.closest('.x')) return; setFocus(p.id);
397
- });
398
- el.querySelector('.x').addEventListener('click', function(){ closePane(p.id); });
399
- // xterm
400
- 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({
401
471
  fontFamily: "ui-monospace, 'SF Mono', Menlo, Monaco, 'Apple SD Gothic Neo', 'Noto Sans KR', monospace",
402
472
  fontSize: 12, cursorBlink: true, allowProposedApi: true, scrollback: 4000,
403
473
  theme: { background:'#0b0d12', foreground:'#dee4ec', cursor:'#4ec9b0', selectionBackground:'rgba(78,201,176,.25)', black:'#1c212c', brightBlack:'#5c6675' },
404
474
  });
405
- p.term = term;
406
- p.fit = new window.FitAddon.FitAddon(); term.loadAddon(p.fit);
475
+ var fit=new window.FitAddon.FitAddon(); term.loadAddon(fit);
407
476
  try{ term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion='11'; }catch(e){}
408
- term.open(el.querySelector('.pane-term'));
409
- term.onData(function(d){ if (p.ws && p.ws.readyState===1) p.ws.send(JSON.stringify({t:'i',d:d})); });
410
- p.ro = new ResizeObserver(function(){ fitPane(p); }); p.ro.observe(el.querySelector('.pane-term'));
411
- panes.push(p); paneByRun[runId] = p;
412
- layoutPanes();
413
- connectPane(p);
414
- setFocus(p.id);
415
- renderTree();
416
- }
417
- function connectPane(p){
418
- if (p.closing || !p.term) return;
419
- 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){}
420
486
  var proto = location.protocol==='https:'?'wss':'ws';
421
- var sock = new WebSocket(proto+'://'+location.host+'/ws/term/'+p.runId+'?cols='+p.term.cols+'&rows='+p.term.rows);
422
- p.ws = sock;
423
- 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; };
424
490
  sock.onmessage = function(m){
425
- if (sock!==p.ws || !p.term) return;
491
+ if (sock!==t.ws || !t.term) return;
426
492
  try{ var d = JSON.parse(m.data);
427
- if (d.t==='o') p.term.write(d.d);
428
- else if (d.t==='err') p.term.write('\\r\\n\\x1b[31m'+d.d+'\\x1b[0m\\r\\n');
429
- 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');
430
496
  }catch(e){}
431
497
  };
432
498
  sock.onclose = function(){
433
- if (sock!==p.ws || p.closing) return;
434
- var delay = Math.min(8000, 800 * Math.pow(2, p.retry++));
435
- 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);
436
502
  };
437
503
  }
438
- function closePane(id){
439
- var i = panes.findIndex(function(p){return p.id===id;});
440
- if (i<0) return;
441
- var p = panes[i]; p.closing = true;
442
- try{ if (p.ws) p.ws.close(); }catch(e){}
443
- try{ if (p.ro) p.ro.disconnect(); }catch(e){}
444
- try{ if (p.term) p.term.dispose(); }catch(e){}
445
- try{ p.el.remove(); }catch(e){}
446
- delete paneByRun[p.runId];
447
- panes.splice(i,1);
448
- if (focusId===id) focusId = panes.length ? panes[panes.length-1].id : null;
449
- layoutPanes(); if (focusId) setFocus(focusId);
450
- renderTree();
451
- }
452
- // 페인 상태/타이틀을 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 탭 정리.
453
630
  function syncPanes(){
454
- panes.forEach(function(p){
455
- var r = runById[p.runId];
456
- var chip = p.el.querySelector('[data-role=chip]'); var title = p.el.querySelector('[data-role=title]');
457
- var vslot = p.el.querySelector('[data-role=vslot]');
458
- if (r){ if(chip){ chip.className='chip '+r.status; chip.textContent=r.status; } if(title) title.textContent = r.tmuxWindow||'terminal';
459
- 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);
460
641
  });
461
642
  }
462
- $('closeBtn').addEventListener('click', function(){ if (focusId) closePane(focusId); });
463
643
 
464
- // ── 자유 작업 세션(workbench) — 에이전트에 안 묶인 터미널. repo 워크트리 + tmux 셸. ──
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); });
710
+
711
+ // ── 자유 세션 — 폴더를 지정해 tmux 셸(프로젝트 비소속). ──
712
+ var pickPathCur = '';
713
+ function machineSlug(){ return (fleet.machines && fleet.machines[0] && fleet.machines[0].slug) || 'local'; }
714
+ function openSession(){ $('pickModal').classList.add('on'); $('pickName').value=''; browseTo(''); }
715
+ function closePicker(){ $('pickModal').classList.remove('on'); }
716
+ async function browseTo(p){
717
+ try{
718
+ var d = await (await fetch('/api/browse'+(p?('?path='+encodeURIComponent(p)):''))).json();
719
+ pickPathCur = d.path; $('pickPath').textContent = d.path;
720
+ var html = '';
721
+ if (d.parent && d.parent!==d.path) html += '<div class="pick-row up" data-go="'+esc(d.parent)+'"><span class="ic">↑</span><span>..</span></div>';
722
+ (d.dirs||[]).forEach(function(e){
723
+ var full = d.path==='/' ? '/'+e.name : d.path+'/'+e.name;
724
+ html += '<div class="pick-row" data-go="'+esc(full)+'"><span class="ic">'+(e.isRepo?'◆':'▸')+'</span>'
725
+ + '<span>'+esc(e.name)+'</span>'+(e.isRepo?'<span class="rp">git</span>':'')+'</div>';
726
+ });
727
+ if (!(d.dirs||[]).length) html += '<div class="pick-row" style="cursor:default;color:var(--faint)">하위 폴더 없음 — 이 폴더에서 열 수 있습니다</div>';
728
+ $('pickList').innerHTML = html;
729
+ }catch(e){ $('pickList').innerHTML = '<div class="pick-row" style="color:var(--failed)">폴더를 읽을 수 없습니다</div>'; }
730
+ }
731
+ $('pickList').addEventListener('click', function(e){ var r=e.target.closest('[data-go]'); if (r) browseTo(r.dataset.go); });
732
+ $('pickClose').addEventListener('click', closePicker);
733
+ $('pickHome').addEventListener('click', function(){ browseTo(''); });
734
+ $('pickModal').addEventListener('click', function(e){ if (e.target===this) closePicker(); });
465
735
  var openingSession = false;
466
- async function openSession(){
467
- if (openingSession) return;
468
- var repos = fleet.repos||[];
469
- if (!repos.length){ toast('먼저 repo 를 등록하세요 — 보드(← Board)에서 Add repository'); return; }
470
- var repoId = Number($('reqRepo').value) || repos[0].id;
471
- var rp = repoById[repoId];
472
- openingSession = true; $('sessionBtn').disabled = true;
736
+ $('pickGo').addEventListener('click', async function(){
737
+ if (openingSession || !pickPathCur) return;
738
+ openingSession=true; $('pickGo').disabled=true;
473
739
  try{
474
- var res = await fetch('/api/workbench',{method:'POST',headers:{'content-type':'application/json'},
475
- body:JSON.stringify({repoId:repoId, title:'Session', root:true})});
740
+ var nm = $('pickName').value.trim();
741
+ var res = await fetch('/api/session',{method:'POST',headers:{'content-type':'application/json'},
742
+ body:JSON.stringify({machineSlug:machineSlug(), path:pickPathCur, title:nm})});
476
743
  var j = await res.json().catch(function(){return{};});
477
- if (res.ok && j.runId){
478
- await hydrate();
479
- openRunPane(j.runId);
480
- toast('세션 열림'+(rp?(' · '+rp.name+' 최상위'):'')+' — 이 터미널에서 직접 에이전트를 구동하세요');
481
- } else { toast('세션 실패: '+(j.detail||j.error||res.status)); }
744
+ if (res.ok && j.runId){ closePicker(); await hydrate(); openRunPane(j.runId); toast('세션 열림 · '+(nm||pickPathCur)); }
745
+ else toast('세션 실패: '+(j.detail||j.error||res.status));
482
746
  }catch(e){ toast('세션 실패: '+e); }
483
- finally{ openingSession=false; $('sessionBtn').disabled=false; }
484
- }
747
+ finally{ openingSession=false; $('pickGo').disabled=false; }
748
+ });
749
+ $('pickName').addEventListener('keydown', function(e){ if(e.key==='Enter'){ e.preventDefault(); $('pickGo').click(); } });
485
750
  $('sessionBtn').addEventListener('click', openSession);
486
751
  $('sessionCta').addEventListener('click', openSession);
487
752
 
488
753
  // ── 요청바: New(팬아웃) / Steer / Broadcast ──
489
754
  var reqMode = 'new';
490
- function focusRun(){ if (!focusId) return null; var p = panes.find(function(x){return x.id===focusId;}); return p?p.runId:null; }
491
755
  function setMode(m){
492
756
  reqMode = m;
493
757
  Array.prototype.forEach.call(document.querySelectorAll('.mode'), function(b){ b.classList.toggle('on', b.dataset.mode===m); });
@@ -499,9 +763,9 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
499
763
  else if (m==='steer'){ var rid=focusRun(); go.textContent='Steer ⏎';
500
764
  $('reqTgt').textContent = rid?('➤ r'+rid+' 에 후속 지시'):'포커스한 페인 없음';
501
765
  inp.placeholder = rid?('r'+rid+' 에이전트에게 다음 지시…'):'왼쪽 트리에서 run 을 열어 포커스하세요'; }
502
- else { go.textContent='Send ⏎'; var n=panes.length;
503
- $('reqTgt').textContent = '⊞ 열린 페인 '+n+'개에 브로드캐스트';
504
- inp.placeholder = n?('열린 '+n+'개 터미널에 그대로 전송 (엔터 포함)'):'열린 페인이 없습니다'; }
766
+ else { go.textContent='Send ⏎'; var n=tabOrder.length;
767
+ $('reqTgt').textContent = '⊞ 열린 '+n+'개에 브로드캐스트';
768
+ inp.placeholder = n?('열린 '+n+'개 터미널에 그대로 전송 (엔터 포함)'):'열린 탭이 없습니다'; }
505
769
  }
506
770
  Array.prototype.forEach.call(document.querySelectorAll('.mode'), function(b){ b.addEventListener('click', function(){ setMode(b.dataset.mode); $('reqInput').focus(); }); });
507
771
 
@@ -523,8 +787,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
523
787
  var ids = (rr&&rr.runs||[]).map(function(x){return x.id;});
524
788
  inp.value='';
525
789
  await hydrate();
526
- // 팬아웃 자동 타일: 생성된 run 전부 페인으로
527
- ids.forEach(function(id){ openRunPane(id); });
790
+ // 팬아웃은 비교가 목적 N개 탭을 만들고 자동 타일 배치
791
+ tileTabs(ids);
528
792
  toast(ids.length+'개 에이전트 팬아웃 · r'+ids.join(' r'));
529
793
  }catch(e){ toast('요청 실패: '+e); }
530
794
  finally{ submitting=false; $('reqGo').disabled=false; }
@@ -540,12 +804,12 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
540
804
  }catch(e){ toast('steer 실패: '+e); }
541
805
  finally{ submitting=false; $('reqGo').disabled=false; }
542
806
  } else { // broadcast
543
- if (!panes.length){ toast('열린 페인이 없습니다'); return; }
807
+ if (!tabOrder.length){ toast('열린 탭이 없습니다'); return; }
544
808
  var payload = text + '\\r';
545
809
  var sent=0;
546
- 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++; } });
547
811
  inp.value='';
548
- toast('브로드캐스트 → '+sent+'개 페인');
812
+ toast('브로드캐스트 → '+sent+'개 ');
549
813
  }
550
814
  }
551
815
  $('reqGo').addEventListener('click', submitReq);
@@ -559,8 +823,10 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
559
823
  $('vtReview').addEventListener('click', showReview);
560
824
  function reviewableTasks(){
561
825
  var byTask = {}; (fleet.runs||[]).forEach(function(r){ byTask[r.taskId]=(byTask[r.taskId]||0)+1; });
562
- return (fleet.tasks||[]).filter(function(t){ return (byTask[t.id]||0)>=1; })
563
- .sort(function(a,b){ return b.id-a.id; });
826
+ return (fleet.tasks||[]).filter(function(t){
827
+ var rp = repoById[t.repoId]; if (rp && rp.kind==='sessions') return false; // 세션은 리뷰 대상 아님
828
+ return (byTask[t.id]||0)>=1;
829
+ }).sort(function(a,b){ return b.id-a.id; });
564
830
  }
565
831
  function renderReviewPicker(){
566
832
  var tasks = reviewableTasks(); var sel=$('rvTask');
@@ -672,10 +938,10 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
672
938
  function scheduleHydrate(){ if (hydT) return; hydT = setTimeout(function(){ hydT=null; hydrate(); }, 400); }
673
939
 
674
940
  setMode('new');
675
- layoutPanes(); // 초기 빈 상태 정합(paneCount·close 비활성·panes 숨김)
941
+ render(); // 초기 빈 상태 정합( 없음 → empty)
676
942
  hydrate();
677
943
  wsConnect();
678
- window.addEventListener('resize', function(){ panes.forEach(fitPane); });
944
+ window.addEventListener('resize', fitAllVisible);
679
945
  </script>
680
946
  </body>
681
947
  </html>`;