coxpit 4.5.0 → 4.7.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/README.md +9 -1
- package/package.json +1 -1
- package/src/board.ts +944 -61
- package/src/db/index.ts +3 -0
- package/src/db/schema.ts +2 -0
- package/src/orchestrator.ts +453 -7
- package/src/server.ts +251 -8
package/src/board.ts
CHANGED
|
@@ -125,6 +125,13 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
125
125
|
.seg-opt.on{background:var(--surface2);color:var(--ink)}
|
|
126
126
|
.seg-opt[data-real="1"].on{background:var(--brand);color:var(--brand-ink)}
|
|
127
127
|
.seg-hint{font-family:var(--mono);font-size:9.5px;font-weight:500;text-transform:uppercase;letter-spacing:.06em;opacity:.7}
|
|
128
|
+
/* ── deliverable 계약 칩(Task compose) ── */
|
|
129
|
+
.ochips{display:flex;flex-wrap:wrap;gap:5px}
|
|
130
|
+
.ochip{background:transparent;color:var(--muted);border:1px solid var(--line);cursor:pointer;
|
|
131
|
+
font-family:var(--mono);font-size:11px;padding:4px 10px;border-radius:999px;
|
|
132
|
+
transition:border-color .15s,color .15s,background .15s}
|
|
133
|
+
.ochip:hover{color:var(--ink);border-color:var(--line-hi)}
|
|
134
|
+
.ochip.on{background:var(--brand-dim);color:var(--brand);border-color:rgba(78,201,176,.4)}
|
|
128
135
|
|
|
129
136
|
/* ── buttons ────────────────────────────── */
|
|
130
137
|
.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:none;cursor:pointer;
|
|
@@ -152,6 +159,114 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
152
159
|
.gband.folded .gband-grid{display:none}
|
|
153
160
|
.gband-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:14px}
|
|
154
161
|
.attempt{color:var(--brand);opacity:.8}
|
|
162
|
+
|
|
163
|
+
/* ── goal workroom (v4.6) — 한 goal 을 여는 단일 방 ── */
|
|
164
|
+
.gband-open{margin-left:2px}
|
|
165
|
+
.room{width:min(920px,96vw);max-height:90vh;background:var(--surface);border:1px solid var(--line);
|
|
166
|
+
border-radius:16px;display:flex;flex-direction:column;overflow:hidden;box-shadow:var(--shadow)}
|
|
167
|
+
.rh{display:flex;align-items:center;gap:10px;padding:14px 18px;border-bottom:1px solid var(--line);
|
|
168
|
+
background:linear-gradient(180deg,var(--brand-dim),transparent)}
|
|
169
|
+
.rh .rh-glyph{color:var(--brand);font-family:var(--mono);font-size:15px}
|
|
170
|
+
.rh .rh-t{font-weight:600;font-size:14px;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
171
|
+
.rh .rh-n{font-family:var(--mono);font-size:11px;color:var(--faint)}
|
|
172
|
+
.rh .x{background:transparent;border:none;color:var(--muted);font-size:19px;cursor:pointer;
|
|
173
|
+
padding:2px 9px;border-radius:6px;line-height:1}
|
|
174
|
+
.rh .x:hover{color:var(--ink);background:var(--surface2)}
|
|
175
|
+
.chips{display:flex;flex-wrap:wrap;gap:7px;padding:12px 18px;border-bottom:1px solid var(--line)}
|
|
176
|
+
.room .chip{cursor:pointer;transition:border-color .15s,filter .15s}
|
|
177
|
+
.room .chip:hover{filter:brightness(1.15)}
|
|
178
|
+
.room .chip .cid{font-weight:600}
|
|
179
|
+
.room .chip .cmeta{opacity:.7;text-transform:none;letter-spacing:0}
|
|
180
|
+
.room .chip.new{border-style:dashed}
|
|
181
|
+
.body{overflow:auto;flex:1;min-height:120px}
|
|
182
|
+
.feed{display:flex;flex-direction:column;gap:6px;padding:12px 18px;font-family:var(--mono);font-size:11.5px}
|
|
183
|
+
.feed .fl{display:flex;gap:9px;align-items:baseline;min-width:0}
|
|
184
|
+
.feed .fl .who{min-width:52px;flex:none;opacity:.9}
|
|
185
|
+
.feed .fl .ft{color:var(--muted);word-break:break-word;flex:1}
|
|
186
|
+
.feed .fl.mark .who{color:var(--brand)}
|
|
187
|
+
.feed .empty-note{color:var(--faint)}
|
|
188
|
+
.conv{display:flex;flex-direction:column;gap:12px;padding:12px 18px;font-size:12.5px}
|
|
189
|
+
.conv .empty-note{color:var(--faint);font-family:var(--mono);font-size:11.5px}
|
|
190
|
+
.conv .turn{display:flex;flex-direction:column;gap:3px}
|
|
191
|
+
.conv .turn .role{font-family:var(--mono);font-size:9.5px;font-weight:600;text-transform:uppercase;letter-spacing:.06em}
|
|
192
|
+
.conv .turn.you .role{color:var(--muted)}
|
|
193
|
+
.conv .turn.coord .role{color:var(--brand)}
|
|
194
|
+
.conv .turn .msg{color:var(--ink);white-space:pre-wrap;word-break:break-word;line-height:1.55}
|
|
195
|
+
.conv .turn.you .msg{color:var(--muted)}
|
|
196
|
+
.conv .turn .ro-note{font-family:var(--mono);font-size:9.5px;color:var(--faint);letter-spacing:.02em}
|
|
197
|
+
/* ── v4.7 converge cockpit — group action bar + per-run decision rows ── */
|
|
198
|
+
.gbar{display:flex;align-items:center;gap:12px;padding:10px 18px;border-bottom:1px solid var(--line);
|
|
199
|
+
background:var(--surface2);font-family:var(--mono);font-size:11.5px}
|
|
200
|
+
.gbar .selc{color:var(--muted)}
|
|
201
|
+
.gbar .sp{flex:1}
|
|
202
|
+
.gbar button{font-family:var(--mono);font-size:11.5px;background:transparent;border:1px solid var(--line-hi);
|
|
203
|
+
color:var(--muted);border-radius:7px;padding:5px 11px;cursor:pointer}
|
|
204
|
+
.gbar button:hover{color:var(--ink);border-color:var(--line-hi)}
|
|
205
|
+
.gbar button.brand{background:var(--brand);border-color:var(--brand);color:var(--brand-ink);font-weight:700}
|
|
206
|
+
.gbar button.brand[disabled]{opacity:.4;cursor:not-allowed}
|
|
207
|
+
.gbar button.danger{color:var(--s-failed);border-color:rgba(226,91,103,.4)}
|
|
208
|
+
.runs{padding:10px 12px;display:flex;flex-direction:column;gap:8px}
|
|
209
|
+
.runs .empty-note{color:var(--faint);font-family:var(--mono);font-size:11.5px;text-align:center;padding:24px 0}
|
|
210
|
+
.run{border:1px solid var(--line);border-radius:12px;background:var(--surface);overflow:hidden}
|
|
211
|
+
.run.sel{border-color:var(--brand);box-shadow:inset 0 0 0 1px var(--brand-dim)}
|
|
212
|
+
.run.dim{opacity:.6}
|
|
213
|
+
.run-h{display:flex;align-items:center;gap:11px;padding:11px 13px;cursor:pointer}
|
|
214
|
+
.run-h .cb{width:14px;height:14px;border:1px solid var(--line-hi);border-radius:4px;flex:none;
|
|
215
|
+
display:inline-flex;align-items:center;justify-content:center;color:var(--brand);font-size:10px;cursor:pointer}
|
|
216
|
+
.run.sel .run-h .cb{background:var(--brand);border-color:var(--brand);color:var(--brand-ink)}
|
|
217
|
+
.run .dot{width:8px;height:8px;border-radius:50%;flex:none}
|
|
218
|
+
.run-id{font-family:var(--mono);font-size:12px;font-weight:700;color:var(--ink);flex:none}
|
|
219
|
+
.run-title{font-size:13.5px;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
|
|
220
|
+
.run-meta{font-family:var(--mono);font-size:10.5px;color:var(--faint);flex:none}
|
|
221
|
+
.run-badge{font-family:var(--mono);font-size:10px;padding:2px 7px;border-radius:999px;flex:none}
|
|
222
|
+
.run-badge.merged{color:var(--s-merged);border:1px solid rgba(78,201,176,.4)}
|
|
223
|
+
.run-badge.running{color:var(--s-running);border:1px solid rgba(85,167,224,.4)}
|
|
224
|
+
.run-h .grow{flex:1}
|
|
225
|
+
.run .acts{display:flex;gap:6px;flex:none}
|
|
226
|
+
.run .acts .b{font-family:var(--sans);font-size:11.5px;font-weight:600;border-radius:7px;padding:5px 11px;
|
|
227
|
+
cursor:pointer;border:1px solid var(--line-hi);background:transparent;color:var(--muted)}
|
|
228
|
+
.run .acts .b:hover{color:var(--ink)}
|
|
229
|
+
.run .acts .b.merge{background:var(--brand);border-color:var(--brand);color:var(--brand-ink)}
|
|
230
|
+
.run .acts .b.close:hover{color:var(--s-failed);border-color:rgba(226,91,103,.45)}
|
|
231
|
+
.run .acts .b.ghost{color:var(--muted)}
|
|
232
|
+
.run .caret{color:var(--faint);font-family:var(--mono);font-size:12px;flex:none;width:14px;text-align:center;
|
|
233
|
+
transition:transform .16s}
|
|
234
|
+
.run.open .caret{transform:rotate(90deg)}
|
|
235
|
+
.run-b{display:none;border-top:1px solid var(--line);padding:12px 14px 14px;background:#0e1118}
|
|
236
|
+
.run.open .run-b{display:block}
|
|
237
|
+
.run-b .rout{margin-bottom:10px}
|
|
238
|
+
.run-b .rout-cards{display:flex;flex-direction:column;gap:6px}
|
|
239
|
+
.run-b .ocard{padding:8px 11px}
|
|
240
|
+
.run-b .rout-detail{margin-top:8px;border:1px solid var(--line);border-radius:8px;overflow:hidden}
|
|
241
|
+
.run-b .rout-back{font-family:var(--mono);font-size:10.5px;color:var(--muted);cursor:pointer;
|
|
242
|
+
padding:7px 11px;border-bottom:1px solid var(--line);background:var(--surface2)}
|
|
243
|
+
.run-b .rout-back:hover{color:var(--brand)}
|
|
244
|
+
.run-b .rout-body{padding:10px 12px;max-height:300px;overflow:auto}
|
|
245
|
+
.run-b .review{margin:10px 0;display:flex;gap:8px;align-items:flex-start;font-size:12.5px;line-height:1.55}
|
|
246
|
+
.run-b .review .rk{font-family:var(--mono);font-size:10.5px;color:var(--brand);flex:none;padding-top:2px}
|
|
247
|
+
.run-b .review .rt{color:var(--muted)}
|
|
248
|
+
.run-b .fix{margin-top:10px;display:flex;gap:8px}
|
|
249
|
+
.run-b .fix input{flex:1;background:var(--bg);border:1px solid var(--line-hi);border-radius:7px;color:var(--ink);
|
|
250
|
+
font-family:var(--sans);font-size:12.5px;padding:8px 11px;outline:none}
|
|
251
|
+
.run-b .fix input:focus{border-color:var(--brand)}
|
|
252
|
+
.run-b .fix .b{white-space:nowrap;font-family:var(--sans);font-size:11.5px;font-weight:600;border-radius:7px;
|
|
253
|
+
padding:5px 11px;cursor:pointer;border:1px solid var(--line-hi);background:transparent;color:var(--muted)}
|
|
254
|
+
.run-b .fix .b:hover{color:var(--ink);border-color:var(--brand)}
|
|
255
|
+
.send-only{display:flex;gap:8px;align-items:center}
|
|
256
|
+
.send-only .ro-hint{flex:1;font-family:var(--mono);font-size:10px;color:var(--faint);letter-spacing:.02em}
|
|
257
|
+
.composer{border-top:1px solid var(--line);padding:12px 18px;display:flex;flex-direction:column;gap:8px}
|
|
258
|
+
.composer .comp-seg{flex:0 0 auto}
|
|
259
|
+
.composer .comp-hint{font-family:var(--mono);font-size:10.5px;color:var(--faint);letter-spacing:.02em}
|
|
260
|
+
.composer textarea{min-height:52px}
|
|
261
|
+
.verbs{display:flex;gap:8px;align-items:center}
|
|
262
|
+
.verbs .grow{flex:1}
|
|
263
|
+
.conv-menu{position:relative}
|
|
264
|
+
.conv-pop{position:absolute;bottom:calc(100% + 6px);right:0;z-index:5;background:var(--surface2);
|
|
265
|
+
border:1px solid var(--line-hi);border-radius:9px;box-shadow:var(--shadow);padding:4px;min-width:200px;display:none}
|
|
266
|
+
.conv-menu.open .conv-pop{display:block}
|
|
267
|
+
.conv-pop button{display:block;width:100%;text-align:left;background:transparent;border:none;
|
|
268
|
+
color:var(--muted);font-size:12.5px;padding:8px 10px;border-radius:6px;cursor:pointer}
|
|
269
|
+
.conv-pop button:hover{background:var(--surface);color:var(--ink)}
|
|
155
270
|
/* ── active/archive view seg + archive list ── */
|
|
156
271
|
.view-seg{padding:2px;gap:2px}
|
|
157
272
|
.view-seg .seg-opt{padding:4px 12px;font-size:11.5px}
|
|
@@ -386,9 +501,59 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
386
501
|
.doc-md p{margin:0 0 8px}
|
|
387
502
|
.doc-frame{width:100%;height:420px;border:1px solid var(--line);border-radius:8px;background:#fff;margin-bottom:16px}
|
|
388
503
|
.pane-tgl{margin-left:auto;font-size:10px;padding:2px 9px;text-transform:none;letter-spacing:0}
|
|
389
|
-
/* run 모달의 diff 라인 = steer 참조 클릭 타깃 */
|
|
390
|
-
#
|
|
391
|
-
#
|
|
504
|
+
/* run 모달의 diff 라인 = steer 참조 클릭 타깃(code 카드 상세) */
|
|
505
|
+
#outDetail .dl-line{cursor:pointer;border-radius:3px}
|
|
506
|
+
#outDetail .dl-line:hover{background:rgba(78,201,176,.09)}
|
|
507
|
+
|
|
508
|
+
/* ── v4.7 산출물 계약(contract strip) — 모달 헤더 아래 요청 산출물 요약 ── */
|
|
509
|
+
.contract{display:flex;flex-wrap:wrap;align-items:center;gap:7px;padding:9px 18px;
|
|
510
|
+
border-bottom:1px solid var(--line);background:var(--surface2);
|
|
511
|
+
font-family:var(--mono);font-size:10.5px;color:var(--faint);line-height:1.5}
|
|
512
|
+
.contract[hidden]{display:none}
|
|
513
|
+
.contract .clabel{letter-spacing:.04em;text-transform:none}
|
|
514
|
+
.contract .csep{color:var(--line-hi)}
|
|
515
|
+
.req{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:999px;
|
|
516
|
+
border:1px solid var(--line);color:var(--muted);text-transform:none;letter-spacing:.02em}
|
|
517
|
+
.req.ok{color:var(--s-done);border-color:rgba(88,179,104,.4)}
|
|
518
|
+
.req.warn{color:var(--s-preparing);border-color:rgba(214,162,73,.45)}
|
|
519
|
+
.req.aux{color:var(--faint);border-style:dashed}
|
|
520
|
+
.req .rg{font-size:11px;line-height:1}
|
|
521
|
+
|
|
522
|
+
/* ── v4.7 출력 카드 목록(오른쪽 컬럼) ── */
|
|
523
|
+
#outWrap{display:flex;flex-direction:column;min-height:0;flex:1}
|
|
524
|
+
#outCards{display:flex;flex-direction:column;gap:8px;padding:12px 16px;overflow:auto;flex:1;min-height:0}
|
|
525
|
+
.ocard{display:flex;align-items:center;gap:10px;padding:11px 13px;border:1px solid var(--line);
|
|
526
|
+
border-radius:var(--r-card);background:var(--surface);cursor:pointer;
|
|
527
|
+
transition:border-color .16s,transform .16s}
|
|
528
|
+
.ocard:hover{border-color:#323b4e;transform:translateY(-1px)}
|
|
529
|
+
.ocard.miss{opacity:.72;border-style:dashed;cursor:default}
|
|
530
|
+
.ocard.miss:hover{transform:none;border-color:var(--line)}
|
|
531
|
+
.ocard .og{font-size:15px;line-height:1;flex:0 0 auto;width:20px;text-align:center;color:var(--muted)}
|
|
532
|
+
.ocard .ob{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
|
|
533
|
+
.ocard .ot{font-family:var(--sans);font-size:12.5px;color:var(--ink);font-weight:600;
|
|
534
|
+
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
535
|
+
.ocard .om{font-family:var(--mono);font-size:10px;color:var(--faint);
|
|
536
|
+
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
537
|
+
.ocard .obadge{font-family:var(--mono);font-size:9.5px;text-transform:uppercase;letter-spacing:.06em;
|
|
538
|
+
padding:2px 7px;border-radius:999px;border:1px solid var(--line);color:var(--faint);flex:0 0 auto}
|
|
539
|
+
.ocard .obadge.req{color:var(--brand);border-color:rgba(78,201,176,.4)}
|
|
540
|
+
.ocard .obadge.warn{color:var(--s-preparing);border-color:rgba(214,162,73,.45)}
|
|
541
|
+
.ocard .oc{color:var(--faint);font-size:13px;flex:0 0 auto}
|
|
542
|
+
.ocards-empty{color:var(--faint);font-family:var(--mono);font-size:11px;padding:22px 4px;text-align:center}
|
|
543
|
+
|
|
544
|
+
/* ── v4.7 출력 카드 상세 뷰어 ── */
|
|
545
|
+
#outDetail{display:none;flex-direction:column;min-height:0;flex:1}
|
|
546
|
+
#outDetail.on{display:flex}
|
|
547
|
+
#outWrap.detailing #outCards{display:none}
|
|
548
|
+
.oback{display:inline-flex;align-items:center;gap:6px;font-family:var(--mono);font-size:11px;
|
|
549
|
+
color:var(--muted);cursor:pointer;padding:9px 16px;border-bottom:1px solid var(--line);
|
|
550
|
+
background:var(--surface2);flex:0 0 auto}
|
|
551
|
+
.oback:hover{color:var(--brand)}
|
|
552
|
+
.odetail-c{overflow:auto;padding:12px 16px;flex:1;min-height:0}
|
|
553
|
+
.oimg{max-width:100%;border:1px solid var(--line);border-radius:8px;background:#fff}
|
|
554
|
+
.odl{display:inline-block;margin-top:6px;font-family:var(--mono);font-size:11.5px;color:var(--brand);
|
|
555
|
+
text-decoration:none;border:1px solid var(--line);border-radius:var(--r-ctl);padding:6px 11px}
|
|
556
|
+
.odl:hover{border-color:var(--brand)}
|
|
392
557
|
|
|
393
558
|
/* ── terminal ───────────────────────────── */
|
|
394
559
|
.term-body{flex:1;min-height:0;background:#0b0d12;padding:8px 4px 4px 10px}
|
|
@@ -499,6 +664,14 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
499
664
|
<datalist id="modelHist"></datalist>
|
|
500
665
|
<p class="flabel">design capture · optional</p>
|
|
501
666
|
<select id="taskCapture"><option value="">no design capture</option></select>
|
|
667
|
+
<p class="flabel">deliverables · optional (계약)</p>
|
|
668
|
+
<div class="ochips" id="taskOutputs" role="group" aria-label="declared deliverables">
|
|
669
|
+
<button type="button" class="ochip" data-out="answer">답변</button>
|
|
670
|
+
<button type="button" class="ochip" data-out="code">코드</button>
|
|
671
|
+
<button type="button" class="ochip" data-out="doc">문서</button>
|
|
672
|
+
<button type="button" class="ochip" data-out="page">페이지</button>
|
|
673
|
+
<button type="button" class="ochip" data-out="file">파일</button>
|
|
674
|
+
</div>
|
|
502
675
|
</div>
|
|
503
676
|
<div id="panelGoal" hidden style="flex-direction:column;gap:8px">
|
|
504
677
|
<textarea id="planGoal" placeholder="One goal — a planner agent reads the repo, splits it into independent tasks, and launches them all. Converge with Select runs → Integrate."></textarea>
|
|
@@ -540,6 +713,8 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
540
713
|
<div class="arch-bar">
|
|
541
714
|
<input id="archQ" placeholder="search title…" autocomplete="off" />
|
|
542
715
|
<select id="archRepo"><option value="">all repos</option></select>
|
|
716
|
+
<button type="button" class="btn-ghost sm" id="reclaimBtn" hidden
|
|
717
|
+
title="remove worktrees left by cleaned/failed runs — reclaims disk (active work untouched)">♻ Reclaim <span id="reclaimHint"></span></button>
|
|
543
718
|
</div>
|
|
544
719
|
<div id="archList"></div>
|
|
545
720
|
<div style="text-align:center;margin-top:14px"><button class="btn-ghost sm" id="archMore" hidden>load 50 more</button></div>
|
|
@@ -555,15 +730,21 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
555
730
|
<span class="chip" id="mChip"><i></i><span id="mChipTxt"></span></span>
|
|
556
731
|
<button class="x" id="mClose" aria-label="close">×</button>
|
|
557
732
|
</div>
|
|
733
|
+
<div class="contract" id="mContract" hidden></div>
|
|
558
734
|
<div class="modal-b">
|
|
559
735
|
<div class="pane">
|
|
560
736
|
<div class="pane-h">Timeline</div>
|
|
561
737
|
<div class="pane-c tl" id="mTimeline"></div>
|
|
562
738
|
</div>
|
|
563
739
|
<div class="pane">
|
|
564
|
-
<div class="pane-h" style="display:flex;align-items:center;gap:8px">
|
|
565
|
-
|
|
566
|
-
|
|
740
|
+
<div class="pane-h" style="display:flex;align-items:center;gap:8px">Outputs <span id="mStat" style="text-transform:none;letter-spacing:0"></span></div>
|
|
741
|
+
<div id="outWrap">
|
|
742
|
+
<div id="outCards"><div class="ocards-empty">loading…</div></div>
|
|
743
|
+
<div id="outDetail">
|
|
744
|
+
<div class="oback" id="outBack">‹ 산출물 목록</div>
|
|
745
|
+
<div class="odetail-c" id="outDetailC"></div>
|
|
746
|
+
</div>
|
|
747
|
+
</div>
|
|
567
748
|
</div>
|
|
568
749
|
</div>
|
|
569
750
|
<div class="modal-f" id="steerRow" style="border-top:1px solid var(--line)">
|
|
@@ -576,7 +757,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
576
757
|
</div>
|
|
577
758
|
<div class="modal-f">
|
|
578
759
|
<button class="btn-ghost sm" id="mTerm">Terminal</button>
|
|
579
|
-
<button class="btn-ghost sm" id="mRefreshDiff">Refresh
|
|
760
|
+
<button class="btn-ghost sm" id="mRefreshDiff">Refresh outputs</button>
|
|
580
761
|
<button class="btn-ghost sm" id="mCompare">Compare runs</button>
|
|
581
762
|
<button class="btn-ghost sm" id="mExport">Export files…</button>
|
|
582
763
|
<button class="btn-ghost sm" id="mSync">Sync base</button>
|
|
@@ -589,6 +770,53 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
589
770
|
</div>
|
|
590
771
|
</div>
|
|
591
772
|
|
|
773
|
+
<div class="overlay" id="groupRoomOverlay">
|
|
774
|
+
<div class="room">
|
|
775
|
+
<div class="rh">
|
|
776
|
+
<span class="rh-glyph">⌒</span>
|
|
777
|
+
<span class="rh-t" id="roomTitle">Goal</span>
|
|
778
|
+
<span class="rh-n" id="roomCount"></span>
|
|
779
|
+
<button class="x" id="roomClose" aria-label="close">×</button>
|
|
780
|
+
</div>
|
|
781
|
+
<div class="chips" id="roomChips"></div>
|
|
782
|
+
<div class="gbar" id="roomGbar">
|
|
783
|
+
<span class="selc" id="roomSelC">☑ 0 selected</span>
|
|
784
|
+
<button type="button" class="brand" id="roomIntegrateSel">Integrate 선택 (0)</button>
|
|
785
|
+
<span class="sp"></span>
|
|
786
|
+
<button type="button" id="roomReviewAll">전체 리뷰</button>
|
|
787
|
+
<button type="button" class="danger" id="roomGroupClose">그룹 클로즈</button>
|
|
788
|
+
</div>
|
|
789
|
+
<div class="body">
|
|
790
|
+
<div class="runs" id="roomRuns"></div>
|
|
791
|
+
<div class="conv" id="roomConv" hidden></div>
|
|
792
|
+
</div>
|
|
793
|
+
<div class="composer">
|
|
794
|
+
<div class="seg comp-seg" id="roomSeg" role="group" aria-label="workroom mode">
|
|
795
|
+
<button type="button" class="seg-opt on" data-rmode="work">✎ Work</button>
|
|
796
|
+
<button type="button" class="seg-opt" data-rmode="ask">? Ask</button>
|
|
797
|
+
</div>
|
|
798
|
+
<div class="comp-hint" id="roomHint"></div>
|
|
799
|
+
<textarea id="roomInput" placeholder="New attempt prompt, or a broadcast to the settled runs…"></textarea>
|
|
800
|
+
<div class="verbs" id="roomVerbs">
|
|
801
|
+
<button type="button" class="btn-ghost sm" id="roomNew">+ New attempt</button>
|
|
802
|
+
<button type="button" class="btn-ghost sm" id="roomBroadcast">→ Broadcast</button>
|
|
803
|
+
<span class="grow"></span>
|
|
804
|
+
<div class="conv-menu" id="roomConvMenu">
|
|
805
|
+
<button type="button" class="btn sm" id="roomConverge">Converge ▾</button>
|
|
806
|
+
<div class="conv-pop">
|
|
807
|
+
<button type="button" id="roomReview">Review group</button>
|
|
808
|
+
<button type="button" id="roomIntegrate">Select runs to integrate</button>
|
|
809
|
+
</div>
|
|
810
|
+
</div>
|
|
811
|
+
</div>
|
|
812
|
+
<div class="send-only" id="roomSend" hidden>
|
|
813
|
+
<span class="ro-hint">read-only — switch to Work to act</span>
|
|
814
|
+
<button type="button" class="btn sm" id="roomAsk">Ask</button>
|
|
815
|
+
</div>
|
|
816
|
+
</div>
|
|
817
|
+
</div>
|
|
818
|
+
</div>
|
|
819
|
+
|
|
592
820
|
<div class="overlay" id="cmpOverlay">
|
|
593
821
|
<div class="modal wide">
|
|
594
822
|
<div class="modal-h">
|
|
@@ -636,9 +864,14 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
636
864
|
</div>
|
|
637
865
|
<div class="brw-list" id="brwList"></div>
|
|
638
866
|
<div class="brw-f">
|
|
639
|
-
<span class="hint"
|
|
867
|
+
<span class="hint"><span style="color:var(--brand)">git</span> badge = repo (Register) · empty folder = Start here</span>
|
|
868
|
+
<button class="btn-ghost sm" id="brwNewFolder">+ New folder here</button>
|
|
640
869
|
<button class="btn sm" id="brwRegHere" style="display:none">Register this folder</button>
|
|
641
870
|
</div>
|
|
871
|
+
<form class="brw-f" id="brwNewForm" hidden>
|
|
872
|
+
<input id="brwNewName" placeholder="new-project-folder-name" style="flex:1" />
|
|
873
|
+
<button class="btn sm" type="submit" id="brwNewGo">Create & start</button>
|
|
874
|
+
</form>
|
|
642
875
|
</div>
|
|
643
876
|
</div>
|
|
644
877
|
|
|
@@ -809,7 +1042,8 @@ async function brwGo(p){
|
|
|
809
1042
|
(d.error ? '<div class="brw-row"><span class="nm" style="color:var(--s-failed)">'+esc(d.error)+'</span></div>' : '')
|
|
810
1043
|
+ (d.dirs.map(x =>
|
|
811
1044
|
'<div class="brw-row" data-n="'+esc(x.name)+'"><span class="ico">▸</span><span class="nm">'+esc(x.name)+'</span>'
|
|
812
|
-
+ (x.isRepo ? '<span class="gitchip">git</span><button type="button" class="btn sm" data-reg="'+esc(x.name)+'">Register</button>'
|
|
1045
|
+
+ (x.isRepo ? '<span class="gitchip">git</span><button type="button" class="btn sm" data-reg="'+esc(x.name)+'">Register</button>'
|
|
1046
|
+
: x.isEmpty ? '<button type="button" class="btn-ghost sm" data-start="'+esc(x.name)+'">Start here</button>' : '')
|
|
813
1047
|
+ '</div>').join('')
|
|
814
1048
|
|| '<div class="brw-row"><span class="nm" style="color:var(--faint)">no folders here</span></div>');
|
|
815
1049
|
}
|
|
@@ -834,18 +1068,42 @@ async function brwRegister(fullPath){
|
|
|
834
1068
|
$('repoBrowse').addEventListener('click', ()=>{
|
|
835
1069
|
const m = machines.find(x=>x.slug===$('repoMachine').value);
|
|
836
1070
|
if (m && m.address){ toast('remote machine — type the path manually for now', 'error'); return; }
|
|
1071
|
+
$('brwNewForm').hidden = true; $('brwNewName').value = '';
|
|
837
1072
|
$('brwOverlay').classList.add('open');
|
|
838
1073
|
brwGo(brwCur || '');
|
|
839
1074
|
});
|
|
840
1075
|
$('brwList').addEventListener('click',(e)=>{
|
|
841
1076
|
const reg = e.target.closest('button[data-reg]');
|
|
842
1077
|
if (reg){ brwRegister(brwCur.replace(/\\/$/,'')+'/'+reg.dataset.reg); return; }
|
|
1078
|
+
const start = e.target.closest('button[data-start]');
|
|
1079
|
+
if (start){ brwStartHere(brwCur.replace(/\\/$/,'')+'/'+start.dataset.start); return; }
|
|
843
1080
|
const row = e.target.closest('.brw-row[data-n]');
|
|
844
1081
|
if (row) brwGo(brwCur.replace(/\\/$/,'')+'/'+row.dataset.n);
|
|
845
1082
|
});
|
|
846
1083
|
$('brwUp').addEventListener('click',(e)=>brwGo(e.currentTarget.dataset.p));
|
|
847
1084
|
$('brwHome').addEventListener('click',(e)=>brwGo(e.currentTarget.dataset.p));
|
|
848
1085
|
$('brwRegHere').addEventListener('click',()=>brwRegister(brwCur));
|
|
1086
|
+
/* 빈 폴더 행의 Start here — 클릭한 폴더에 greenfield 씨앗 심고 등록 */
|
|
1087
|
+
async function brwStartHere(fullPath){
|
|
1088
|
+
if (await createNewProject(fullPath)) $('brwOverlay').classList.remove('open');
|
|
1089
|
+
}
|
|
1090
|
+
/* + New folder here — 현재 탐색 위치에 새 폴더명만 받아 생성+시작 (절대경로 타이핑 불필요) */
|
|
1091
|
+
$('brwNewFolder').addEventListener('click', ()=>{
|
|
1092
|
+
const f = $('brwNewForm'); f.hidden = !f.hidden; if(!f.hidden) $('brwNewName').focus();
|
|
1093
|
+
});
|
|
1094
|
+
$('brwNewForm').addEventListener('submit', async (e)=>{
|
|
1095
|
+
e.preventDefault();
|
|
1096
|
+
const name = $('brwNewName').value.trim();
|
|
1097
|
+
if (!name){ toast('enter a folder name', 'error'); return; }
|
|
1098
|
+
if (name.includes('/')){ toast('folder name cannot contain /', 'error'); return; }
|
|
1099
|
+
const go = $('brwNewGo'); go.disabled = true; go.textContent = 'Creating…';
|
|
1100
|
+
try{
|
|
1101
|
+
if (await createNewProject(brwCur.replace(/\\/$/,'')+'/'+name)){
|
|
1102
|
+
$('brwNewName').value=''; $('brwNewForm').hidden=true;
|
|
1103
|
+
$('brwOverlay').classList.remove('open');
|
|
1104
|
+
}
|
|
1105
|
+
} finally { go.disabled=false; go.textContent='Create & start'; }
|
|
1106
|
+
});
|
|
849
1107
|
$('brwClose').addEventListener('click',()=>$('brwOverlay').classList.remove('open'));
|
|
850
1108
|
$('brwOverlay').addEventListener('click',(e)=>{ if(e.target===$('brwOverlay')) $('brwOverlay').classList.remove('open'); });
|
|
851
1109
|
|
|
@@ -985,6 +1243,7 @@ function bandHTML(g, grpRuns){
|
|
|
985
1243
|
+ '<span class="gband-t">'+title+'</span>'
|
|
986
1244
|
+ '<span class="gband-n">'+taskIds.length+' task'+(taskIds.length>1?'s':'')+' · '+settled+' settled</span>'
|
|
987
1245
|
+ '<span class="gband-sp"></span>'
|
|
1246
|
+
+ '<button class="btn-ghost sm gband-open" data-groom="'+g.id+'">⌒ Open workroom</button>'
|
|
988
1247
|
+ '<button class="btn-ghost sm" data-gsel="'+g.id+'">Select runs</button>'
|
|
989
1248
|
+ '<button class="btn-ghost sm" data-gclose="'+g.id+'">Close group</button>'
|
|
990
1249
|
+ '<button class="gband-fold" data-gfold="'+g.id+'" title="fold">'+(folded?'▸':'▾')+'</button></div>'
|
|
@@ -1025,7 +1284,7 @@ function setView(v){
|
|
|
1025
1284
|
$('grid').style.display = archive ? 'none' : '';
|
|
1026
1285
|
$('empty').style.display = archive ? 'none' : ($('grid').innerHTML ? 'none' : 'flex');
|
|
1027
1286
|
document.querySelector('.toolbar').style.display = archive ? 'none' : '';
|
|
1028
|
-
if (archive){ paintArchRepos(); archFetch(true); }
|
|
1287
|
+
if (archive){ paintArchRepos(); archFetch(true); reclaimRefresh(); }
|
|
1029
1288
|
else render();
|
|
1030
1289
|
}
|
|
1031
1290
|
document.querySelectorAll('#viewSeg .seg-opt').forEach(b=>b.addEventListener('click', ()=>setView(b.dataset.view)));
|
|
@@ -1077,6 +1336,32 @@ $('archList').addEventListener('click', async (e)=>{
|
|
|
1077
1336
|
}catch{ toast('could not open task', 'error'); }
|
|
1078
1337
|
});
|
|
1079
1338
|
|
|
1339
|
+
/* ── Reclaim orphaned worktrees ── cleaned/failed run worktrees are disk debt
|
|
1340
|
+
(~180MB each — node_modules lives inside). Only shown when there's something
|
|
1341
|
+
to reclaim; active/successful work is never listed by the server. */
|
|
1342
|
+
let reclaimN = 0;
|
|
1343
|
+
async function reclaimRefresh(){
|
|
1344
|
+
try{
|
|
1345
|
+
const j = await fetch('/api/worktrees').then(x=>x.json());
|
|
1346
|
+
reclaimN = (j.items||[]).length;
|
|
1347
|
+
const mb = Math.round((j.totalKb||0)/1024);
|
|
1348
|
+
$('reclaimHint').textContent = reclaimN ? (reclaimN+' · ~'+mb+'MB') : '';
|
|
1349
|
+
$('reclaimBtn').hidden = reclaimN===0;
|
|
1350
|
+
}catch{ $('reclaimBtn').hidden = true; }
|
|
1351
|
+
}
|
|
1352
|
+
$('reclaimBtn').addEventListener('click', async ()=>{
|
|
1353
|
+
if (!reclaimN) return;
|
|
1354
|
+
const ok = await confirmUI('remove '+reclaimN+' cleaned/failed run worktree'+(reclaimN===1?'':'s')+'?', {
|
|
1355
|
+
sub:'active work is untouched — only closed tasks and failed/error/stopped runs are reclaimed',
|
|
1356
|
+
okLabel:'Reclaim', danger:true });
|
|
1357
|
+
if (!ok) return;
|
|
1358
|
+
try{
|
|
1359
|
+
const r = await fetch('/api/worktrees/prune',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({})}).then(x=>x.json());
|
|
1360
|
+
toast('reclaimed '+(r.count||0)+' worktree'+((r.count||0)===1?'':'s'), 'ok');
|
|
1361
|
+
}catch{ toast('reclaim failed', 'error'); }
|
|
1362
|
+
reclaimRefresh();
|
|
1363
|
+
});
|
|
1364
|
+
|
|
1080
1365
|
/* ── first-run onboarding (빈 보드 = 준비 상태 점검 + 시작 안내) ── */
|
|
1081
1366
|
let readiness = null, probing = false;
|
|
1082
1367
|
async function probeFirstMachine(){
|
|
@@ -1285,6 +1570,7 @@ function connectWS(){
|
|
|
1285
1570
|
let ev; try{ ev = JSON.parse(m.data); }catch{ return; }
|
|
1286
1571
|
// 아카이브 뷰는 정적 스냅샷 — 라이브 갱신으로 뒤엎지 않는다.
|
|
1287
1572
|
if (boardView==='archive' && (ev.type==='run'||ev.type==='event'||ev.type==='task')) return;
|
|
1573
|
+
roomOnWS(ev); // 워크룸이 열려있으면 그룹 소속 run/event 를 방에 반영
|
|
1288
1574
|
if (ev.type==='run'){
|
|
1289
1575
|
const rid = ev.runId ?? ev.id;
|
|
1290
1576
|
const known = runs.has(rid);
|
|
@@ -1312,6 +1598,14 @@ function connectWS(){
|
|
|
1312
1598
|
|
|
1313
1599
|
/* ── run detail modal ── */
|
|
1314
1600
|
let openRunId = null;
|
|
1601
|
+
/* 터미널이 붙을 수 없는 사유(있으면 그 문자열, 없으면 '') — 닫힌 task·정리된 worktree.
|
|
1602
|
+
cleanupRun 이 worktreePath 를 비우므로 merged/cleanup/closed 는 모두 여기서 걸린다. */
|
|
1603
|
+
function termUnavailReason(r, task){
|
|
1604
|
+
if (task && task.status==='closed') return 'worktree cleaned — terminal unavailable (task closed)';
|
|
1605
|
+
if (r.status==='merged') return 'worktree cleaned — terminal unavailable (merged)';
|
|
1606
|
+
if ('worktreePath' in r && !r.worktreePath) return 'worktree cleaned — terminal unavailable';
|
|
1607
|
+
return '';
|
|
1608
|
+
}
|
|
1315
1609
|
function paintModal(){
|
|
1316
1610
|
if (openRunId==null) return;
|
|
1317
1611
|
const r = runs.get(openRunId); if(!r) return;
|
|
@@ -1326,40 +1620,157 @@ function paintModal(){
|
|
|
1326
1620
|
$('mStop').style.display = (r.status==='running'||r.status==='preparing'||r.status==='pending') ? '' : 'none';
|
|
1327
1621
|
// steer 는 정착한 real run 에서만 의미(드라이런은 세션 없음 — 서버가 사유와 함께 거절)
|
|
1328
1622
|
$('steerRow').style.display = ['done','failed','stopped'].includes(r.status) ? '' : 'none';
|
|
1623
|
+
// 터미널 가드 — 닫힌 task·worktree 정리된 run(merged/cleanup) 은 tmux 세션이 죽어
|
|
1624
|
+
// attach 하면 에러. 버튼을 잠그고 사유를 툴팁으로.
|
|
1625
|
+
const termGuard = termUnavailReason(r, task);
|
|
1626
|
+
const tbtn = $('mTerm');
|
|
1627
|
+
tbtn.disabled = !!termGuard;
|
|
1628
|
+
tbtn.title = termGuard || 'attach a tmux terminal to this run';
|
|
1329
1629
|
$('mTimeline').innerHTML = humanLines(r.events).map(h =>
|
|
1330
1630
|
'<div class="ev"><span class="k">'+esc(h.k)+'</span><span class="t">'+esc(h.t)+'</span></div>'
|
|
1331
1631
|
).join('') || '<span style="color:var(--faint)">no events yet</span>';
|
|
1332
1632
|
}
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1633
|
+
/* ── v4.7 산출물 계약 · 출력 카드 — 오른쪽 컬럼(diff/Rendered 토글을 흡수) ──
|
|
1634
|
+
/outputs 로 카드 목록을 받아 렌더하고, 클릭하면 타입별 실뷰어를 오른쪽에 띄운다.
|
|
1635
|
+
answer/doc → mdLite · page → sandbox iframe · code → 기존 diff 렌더러 · file → 이미지/다운로드. */
|
|
1636
|
+
let outCards = []; // 이 run 의 마지막 카드 목록(RunOutputCard[])
|
|
1637
|
+
const OUT_GLYPH = { answer:'✦', code:'‹›', doc:'▤', page:'◱', file:'⎘' };
|
|
1638
|
+
const OUT_LABEL = { answer:'답변', code:'코드', doc:'문서', page:'페이지', file:'파일' };
|
|
1639
|
+
function contractHTML(cards){
|
|
1640
|
+
const declared = cards.filter(c=>c.required);
|
|
1641
|
+
const aux = cards.filter(c=>!c.required && c.present);
|
|
1642
|
+
if (!declared.length && !aux.length) return ''; // 계약도 부수도 없으면 스트립 숨김
|
|
1643
|
+
let h = '<span class="clabel">요청 산출물(계약):</span>';
|
|
1644
|
+
if (declared.length){
|
|
1645
|
+
h += declared.map(c=>{
|
|
1646
|
+
const ok = c.present;
|
|
1647
|
+
return '<span class="req '+(ok?'ok':'warn')+'"><span class="rg">'+(ok?'✓':'!')+'</span>'
|
|
1648
|
+
+ esc(OUT_LABEL[c.type]||c.type)+'</span>';
|
|
1649
|
+
}).join('');
|
|
1650
|
+
} else h += '<span class="req aux">없음</span>';
|
|
1651
|
+
if (aux.length){
|
|
1652
|
+
h += '<span class="csep">—</span><span class="clabel">부수:</span>'
|
|
1653
|
+
+ aux.map(c=>'<span class="req aux">'+esc(OUT_LABEL[c.type]||c.type)+'</span>').join('');
|
|
1654
|
+
}
|
|
1655
|
+
return h;
|
|
1656
|
+
}
|
|
1657
|
+
function outCardHTML(c, i){
|
|
1658
|
+
const miss = !c.present;
|
|
1659
|
+
const badge = c.required
|
|
1660
|
+
? '<span class="obadge '+(c.present?'req':'warn')+'">요청됨</span>'
|
|
1661
|
+
: '<span class="obadge">부수</span>';
|
|
1662
|
+
const glyph = OUT_GLYPH[c.type] || '•';
|
|
1663
|
+
const meta = miss ? '산출물 미충족 — '+esc(c.meta||'') : esc(c.meta||'');
|
|
1664
|
+
return '<div class="ocard'+(miss?' miss':'')+'" data-oi="'+i+'">'
|
|
1665
|
+
+ '<span class="og">'+esc(glyph)+'</span>'
|
|
1666
|
+
+ '<span class="ob"><span class="ot">'+esc(c.title||c.type)+'</span>'
|
|
1667
|
+
+ '<span class="om">'+meta+'</span></span>'
|
|
1668
|
+
+ badge
|
|
1669
|
+
+ (miss?'':'<span class="oc">›</span>')
|
|
1670
|
+
+ '</div>';
|
|
1671
|
+
}
|
|
1672
|
+
/* run 형태로 기본 카드 선택 — 코드 변경 없고 answer/doc 있으면 그걸, 코드 위주면 code,
|
|
1673
|
+
아니면 첫 present 선언 카드(없으면 첫 present 카드). 미충족 카드는 절대 자동 오픈 안 함. */
|
|
1674
|
+
function pickDefaultCard(cards){
|
|
1675
|
+
const present = cards.filter(c=>c.present);
|
|
1676
|
+
if (!present.length) return -1;
|
|
1677
|
+
const hasCode = present.some(c=>c.type==='code');
|
|
1678
|
+
const docish = present.find(c=>c.type==='answer' || c.type==='doc');
|
|
1679
|
+
if (!hasCode && docish) return cards.indexOf(docish);
|
|
1680
|
+
if (hasCode){
|
|
1681
|
+
const codeCard = present.find(c=>c.type==='code');
|
|
1682
|
+
// 코드 위주라도 선언된 문서/답변이 있으면 그 계약을 우선(코드는 언제나 카드로 접근 가능)
|
|
1683
|
+
const declaredDoc = present.find(c=>c.required && (c.type==='answer'||c.type==='doc'||c.type==='page'));
|
|
1684
|
+
if (declaredDoc) return cards.indexOf(declaredDoc);
|
|
1685
|
+
return cards.indexOf(codeCard);
|
|
1686
|
+
}
|
|
1687
|
+
const declared = present.find(c=>c.required);
|
|
1688
|
+
return cards.indexOf(declared || present[0]);
|
|
1689
|
+
}
|
|
1690
|
+
async function loadDiff(){ // 이름은 유지(모달 refresh·정착 이벤트가 호출) — 이제 outputs 를 로드
|
|
1691
|
+
if (openRunId==null) return;
|
|
1336
1692
|
const rid = openRunId;
|
|
1337
|
-
|
|
1693
|
+
$('outCards').innerHTML = '<div class="ocards-empty">loading…</div>';
|
|
1694
|
+
$('mStat').textContent='';
|
|
1695
|
+
showOutCards(); // 상세에서 목록으로
|
|
1338
1696
|
try{
|
|
1339
|
-
const
|
|
1340
|
-
if (
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
$('
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1697
|
+
const j = await fetch('/api/runs/'+rid+'/outputs').then(x=>x.json());
|
|
1698
|
+
if (rid !== openRunId) return;
|
|
1699
|
+
outCards = (j.outputs)||[];
|
|
1700
|
+
$('mContract').innerHTML = contractHTML(outCards);
|
|
1701
|
+
$('mContract').hidden = !$('mContract').innerHTML;
|
|
1702
|
+
const nPresent = outCards.filter(c=>c.present).length;
|
|
1703
|
+
$('mStat').textContent = nPresent ? '· '+nPresent+' output'+(nPresent>1?'s':'') : '· none';
|
|
1704
|
+
if (!outCards.length){ $('outCards').innerHTML = '<div class="ocards-empty">no outputs yet</div>'; return; }
|
|
1705
|
+
$('outCards').innerHTML = outCards.map(outCardHTML).join('');
|
|
1706
|
+
const def = pickDefaultCard(outCards);
|
|
1707
|
+
if (def>=0) openOutCard(def);
|
|
1708
|
+
}catch{ $('outCards').innerHTML = '<div class="ocards-empty">outputs failed</div>'; }
|
|
1709
|
+
}
|
|
1710
|
+
function showOutCards(){
|
|
1711
|
+
$('outWrap').classList.remove('detailing');
|
|
1712
|
+
$('outDetail').classList.remove('on');
|
|
1713
|
+
}
|
|
1714
|
+
function showOutDetail(){
|
|
1715
|
+
$('outWrap').classList.add('detailing');
|
|
1716
|
+
$('outDetail').classList.add('on');
|
|
1717
|
+
}
|
|
1718
|
+
/* 카드 상세 렌더러(재사용) — 모달 오른쪽 컬럼과 워크룸 펼침 peek 이 공유.
|
|
1719
|
+
rid = 대상 run, c = 카드, box = 그릴 곳, alive() = 아직 이 뷰가 유효한지(스테일 가드). */
|
|
1720
|
+
async function renderOutCardInto(rid, c, box, alive){
|
|
1721
|
+
if (!c || !c.present) return;
|
|
1722
|
+
const ok = alive || (()=>true);
|
|
1723
|
+
box.innerHTML = '<span style="color:var(--faint);font-family:var(--mono);font-size:11px">rendering…</span>';
|
|
1356
1724
|
try{
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1725
|
+
if (c.type==='answer' || c.type==='doc'){
|
|
1726
|
+
const q = '/api/runs/'+rid+'/output?type='+c.type+(c.path?'&path='+encodeURIComponent(c.path):'');
|
|
1727
|
+
const j = await fetch(q).then(x=>x.json());
|
|
1728
|
+
if (!ok()) return;
|
|
1729
|
+
box.innerHTML = (c.path?'<div class="doc-h">'+esc(c.path)+'</div>':'')
|
|
1730
|
+
+ '<div class="doc-md">'+mdLite(j.content||'')+'</div>';
|
|
1731
|
+
} else if (c.type==='page'){
|
|
1732
|
+
const q = '/api/runs/'+rid+'/output?type=page'+(c.path?'&path='+encodeURIComponent(c.path):'');
|
|
1733
|
+
const j = await fetch(q).then(x=>x.json());
|
|
1734
|
+
if (!ok()) return;
|
|
1735
|
+
box.innerHTML = (c.path?'<div class="doc-h">'+esc(c.path)+'</div>':'')
|
|
1736
|
+
+ '<iframe class="doc-frame" sandbox="" srcdoc="'+escA(j.content||'')+'"></iframe>';
|
|
1737
|
+
} else if (c.type==='code'){
|
|
1738
|
+
box.innerHTML = '<pre class="diff">loading…</pre>';
|
|
1739
|
+
const d = await fetch('/api/runs/'+rid+'/diff').then(x=>x.json());
|
|
1740
|
+
if (!ok()) return;
|
|
1741
|
+
const pre = box.querySelector('pre.diff'); if (!pre) return;
|
|
1742
|
+
if (!d.ok){ pre.textContent = d.stat||'no worktree — diff unavailable'; return; }
|
|
1743
|
+
pre.innerHTML = diffHTML(d.diff||'');
|
|
1744
|
+
} else if (c.type==='file'){
|
|
1745
|
+
const raw = '/api/runs/'+rid+'/file?path='+encodeURIComponent(c.path||'');
|
|
1746
|
+
const isImg = /\\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test(c.path||'');
|
|
1747
|
+
if (isImg){
|
|
1748
|
+
box.innerHTML = '<div class="doc-h">'+esc(c.path||'')+'</div>'
|
|
1749
|
+
+ '<img class="oimg" src="'+escA(raw)+'" alt="'+escA(c.path||'')+'">';
|
|
1750
|
+
} else {
|
|
1751
|
+
const isText = /\\.(txt|md|json|csv|log|ya?ml|ini|cfg|xml|ts|js|py|sh)$/i.test(c.path||'');
|
|
1752
|
+
let extra = '';
|
|
1753
|
+
if (isText){
|
|
1754
|
+
try{
|
|
1755
|
+
const r = await fetch(raw);
|
|
1756
|
+
if (ok() && r.ok){
|
|
1757
|
+
const t = await r.text();
|
|
1758
|
+
extra = '<pre class="diff" style="margin-top:8px">'+esc(t.slice(0,20000))+'</pre>';
|
|
1759
|
+
}
|
|
1760
|
+
}catch{}
|
|
1761
|
+
}
|
|
1762
|
+
box.innerHTML = '<div class="doc-h">'+esc(c.path||'')+'</div>'
|
|
1763
|
+
+ '<a class="odl" href="'+escA(raw)+'" download>↓ download</a>' + extra;
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
}catch{ box.innerHTML = '<span style="color:var(--faint)">viewer failed</span>'; }
|
|
1767
|
+
}
|
|
1768
|
+
async function openOutCard(i){
|
|
1769
|
+
const c = outCards[i]; if (!c || !c.present) return;
|
|
1770
|
+
const rid = openRunId;
|
|
1771
|
+
showOutDetail();
|
|
1772
|
+
await renderOutCardInto(rid, c, $('outDetailC'), ()=>rid===openRunId);
|
|
1360
1773
|
}
|
|
1361
|
-
/* ── doc 모드 — diff 대신 렌더된 문서 산출물 ── */
|
|
1362
|
-
let docMode = false;
|
|
1363
1774
|
function docsHTML(docs){
|
|
1364
1775
|
if (!docs.length) return '<span style="color:var(--faint)">no changed docs (md/html) in this worktree</span>';
|
|
1365
1776
|
return docs.map(d=>'<div class="doc-h">'+esc(d.path)+'</div>'
|
|
@@ -1367,25 +1778,15 @@ function docsHTML(docs){
|
|
|
1367
1778
|
? '<div class="doc-md">'+mdLite(d.content)+'</div>'
|
|
1368
1779
|
: '<iframe class="doc-frame" sandbox="" srcdoc="'+escA(d.content)+'"></iframe>')).join('');
|
|
1369
1780
|
}
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
}
|
|
1380
|
-
function setDocMode(on){
|
|
1381
|
-
docMode = on;
|
|
1382
|
-
$('mDocsTgl').textContent = on ? 'Diff' : 'Rendered';
|
|
1383
|
-
if (on) paintDocs();
|
|
1384
|
-
else { $('mDiffWrap').innerHTML = '<pre class="diff" id="mDiff">loading…</pre>'; loadDiff(); }
|
|
1385
|
-
}
|
|
1386
|
-
$('mDocsTgl').addEventListener('click', ()=>setDocMode(!docMode));
|
|
1387
|
-
/* diff 라인 클릭 → steer 입력에 참조 인용 (정착 run 에서만 — steerRow 표시 중일 때) */
|
|
1388
|
-
$('mDiffWrap').addEventListener('click', (e)=>{
|
|
1781
|
+
/* 출력 카드 클릭 → 상세 뷰어 */
|
|
1782
|
+
$('outCards').addEventListener('click', (e)=>{
|
|
1783
|
+
const card = e.target.closest ? e.target.closest('.ocard') : null;
|
|
1784
|
+
if (!card || card.classList.contains('miss')) return;
|
|
1785
|
+
openOutCard(Number(card.dataset.oi));
|
|
1786
|
+
});
|
|
1787
|
+
$('outBack').addEventListener('click', showOutCards);
|
|
1788
|
+
/* code 카드 diff 라인 클릭 → steer 입력에 참조 인용 (정착 run 에서만 — steerRow 표시 중일 때) */
|
|
1789
|
+
$('outDetail').addEventListener('click', (e)=>{
|
|
1389
1790
|
const t = e.target.closest ? e.target.closest('.dl-line') : null;
|
|
1390
1791
|
if (!t) return;
|
|
1391
1792
|
if ($('steerRow').style.display === 'none') return;
|
|
@@ -1396,9 +1797,11 @@ $('mDiffWrap').addEventListener('click', (e)=>{
|
|
|
1396
1797
|
});
|
|
1397
1798
|
async function openModal(id){
|
|
1398
1799
|
openRunId = id;
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1800
|
+
outCards = [];
|
|
1801
|
+
showOutCards();
|
|
1802
|
+
$('mContract').hidden = true; $('mContract').innerHTML = '';
|
|
1803
|
+
$('outCards').innerHTML = '<div class="ocards-empty">loading…</div>';
|
|
1804
|
+
paintModal(); $('overlay').classList.add('open'); loadDiff(); // loadDiff = /outputs 로드
|
|
1402
1805
|
// fleet 는 run 당 최근 40 이벤트만 내림 — 모달은 전체 타임라인을 다시 가져온다.
|
|
1403
1806
|
try{
|
|
1404
1807
|
const j = await fetch('/api/runs/'+id).then(x=>x.json());
|
|
@@ -1436,6 +1839,8 @@ $('selGo').addEventListener('click', async ()=>{
|
|
|
1436
1839
|
|
|
1437
1840
|
/* ── 그룹 밴드 액션 (fold / Select runs / Close group) ── */
|
|
1438
1841
|
$('grid').addEventListener('click', async (e)=>{
|
|
1842
|
+
const groom = e.target.closest('[data-groom]');
|
|
1843
|
+
if (groom){ openRoom(Number(groom.dataset.groom)); return; }
|
|
1439
1844
|
const fold = e.target.closest('[data-gfold]');
|
|
1440
1845
|
if (fold){ const g=Number(fold.dataset.gfold); if(gfold.has(g)) gfold.delete(g); else gfold.add(g); saveGfold(); render(); return; }
|
|
1441
1846
|
const gsel = e.target.closest('[data-gsel]');
|
|
@@ -1496,7 +1901,7 @@ $('grid').addEventListener('click',(e)=>{
|
|
|
1496
1901
|
});
|
|
1497
1902
|
$('mClose').addEventListener('click', closeModal);
|
|
1498
1903
|
$('overlay').addEventListener('click',(e)=>{ if(e.target===$('overlay')) closeModal(); });
|
|
1499
|
-
document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ closeDropdowns(); cfmClose(false); $('brwOverlay').classList.remove('open'); $('expOverlay').classList.remove('open'); $('ghOverlay').classList.remove('open'); $('npOverlay').classList.remove('open'); $('brOverlay').classList.remove('open'); $('remoteOverlay').classList.remove('open'); closeTerm(); closeModal(); cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
|
|
1904
|
+
document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ closeDropdowns(); cfmClose(false); $('brwOverlay').classList.remove('open'); $('expOverlay').classList.remove('open'); $('ghOverlay').classList.remove('open'); $('npOverlay').classList.remove('open'); $('brOverlay').classList.remove('open'); $('remoteOverlay').classList.remove('open'); closeRoom(); closeTerm(); closeModal(); cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
|
|
1500
1905
|
$('mRefreshDiff').addEventListener('click', loadDiff);
|
|
1501
1906
|
$('mExport').addEventListener('click', ()=>{
|
|
1502
1907
|
if (openRunId==null) return;
|
|
@@ -1698,6 +2103,470 @@ $('mCompare').addEventListener('click', ()=>{
|
|
|
1698
2103
|
closeModal(); openCompare(r.taskId);
|
|
1699
2104
|
});
|
|
1700
2105
|
|
|
2106
|
+
/* ── goal workroom (v4.6 L1) — 한 goal 을 여는 단일 방 ── */
|
|
2107
|
+
let roomGroupId = null; // 열려있는 그룹 id (null = 닫힘)
|
|
2108
|
+
const roomRunSet = new Set(); // 이 그룹에 속한 runId — WS 필터용
|
|
2109
|
+
let roomRuns = []; // 마지막 aggregate 의 runs (chips/scope hint)
|
|
2110
|
+
const roomTurns = []; // Ask 대화 턴 [{role:'you'|'coord', text}] — 읽기 전용
|
|
2111
|
+
let roomMode = 'work'; // 'work' | 'ask' — 방 composer 모드(스틸모드와 무관, 방 전용)
|
|
2112
|
+
/* run → 상태색 (chip / who) */
|
|
2113
|
+
function roomStatusColor(s){ return statusColor(s); }
|
|
2114
|
+
function roomChipHTML(r){
|
|
2115
|
+
const s = r.status||'pending';
|
|
2116
|
+
const meta = ['done','failed','stopped','merged'].includes(s)
|
|
2117
|
+
? (r.filesChanged? '+'+r.filesChanged+'f' : 'no changes')
|
|
2118
|
+
: (r.agent||'agent')+(s==='running'?' · editing…':'');
|
|
2119
|
+
return '<span class="chip'+(s==='running'?' running':'')+(r.isNew?' new':'')+'" data-roomrun="'+r.runId+'"'
|
|
2120
|
+
+ ' style="color:'+roomStatusColor(s)+';border-color:'+roomStatusColor(s)+'">'
|
|
2121
|
+
+ '<i></i><span class="cid">r'+r.runId+'</span> <span class="cmeta">'+esc(meta)+'</span></span>';
|
|
2122
|
+
}
|
|
2123
|
+
function roomRenderChips(){
|
|
2124
|
+
$('roomChips').innerHTML = roomRuns.length
|
|
2125
|
+
? roomRuns.map(roomChipHTML).join('')
|
|
2126
|
+
: '<span style="color:var(--faint);font-family:var(--mono);font-size:11.5px">no runs yet</span>';
|
|
2127
|
+
}
|
|
2128
|
+
/* ── 수렴 콕핏(Work body) — run 하나당 결정 행. 서버 엔드포인트만 재사용(merge/steer/review/close). ──
|
|
2129
|
+
접힘 head = 체크박스·상태점·rN·제목·메타·인라인[리뷰][수정][머지][클로즈]·caret.
|
|
2130
|
+
펼침 body = P2 출력 카드(리뷰 peek, renderRunOutputsInto 재사용) + 수정(steer) 입력. */
|
|
2131
|
+
const roomSel = new Set(); // Integrate 선택된 runId
|
|
2132
|
+
const roomOpen = new Set(); // 펼쳐진 runId (WS 재렌더에도 유지)
|
|
2133
|
+
function roomDone(s){ return ['done','failed','stopped'].includes(s); }
|
|
2134
|
+
function roomRunRowHTML(r){
|
|
2135
|
+
const s = r.status||'pending';
|
|
2136
|
+
const merged = s==='merged';
|
|
2137
|
+
const running = r.live || s==='running' || s==='preparing' || s==='pending';
|
|
2138
|
+
const sel = roomSel.has(r.runId);
|
|
2139
|
+
const open = roomOpen.has(r.runId);
|
|
2140
|
+
const dotCls = merged?'merged':(running?'running':(s==='failed'?'failed':'done'));
|
|
2141
|
+
const meta = (r.agent?esc(r.agent)+' · ':'') + (r.filesChanged? '+'+r.filesChanged+'f' : 'no changes');
|
|
2142
|
+
let acts;
|
|
2143
|
+
if (merged){
|
|
2144
|
+
acts = '<div class="acts"><button class="b ghost" data-ract="open" data-rrid="'+r.runId+'">열기</button></div>';
|
|
2145
|
+
} else if (running){
|
|
2146
|
+
acts = '<div class="acts"><button class="b ghost" data-ract="open" data-rrid="'+r.runId+'">열기 · 정착 후 결정</button></div>';
|
|
2147
|
+
} else {
|
|
2148
|
+
acts = '<div class="acts">'
|
|
2149
|
+
+ '<button class="b ghost" data-ract="review" data-rrid="'+r.runId+'">리뷰</button>'
|
|
2150
|
+
+ '<button class="b" data-ract="fix" data-rrid="'+r.runId+'">수정</button>'
|
|
2151
|
+
+ '<button class="b merge" data-ract="merge" data-rrid="'+r.runId+'">머지</button>'
|
|
2152
|
+
+ '<button class="b close" data-ract="close" data-rrid="'+r.runId+'">클로즈</button>'
|
|
2153
|
+
+ '</div>';
|
|
2154
|
+
}
|
|
2155
|
+
const badge = merged
|
|
2156
|
+
? '<span class="run-badge merged">merged → base</span>'
|
|
2157
|
+
: (running ? '<span class="run-badge running">running</span>' : '');
|
|
2158
|
+
// running·merged 는 체크박스로 선택 불가(정착·미머지만 Integrate 대상)
|
|
2159
|
+
const selectable = !merged && !running && (r.filesChanged||0)>0;
|
|
2160
|
+
const cb = '<span class="cb" data-rcb="'+(selectable?r.runId:'')+'"'+(selectable?'':' style="opacity:.4;cursor:default"')+'>'+(sel?'✓':'')+'</span>';
|
|
2161
|
+
return '<div class="run'+(sel?' sel':'')+(merged?' dim':'')+(open?' open':'')+'" data-rrun="'+r.runId+'">'
|
|
2162
|
+
+ '<div class="run-h">'+cb
|
|
2163
|
+
+ '<span class="dot '+dotCls+'"></span>'
|
|
2164
|
+
+ '<span class="run-id">r'+r.runId+'</span>'
|
|
2165
|
+
+ '<span class="run-title">'+esc(r.title||'task '+r.taskId)+'</span>'
|
|
2166
|
+
+ '<span class="grow"></span>'
|
|
2167
|
+
+ badge
|
|
2168
|
+
+ '<span class="run-meta">'+meta+'</span>'
|
|
2169
|
+
+ acts
|
|
2170
|
+
+ '<span class="caret">›</span>'
|
|
2171
|
+
+ '</div>'
|
|
2172
|
+
+ '<div class="run-b" data-rbody="'+r.runId+'">'
|
|
2173
|
+
+ '<div class="rout" data-rout="'+r.runId+'"><div class="rout-cards"><div class="ocards-empty">…</div></div></div>'
|
|
2174
|
+
+ (roomDone(s) && r.steerable
|
|
2175
|
+
? '<div class="fix"><input data-rfix="'+r.runId+'" placeholder="수정 지시 — 같은 세션·worktree 이어서…" />'
|
|
2176
|
+
+ '<button class="b" data-ract="fixsend" data-rrid="'+r.runId+'">수정 지시 → 세션 이어서</button></div>'
|
|
2177
|
+
: '<div class="review"><span class="rk">·</span><span class="rt">이 run 은 steer 불가('
|
|
2178
|
+
+ (running?'아직 실행 중':(roomDone(s)?'세션 없음 — dry/정리됨':'상태 '+esc(s)))+').</span></div>')
|
|
2179
|
+
+ '</div></div>';
|
|
2180
|
+
}
|
|
2181
|
+
function roomRenderRuns(){
|
|
2182
|
+
const el = $('roomRuns');
|
|
2183
|
+
if (!roomRuns.length){
|
|
2184
|
+
el.innerHTML = '<div class="empty-note">no runs yet — spawn an attempt or wait for the plan to fan out</div>';
|
|
2185
|
+
roomUpdateGbar(); return;
|
|
2186
|
+
}
|
|
2187
|
+
el.innerHTML = roomRuns.map(roomRunRowHTML).join('');
|
|
2188
|
+
// 펼쳐진 행의 출력 카드 재하이드레이트
|
|
2189
|
+
for (const r of roomRuns){ if (roomOpen.has(r.runId)) roomLoadRunOutputs(r.runId); }
|
|
2190
|
+
roomUpdateGbar();
|
|
2191
|
+
}
|
|
2192
|
+
function roomUpdateGbar(){
|
|
2193
|
+
const n = roomSel.size;
|
|
2194
|
+
$('roomSelC').textContent = '☑ '+n+' selected';
|
|
2195
|
+
$('roomIntegrateSel').textContent = 'Integrate 선택 ('+n+')';
|
|
2196
|
+
$('roomIntegrateSel').disabled = n===0;
|
|
2197
|
+
}
|
|
2198
|
+
/* 펼친 행의 출력 카드 로드 — 모달과 같은 /outputs + renderOutCardInto 재사용(중복 없음). */
|
|
2199
|
+
async function roomLoadRunOutputs(rid){
|
|
2200
|
+
const wrap = document.querySelector('.rout[data-rout="'+rid+'"]'); if (!wrap) return;
|
|
2201
|
+
const cards = wrap.querySelector('.rout-cards');
|
|
2202
|
+
cards.innerHTML = '<div class="ocards-empty">loading…</div>';
|
|
2203
|
+
try{
|
|
2204
|
+
const j = await fetch('/api/runs/'+rid+'/outputs').then(x=>x.json());
|
|
2205
|
+
if (!roomOpen.has(rid)) return;
|
|
2206
|
+
const list = j.outputs||[];
|
|
2207
|
+
if (!list.length){ cards.innerHTML = '<div class="ocards-empty">no outputs yet</div>'; return; }
|
|
2208
|
+
wrap.dataset.cards = JSON.stringify(list);
|
|
2209
|
+
cards.innerHTML = list.map(outCardHTML).join('');
|
|
2210
|
+
}catch{ cards.innerHTML = '<div class="ocards-empty">outputs failed</div>'; }
|
|
2211
|
+
}
|
|
2212
|
+
/* 워크룸 출력 카드 클릭 → 그 행 안에서 상세 뷰어(모달과 동일 렌더러). */
|
|
2213
|
+
function roomOpenCard(rid, i){
|
|
2214
|
+
const wrap = document.querySelector('.rout[data-rout="'+rid+'"]'); if (!wrap) return;
|
|
2215
|
+
const list = wrap.dataset.cards ? JSON.parse(wrap.dataset.cards) : [];
|
|
2216
|
+
const c = list[i]; if (!c || !c.present) return;
|
|
2217
|
+
let det = wrap.querySelector('.rout-detail');
|
|
2218
|
+
if (!det){
|
|
2219
|
+
det = document.createElement('div'); det.className='rout-detail';
|
|
2220
|
+
det.innerHTML = '<div class="rout-back">‹ 산출물 목록</div><div class="rout-body"></div>';
|
|
2221
|
+
wrap.appendChild(det);
|
|
2222
|
+
}
|
|
2223
|
+
det.style.display='';
|
|
2224
|
+
renderOutCardInto(rid, c, det.querySelector('.rout-body'), ()=>roomOpen.has(rid));
|
|
2225
|
+
}
|
|
2226
|
+
/* Ask 대화(읽기 전용) — you / coordinator 턴. 코디네이터 답 아래 read-only 노트. */
|
|
2227
|
+
function roomRenderConv(){
|
|
2228
|
+
if (!roomTurns.length){
|
|
2229
|
+
$('roomConv').innerHTML = '<div class="empty-note">read-only coordinator — ask what these attempts are doing. it never acts.</div>';
|
|
2230
|
+
return;
|
|
2231
|
+
}
|
|
2232
|
+
$('roomConv').innerHTML = roomTurns.map(t=>{
|
|
2233
|
+
const role = t.role==='you' ? 'you' : 'coordinator';
|
|
2234
|
+
const note = t.role==='coord'
|
|
2235
|
+
? '<span class="ro-note">read-only — switch to Work to act</span>' : '';
|
|
2236
|
+
return '<div class="turn '+t.role+'"><span class="role">'+esc(role)+'</span>'
|
|
2237
|
+
+ '<span class="msg">'+esc(t.text)+'</span>'+note+'</div>';
|
|
2238
|
+
}).join('');
|
|
2239
|
+
$('roomConv').scrollTop = $('roomConv').scrollHeight;
|
|
2240
|
+
}
|
|
2241
|
+
function roomRenderHeaderAndHint(){
|
|
2242
|
+
const g = groups.get(roomGroupId) || {};
|
|
2243
|
+
const done = roomRuns.filter(r=>['done','failed','stopped','merged'].includes(r.status)).length;
|
|
2244
|
+
const running = roomRuns.filter(r=>r.live).length;
|
|
2245
|
+
$('roomTitle').textContent = 'Goal: '+(g.title||'');
|
|
2246
|
+
$('roomCount').textContent = '· '+roomRuns.length+' run'+(roomRuns.length===1?'':'s')
|
|
2247
|
+
+' · '+done+' done · '+running+' running';
|
|
2248
|
+
roomRenderHint();
|
|
2249
|
+
}
|
|
2250
|
+
/* Work=정직한 steer 스코프, Ask=읽기 전용 안내. 모드에 따라 힌트/뷰/composer 전환. */
|
|
2251
|
+
function roomRenderHint(){
|
|
2252
|
+
if (roomMode==='ask'){
|
|
2253
|
+
$('roomHint').textContent = 'read-only coordinator · reads run diffs, never acts';
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
const running = roomRuns.filter(r=>r.live).length;
|
|
2257
|
+
const steerable = roomRuns.filter(r=>r.steerable).length;
|
|
2258
|
+
$('roomHint').textContent = steerable+' steerable now · '+running+' still running';
|
|
2259
|
+
}
|
|
2260
|
+
/* runs(수렴 콕핏)↔conv, verbs↔send-only 를 roomMode 로 스왑. Work=결정 패널·Ask=읽기전용. */
|
|
2261
|
+
function roomApplyMode(){
|
|
2262
|
+
const ask = roomMode==='ask';
|
|
2263
|
+
$('roomRuns').hidden = ask;
|
|
2264
|
+
$('roomGbar').hidden = ask; // 그룹 액션 바는 Work 에서만
|
|
2265
|
+
$('roomConv').hidden = !ask;
|
|
2266
|
+
$('roomVerbs').hidden = ask;
|
|
2267
|
+
$('roomSend').hidden = !ask;
|
|
2268
|
+
$('roomInput').placeholder = ask
|
|
2269
|
+
? 'Ask the coordinator about these attempts (read-only)…'
|
|
2270
|
+
: 'New attempt prompt, or a broadcast to the settled runs…';
|
|
2271
|
+
document.querySelectorAll('#roomSeg .seg-opt').forEach(x=>x.classList.toggle('on', x.dataset.rmode===roomMode));
|
|
2272
|
+
roomRenderHint();
|
|
2273
|
+
if (ask) roomRenderConv();
|
|
2274
|
+
}
|
|
2275
|
+
async function openRoom(groupId){
|
|
2276
|
+
roomGroupId = groupId;
|
|
2277
|
+
roomRunSet.clear(); roomRuns = []; roomTurns.length = 0;
|
|
2278
|
+
roomSel.clear(); roomOpen.clear();
|
|
2279
|
+
roomMode = 'work';
|
|
2280
|
+
$('roomInput').value = '';
|
|
2281
|
+
$('roomChips').innerHTML = '<span style="color:var(--faint);font-family:var(--mono);font-size:11.5px">loading…</span>';
|
|
2282
|
+
$('roomRuns').innerHTML = '';
|
|
2283
|
+
$('roomConv').innerHTML = '';
|
|
2284
|
+
$('roomTitle').textContent = 'Goal';
|
|
2285
|
+
$('roomCount').textContent = '';
|
|
2286
|
+
$('roomHint').textContent = '';
|
|
2287
|
+
roomUpdateGbar();
|
|
2288
|
+
roomApplyMode();
|
|
2289
|
+
$('groupRoomOverlay').classList.add('open');
|
|
2290
|
+
await roomLoad();
|
|
2291
|
+
}
|
|
2292
|
+
async function roomLoad(){
|
|
2293
|
+
if (roomGroupId==null) return;
|
|
2294
|
+
let j;
|
|
2295
|
+
try{ j = await fetch('/api/groups/'+roomGroupId).then(x=>x.json()); }
|
|
2296
|
+
catch{ toast('workroom load failed', 'error'); return; }
|
|
2297
|
+
if (!j || !j.group){ toast('group not found', 'error'); return; }
|
|
2298
|
+
if (!groups.has(j.group.id)) groups.set(j.group.id, j.group);
|
|
2299
|
+
roomRuns = j.runs||[];
|
|
2300
|
+
roomRunSet.clear();
|
|
2301
|
+
roomRuns.forEach(r=>roomRunSet.add(r.runId));
|
|
2302
|
+
// 사라진 run 은 선택/펼침에서 정리
|
|
2303
|
+
for (const id of [...roomSel]) if (!roomRunSet.has(id)) roomSel.delete(id);
|
|
2304
|
+
for (const id of [...roomOpen]) if (!roomRunSet.has(id)) roomOpen.delete(id);
|
|
2305
|
+
roomRenderChips();
|
|
2306
|
+
roomRenderHeaderAndHint();
|
|
2307
|
+
roomRenderRuns();
|
|
2308
|
+
}
|
|
2309
|
+
function closeRoom(){ roomGroupId = null; $('groupRoomOverlay').classList.remove('open'); }
|
|
2310
|
+
/* chip 클릭 → 그 run 을 기존 run 모달로 */
|
|
2311
|
+
$('roomChips').addEventListener('click',(e)=>{
|
|
2312
|
+
const c = e.target.closest('[data-roomrun]'); if(!c) return;
|
|
2313
|
+
const rid = Number(c.dataset.roomrun);
|
|
2314
|
+
closeRoom();
|
|
2315
|
+
if (runs.has(rid)) openModal(rid);
|
|
2316
|
+
else { // 아카이브 등 맵에 없는 run — 태스크로 하이드레이트 후 연다
|
|
2317
|
+
fetch('/api/runs/'+rid).then(x=>x.json()).then(d=>{ if(d.run){ runs.set(rid, {...d.run, events:d.events||[]}); openModal(rid); } }).catch(()=>{});
|
|
2318
|
+
}
|
|
2319
|
+
});
|
|
2320
|
+
$('roomClose').addEventListener('click', closeRoom);
|
|
2321
|
+
$('groupRoomOverlay').addEventListener('click',(e)=>{ if(e.target===$('groupRoomOverlay')) closeRoom(); });
|
|
2322
|
+
|
|
2323
|
+
/* ── 수렴 콕핏 상호작용 — 행 펼침·체크박스·인라인 액션(전부 기존 엔드포인트 재사용) ── */
|
|
2324
|
+
$('roomRuns').addEventListener('click', async (e)=>{
|
|
2325
|
+
// 출력 카드 클릭 → 행 안 상세 뷰어
|
|
2326
|
+
const card = e.target.closest ? e.target.closest('.ocard') : null;
|
|
2327
|
+
if (card && !card.classList.contains('miss')){
|
|
2328
|
+
const wrap = card.closest('.rout'); if (wrap){ roomOpenCard(Number(wrap.dataset.rout), Number(card.dataset.oi)); return; }
|
|
2329
|
+
}
|
|
2330
|
+
const back = e.target.closest ? e.target.closest('.rout-back') : null;
|
|
2331
|
+
if (back){ const det = back.closest('.rout-detail'); if (det) det.style.display='none'; return; }
|
|
2332
|
+
// 체크박스 → Integrate 선택 토글
|
|
2333
|
+
const cb = e.target.closest ? e.target.closest('.cb') : null;
|
|
2334
|
+
if (cb){
|
|
2335
|
+
e.stopPropagation();
|
|
2336
|
+
const id = Number(cb.dataset.rcb); if (!id) return;
|
|
2337
|
+
if (roomSel.has(id)) roomSel.delete(id); else roomSel.add(id);
|
|
2338
|
+
cb.textContent = roomSel.has(id) ? '✓' : '';
|
|
2339
|
+
cb.closest('.run').classList.toggle('sel', roomSel.has(id));
|
|
2340
|
+
roomUpdateGbar();
|
|
2341
|
+
return;
|
|
2342
|
+
}
|
|
2343
|
+
// 인라인 액션 버튼
|
|
2344
|
+
const ab = e.target.closest ? e.target.closest('[data-ract]') : null;
|
|
2345
|
+
if (ab){ e.stopPropagation(); await roomRunAction(ab.dataset.ract, Number(ab.dataset.rrid)); return; }
|
|
2346
|
+
// head 클릭 → 펼침 토글
|
|
2347
|
+
const head = e.target.closest ? e.target.closest('.run-h') : null;
|
|
2348
|
+
if (head){
|
|
2349
|
+
const rid = Number(head.parentElement.dataset.rrun);
|
|
2350
|
+
if (roomOpen.has(rid)){ roomOpen.delete(rid); head.parentElement.classList.remove('open'); }
|
|
2351
|
+
else { roomOpen.add(rid); head.parentElement.classList.add('open'); roomLoadRunOutputs(rid); }
|
|
2352
|
+
}
|
|
2353
|
+
});
|
|
2354
|
+
/* 행 액션 디스패치 — merge/steer/review/close 서버 엔드포인트만. 결정 로직 재구현 없음. */
|
|
2355
|
+
async function roomRunAction(act, rid){
|
|
2356
|
+
const r = roomRuns.find(x=>x.runId===rid); if(!r) return;
|
|
2357
|
+
if (act==='open'){
|
|
2358
|
+
closeRoom();
|
|
2359
|
+
if (runs.has(rid)) openModal(rid);
|
|
2360
|
+
else fetch('/api/runs/'+rid).then(x=>x.json()).then(d=>{ if(d.run){ runs.set(rid,{...d.run,events:d.events||[]}); openModal(rid); } }).catch(()=>{});
|
|
2361
|
+
return;
|
|
2362
|
+
}
|
|
2363
|
+
if (act==='fix'){ // 펼치고 steer 입력에 포커스
|
|
2364
|
+
if (!roomOpen.has(rid)){ roomOpen.add(rid); const row=document.querySelector('.run[data-rrun="'+rid+'"]'); if(row){ row.classList.add('open'); roomLoadRunOutputs(rid); } }
|
|
2365
|
+
const inp = document.querySelector('[data-rfix="'+rid+'"]'); if (inp) inp.focus();
|
|
2366
|
+
else toast('r'+rid+': steer 불가(정착·세션 필요)', 'error');
|
|
2367
|
+
return;
|
|
2368
|
+
}
|
|
2369
|
+
if (act==='fixsend'){
|
|
2370
|
+
const inp = document.querySelector('[data-rfix="'+rid+'"]'); const msg = inp ? inp.value.trim() : '';
|
|
2371
|
+
if (!msg){ if(inp) inp.focus(); return; }
|
|
2372
|
+
const res = await fetch('/api/runs/'+rid+'/steer',{method:'POST',
|
|
2373
|
+
headers:{'content-type':'application/json'}, body:JSON.stringify({message:msg, mode:'work'})});
|
|
2374
|
+
if (res.ok){ if(inp) inp.value=''; toast('r'+rid+': 수정 지시 — 같은 세션 이어서', 'ok'); }
|
|
2375
|
+
else { const j = await res.json().catch(()=>({})); toast('steer: '+(j.detail||res.status), 'error'); }
|
|
2376
|
+
return;
|
|
2377
|
+
}
|
|
2378
|
+
if (act==='review'){
|
|
2379
|
+
await reviewOneRun(r);
|
|
2380
|
+
return;
|
|
2381
|
+
}
|
|
2382
|
+
if (act==='merge'){
|
|
2383
|
+
const yes = await confirmUI('Merge r'+rid+' into the base branch?',
|
|
2384
|
+
{ sub: 'Uncommitted worktree changes are committed first. Conflicts abort automatically.', okLabel: 'Merge' });
|
|
2385
|
+
if (!yes) return;
|
|
2386
|
+
const res = await fetch('/api/runs/'+rid+'/merge',{method:'POST'});
|
|
2387
|
+
const j = await res.json().catch(()=>({detail:'merge failed'}));
|
|
2388
|
+
if (res.ok){ toast('r'+rid+' merged to base', 'ok'); await roomLoad(); hydrate(); }
|
|
2389
|
+
else toast('merge: '+(j.detail||res.status), 'error');
|
|
2390
|
+
return;
|
|
2391
|
+
}
|
|
2392
|
+
if (act==='close'){
|
|
2393
|
+
const yes = await confirmUI('Close r'+rid+' task?',
|
|
2394
|
+
{ sub: 'Stops any live runs and removes every worktree and branch of the task.', danger:true, okLabel:'Close task' });
|
|
2395
|
+
if (!yes) return;
|
|
2396
|
+
const close = (force)=>fetch('/api/tasks/'+r.taskId+'/close',{method:'POST',
|
|
2397
|
+
headers:{'content-type':'application/json'}, body:JSON.stringify({force})});
|
|
2398
|
+
let res = await close(false);
|
|
2399
|
+
if (res.status===409){
|
|
2400
|
+
const j = await res.json().catch(()=>({}));
|
|
2401
|
+
const risk = (j.atRisk||[]).map(a=>'r'+a.runId+' · '+a.filesChanged+' file'+(a.filesChanged>1?'s':'')).join(' · ');
|
|
2402
|
+
const ok = await confirmUI('Close and delete unmerged output?',
|
|
2403
|
+
{ danger:true, sub: risk+' — not merged, not exported. Worktrees are deleted on close.', okLabel:'Close anyway' });
|
|
2404
|
+
if (!ok) return;
|
|
2405
|
+
res = await close(true);
|
|
2406
|
+
}
|
|
2407
|
+
if (!res.ok){ toast('close failed ('+res.status+')', 'error'); return; }
|
|
2408
|
+
toast('r'+rid+' task closed', 'ok');
|
|
2409
|
+
await roomLoad(); hydrate();
|
|
2410
|
+
return;
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
/* [리뷰] — 그 run 의 태스크에 reviewTask(/tasks/:id/review) 를 돌려 ◆ 요약을 행 안에 인라인 표시. */
|
|
2414
|
+
async function reviewOneRun(r){
|
|
2415
|
+
const row = document.querySelector('.run[data-rrun="'+r.runId+'"]'); if(!row) return;
|
|
2416
|
+
if (!roomOpen.has(r.runId)){ roomOpen.add(r.runId); row.classList.add('open'); roomLoadRunOutputs(r.runId); }
|
|
2417
|
+
const body = row.querySelector('.run-b');
|
|
2418
|
+
let rv = body.querySelector('.review.airev');
|
|
2419
|
+
if (!rv){ rv = document.createElement('div'); rv.className='review airev'; body.insertBefore(rv, body.querySelector('.fix')||null); }
|
|
2420
|
+
const real = $('taskReal').checked;
|
|
2421
|
+
rv.innerHTML = '<span class="rk">◆ 리뷰</span><span class="rt">reviewing… ('+(real?'real':'dry')+')</span>';
|
|
2422
|
+
try{
|
|
2423
|
+
const res = await fetch('/api/tasks/'+r.taskId+'/review',{method:'POST',
|
|
2424
|
+
headers:{'content-type':'application/json'}, body:JSON.stringify({real})});
|
|
2425
|
+
const j = await res.json().catch(()=>({}));
|
|
2426
|
+
if (res.ok){ rv.innerHTML = '<span class="rk">◆ 리뷰</span><span class="rt">'+mdLite(j.review||'(no summary)')+'</span>'; }
|
|
2427
|
+
else { rv.innerHTML = '<span class="rk">◆ 리뷰</span><span class="rt">review 실패 — '+esc(j.detail||String(res.status))+'</span>'; toast('review: '+(j.detail||res.status), 'error'); }
|
|
2428
|
+
}catch{ rv.innerHTML = '<span class="rk">◆ 리뷰</span><span class="rt">review 요청 실패</span>'; }
|
|
2429
|
+
}
|
|
2430
|
+
/* steer 입력 Enter → 전송 */
|
|
2431
|
+
$('roomRuns').addEventListener('keydown', (e)=>{
|
|
2432
|
+
const inp = e.target.closest ? e.target.closest('[data-rfix]') : null;
|
|
2433
|
+
if (inp && e.key==='Enter'){ e.preventDefault(); roomRunAction('fixsend', Number(inp.dataset.rfix)); }
|
|
2434
|
+
});
|
|
2435
|
+
/* ── 그룹 액션 바 — Integrate 선택 · 전체 리뷰 · 그룹 클로즈(전부 기존 흐름 재사용) ── */
|
|
2436
|
+
$('roomIntegrateSel').addEventListener('click', async ()=>{
|
|
2437
|
+
if (!roomSel.size){ toast('select settled runs with changes first', 'error'); return; }
|
|
2438
|
+
// 기존 select mode + selbar + /api/integrate 재사용 — 방을 닫고 선택을 밴드 select 로 옮긴다.
|
|
2439
|
+
const ids = [...roomSel].sort((a,b)=>a-b);
|
|
2440
|
+
closeRoom();
|
|
2441
|
+
if (!selectMode) setSelectMode(true);
|
|
2442
|
+
selected.clear(); selOrder.length = 0;
|
|
2443
|
+
for (const id of ids){
|
|
2444
|
+
const r = runs.get(id);
|
|
2445
|
+
const ok = r && ['done','failed','stopped'].includes(r.status) && (r.filesChanged||0)>0 && r.status!=='merged';
|
|
2446
|
+
if (ok){ selected.add(id); selOrder.push(id); }
|
|
2447
|
+
}
|
|
2448
|
+
$('selCnt').textContent = selected.size + ' selected';
|
|
2449
|
+
render();
|
|
2450
|
+
if (!selected.size) toast('selected runs are no longer integratable', 'error');
|
|
2451
|
+
else toast(selected.size+' run(s) staged — press Integrate in the bar', 'ok');
|
|
2452
|
+
});
|
|
2453
|
+
$('roomReviewAll').addEventListener('click', async ()=>{
|
|
2454
|
+
// 정착·변경 있는 run 을 위→아래로 리뷰(reviewTask 루프). dry/real 은 전역 토글.
|
|
2455
|
+
const targets = roomRuns.filter(r=>roomDone(r.status) && (r.filesChanged||0)>0 && r.status!=='merged');
|
|
2456
|
+
if (!targets.length){ toast('no settled runs with changes to review', 'error'); return; }
|
|
2457
|
+
const btn = $('roomReviewAll'); btn.disabled = true;
|
|
2458
|
+
try{ for (const r of targets) await reviewOneRun(r); }
|
|
2459
|
+
finally { btn.disabled = false; }
|
|
2460
|
+
});
|
|
2461
|
+
$('roomGroupClose').addEventListener('click', async ()=>{
|
|
2462
|
+
if (roomGroupId==null) return;
|
|
2463
|
+
const g = roomGroupId;
|
|
2464
|
+
await closeGroup(g); // 기존 그룹 클로즈(guard·atRisk 집계 재사용)
|
|
2465
|
+
if (roomGroupId===g) await roomLoad();
|
|
2466
|
+
});
|
|
2467
|
+
/* + New attempt — 같은 그룹에 새 시도 발사 (현재 dry/real 토글 반영) */
|
|
2468
|
+
$('roomNew').addEventListener('click', async ()=>{
|
|
2469
|
+
if (roomGroupId==null) return;
|
|
2470
|
+
const prompt = $('roomInput').value.trim();
|
|
2471
|
+
if (!prompt){ toast('write a prompt for the new attempt', 'error'); return; }
|
|
2472
|
+
const real = $('taskReal').checked;
|
|
2473
|
+
const btn = $('roomNew'); btn.disabled = true;
|
|
2474
|
+
try{
|
|
2475
|
+
const res = await fetch('/api/groups/'+roomGroupId+'/spawn',{method:'POST',
|
|
2476
|
+
headers:{'content-type':'application/json'}, body:JSON.stringify({prompt, real})});
|
|
2477
|
+
const j = await res.json().catch(()=>({}));
|
|
2478
|
+
if (res.ok){
|
|
2479
|
+
$('roomInput').value='';
|
|
2480
|
+
(j.tasks||[]).forEach(t=>{ roomRunSet.add(t.runId); });
|
|
2481
|
+
toast((j.tasks||[]).length+' attempt(s) launched'+(real?'':' (dry)'), 'ok');
|
|
2482
|
+
await roomLoad(); hydrate();
|
|
2483
|
+
} else toast('spawn: '+(j.error||res.status), 'error');
|
|
2484
|
+
} finally { btn.disabled = false; }
|
|
2485
|
+
});
|
|
2486
|
+
/* → Broadcast — 그룹의 정착 steerable run 전부에 후속 지시(라이브/드라이는 정직하게 skip) */
|
|
2487
|
+
$('roomBroadcast').addEventListener('click', async ()=>{
|
|
2488
|
+
if (roomGroupId==null) return;
|
|
2489
|
+
const message = $('roomInput').value.trim();
|
|
2490
|
+
if (!message){ toast('write a broadcast message', 'error'); return; }
|
|
2491
|
+
const btn = $('roomBroadcast'); btn.disabled = true;
|
|
2492
|
+
try{
|
|
2493
|
+
const res = await fetch('/api/groups/'+roomGroupId+'/steer',{method:'POST',
|
|
2494
|
+
headers:{'content-type':'application/json'}, body:JSON.stringify({message, mode:roomMode})});
|
|
2495
|
+
const j = await res.json().catch(()=>({}));
|
|
2496
|
+
if (res.ok){
|
|
2497
|
+
$('roomInput').value='';
|
|
2498
|
+
toast('broadcast: '+j.detail, (j.skipped&&j.skipped.length)?undefined:'ok');
|
|
2499
|
+
await roomLoad();
|
|
2500
|
+
} else toast('broadcast: '+(j.error||res.status), 'error');
|
|
2501
|
+
} finally { btn.disabled = false; }
|
|
2502
|
+
});
|
|
2503
|
+
/* Converge ▾ — 기존 review/integrate 를 노출(재구현 아님) */
|
|
2504
|
+
$('roomConverge').addEventListener('click',(e)=>{ e.stopPropagation(); $('roomConvMenu').classList.toggle('open'); });
|
|
2505
|
+
document.addEventListener('click',()=>$('roomConvMenu').classList.remove('open'));
|
|
2506
|
+
$('roomReview').addEventListener('click', ()=>{
|
|
2507
|
+
$('roomConvMenu').classList.remove('open');
|
|
2508
|
+
// 그룹 태스크가 여럿이면 각 태스크가 자체 비교/리뷰를 가짐 — 첫 태스크의 Compare 로 진입.
|
|
2509
|
+
const first = roomRuns[0];
|
|
2510
|
+
if (!first){ toast('no runs to review', 'error'); return; }
|
|
2511
|
+
closeRoom(); openCompare(first.taskId);
|
|
2512
|
+
});
|
|
2513
|
+
$('roomIntegrate').addEventListener('click', ()=>{
|
|
2514
|
+
$('roomConvMenu').classList.remove('open');
|
|
2515
|
+
const g = roomGroupId; closeRoom();
|
|
2516
|
+
if (!selectMode) setSelectMode(true);
|
|
2517
|
+
// 그룹의 정착·변경 run 전부 사전 선택(기존 밴드 Select runs 와 동일 규칙)
|
|
2518
|
+
const tids = new Set([...tasks.values()].filter(t=>t.groupId===g).map(t=>t.id));
|
|
2519
|
+
for (const r of [...runs.values()].sort((a,b)=>a.id-b.id)){
|
|
2520
|
+
if (!tids.has(r.taskId)) continue;
|
|
2521
|
+
const ok = ['done','failed','stopped'].includes(r.status) && (r.filesChanged||0)>0 && r.status!=='merged';
|
|
2522
|
+
if (ok && !selected.has(r.id)){ selected.add(r.id); selOrder.push(r.id); }
|
|
2523
|
+
}
|
|
2524
|
+
$('selCnt').textContent = selected.size + ' selected';
|
|
2525
|
+
render();
|
|
2526
|
+
if (!selected.size) toast('no settled runs with changes in this group yet', 'error');
|
|
2527
|
+
});
|
|
2528
|
+
/* Work | Ask 세그 — Work=행동(spawn/broadcast/converge), Ask=읽기 전용 코디네이터. 방 전용 모드. */
|
|
2529
|
+
document.querySelectorAll('#roomSeg .seg-opt').forEach(b=>{
|
|
2530
|
+
b.addEventListener('click', ()=>{
|
|
2531
|
+
roomMode = b.dataset.rmode;
|
|
2532
|
+
roomApplyMode();
|
|
2533
|
+
});
|
|
2534
|
+
});
|
|
2535
|
+
/* ? Ask — 그룹 스코프 읽기 전용 코디네이터. 절대 발사·steer·쓰기 없음 (POST /ask). */
|
|
2536
|
+
$('roomAsk').addEventListener('click', async ()=>{
|
|
2537
|
+
if (roomGroupId==null) return;
|
|
2538
|
+
const message = $('roomInput').value.trim();
|
|
2539
|
+
if (!message){ toast('write a question for the coordinator', 'error'); return; }
|
|
2540
|
+
const real = $('taskReal').checked;
|
|
2541
|
+
const btn = $('roomAsk'); btn.disabled = true;
|
|
2542
|
+
roomTurns.push({ role:'you', text:message });
|
|
2543
|
+
roomRenderConv();
|
|
2544
|
+
$('roomInput').value='';
|
|
2545
|
+
try{
|
|
2546
|
+
const res = await fetch('/api/groups/'+roomGroupId+'/ask',{method:'POST',
|
|
2547
|
+
headers:{'content-type':'application/json'}, body:JSON.stringify({message, real})});
|
|
2548
|
+
const j = await res.json().catch(()=>({}));
|
|
2549
|
+
if (res.ok && j.answer){
|
|
2550
|
+
roomTurns.push({ role:'coord', text:j.answer });
|
|
2551
|
+
} else {
|
|
2552
|
+
roomTurns.push({ role:'coord', text:'(ask failed — '+(j.error||res.status)+')' });
|
|
2553
|
+
toast('ask: '+(j.error||res.status), 'error');
|
|
2554
|
+
}
|
|
2555
|
+
roomRenderConv();
|
|
2556
|
+
} catch { toast('ask request failed', 'error'); }
|
|
2557
|
+
finally { btn.disabled = false; }
|
|
2558
|
+
});
|
|
2559
|
+
/* WS 라이브 — 그룹 소속 run/event 만 방에 반영 (connectWS 가 호출) */
|
|
2560
|
+
function roomOnWS(ev){
|
|
2561
|
+
if (roomGroupId==null) return;
|
|
2562
|
+
if (ev.type==='run'){
|
|
2563
|
+
const rid = ev.runId ?? ev.id;
|
|
2564
|
+
if (!roomRunSet.has(rid)) return; // 이 방 소속만
|
|
2565
|
+
roomLoad(); // 상태·steerable 재계산 → 결정 행 재렌더(aggregate, 정확)
|
|
2566
|
+
}
|
|
2567
|
+
// event 티커는 수렴 콕핏에선 행 안 출력 카드로 대체 — 상태 변화는 run 이벤트가 재렌더한다.
|
|
2568
|
+
}
|
|
2569
|
+
|
|
1701
2570
|
/* ── terminal — 초기크기 접속·자동 재연결(백오프)·unicode11·CJK 폰트 ── */
|
|
1702
2571
|
let termWS = null, termObj = null, fitAddon = null, termResizeObs = null;
|
|
1703
2572
|
let termRunId = null, termClosing = false, termRetry = 0, termRetryTimer = null;
|
|
@@ -1897,15 +2766,29 @@ $('taskForm').addEventListener('submit', async (e)=>{
|
|
|
1897
2766
|
const title = $('taskTitle').value.trim();
|
|
1898
2767
|
if (!repoId || !title){ toast(!repoId?'register a repo first':'task title required', 'error'); return; }
|
|
1899
2768
|
const capId = Number($('taskCapture').value) || undefined;
|
|
2769
|
+
const outputs = selectedOutputs();
|
|
1900
2770
|
const t = await fetch('/api/tasks',{method:'POST',headers:{'content-type':'application/json'},
|
|
1901
|
-
body:JSON.stringify({repoId,title,prompt:$('taskPrompt').value,designCaptureId:capId})}).then(x=>x.json());
|
|
2771
|
+
body:JSON.stringify({repoId,title,prompt:$('taskPrompt').value,designCaptureId:capId,outputs})}).then(x=>x.json());
|
|
1902
2772
|
if (!t.ok){ toast('task create failed', 'error'); return; }
|
|
1903
2773
|
tasks.set(t.task.id, t.task);
|
|
1904
2774
|
const model = $('taskModel').value.trim();
|
|
1905
2775
|
await fetch('/api/tasks/'+t.task.id+'/run',{method:'POST',headers:{'content-type':'application/json'},
|
|
1906
2776
|
body:JSON.stringify({count:Number($('taskCount').value)||1, real: $('taskReal').checked, agent: selAgent, model})});
|
|
1907
2777
|
if (model) rememberModel(model);
|
|
1908
|
-
$('taskTitle').value=''; $('taskPrompt').value='';
|
|
2778
|
+
$('taskTitle').value=''; $('taskPrompt').value=''; clearOutputs();
|
|
2779
|
+
});
|
|
2780
|
+
/* ── deliverable 계약 칩 — 선택된 타입 배열을 tasks.outputs 로 보낸다(기본=빈=오늘의 동작) ── */
|
|
2781
|
+
const OUTPUT_ORDER = ['answer','code','doc','page','file'];
|
|
2782
|
+
function selectedOutputs(){
|
|
2783
|
+
const on = new Set(Array.from(document.querySelectorAll('#taskOutputs .ochip.on')).map(b=>b.dataset.out));
|
|
2784
|
+
return OUTPUT_ORDER.filter(t=>on.has(t));
|
|
2785
|
+
}
|
|
2786
|
+
function clearOutputs(){ document.querySelectorAll('#taskOutputs .ochip.on').forEach(b=>b.classList.remove('on')); }
|
|
2787
|
+
document.querySelectorAll('#taskOutputs .ochip').forEach(b=>{
|
|
2788
|
+
b.addEventListener('click', ()=>{
|
|
2789
|
+
const on = b.classList.toggle('on');
|
|
2790
|
+
b.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
2791
|
+
});
|
|
1909
2792
|
});
|
|
1910
2793
|
|
|
1911
2794
|
/* ── model 최근값 기억(기기별, 최대 5) ── */
|