coxpit 5.4.0 → 5.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/cockpit.ts +506 -53
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "5.4.0",
3
+ "version": "5.6.0",
4
4
  "description": "Self-hosted cockpit for running a fleet of AI coding agents across your own machines — parallel worktree runs, live board, compare & merge, web terminal, design capture.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cockpit.ts CHANGED
@@ -1,6 +1,6 @@
1
- // Cockpit — 터미널 우선 셸 (병행 개발용, /cockpit). board.ts 처럼 자가완결 단일 HTML(빌드 0).
1
+ // Cockpit — 터미널 우선 셸 (병행 개발, /cockpit). board.ts 처럼 자가완결 단일 HTML(빌드 0).
2
2
  // 백엔드(server 라우트·term.ts·orchestrator)는 보드와 전부 공유. Phase 5에서 데스크톱 기본을 여기로 플립.
3
- // Phase 0 = 스캐폴드(레이아웃 골격 + Board 토글 + 모바일 보드 리다이렉트). 트리·페인 그리드·요청바는 Phase 2~3.
3
+ // Phase 2 = 워크스페이스 트리(/api/fleet 라이브) + 페인 그리드 터미널(오토타일=창분할, 페인 /ws/term attach).
4
4
  export const COCKPIT_HTML = /* html */ `<!doctype html>
5
5
  <html lang="en">
6
6
  <head>
@@ -10,6 +10,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
10
10
  <link rel="icon" href="/brand/favicon.ico" sizes="any" />
11
11
  <link rel="icon" type="image/png" sizes="32x32" href="/brand/favicon-32.png" />
12
12
  <link rel="apple-touch-icon" href="/brand/apple-touch-icon.png" />
13
+ <link rel="stylesheet" href="/vendor/xterm.css" />
13
14
  <style>
14
15
  @font-face{font-family:'Pixelify';src:url('/brand/pixelify.woff2') format('woff2');font-weight:400 700;font-display:swap}
15
16
  :root{
@@ -17,7 +18,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
17
18
  --line:#222835; --line-hi:#2f3648;
18
19
  --ink:#dee4ec; --muted:#8792a2; --faint:#5c6675;
19
20
  --brand:#4ec9b0; --brand-ink:#062822; --brand-dim:rgba(78,201,176,.13);
20
- --running:#55a7e0; --done:#58b368; --blocked:#d6a249; --failed:#e25b67;
21
+ --running:#55a7e0; --done:#58b368; --blocked:#d6a249; --failed:#e25b67; --merged:#4ec9b0; --open:#7f9cf5; --stopped:#b58be0;
21
22
  --mono:ui-monospace,'SF Mono',SFMono-Regular,Menlo,Consolas,monospace;
22
23
  --sans:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,Roboto,sans-serif;
23
24
  }
@@ -25,8 +26,9 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
25
26
  html,body{height:100%}
26
27
  body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:14px;line-height:1.5;
27
28
  -webkit-font-smoothing:antialiased;overflow:hidden}
29
+ button{font-family:var(--sans)}
30
+ :focus-visible{outline:2px solid rgba(78,201,176,.5);outline-offset:1px;border-radius:4px}
28
31
 
29
- /* ── topbar ── */
30
32
  header{display:flex;align-items:center;gap:13px;height:46px;padding:0 14px;
31
33
  border-bottom:1px solid var(--line);background:rgba(18,21,28,.92);font-family:var(--mono)}
32
34
  .brand{display:inline-flex;align-items:center;gap:8px;text-decoration:none}
@@ -38,34 +40,109 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
38
40
  .vtab{font-size:12px;color:var(--muted);padding:6px 11px;border-radius:7px;display:inline-flex;align-items:center;gap:7px;cursor:pointer;background:none;border:none;font-family:var(--mono)}
39
41
  .vtab.on{background:var(--brand-dim);color:var(--ink);box-shadow:inset 0 0 0 1px rgba(78,201,176,.28)}
40
42
  .vtab .g{color:var(--brand)}
43
+ .vtab[disabled]{opacity:.5;cursor:default}
41
44
  .right{margin-left:auto;display:flex;align-items:center;gap:9px}
45
+ .ws{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted)}
46
+ .ws .dot{width:6px;height:6px;border-radius:50%;background:var(--faint)}
47
+ .ws.on .dot{background:var(--done)}
42
48
  .wip{font-size:10.5px;color:var(--blocked);border:1px solid rgba(214,162,73,.4);border-radius:999px;padding:2px 9px}
43
49
  .toggle{font-size:12px;color:var(--muted);text-decoration:none;border:1px solid var(--line);border-radius:7px;padding:5px 11px}
44
50
  .toggle:hover{color:var(--ink);border-color:var(--line-hi)}
45
51
 
46
- /* ── body: tree | main ── */
47
- .layout{display:grid;grid-template-columns:264px 1fr;height:calc(100vh - 46px)}
48
- .rail{border-right:1px solid var(--line);overflow:auto;padding:12px 10px}
49
- .lbl{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);padding:5px 8px 10px}
50
- .skel{height:11px;border-radius:5px;background:linear-gradient(90deg,var(--surface) 0%,var(--surface2) 50%,var(--surface) 100%);
51
- background-size:200% 100%;animation:sh 1.4s linear infinite;margin:9px 8px}
52
- @keyframes sh{to{background-position:-200% 0}}
53
- @media (prefers-reduced-motion:reduce){.skel{animation:none}}
54
-
55
- .main{display:flex;flex-direction:column;min-width:0;background:var(--bg)}
56
- .stage{flex:1;display:flex;align-items:center;justify-content:center;text-align:center;padding:24px}
57
- .stage .card{max-width:460px}
58
- .stage .glyph{font-family:var(--mono);font-size:26px;color:#2c3444;letter-spacing:5px;margin-bottom:14px}
59
- .stage h1{font-family:var(--mono);font-size:17px;margin:0 0 8px;color:var(--ink);font-weight:700}
60
- .stage p{color:var(--muted);font-size:13.5px;margin:0 0 6px;line-height:1.6}
61
- .stage .steps{font-family:var(--mono);font-size:12px;color:var(--faint);margin-top:16px;text-align:left;
62
- border:1px solid var(--line);border-radius:10px;background:var(--surface);padding:14px 16px;line-height:1.9}
63
- .stage .steps b{color:var(--brand)}
64
- .reqbar{display:flex;align-items:center;gap:10px;border-top:1px solid var(--line);background:var(--surface);padding:11px 14px;font-family:var(--mono)}
65
- .reqbar .ctx{font-size:11px;color:var(--brand-ink);background:var(--brand);border-radius:6px;padding:3px 9px;font-weight:600}
66
- .reqbar .caret{color:var(--brand)}
67
- .reqbar .ph{color:var(--faint);font-size:13px;flex:1}
68
- .reqbar .kbd{border:1px solid var(--line-hi);border-radius:5px;padding:1px 6px;color:var(--faint);font-size:11px}
52
+ .layout{display:grid;grid-template-columns:270px 1fr;height:calc(100vh - 46px)}
53
+ .layout > *{min-height:0;min-width:0}
54
+
55
+ /* ── workspace tree ── */
56
+ .rail{border-right:1px solid var(--line);overflow:auto;padding:10px 8px;font-family:var(--mono);font-size:12.5px;display:flex;flex-direction:column}
57
+ .lbl{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);padding:6px 8px 10px;display:flex;justify-content:space-between}
58
+ .tnode{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:7px;color:var(--muted);white-space:nowrap;cursor:default}
59
+ .tnode .car{color:var(--faint);width:9px;display:inline-block;text-align:center;cursor:pointer}
60
+ .tnode .n{overflow:hidden;text-overflow:ellipsis;flex:1}
61
+ .tnode .meta{color:var(--faint);font-size:11px}
62
+ .tnode.repo{color:var(--ink)}
63
+ .tnode.goal{padding-left:20px} .tnode.goal .gi{color:var(--brand)}
64
+ .tnode.task{padding-left:30px}
65
+ .tnode.run{padding-left:44px;font-size:12px;cursor:pointer}
66
+ .tnode.run:hover{background:var(--surface)}
67
+ .tnode.run.open{background:var(--brand-dim);color:var(--ink);box-shadow:inset 0 0 0 1px rgba(78,201,176,.22)}
68
+ .tnode.empty{color:var(--faint);padding:8px}
69
+ .st{width:6px;height:6px;border-radius:50%;flex:none}
70
+ .st.running{background:var(--running);box-shadow:0 0 0 3px rgba(85,167,224,.16)}
71
+ .st.done{background:var(--done)} .st.blocked{background:var(--blocked)} .st.failed,.st.error{background:var(--failed)}
72
+ .st.merged{background:var(--merged)} .st.open{background:var(--open)} .st.stopped{background:var(--stopped)}
73
+ .st.preparing,.st.pending,.st.starting{background:var(--blocked)}
74
+ .tree-sep{height:1px;background:var(--line);margin:8px 4px}
75
+ .railfoot{margin-top:auto;padding:8px;color:var(--faint);font-size:11px;border-top:1px solid var(--line)}
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:grid;gap:1px;background:var(--line);padding:1px;min-height:0}
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}
91
+ .chip.running{color:var(--running);background:rgba(85,167,224,.14)}
92
+ .chip.done,.chip.merged{color:var(--done);background:rgba(88,179,104,.14)}
93
+ .chip.blocked,.chip.preparing,.chip.pending,.chip.starting{color:var(--blocked);background:rgba(214,162,73,.16)}
94
+ .chip.failed,.chip.error{color:var(--failed);background:rgba(226,91,103,.14)}
95
+ .chip.open{color:var(--open);background:rgba(127,156,245,.14)}
96
+ .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
+ .pane-term{flex:1;min-height:0;padding:4px 2px 2px 8px}
101
+ .pane-term .xterm{height:100%}
102
+
103
+ .empty{flex:1;display:flex;align-items:center;justify-content:center;text-align:center;padding:24px}
104
+ .empty .card{max-width:440px}
105
+ .empty .glyph{font-family:var(--mono);font-size:24px;color:#2c3444;letter-spacing:5px;margin-bottom:14px}
106
+ .empty h1{font-family:var(--mono);font-size:16px;margin:0 0 8px;color:var(--ink)}
107
+ .empty p{color:var(--muted);font-size:13px;margin:0}
108
+
109
+ .reqbar{display:flex;align-items:center;gap:9px;border-top:1px solid var(--line);background:var(--surface);padding:9px 12px;font-family:var(--mono)}
110
+ .modes{display:inline-flex;gap:2px;border:1px solid var(--line);border-radius:8px;padding:2px;background:var(--panel)}
111
+ .mode{font-size:11px;color:var(--muted);padding:4px 9px;border-radius:6px;cursor:pointer;background:none;border:none;font-family:var(--mono);white-space:nowrap}
112
+ .mode.on{background:var(--brand-dim);color:var(--ink);box-shadow:inset 0 0 0 1px rgba(78,201,176,.3)}
113
+ .reqbar select{font-family:var(--mono);font-size:11.5px;color:var(--ink);background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:5px 7px;max-width:150px}
114
+ .reqbar select:disabled{opacity:.35}
115
+ .reqbar .tgt{font-size:11px;color:var(--brand);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px}
116
+ .rlbl{display:inline-flex;align-items:center;gap:5px}
117
+ .rchk{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--muted);cursor:pointer;white-space:nowrap}
118
+ .reqinput{flex:1;min-width:80px;font-family:var(--mono);font-size:13px;color:var(--ink);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:8px 11px}
119
+ .reqinput:focus{outline:none;border-color:var(--line-hi);box-shadow:0 0 0 2px rgba(78,201,176,.18)}
120
+ .reqinput::placeholder{color:var(--faint)}
121
+ .reqgo{font-family:var(--mono);font-size:12px;font-weight:600;color:var(--brand-ink);background:var(--brand);border:none;border-radius:8px;padding:8px 13px;cursor:pointer}
122
+ .reqgo:disabled{opacity:.4;cursor:default}
123
+ .reqgo.bcast{background:var(--open);color:#0b0d12}
124
+ .reqgo.steer{background:var(--running);color:#04121e}
125
+ .toast{position:fixed;bottom:64px;left:50%;transform:translateX(-50%);background:var(--surface2);border:1px solid var(--line-hi);color:var(--ink);
126
+ font-family:var(--mono);font-size:12px;padding:8px 14px;border-radius:9px;opacity:0;transition:opacity .2s;pointer-events:none;z-index:40;max-width:80vw}
127
+ .toast.show{opacity:1}
128
+
129
+ /* ── Review (compare/merge) ── */
130
+ .review{position:absolute;inset:46px 0 0 0;background:var(--bg);display:none;flex-direction:column;overflow:hidden;z-index:20}
131
+ .review.on{display:flex}
132
+ .rv-head{display:flex;align-items:center;gap:10px;height:40px;padding:0 14px;border-bottom:1px solid var(--line);background:var(--panel);font-family:var(--mono);font-size:12px;color:var(--muted)}
133
+ .rv-head select{font-family:var(--mono);font-size:12px;color:var(--ink);background:var(--surface);border:1px solid var(--line);border-radius:7px;padding:5px 8px;max-width:340px}
134
+ .rv-cols{flex:1;display:flex;gap:1px;background:var(--line);overflow-x:auto;min-height:0}
135
+ .rv-col{flex:1 0 340px;min-width:300px;display:flex;flex-direction:column;background:var(--bg)}
136
+ .rv-col-h{display:flex;align-items:center;gap:8px;padding:9px 12px;border-bottom:1px solid var(--line);background:var(--surface);font-family:var(--mono);font-size:12px}
137
+ .rv-col-h .rid{color:var(--ink);font-weight:600}
138
+ .rv-col-h .stat{color:var(--faint);font-size:11px;margin-left:auto}
139
+ .rv-merge{font-family:var(--mono);font-size:10.5px;font-weight:600;color:var(--brand-ink);background:var(--brand);border:none;border-radius:6px;padding:4px 9px;cursor:pointer}
140
+ .rv-merge:disabled{opacity:.35;cursor:default;background:var(--line-hi);color:var(--faint)}
141
+ .rv-diff{flex:1;overflow:auto;padding:8px 10px;font-family:var(--mono);font-size:11.5px;line-height:1.5;white-space:pre;color:var(--muted)}
142
+ .rv-diff > span{display:block;min-height:1.2em}
143
+ .dl-file{color:var(--brand)} .dl-hunk{color:var(--open)} .dl-ctx{color:var(--muted)}
144
+ .dl-add{color:#7fdca0;background:rgba(88,179,104,.08)} .dl-del{color:#e58a92;background:rgba(226,91,103,.08)}
145
+ .rv-empty{flex:1;display:flex;align-items:center;justify-content:center;color:var(--faint);font-family:var(--mono);font-size:13px;text-align:center;padding:24px}
69
146
  </style>
70
147
  </head>
71
148
  <body>
@@ -73,53 +150,429 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
73
150
  <a class="brand" href="/"><img src="/brand/mark.png" alt="" /><span class="wm">coxpit</span></a>
74
151
  <span class="mach"><span class="dot"></span><span id="mach">local</span></span>
75
152
  <div class="vtabs">
76
- <button type="button" class="vtab on"><span class="g">⌗</span>Terminal</button>
77
- <button type="button" class="vtab"><span class="g">⧉</span>Review</button>
78
- <button type="button" class="vtab"><span class="g">▤</span>Docs</button>
153
+ <button type="button" class="vtab on" id="vtTerm"><span class="g">⌗</span>Terminal</button>
154
+ <button type="button" class="vtab" id="vtReview"><span class="g">⧉</span>Review</button>
155
+ <button type="button" class="vtab" disabled title="Docs = 보드"><span class="g">▤</span>Docs</button>
79
156
  </div>
80
157
  <div class="right">
81
- <span class="wip">preview · Phase 0</span>
158
+ <span class="ws" id="ws"><span class="dot"></span><span id="wstext">connecting</span></span>
159
+ <span class="wip">preview · Phase 3</span>
82
160
  <a class="toggle" href="/">← Board</a>
83
161
  </div>
84
162
  </header>
85
163
 
86
164
  <div class="layout">
87
165
  <aside class="rail">
88
- <div class="lbl">Workspace</div>
89
- <div class="skel" style="width:70%"></div>
90
- <div class="skel" style="width:52%"></div>
91
- <div class="skel" style="width:60%"></div>
92
- <div class="skel" style="width:44%"></div>
166
+ <div class="lbl"><span>Workspace</span><span id="machName" style="color:var(--faint)">local</span></div>
167
+ <div id="tree"></div>
168
+ <div class="railfoot">클릭한 run 은 오른쪽 페인에서 열립니다 · 분할(⊞)로 여러 에이전트를 나란히</div>
93
169
  </aside>
94
170
 
95
- <main class="main">
96
- <div class="stage">
171
+ <main class="stage">
172
+ <div class="panebar">
173
+ <span id="paneCount">0 panes</span>
174
+ <span style="color:var(--faint)">· run 을 열수록 자동 분할(타일)</span>
175
+ <span class="grow"></span>
176
+ <button class="pb-btn" id="closeBtn" disabled title="포커스 페인 닫기">× close focused</button>
177
+ </div>
178
+ <div class="panes" id="panes"></div>
179
+ <div class="empty" id="empty">
97
180
  <div class="card">
98
181
  <div class="glyph">⌗ ⌗ ⌗</div>
99
- <h1>Cockpit 터미널 우선 셸</h1>
100
- <p>이 화면은 셸의 골격입니다. 백엔드는 보드와 공유하고, 여기에 트리·페인 그리드 터미널·요청바를 단계로 얹습니다.</p>
101
- <div class="steps">
102
- <div><b>Phase 1</b> · tmux 재-adopt (재시작 내성 토대)</div>
103
- <div><b>Phase 2</b> · 워크스페이스 트리 + 페인 그리드 터미널</div>
104
- <div><b>Phase 3</b> · 요청바 · 팬아웃 타일 · Review · 브로드캐스트</div>
105
- <div><b>Phase 4</b> · verify in-loop</div>
106
- </div>
182
+ <h1>페인이 비어 있습니다</h1>
183
+ <p>왼쪽 트리에서 <b>run</b> 클릭하면 에이전트 터미널이 여기 페인으로 열립니다. 여러 열면 자동으로 분할(타일)됩니다.</p>
107
184
  </div>
108
185
  </div>
109
186
  <div class="reqbar">
110
- <span class="ctx">cockpit</span><span class="caret">›</span>
111
- <span class="ph">요청바 Phase 3</span>
112
- <span class="kbd">⏎</span>
187
+ <div class="modes">
188
+ <button type="button" class="mode on" data-mode="new" title="새 태스크를 만들고 N개 에이전트로 팬아웃">⌗ New</button>
189
+ <button type="button" class="mode" data-mode="steer" title="포커스한 run 에 후속 지시">➤ Steer</button>
190
+ <button type="button" class="mode" data-mode="bcast" title="열린 모든 페인 터미널에 그대로 입력">⊞ Broadcast</button>
191
+ </div>
192
+ <span class="rlbl" id="newCtl">
193
+ <select id="reqRepo" title="대상 repo"></select>
194
+ <select id="reqAgent" title="에이전트"></select>
195
+ <select id="reqCount" title="팬아웃 개수">
196
+ <option value="1">×1</option><option value="2">×2</option><option value="3" selected>×3</option><option value="4">×4</option>
197
+ </select>
198
+ <label class="rchk" title="실제 CLI 실행 (기본=드라이런)"><input type="checkbox" id="reqReal" /> real</label>
199
+ </span>
200
+ <span class="tgt" id="reqTgt" style="display:none"></span>
201
+ <input class="reqinput" id="reqInput" placeholder="무엇을 만들까요? — 요청을 적고 ⏎ 로 3개 에이전트에 팬아웃" autocomplete="off" />
202
+ <button type="button" class="reqgo" id="reqGo">Run ⏎</button>
113
203
  </div>
114
204
  </main>
115
205
  </div>
116
206
 
207
+ <section class="review" id="review">
208
+ <div class="rv-head">
209
+ <span style="color:var(--brand)">⧉ Review</span>
210
+ <span>·</span>
211
+ <select id="rvTask" title="비교할 태스크"></select>
212
+ <span id="rvHint" style="margin-left:auto;color:var(--faint)">run diff 를 나란히 · 승자를 base 에 merge</span>
213
+ </div>
214
+ <div class="rv-cols" id="rvCols"></div>
215
+ </section>
216
+
217
+ <div class="toast" id="toast"></div>
218
+
219
+ <script src="/vendor/xterm.js"></script>
220
+ <script src="/vendor/addon-fit.js"></script>
221
+ <script src="/vendor/addon-unicode11.js"></script>
117
222
  <script>
118
- // 모바일은 터미널 우선 대신 보드(모니터)로 — Cockpit 은 데스크톱 우선.
119
- // (뷰포트 폭 + 터치 힌트로 판정 — screen.width 는 헤드리스/멀티모니터서 부정확)
223
+ // 모바일은 터미널 우선 대신 보드(모니터)로.
120
224
  if (window.innerWidth <= 860 && matchMedia('(pointer:coarse)').matches) location.replace('/');
121
- // health 머신명 정도만 채운다(스캐폴드).
122
- fetch('/api/health').then(function(r){return r.json()}).then(function(){}).catch(function(){});
225
+ var esc = function(s){ return String(s==null?'':s).replace(/[&<>"]/g, function(c){ return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'})[c]; }); };
226
+ var $ = function(id){ return document.getElementById(id); };
227
+
228
+ function toast(msg){ var t=$('toast'); t.textContent=msg; t.classList.add('show'); clearTimeout(toast._h); toast._h=setTimeout(function(){ t.classList.remove('show'); }, 2600); }
229
+
230
+ // ── fleet 상태 ──
231
+ var fleet = { machines:[], repos:[], tasks:[], groups:[], runs:[], providers:[] };
232
+ var runById = {}, taskById = {}, repoById = {};
233
+ var fold = {}; // 접힘 상태(repo/goal/task 노드)
234
+ function isFold(k){ return fold[k]===true; }
235
+ function repoOfRun(runId){ var r=runById[runId]; var t=r&&taskById[r.taskId]; return t?t.repoId:null; }
236
+
237
+ async function hydrate(){
238
+ try{
239
+ var d = await (await fetch('/api/fleet?view=all')).json();
240
+ fleet = d; runById = {}; taskById = {}; repoById = {};
241
+ (d.runs||[]).forEach(function(r){ runById[r.id]=r; });
242
+ (d.tasks||[]).forEach(function(t){ taskById[t.id]=t; });
243
+ (d.repos||[]).forEach(function(r){ repoById[r.id]=r; });
244
+ if (d.machines && d.machines[0]) { $('mach').textContent = d.machines[0].slug; $('machName').textContent = d.machines[0].slug; }
245
+ renderTree();
246
+ syncPanes();
247
+ populateReq();
248
+ if (reviewOn) renderReviewPicker();
249
+ }catch(e){ /* 재시도는 WS 재연결 or 다음 hydrate */ }
250
+ }
251
+
252
+ // ── 요청바 셀렉트 채우기(선택 유지) ──
253
+ function fillSelect(sel, items, val, label, keep){
254
+ var cur = keep && sel.value; sel.innerHTML='';
255
+ items.forEach(function(it){ var o=document.createElement('option'); o.value=val(it); o.textContent=label(it); sel.appendChild(o); });
256
+ if (cur){ for (var i=0;i<sel.options.length;i++){ if (sel.options[i].value===cur){ sel.value=cur; break; } } }
257
+ }
258
+ function populateReq(){
259
+ var repos = fleet.repos||[];
260
+ fillSelect($('reqRepo'), repos, function(r){return String(r.id);}, function(r){return r.name;}, true);
261
+ // repo 미선택 상태면 포커스 페인의 repo 로 기본
262
+ if (focusId){ var rp = repoOfRun((panes.find(function(p){return p.id===focusId;})||{}).runId); if (rp!=null) $('reqRepo').value=String(rp); }
263
+ var provs = fleet.providers||[];
264
+ if (provs.length) fillSelect($('reqAgent'), provs, function(p){return p.id;}, function(p){return p.label||p.id;}, true);
265
+ else if (!$('reqAgent').options.length){ var o=document.createElement('option'); o.value='claude-code'; o.textContent='claude-code'; $('reqAgent').appendChild(o); }
266
+ }
267
+
268
+ // ── 트리 렌더: Machine ▸ Repo ▸ Goal ▸ Task ▸ Run ──
269
+ function renderTree(){
270
+ var el = $('tree');
271
+ var repos = fleet.repos||[], tasks = fleet.tasks||[], groups = fleet.groups||[], runs = fleet.runs||[];
272
+ var groupById = {}; groups.forEach(function(g){ groupById[g.id]=g; });
273
+ var tasksByRepo = {}; tasks.forEach(function(t){ (tasksByRepo[t.repoId]=tasksByRepo[t.repoId]||[]).push(t); });
274
+ var runsByTask = {}; runs.forEach(function(r){ (runsByTask[r.taskId]=runsByTask[r.taskId]||[]).push(r); });
275
+ if (!repos.length){ el.innerHTML = '<div class="tnode empty">등록된 repo 가 없습니다 — 보드에서 추가하세요.</div>'; return; }
276
+ var html = '';
277
+ repos.forEach(function(repo){
278
+ var rk = 'repo'+repo.id;
279
+ var rTasks = (tasksByRepo[repo.id]||[]).filter(function(t){ return t.status!=='closed'; });
280
+ var runCount = rTasks.reduce(function(n,t){ return n+((runsByTask[t.id]||[]).length); }, 0);
281
+ html += '<div class="tnode repo" data-fold="'+rk+'"><span class="car">'+(isFold(rk)?'▸':'▾')+'</span>'
282
+ + '<span class="n">'+esc(repo.name)+'</span><span class="meta">'+runCount+' run'+(runCount===1?'':'s')+'</span></div>';
283
+ if (isFold(rk)) return;
284
+ // goal(group) 로 묶기
285
+ var byGroup = {}, ungrouped = [];
286
+ rTasks.forEach(function(t){ if (t.groupId && groupById[t.groupId]) (byGroup[t.groupId]=byGroup[t.groupId]||[]).push(t); else ungrouped.push(t); });
287
+ Object.keys(byGroup).forEach(function(gid){
288
+ var g = groupById[gid]; var gk='goal'+gid;
289
+ html += '<div class="tnode goal" data-fold="'+gk+'"><span class="car">'+(isFold(gk)?'▸':'▾')+'</span>'
290
+ + '<span class="gi">⌁</span><span class="n">'+esc(g.title)+'</span><span class="meta">'+(g.kind||'goal')+'</span></div>';
291
+ if (!isFold(gk)) byGroup[gid].forEach(function(t){ html += taskHTML(t, runsByTask[t.id]||[]); });
292
+ });
293
+ ungrouped.forEach(function(t){ html += taskHTML(t, runsByTask[t.id]||[]); });
294
+ if (!rTasks.length) html += '<div class="tnode empty" style="padding-left:30px">태스크 없음</div>';
295
+ });
296
+ el.innerHTML = html;
297
+ }
298
+ function taskHTML(t, rns){
299
+ var tk='task'+t.id;
300
+ var s = '<div class="tnode task" data-fold="'+tk+'"><span class="car">'+(rns.length?(isFold(tk)?'▸':'▾'):' ')+'</span>'
301
+ + '<span class="n">'+esc(t.title)+'</span></div>';
302
+ if (!isFold(tk)) rns.sort(function(a,b){return a.id-b.id;}).forEach(function(r){
303
+ var open = paneByRun[r.id] ? ' open' : '';
304
+ s += '<div class="tnode run'+open+'" data-run="'+r.id+'"><span class="st '+esc(r.status)+'"></span>'
305
+ + '<span class="n">r'+r.id+' · '+esc(r.status)+'</span></div>';
306
+ });
307
+ return s;
308
+ }
309
+ $('tree').addEventListener('click', function(e){
310
+ var run = e.target.closest('.tnode.run');
311
+ if (run){ openRunPane(+run.dataset.run); return; }
312
+ var fn = e.target.closest('[data-fold]');
313
+ if (fn){ var k = fn.dataset.fold; fold[k] = !isFold(k); renderTree(); }
314
+ });
315
+
316
+ // ── 페인 그리드(창분할, 오토타일) ──
317
+ var panes = []; // { id, runId, el, term, fit, ws, retry, closing, ro }
318
+ var paneByRun = {}; // runId -> pane
319
+ var focusId = null;
320
+ var paneSeq = 0;
321
+ var MAX_PANES = 8;
322
+
323
+ function layoutPanes(){
324
+ var host = $('panes'); var n = panes.length;
325
+ $('empty').style.display = n ? 'none' : 'flex';
326
+ host.style.display = n ? 'grid' : 'none';
327
+ $('paneCount').textContent = n + ' pane' + (n===1?'':'s');
328
+ $('closeBtn').disabled = !focusId;
329
+ if (typeof reqMode!=='undefined' && reqMode==='bcast') setMode('bcast');
330
+ if (!n) return;
331
+ var cols = Math.ceil(Math.sqrt(n));
332
+ var rows = Math.ceil(n / cols);
333
+ host.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
334
+ host.style.gridTemplateRows = 'repeat(' + rows + ', 1fr)';
335
+ // 레이아웃 후 각 터미널 리핏
336
+ requestAnimationFrame(function(){ panes.forEach(fitPane); });
337
+ }
338
+ function fitPane(p){
339
+ if (!p.fit || !p.term) return;
340
+ 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){}
341
+ }
342
+ function setFocus(id){
343
+ focusId = id;
344
+ panes.forEach(function(p){ p.el.classList.toggle('focus', p.id===id); });
345
+ $('closeBtn').disabled = !focusId;
346
+ var p = panes.find(function(x){return x.id===id;});
347
+ if (p && p.term) try{ p.term.focus(); }catch(e){}
348
+ if (typeof reqMode!=='undefined' && reqMode!=='new') setMode(reqMode);
349
+ }
350
+ function openRunPane(runId){
351
+ var existing = paneByRun[runId];
352
+ if (existing){ setFocus(existing.id); return; }
353
+ if (panes.length >= MAX_PANES){ return; }
354
+ var r = runById[runId];
355
+ var p = { id: ++paneSeq, runId: runId, retry: 0, closing: false };
356
+ var el = document.createElement('div'); el.className = 'pane'; p.el = el;
357
+ el.innerHTML = '<div class="pane-h"><span class="rid">r'+runId+'</span>'
358
+ + '<span class="chip '+esc(r?r.status:'')+'" data-role="chip">'+esc(r?r.status:'')+'</span>'
359
+ + '<span class="title" data-role="title">'+esc(r&&r.tmuxWindow||'terminal')+'</span>'
360
+ + '<button class="x" title="close">×</button></div>'
361
+ + '<div class="pane-term"></div>';
362
+ $('panes').appendChild(el);
363
+ el.querySelector('.pane-h').addEventListener('click', function(ev){
364
+ if (ev.target.closest('.x')) return; setFocus(p.id);
365
+ });
366
+ el.querySelector('.x').addEventListener('click', function(){ closePane(p.id); });
367
+ // xterm
368
+ var term = new window.Terminal({
369
+ fontFamily: "ui-monospace, 'SF Mono', Menlo, Monaco, 'Apple SD Gothic Neo', 'Noto Sans KR', monospace",
370
+ fontSize: 12, cursorBlink: true, allowProposedApi: true, scrollback: 4000,
371
+ theme: { background:'#0b0d12', foreground:'#dee4ec', cursor:'#4ec9b0', selectionBackground:'rgba(78,201,176,.25)', black:'#1c212c', brightBlack:'#5c6675' },
372
+ });
373
+ p.term = term;
374
+ p.fit = new window.FitAddon.FitAddon(); term.loadAddon(p.fit);
375
+ try{ term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion='11'; }catch(e){}
376
+ term.open(el.querySelector('.pane-term'));
377
+ term.onData(function(d){ if (p.ws && p.ws.readyState===1) p.ws.send(JSON.stringify({t:'i',d:d})); });
378
+ p.ro = new ResizeObserver(function(){ fitPane(p); }); p.ro.observe(el.querySelector('.pane-term'));
379
+ panes.push(p); paneByRun[runId] = p;
380
+ layoutPanes();
381
+ connectPane(p);
382
+ setFocus(p.id);
383
+ renderTree();
384
+ }
385
+ function connectPane(p){
386
+ if (p.closing || !p.term) return;
387
+ try{ p.fit.fit(); }catch(e){}
388
+ var proto = location.protocol==='https:'?'wss':'ws';
389
+ var sock = new WebSocket(proto+'://'+location.host+'/ws/term/'+p.runId+'?cols='+p.term.cols+'&rows='+p.term.rows);
390
+ p.ws = sock;
391
+ sock.onopen = function(){ if (sock!==p.ws){ try{sock.close();}catch(e){} return; } p.retry=0; };
392
+ sock.onmessage = function(m){
393
+ if (sock!==p.ws || !p.term) return;
394
+ try{ var d = JSON.parse(m.data);
395
+ if (d.t==='o') p.term.write(d.d);
396
+ else if (d.t==='err') p.term.write('\\r\\n\\x1b[31m'+d.d+'\\x1b[0m\\r\\n');
397
+ else if (d.t==='exit') p.term.write('\\r\\n\\x1b[90m[session ended — 재연결 시 소생]\\x1b[0m\\r\\n');
398
+ }catch(e){}
399
+ };
400
+ sock.onclose = function(){
401
+ if (sock!==p.ws || p.closing) return;
402
+ var delay = Math.min(8000, 800 * Math.pow(2, p.retry++));
403
+ setTimeout(function(){ connectPane(p); }, delay);
404
+ };
405
+ }
406
+ function closePane(id){
407
+ var i = panes.findIndex(function(p){return p.id===id;});
408
+ if (i<0) return;
409
+ var p = panes[i]; p.closing = true;
410
+ try{ if (p.ws) p.ws.close(); }catch(e){}
411
+ try{ if (p.ro) p.ro.disconnect(); }catch(e){}
412
+ try{ if (p.term) p.term.dispose(); }catch(e){}
413
+ try{ p.el.remove(); }catch(e){}
414
+ delete paneByRun[p.runId];
415
+ panes.splice(i,1);
416
+ if (focusId===id) focusId = panes.length ? panes[panes.length-1].id : null;
417
+ layoutPanes(); if (focusId) setFocus(focusId);
418
+ renderTree();
419
+ }
420
+ // 페인 상태/타이틀을 fleet 갱신에 맞춰 갱신 + 사라진 run 페인 정리
421
+ function syncPanes(){
422
+ panes.forEach(function(p){
423
+ var r = runById[p.runId];
424
+ var chip = p.el.querySelector('[data-role=chip]'); var title = p.el.querySelector('[data-role=title]');
425
+ if (r){ if(chip){ chip.className='chip '+r.status; chip.textContent=r.status; } if(title) title.textContent = r.tmuxWindow||'terminal'; }
426
+ });
427
+ }
428
+ $('closeBtn').addEventListener('click', function(){ if (focusId) closePane(focusId); });
429
+
430
+ // ── 요청바: New(팬아웃) / Steer / Broadcast ──
431
+ var reqMode = 'new';
432
+ function focusRun(){ if (!focusId) return null; var p = panes.find(function(x){return x.id===focusId;}); return p?p.runId:null; }
433
+ function setMode(m){
434
+ reqMode = m;
435
+ Array.prototype.forEach.call(document.querySelectorAll('.mode'), function(b){ b.classList.toggle('on', b.dataset.mode===m); });
436
+ var go=$('reqGo'), inp=$('reqInput');
437
+ $('newCtl').style.display = m==='new'?'inline-flex':'none';
438
+ $('reqTgt').style.display = m==='new'?'none':'inline';
439
+ go.className = 'reqgo' + (m==='bcast'?' bcast':m==='steer'?' steer':'');
440
+ if (m==='new'){ go.textContent='Run ⏎'; inp.placeholder='무엇을 만들까요? — 요청을 적고 ⏎ 로 팬아웃'; }
441
+ else if (m==='steer'){ var rid=focusRun(); go.textContent='Steer ⏎';
442
+ $('reqTgt').textContent = rid?('➤ r'+rid+' 에 후속 지시'):'포커스한 페인 없음';
443
+ inp.placeholder = rid?('r'+rid+' 에이전트에게 다음 지시…'):'왼쪽 트리에서 run 을 열어 포커스하세요'; }
444
+ else { go.textContent='Send ⏎'; var n=panes.length;
445
+ $('reqTgt').textContent = '⊞ 열린 페인 '+n+'개에 브로드캐스트';
446
+ inp.placeholder = n?('열린 '+n+'개 터미널에 그대로 전송 (엔터 포함)'):'열린 페인이 없습니다'; }
447
+ }
448
+ Array.prototype.forEach.call(document.querySelectorAll('.mode'), function(b){ b.addEventListener('click', function(){ setMode(b.dataset.mode); $('reqInput').focus(); }); });
449
+
450
+ var submitting = false;
451
+ async function submitReq(){
452
+ var inp = $('reqInput'); var text = inp.value.trim();
453
+ if (reqMode==='new'){
454
+ if (!text || submitting) return;
455
+ var repoId = Number($('reqRepo').value); if (!repoId){ toast('repo 를 먼저 등록/선택하세요'); return; }
456
+ submitting=true; $('reqGo').disabled=true;
457
+ try{
458
+ var title = text.length>72 ? text.slice(0,69)+'…' : text;
459
+ var tk = await (await fetch('/api/tasks',{method:'POST',headers:{'content-type':'application/json'},
460
+ body:JSON.stringify({repoId:repoId, title:title, prompt:text})})).json();
461
+ if (!tk || !tk.task){ toast('태스크 생성 실패'); return; }
462
+ var count = Number($('reqCount').value)||1;
463
+ var rr = await (await fetch('/api/tasks/'+tk.task.id+'/run',{method:'POST',headers:{'content-type':'application/json'},
464
+ body:JSON.stringify({agent:$('reqAgent').value, count:count, real:$('reqReal').checked})})).json();
465
+ var ids = (rr&&rr.runs||[]).map(function(x){return x.id;});
466
+ inp.value='';
467
+ await hydrate();
468
+ // 팬아웃 자동 타일: 생성된 run 을 전부 페인으로
469
+ ids.forEach(function(id){ openRunPane(id); });
470
+ toast(ids.length+'개 에이전트 팬아웃 · r'+ids.join(' r'));
471
+ }catch(e){ toast('요청 실패: '+e); }
472
+ finally{ submitting=false; $('reqGo').disabled=false; }
473
+ } else if (reqMode==='steer'){
474
+ var rid = focusRun(); if (!rid){ toast('포커스한 페인이 없습니다'); return; }
475
+ if (!text || submitting) return;
476
+ submitting=true; $('reqGo').disabled=true;
477
+ try{
478
+ var res = await fetch('/api/runs/'+rid+'/steer',{method:'POST',headers:{'content-type':'application/json'},
479
+ body:JSON.stringify({message:text})});
480
+ if (res.ok){ inp.value=''; toast('r'+rid+' 에 후속 지시 전송'); }
481
+ else { var j = await res.json().catch(function(){return{};}); toast('steer 불가: '+(j.error||res.status)); }
482
+ }catch(e){ toast('steer 실패: '+e); }
483
+ finally{ submitting=false; $('reqGo').disabled=false; }
484
+ } else { // broadcast
485
+ if (!panes.length){ toast('열린 페인이 없습니다'); return; }
486
+ var payload = text + '\\r';
487
+ var sent=0;
488
+ panes.forEach(function(p){ if (p.ws && p.ws.readyState===1){ p.ws.send(JSON.stringify({t:'i',d:payload})); sent++; } });
489
+ inp.value='';
490
+ toast('브로드캐스트 → '+sent+'개 페인');
491
+ }
492
+ }
493
+ $('reqGo').addEventListener('click', submitReq);
494
+ $('reqInput').addEventListener('keydown', function(e){ if (e.key==='Enter' && !e.isComposing){ e.preventDefault(); submitReq(); } });
495
+
496
+ // ── Review 탭: compare(diff 나란히) + merge 승자 ──
497
+ var reviewOn = false, rvTaskId = null;
498
+ function showTerminal(){ reviewOn=false; $('review').classList.remove('on'); $('vtReview').classList.remove('on'); $('vtTerm').classList.add('on'); }
499
+ function showReview(){ reviewOn=true; $('review').classList.add('on'); $('vtReview').classList.add('on'); $('vtTerm').classList.remove('on'); renderReviewPicker(); }
500
+ $('vtTerm').addEventListener('click', showTerminal);
501
+ $('vtReview').addEventListener('click', showReview);
502
+ function reviewableTasks(){
503
+ var byTask = {}; (fleet.runs||[]).forEach(function(r){ byTask[r.taskId]=(byTask[r.taskId]||0)+1; });
504
+ return (fleet.tasks||[]).filter(function(t){ return (byTask[t.id]||0)>=1; })
505
+ .sort(function(a,b){ return b.id-a.id; });
506
+ }
507
+ function renderReviewPicker(){
508
+ var tasks = reviewableTasks(); var sel=$('rvTask');
509
+ var cur = rvTaskId!=null ? String(rvTaskId) : (focusRun()!=null && runById[focusRun()] ? String(runById[focusRun()].taskId) : (tasks[0]?String(tasks[0].id):''));
510
+ sel.innerHTML='';
511
+ tasks.forEach(function(t){ var rp=repoById[t.repoId]; var o=document.createElement('option'); o.value=String(t.id);
512
+ o.textContent = (rp?rp.name+' · ':'')+t.title; sel.appendChild(o); });
513
+ if (!tasks.length){ $('rvCols').innerHTML='<div class="rv-empty">비교할 run 이 있는 태스크가 아직 없습니다. Terminal 에서 팬아웃해 보세요.</div>'; rvTaskId=null; return; }
514
+ if (cur){ sel.value=cur; } rvTaskId = Number(sel.value);
515
+ loadCompare(rvTaskId);
516
+ }
517
+ $('rvTask').addEventListener('change', function(){ rvTaskId=Number(this.value); loadCompare(rvTaskId); });
518
+ function diffHTML(text){
519
+ if (!text || !text.trim()) return '<span style="color:var(--faint)">no changes</span>';
520
+ return text.split('\\n').map(function(l){
521
+ var e = esc(l) || '&nbsp;';
522
+ if (l.indexOf('diff --git')===0 || l.indexOf('+++')===0 || l.indexOf('---')===0) return '<span class="dl-file">'+e+'</span>';
523
+ if (l.indexOf('@@')===0) return '<span class="dl-hunk">'+e+'</span>';
524
+ if (l.charAt(0)==='+') return '<span class="dl-add">'+e+'</span>';
525
+ if (l.charAt(0)==='-') return '<span class="dl-del">'+e+'</span>';
526
+ return '<span class="dl-ctx">'+e+'</span>';
527
+ }).join('');
528
+ }
529
+ async function loadCompare(taskId){
530
+ var host=$('rvCols'); host.innerHTML='<div class="rv-empty">불러오는 중…</div>';
531
+ try{
532
+ var d = await (await fetch('/api/tasks/'+taskId+'/compare')).json();
533
+ var rns = (d.runs||[]).sort(function(a,b){return a.id-b.id;});
534
+ if (!rns.length){ host.innerHTML='<div class="rv-empty">run 이 없습니다.</div>'; return; }
535
+ host.innerHTML='';
536
+ rns.forEach(function(r){
537
+ var col=document.createElement('div'); col.className='rv-col';
538
+ var st = typeof r.stat==='string' ? r.stat.split('\\n').pop().trim() : '';
539
+ var mergeable = (r.status==='done'||r.status==='open'||r.status==='merged') && r.filesChanged>0;
540
+ col.innerHTML = '<div class="rv-col-h"><span class="st '+esc(r.status)+'" style="width:7px;height:7px;border-radius:50%;display:inline-block"></span>'
541
+ + '<span class="rid">r'+r.id+'</span><span class="chip '+esc(r.status)+'">'+esc(r.status)+'</span>'
542
+ + '<span class="stat">'+esc(st)+'</span>'
543
+ + '<button class="rv-merge" data-merge="'+r.id+'"'+((r.status==='merged'||!mergeable)?' disabled':'')+'>'
544
+ + (r.status==='merged'?'merged':'merge ▸')+'</button></div>'
545
+ + '<div class="rv-diff">'+diffHTML(r.diff)+'</div>';
546
+ host.appendChild(col);
547
+ });
548
+ }catch(e){ host.innerHTML='<div class="rv-empty">compare 실패: '+esc(String(e))+'</div>'; }
549
+ }
550
+ $('rvCols').addEventListener('click', async function(e){
551
+ var b = e.target.closest('[data-merge]'); if (!b) return;
552
+ var rid = b.dataset.merge; b.disabled=true; b.textContent='merging…';
553
+ try{
554
+ var res = await fetch('/api/runs/'+rid+'/merge',{method:'POST'});
555
+ var j = await res.json().catch(function(){return{};});
556
+ if (res.ok){ toast('r'+rid+' merge 완료'); await hydrate(); if (rvTaskId!=null) loadCompare(rvTaskId); }
557
+ else { toast('merge 불가: '+(j.error||j.reason||res.status)); b.disabled=false; b.textContent='merge ▸'; }
558
+ }catch(err){ toast('merge 실패: '+err); b.disabled=false; b.textContent='merge ▸'; }
559
+ });
560
+
561
+ // ── /ws 라이브 구독 → 트리·페인 갱신 ──
562
+ function wsConnect(){
563
+ var proto = location.protocol==='https:'?'wss':'ws';
564
+ var ws = new WebSocket(proto+'://'+location.host+'/ws');
565
+ ws.onopen = function(){ $('ws').classList.add('on'); $('wstext').textContent='live'; };
566
+ ws.onclose = function(){ $('ws').classList.remove('on'); $('wstext').textContent='reconnecting'; setTimeout(wsConnect, 1500); };
567
+ ws.onmessage = function(){ /* 델타는 종류가 많아 단순히 전체 리하이드레이트(디바운스) */ scheduleHydrate(); };
568
+ }
569
+ var hydT=null;
570
+ function scheduleHydrate(){ if (hydT) return; hydT = setTimeout(function(){ hydT=null; hydrate(); }, 400); }
571
+
572
+ setMode('new');
573
+ hydrate();
574
+ wsConnect();
575
+ window.addEventListener('resize', function(){ panes.forEach(fitPane); });
123
576
  </script>
124
577
  </body>
125
578
  </html>`;