coxpit 4.6.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/package.json +1 -1
- package/src/board.ts +610 -99
- package/src/db/index.ts +2 -0
- package/src/db/schema.ts +1 -0
- package/src/orchestrator.ts +317 -0
- package/src/server.ts +136 -6
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;
|
|
@@ -187,6 +194,64 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
187
194
|
.conv .turn .msg{color:var(--ink);white-space:pre-wrap;word-break:break-word;line-height:1.55}
|
|
188
195
|
.conv .turn.you .msg{color:var(--muted)}
|
|
189
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)}
|
|
190
255
|
.send-only{display:flex;gap:8px;align-items:center}
|
|
191
256
|
.send-only .ro-hint{flex:1;font-family:var(--mono);font-size:10px;color:var(--faint);letter-spacing:.02em}
|
|
192
257
|
.composer{border-top:1px solid var(--line);padding:12px 18px;display:flex;flex-direction:column;gap:8px}
|
|
@@ -436,9 +501,59 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
436
501
|
.doc-md p{margin:0 0 8px}
|
|
437
502
|
.doc-frame{width:100%;height:420px;border:1px solid var(--line);border-radius:8px;background:#fff;margin-bottom:16px}
|
|
438
503
|
.pane-tgl{margin-left:auto;font-size:10px;padding:2px 9px;text-transform:none;letter-spacing:0}
|
|
439
|
-
/* run 모달의 diff 라인 = steer 참조 클릭 타깃 */
|
|
440
|
-
#
|
|
441
|
-
#
|
|
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)}
|
|
442
557
|
|
|
443
558
|
/* ── terminal ───────────────────────────── */
|
|
444
559
|
.term-body{flex:1;min-height:0;background:#0b0d12;padding:8px 4px 4px 10px}
|
|
@@ -549,6 +664,14 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
549
664
|
<datalist id="modelHist"></datalist>
|
|
550
665
|
<p class="flabel">design capture · optional</p>
|
|
551
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>
|
|
552
675
|
</div>
|
|
553
676
|
<div id="panelGoal" hidden style="flex-direction:column;gap:8px">
|
|
554
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>
|
|
@@ -590,6 +713,8 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
590
713
|
<div class="arch-bar">
|
|
591
714
|
<input id="archQ" placeholder="search title…" autocomplete="off" />
|
|
592
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>
|
|
593
718
|
</div>
|
|
594
719
|
<div id="archList"></div>
|
|
595
720
|
<div style="text-align:center;margin-top:14px"><button class="btn-ghost sm" id="archMore" hidden>load 50 more</button></div>
|
|
@@ -605,15 +730,21 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
605
730
|
<span class="chip" id="mChip"><i></i><span id="mChipTxt"></span></span>
|
|
606
731
|
<button class="x" id="mClose" aria-label="close">×</button>
|
|
607
732
|
</div>
|
|
733
|
+
<div class="contract" id="mContract" hidden></div>
|
|
608
734
|
<div class="modal-b">
|
|
609
735
|
<div class="pane">
|
|
610
736
|
<div class="pane-h">Timeline</div>
|
|
611
737
|
<div class="pane-c tl" id="mTimeline"></div>
|
|
612
738
|
</div>
|
|
613
739
|
<div class="pane">
|
|
614
|
-
<div class="pane-h" style="display:flex;align-items:center;gap:8px">
|
|
615
|
-
|
|
616
|
-
|
|
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>
|
|
617
748
|
</div>
|
|
618
749
|
</div>
|
|
619
750
|
<div class="modal-f" id="steerRow" style="border-top:1px solid var(--line)">
|
|
@@ -626,7 +757,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
626
757
|
</div>
|
|
627
758
|
<div class="modal-f">
|
|
628
759
|
<button class="btn-ghost sm" id="mTerm">Terminal</button>
|
|
629
|
-
<button class="btn-ghost sm" id="mRefreshDiff">Refresh
|
|
760
|
+
<button class="btn-ghost sm" id="mRefreshDiff">Refresh outputs</button>
|
|
630
761
|
<button class="btn-ghost sm" id="mCompare">Compare runs</button>
|
|
631
762
|
<button class="btn-ghost sm" id="mExport">Export files…</button>
|
|
632
763
|
<button class="btn-ghost sm" id="mSync">Sync base</button>
|
|
@@ -648,8 +779,15 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
648
779
|
<button class="x" id="roomClose" aria-label="close">×</button>
|
|
649
780
|
</div>
|
|
650
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>
|
|
651
789
|
<div class="body">
|
|
652
|
-
<div class="
|
|
790
|
+
<div class="runs" id="roomRuns"></div>
|
|
653
791
|
<div class="conv" id="roomConv" hidden></div>
|
|
654
792
|
</div>
|
|
655
793
|
<div class="composer">
|
|
@@ -1146,7 +1284,7 @@ function setView(v){
|
|
|
1146
1284
|
$('grid').style.display = archive ? 'none' : '';
|
|
1147
1285
|
$('empty').style.display = archive ? 'none' : ($('grid').innerHTML ? 'none' : 'flex');
|
|
1148
1286
|
document.querySelector('.toolbar').style.display = archive ? 'none' : '';
|
|
1149
|
-
if (archive){ paintArchRepos(); archFetch(true); }
|
|
1287
|
+
if (archive){ paintArchRepos(); archFetch(true); reclaimRefresh(); }
|
|
1150
1288
|
else render();
|
|
1151
1289
|
}
|
|
1152
1290
|
document.querySelectorAll('#viewSeg .seg-opt').forEach(b=>b.addEventListener('click', ()=>setView(b.dataset.view)));
|
|
@@ -1198,6 +1336,32 @@ $('archList').addEventListener('click', async (e)=>{
|
|
|
1198
1336
|
}catch{ toast('could not open task', 'error'); }
|
|
1199
1337
|
});
|
|
1200
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
|
+
|
|
1201
1365
|
/* ── first-run onboarding (빈 보드 = 준비 상태 점검 + 시작 안내) ── */
|
|
1202
1366
|
let readiness = null, probing = false;
|
|
1203
1367
|
async function probeFirstMachine(){
|
|
@@ -1434,6 +1598,14 @@ function connectWS(){
|
|
|
1434
1598
|
|
|
1435
1599
|
/* ── run detail modal ── */
|
|
1436
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
|
+
}
|
|
1437
1609
|
function paintModal(){
|
|
1438
1610
|
if (openRunId==null) return;
|
|
1439
1611
|
const r = runs.get(openRunId); if(!r) return;
|
|
@@ -1448,40 +1620,157 @@ function paintModal(){
|
|
|
1448
1620
|
$('mStop').style.display = (r.status==='running'||r.status==='preparing'||r.status==='pending') ? '' : 'none';
|
|
1449
1621
|
// steer 는 정착한 real run 에서만 의미(드라이런은 세션 없음 — 서버가 사유와 함께 거절)
|
|
1450
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';
|
|
1451
1629
|
$('mTimeline').innerHTML = humanLines(r.events).map(h =>
|
|
1452
1630
|
'<div class="ev"><span class="k">'+esc(h.k)+'</span><span class="t">'+esc(h.t)+'</span></div>'
|
|
1453
1631
|
).join('') || '<span style="color:var(--faint)">no events yet</span>';
|
|
1454
1632
|
}
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
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;
|
|
1458
1692
|
const rid = openRunId;
|
|
1459
|
-
|
|
1693
|
+
$('outCards').innerHTML = '<div class="ocards-empty">loading…</div>';
|
|
1694
|
+
$('mStat').textContent='';
|
|
1695
|
+
showOutCards(); // 상세에서 목록으로
|
|
1460
1696
|
try{
|
|
1461
|
-
const
|
|
1462
|
-
if (
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
$('
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
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>';
|
|
1478
1724
|
try{
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
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);
|
|
1482
1773
|
}
|
|
1483
|
-
/* ── doc 모드 — diff 대신 렌더된 문서 산출물 ── */
|
|
1484
|
-
let docMode = false;
|
|
1485
1774
|
function docsHTML(docs){
|
|
1486
1775
|
if (!docs.length) return '<span style="color:var(--faint)">no changed docs (md/html) in this worktree</span>';
|
|
1487
1776
|
return docs.map(d=>'<div class="doc-h">'+esc(d.path)+'</div>'
|
|
@@ -1489,25 +1778,15 @@ function docsHTML(docs){
|
|
|
1489
1778
|
? '<div class="doc-md">'+mdLite(d.content)+'</div>'
|
|
1490
1779
|
: '<iframe class="doc-frame" sandbox="" srcdoc="'+escA(d.content)+'"></iframe>')).join('');
|
|
1491
1780
|
}
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
}
|
|
1502
|
-
function setDocMode(on){
|
|
1503
|
-
docMode = on;
|
|
1504
|
-
$('mDocsTgl').textContent = on ? 'Diff' : 'Rendered';
|
|
1505
|
-
if (on) paintDocs();
|
|
1506
|
-
else { $('mDiffWrap').innerHTML = '<pre class="diff" id="mDiff">loading…</pre>'; loadDiff(); }
|
|
1507
|
-
}
|
|
1508
|
-
$('mDocsTgl').addEventListener('click', ()=>setDocMode(!docMode));
|
|
1509
|
-
/* diff 라인 클릭 → steer 입력에 참조 인용 (정착 run 에서만 — steerRow 표시 중일 때) */
|
|
1510
|
-
$('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)=>{
|
|
1511
1790
|
const t = e.target.closest ? e.target.closest('.dl-line') : null;
|
|
1512
1791
|
if (!t) return;
|
|
1513
1792
|
if ($('steerRow').style.display === 'none') return;
|
|
@@ -1518,9 +1797,11 @@ $('mDiffWrap').addEventListener('click', (e)=>{
|
|
|
1518
1797
|
});
|
|
1519
1798
|
async function openModal(id){
|
|
1520
1799
|
openRunId = id;
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
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 로드
|
|
1524
1805
|
// fleet 는 run 당 최근 40 이벤트만 내림 — 모달은 전체 타임라인을 다시 가져온다.
|
|
1525
1806
|
try{
|
|
1526
1807
|
const j = await fetch('/api/runs/'+id).then(x=>x.json());
|
|
@@ -1826,7 +2107,6 @@ $('mCompare').addEventListener('click', ()=>{
|
|
|
1826
2107
|
let roomGroupId = null; // 열려있는 그룹 id (null = 닫힘)
|
|
1827
2108
|
const roomRunSet = new Set(); // 이 그룹에 속한 runId — WS 필터용
|
|
1828
2109
|
let roomRuns = []; // 마지막 aggregate 의 runs (chips/scope hint)
|
|
1829
|
-
const roomExtra = []; // goal-level 마커(spawn/broadcast) — 피드에 섞음
|
|
1830
2110
|
const roomTurns = []; // Ask 대화 턴 [{role:'you'|'coord', text}] — 읽기 전용
|
|
1831
2111
|
let roomMode = 'work'; // 'work' | 'ask' — 방 composer 모드(스틸모드와 무관, 방 전용)
|
|
1832
2112
|
/* run → 상태색 (chip / who) */
|
|
@@ -1845,27 +2125,103 @@ function roomRenderChips(){
|
|
|
1845
2125
|
? roomRuns.map(roomChipHTML).join('')
|
|
1846
2126
|
: '<span style="color:var(--faint);font-family:var(--mono);font-size:11.5px">no runs yet</span>';
|
|
1847
2127
|
}
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
function
|
|
1854
|
-
|
|
1855
|
-
const
|
|
1856
|
-
const
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
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;
|
|
1862
2186
|
}
|
|
1863
|
-
|
|
1864
|
-
|
|
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);
|
|
1865
2222
|
}
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
$('roomFeed').scrollTop = $('roomFeed').scrollHeight;
|
|
2223
|
+
det.style.display='';
|
|
2224
|
+
renderOutCardInto(rid, c, det.querySelector('.rout-body'), ()=>roomOpen.has(rid));
|
|
1869
2225
|
}
|
|
1870
2226
|
/* Ask 대화(읽기 전용) — you / coordinator 턴. 코디네이터 답 아래 read-only 노트. */
|
|
1871
2227
|
function roomRenderConv(){
|
|
@@ -1901,10 +2257,11 @@ function roomRenderHint(){
|
|
|
1901
2257
|
const steerable = roomRuns.filter(r=>r.steerable).length;
|
|
1902
2258
|
$('roomHint').textContent = steerable+' steerable now · '+running+' still running';
|
|
1903
2259
|
}
|
|
1904
|
-
/*
|
|
2260
|
+
/* runs(수렴 콕핏)↔conv, verbs↔send-only 를 roomMode 로 스왑. Work=결정 패널·Ask=읽기전용. */
|
|
1905
2261
|
function roomApplyMode(){
|
|
1906
2262
|
const ask = roomMode==='ask';
|
|
1907
|
-
$('
|
|
2263
|
+
$('roomRuns').hidden = ask;
|
|
2264
|
+
$('roomGbar').hidden = ask; // 그룹 액션 바는 Work 에서만
|
|
1908
2265
|
$('roomConv').hidden = !ask;
|
|
1909
2266
|
$('roomVerbs').hidden = ask;
|
|
1910
2267
|
$('roomSend').hidden = !ask;
|
|
@@ -1917,15 +2274,17 @@ function roomApplyMode(){
|
|
|
1917
2274
|
}
|
|
1918
2275
|
async function openRoom(groupId){
|
|
1919
2276
|
roomGroupId = groupId;
|
|
1920
|
-
|
|
2277
|
+
roomRunSet.clear(); roomRuns = []; roomTurns.length = 0;
|
|
2278
|
+
roomSel.clear(); roomOpen.clear();
|
|
1921
2279
|
roomMode = 'work';
|
|
1922
2280
|
$('roomInput').value = '';
|
|
1923
2281
|
$('roomChips').innerHTML = '<span style="color:var(--faint);font-family:var(--mono);font-size:11.5px">loading…</span>';
|
|
1924
|
-
$('
|
|
2282
|
+
$('roomRuns').innerHTML = '';
|
|
1925
2283
|
$('roomConv').innerHTML = '';
|
|
1926
2284
|
$('roomTitle').textContent = 'Goal';
|
|
1927
2285
|
$('roomCount').textContent = '';
|
|
1928
2286
|
$('roomHint').textContent = '';
|
|
2287
|
+
roomUpdateGbar();
|
|
1929
2288
|
roomApplyMode();
|
|
1930
2289
|
$('groupRoomOverlay').classList.add('open');
|
|
1931
2290
|
await roomLoad();
|
|
@@ -1940,9 +2299,12 @@ async function roomLoad(){
|
|
|
1940
2299
|
roomRuns = j.runs||[];
|
|
1941
2300
|
roomRunSet.clear();
|
|
1942
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);
|
|
1943
2305
|
roomRenderChips();
|
|
1944
2306
|
roomRenderHeaderAndHint();
|
|
1945
|
-
|
|
2307
|
+
roomRenderRuns();
|
|
1946
2308
|
}
|
|
1947
2309
|
function closeRoom(){ roomGroupId = null; $('groupRoomOverlay').classList.remove('open'); }
|
|
1948
2310
|
/* chip 클릭 → 그 run 을 기존 run 모달로 */
|
|
@@ -1957,6 +2319,151 @@ $('roomChips').addEventListener('click',(e)=>{
|
|
|
1957
2319
|
});
|
|
1958
2320
|
$('roomClose').addEventListener('click', closeRoom);
|
|
1959
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
|
+
});
|
|
1960
2467
|
/* + New attempt — 같은 그룹에 새 시도 발사 (현재 dry/real 토글 반영) */
|
|
1961
2468
|
$('roomNew').addEventListener('click', async ()=>{
|
|
1962
2469
|
if (roomGroupId==null) return;
|
|
@@ -1970,9 +2477,9 @@ $('roomNew').addEventListener('click', async ()=>{
|
|
|
1970
2477
|
const j = await res.json().catch(()=>({}));
|
|
1971
2478
|
if (res.ok){
|
|
1972
2479
|
$('roomInput').value='';
|
|
1973
|
-
(j.tasks||[]).forEach(t=>{
|
|
2480
|
+
(j.tasks||[]).forEach(t=>{ roomRunSet.add(t.runId); });
|
|
1974
2481
|
toast((j.tasks||[]).length+' attempt(s) launched'+(real?'':' (dry)'), 'ok');
|
|
1975
|
-
|
|
2482
|
+
await roomLoad(); hydrate();
|
|
1976
2483
|
} else toast('spawn: '+(j.error||res.status), 'error');
|
|
1977
2484
|
} finally { btn.disabled = false; }
|
|
1978
2485
|
});
|
|
@@ -1988,9 +2495,8 @@ $('roomBroadcast').addEventListener('click', async ()=>{
|
|
|
1988
2495
|
const j = await res.json().catch(()=>({}));
|
|
1989
2496
|
if (res.ok){
|
|
1990
2497
|
$('roomInput').value='';
|
|
1991
|
-
roomExtra.push({ who:'goal →', text:message.slice(0,80)+' ('+j.detail+')' });
|
|
1992
2498
|
toast('broadcast: '+j.detail, (j.skipped&&j.skipped.length)?undefined:'ok');
|
|
1993
|
-
|
|
2499
|
+
await roomLoad();
|
|
1994
2500
|
} else toast('broadcast: '+(j.error||res.status), 'error');
|
|
1995
2501
|
} finally { btn.disabled = false; }
|
|
1996
2502
|
});
|
|
@@ -2056,18 +2562,9 @@ function roomOnWS(ev){
|
|
|
2056
2562
|
if (ev.type==='run'){
|
|
2057
2563
|
const rid = ev.runId ?? ev.id;
|
|
2058
2564
|
if (!roomRunSet.has(rid)) return; // 이 방 소속만
|
|
2059
|
-
roomLoad(); // 상태·steerable
|
|
2060
|
-
} else if (ev.type==='event'){
|
|
2061
|
-
if (!roomRunSet.has(ev.runId)) return;
|
|
2062
|
-
const h = humanize({ kind:ev.kind, payload:ev.payload });
|
|
2063
|
-
if (h){
|
|
2064
|
-
const r = roomRuns.find(x=>x.runId===ev.runId);
|
|
2065
|
-
const list = Array.isArray(h) ? h : [h];
|
|
2066
|
-
const frag = list.map(x=>roomFeedLine('r'+ev.runId, r?r.status:'pending', x.k+' · '+x.t, false)).join('');
|
|
2067
|
-
$('roomFeed').insertAdjacentHTML('beforeend', frag);
|
|
2068
|
-
$('roomFeed').scrollTop = $('roomFeed').scrollHeight;
|
|
2069
|
-
}
|
|
2565
|
+
roomLoad(); // 상태·steerable 재계산 → 결정 행 재렌더(aggregate, 정확)
|
|
2070
2566
|
}
|
|
2567
|
+
// event 티커는 수렴 콕핏에선 행 안 출력 카드로 대체 — 상태 변화는 run 이벤트가 재렌더한다.
|
|
2071
2568
|
}
|
|
2072
2569
|
|
|
2073
2570
|
/* ── terminal — 초기크기 접속·자동 재연결(백오프)·unicode11·CJK 폰트 ── */
|
|
@@ -2269,15 +2766,29 @@ $('taskForm').addEventListener('submit', async (e)=>{
|
|
|
2269
2766
|
const title = $('taskTitle').value.trim();
|
|
2270
2767
|
if (!repoId || !title){ toast(!repoId?'register a repo first':'task title required', 'error'); return; }
|
|
2271
2768
|
const capId = Number($('taskCapture').value) || undefined;
|
|
2769
|
+
const outputs = selectedOutputs();
|
|
2272
2770
|
const t = await fetch('/api/tasks',{method:'POST',headers:{'content-type':'application/json'},
|
|
2273
|
-
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());
|
|
2274
2772
|
if (!t.ok){ toast('task create failed', 'error'); return; }
|
|
2275
2773
|
tasks.set(t.task.id, t.task);
|
|
2276
2774
|
const model = $('taskModel').value.trim();
|
|
2277
2775
|
await fetch('/api/tasks/'+t.task.id+'/run',{method:'POST',headers:{'content-type':'application/json'},
|
|
2278
2776
|
body:JSON.stringify({count:Number($('taskCount').value)||1, real: $('taskReal').checked, agent: selAgent, model})});
|
|
2279
2777
|
if (model) rememberModel(model);
|
|
2280
|
-
$('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
|
+
});
|
|
2281
2792
|
});
|
|
2282
2793
|
|
|
2283
2794
|
/* ── model 최근값 기억(기기별, 최대 5) ── */
|