coxpit 5.27.5 → 6.0.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 +8 -7
- package/package.json +1 -1
- package/src/agentstate.ts +245 -0
- package/src/auth.ts +18 -2
- package/src/authkey.ts +35 -2
- package/src/board.ts +43 -9
- package/src/cockpit.ts +987 -34
- package/src/db/index.ts +2 -0
- package/src/db/schema.ts +4 -0
- package/src/files.ts +6 -0
- package/src/index.ts +15 -10
- package/src/orchestrator.ts +148 -17
- package/src/server.ts +167 -23
- package/src/workdoc.ts +65 -0
package/src/cockpit.ts
CHANGED
|
@@ -62,6 +62,16 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
62
62
|
.wip{font-size:10.5px;color:var(--blocked);border:1px solid rgba(214,162,73,.4);border-radius:999px;padding:2px 9px}
|
|
63
63
|
.toggle{font-size:12px;color:var(--muted);text-decoration:none;border:1px solid var(--line);border-radius:7px;padding:5px 11px;background:none;cursor:pointer;font-family:var(--mono)}
|
|
64
64
|
.toggle:hover{color:var(--ink);border-color:var(--line-hi)}
|
|
65
|
+
.toggle.on{color:var(--brand);border-color:rgba(78,201,176,.35)} /* 무언가 켜져 있을 때만 액센트 — 켜짐이 헤더에서 보인다 */
|
|
66
|
+
/* ── 주의 환기 팝오버 (v5.28 A5) — 코크핏에는 설정 화면이 없다. 세 가지 취향이 사는
|
|
67
|
+
헤더 버튼 하나 밑의 작은 판. 행은 전부 기존 컴포넌트(.rchk 체크박스 · .modes/.mode 세그)를
|
|
68
|
+
그대로 쓰고, 이 규칙은 띄우는 자리만 만든다. 새 색 없음(래칫 불변). */
|
|
69
|
+
.apwrap{position:relative;display:inline-flex}
|
|
70
|
+
.apop{position:absolute;top:calc(100% + 8px);right:0;z-index:60;width:238px;display:flex;flex-direction:column;gap:9px;
|
|
71
|
+
background:var(--surface);border:1px solid var(--line-hi);border-radius:11px;padding:12px;box-shadow:0 14px 40px rgba(0,0,0,.45)}
|
|
72
|
+
.apop[hidden]{display:none}
|
|
73
|
+
.apop .lbl{padding:0 0 1px}
|
|
74
|
+
.apop-note{font-family:var(--mono);font-size:10px;line-height:1.5;color:var(--faint)}
|
|
65
75
|
|
|
66
76
|
.layout{display:grid;grid-template-columns:270px 1fr;height:calc(100dvh - 46px)}
|
|
67
77
|
/* 포커스 모드 — 트리·요청바 숨기고 페인만(⌘.) */
|
|
@@ -80,8 +90,18 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
80
90
|
.tnode .n{overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
|
|
81
91
|
.tnode .meta{color:var(--faint);font-size:11px}
|
|
82
92
|
.tnode.repo{color:var(--ink)}
|
|
83
|
-
|
|
93
|
+
/* goal/그룹 = repo 섹션 안의 얇은 띠. 보드 .gband 와 같은 규칙(점선 테두리, 좌측 액센트 바 금지)을 트리 폭으로 축약. */
|
|
94
|
+
.tnode.goal{margin:3px 4px 3px 12px;padding:5px 8px;border:1px dashed var(--line);border-radius:6px}
|
|
95
|
+
.tnode.goal .gi{color:var(--brand)}
|
|
84
96
|
.tnode.task{padding-left:30px}
|
|
97
|
+
/* 트리 노드 액션(+ 새 작업 · ▤ WORK.md · + 에이전트) — hover 에서만 드러나고, 터치 기기는 옅게 상주 */
|
|
98
|
+
.tnode .tact{margin-left:auto;flex:none;white-space:nowrap;font-family:var(--mono);font-size:10.5px;line-height:1;
|
|
99
|
+
color:var(--faint);background:none;border:none;border-radius:5px;padding:3px 5px;cursor:pointer;opacity:0}
|
|
100
|
+
/* 한 행에 액션이 둘이면 앞의 것만 밀어낸다(둘 다 auto 면 남은 공간을 나눠 가져 사이가 벌어진다) */
|
|
101
|
+
.tnode .tact+.tact{margin-left:2px}
|
|
102
|
+
.tnode:hover .tact{opacity:1}
|
|
103
|
+
.tnode .tact:hover{color:var(--brand);background:var(--surface2)}
|
|
104
|
+
body.touch .tnode .tact{opacity:.7}
|
|
85
105
|
.tnode.run{padding-left:44px;font-size:12px;cursor:pointer}
|
|
86
106
|
.tnode.run:hover{background:var(--surface)}
|
|
87
107
|
.tnode.run.open{background:var(--brand-dim);color:var(--ink);box-shadow:inset 0 0 0 1px rgba(78,201,176,.22)}
|
|
@@ -91,6 +111,24 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
91
111
|
.st.done{background:var(--done)} .st.blocked{background:var(--blocked)} .st.failed,.st.error{background:var(--failed)}
|
|
92
112
|
.st.merged{background:var(--merged)} .st.open{background:var(--open)} .st.stopped{background:var(--stopped)}
|
|
93
113
|
.st.preparing,.st.pending,.st.starting{background:var(--blocked)}
|
|
114
|
+
/* ── 에이전트 상태 점 (v5.28 A4) — 스트림이 말한 것만 칠한다 ──
|
|
115
|
+
working=--running · waiting=--blocked(맥박) · idle=--faint · exited=--done. 전부 기존 토큰이고 새 색은 없다.
|
|
116
|
+
.st 에 얹히면 트리 run 행의 상태 점을 덮고(트리가 곧 rail 이다), 단독 .as-dot 이면 탭의 작은 상태 점이다.
|
|
117
|
+
상태가 없는 run 은 아무 클래스도 안 붙어 점이 뜨지 않는다 — 없는 것을 그리지 않는다.
|
|
118
|
+
주의: .st.* 색 규칙보다 반드시 **뒤**에 와야 한다(같은 특이도 → 나중 규칙이 이긴다). */
|
|
119
|
+
.as-dot{width:6px;height:6px;border-radius:50%;flex:none;display:none}
|
|
120
|
+
.st.as-working,.as-dot.as-working{background:var(--running);box-shadow:none;display:inline-block}
|
|
121
|
+
.st.as-waiting,.as-dot.as-waiting{background:var(--blocked);box-shadow:none;display:inline-block;animation:aspulse 1.5s ease-in-out infinite}
|
|
122
|
+
.st.as-idle,.as-dot.as-idle{background:var(--faint);box-shadow:none;display:inline-block}
|
|
123
|
+
.st.as-exited,.as-dot.as-exited{background:var(--done);box-shadow:none;display:inline-block}
|
|
124
|
+
@keyframes aspulse{0%,100%{opacity:1}50%{opacity:.3}}
|
|
125
|
+
@media (prefers-reduced-motion:reduce){.st.as-waiting,.as-dot.as-waiting{animation:none}}
|
|
126
|
+
/* 대기 칩 — 헤더에 산다. 그래서 포커스 모드(.layout.focusmode 는 .rail·.reqbar 만 숨긴다)와
|
|
127
|
+
모바일 헤더에서 그대로 살아남는다. 부름은 항상 보여야 하니까. */
|
|
128
|
+
.waitchip{display:inline-flex;align-items:center;gap:5px;font-family:var(--mono);font-size:11.5px;color:var(--blocked);
|
|
129
|
+
background:rgba(214,162,73,.16);border:1px solid rgba(214,162,73,.4);border-radius:999px;padding:3px 10px;cursor:pointer}
|
|
130
|
+
.waitchip[hidden]{display:none}
|
|
131
|
+
.waitchip:hover{color:var(--ink);border-color:var(--blocked)}
|
|
94
132
|
.tree-sep{height:1px;background:var(--line);margin:8px 4px}
|
|
95
133
|
.railfoot{margin-top:auto;padding:8px;color:var(--faint);font-size:11px;border-top:1px solid var(--line)}
|
|
96
134
|
|
|
@@ -247,6 +285,18 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
247
285
|
.pick-name:focus{outline:none;border-color:var(--brand)}
|
|
248
286
|
.pick-name::placeholder{color:var(--faint)}
|
|
249
287
|
|
|
288
|
+
/* ── 작업/에이전트 시트 — 트리 노드에서 뜨는 작은 시트(폴더 피커 .pick 뼈대 재사용) ── */
|
|
289
|
+
.sheet-note{padding:11px 15px 0;font-size:11px;color:var(--faint);line-height:1.55}
|
|
290
|
+
.sheet-note b{color:var(--muted);font-weight:600}
|
|
291
|
+
.sheet-body{padding:12px 15px 4px;display:flex;flex-direction:column;gap:5px}
|
|
292
|
+
.sheet-body .pick-name{width:100%;flex:none}
|
|
293
|
+
.sheet-body .modes{align-self:flex-start;margin-bottom:3px}
|
|
294
|
+
/* 둘 곳 선택 바로 밑의 정직한 한 줄 — 둘 다인 척하지 않는다(P4) */
|
|
295
|
+
.sheet-tradeoff{font-size:11px;color:var(--faint);font-family:var(--mono);line-height:1.5}
|
|
296
|
+
/* 시트 안에서 이유를 말하는 자리(예: IN_PLACE_BUSY) — 토스트로 날려보내지 않는다 */
|
|
297
|
+
.sheet-err{font-size:11px;color:var(--failed);line-height:1.5;margin-top:4px}
|
|
298
|
+
.sheet-err[hidden]{display:none}
|
|
299
|
+
.flabel{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint);margin-top:5px}
|
|
250
300
|
.sec-row{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:7px;font-size:12.5px;color:var(--ink)}
|
|
251
301
|
.sec-row:hover{background:var(--surface2)}
|
|
252
302
|
.sec-row .snm{flex:1;font-family:var(--mono)}
|
|
@@ -267,6 +317,10 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
267
317
|
.leaf-h .lock:hover{color:var(--brand)}
|
|
268
318
|
.leaf-h .sendkey{font:inherit;font-family:var(--mono);font-size:11px;color:var(--ink);background:var(--panel);border:1px solid var(--brand);border-radius:5px;padding:1px 6px;width:150px;outline:none}
|
|
269
319
|
.lbl .lnk{color:var(--brand);cursor:pointer;font-size:10px;letter-spacing:0;text-transform:none}
|
|
320
|
+
/* 섹션 라벨에 액션이 둘 이상이면 한 묶음으로 — 양쪽 끝으로 흩어지지 않게(.tact+.tact 와 같은 이유) */
|
|
321
|
+
.lbl .lacts{display:flex;align-items:center;gap:10px}
|
|
322
|
+
/* v6.0 S1 — Scratch 는 아직 프로젝트가 아닌 것들이다. 한 줄로 그렇게 말한다 */
|
|
323
|
+
.tree-note{padding:0 8px 9px;font-size:10.5px;color:var(--faint);line-height:1.45;white-space:normal}
|
|
270
324
|
.tnode.session{padding-left:20px;cursor:pointer} .tnode.session:hover{background:var(--surface)}
|
|
271
325
|
.tnode.session.open{background:var(--brand-dim);color:var(--ink);box-shadow:inset 0 0 0 1px rgba(78,201,176,.22)}
|
|
272
326
|
/* 이름 우선: .n(flex:1) 이 공간을 갖고, 경로는 끝만 짧게(고정 폭) — hover 시 title 로 전체 표시 */
|
|
@@ -275,6 +329,31 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
275
329
|
.tnode.session .del{margin-left:auto;color:var(--faint);border:none;background:none;cursor:pointer;font-size:13px;line-height:1;padding:0 3px;opacity:0;flex:none}
|
|
276
330
|
.tnode.session:hover .del{opacity:1} .tnode.session .del:hover{color:var(--failed)}
|
|
277
331
|
body.touch .tnode.session .del{opacity:.65}
|
|
332
|
+
/* v6.0 S2 — 승격(⇧). 행에 auto 마진은 **하나만** 둔다: 앞선 .pr 이 가져가고 .del 은 붙어 선다 */
|
|
333
|
+
.tnode.session .pr{margin-left:auto;color:var(--faint);border:none;background:none;cursor:pointer;font-size:12px;line-height:1;padding:0 3px;opacity:0;flex:none}
|
|
334
|
+
.tnode.session .pr+.del{margin-left:0}
|
|
335
|
+
.tnode.session:hover .pr{opacity:1} .tnode.session .pr:hover{color:var(--brand)}
|
|
336
|
+
body.touch .tnode.session .pr{opacity:.65}
|
|
337
|
+
|
|
338
|
+
/* ── v6.0 S1b — Scratch 정리 판 · S2 승격 시트 ── */
|
|
339
|
+
.scrub-row{display:flex;align-items:center;gap:9px;padding:7px 10px;border-radius:7px;font-size:12.5px;color:var(--muted);cursor:pointer}
|
|
340
|
+
.scrub-row:hover{background:var(--surface2)}
|
|
341
|
+
.scrub-row .snm{flex:1;color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
342
|
+
.scrub-row .slive{flex:none;font-size:10.5px;color:var(--done)}
|
|
343
|
+
.scrub-row .slive.dead{color:var(--faint)}
|
|
344
|
+
.scrub-row .sage{flex:none;font-size:10.5px;color:var(--faint)}
|
|
345
|
+
/* 접힌 오래된 세션 — 목록을 훑을 수 있게 접을 뿐, 접힌 것은 지우지 않는다 */
|
|
346
|
+
.scrub-fold{display:flex;align-items:center;gap:8px;padding:7px 10px;border-radius:7px;font-size:11.5px;color:var(--muted);cursor:pointer}
|
|
347
|
+
.scrub-fold:hover{background:var(--surface2)}
|
|
348
|
+
.scrub-fold .sage{font-size:10.5px;color:var(--faint)}
|
|
349
|
+
/* v6.0 T6 — 같은 줄 모양을 프로젝트 정리·고아 터미널 판에서도 그대로 쓴다(새 컴포넌트 없음).
|
|
350
|
+
경고는 색이 아니라 말로 먼저 하고, 색은 이미 있는 주의색 하나만 빌린다 */
|
|
351
|
+
.scrub-row .swarn{flex:none;font-size:10.5px;color:var(--blocked)}
|
|
352
|
+
.scrub-row.risky .snm{color:var(--muted)}
|
|
353
|
+
/* 시트 뼈대(.pick) 바로 밑에 놓인 이유 줄도 같은 좌우 여백을 갖는다 */
|
|
354
|
+
.pick > .sheet-err{padding:2px 15px 10px}
|
|
355
|
+
.pick-row.on{background:var(--surface2);color:var(--ink)}
|
|
356
|
+
.pick-row.on .ic{color:var(--brand)}
|
|
278
357
|
|
|
279
358
|
.toast{position:fixed;bottom:64px;left:50%;transform:translateX(-50%);background:var(--surface2);border:1px solid var(--line-hi);color:var(--ink);
|
|
280
359
|
font-family:var(--mono);font-size:12px;padding:8px 14px;border-radius:9px;opacity:0;transition:opacity .2s;pointer-events:none;z-index:40;max-width:80vw}
|
|
@@ -380,10 +459,25 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
380
459
|
<div class="vtabs">
|
|
381
460
|
<button type="button" class="vtab on" id="vtTerm"><span class="g">⌗</span><span class="b-txt">Terminal</span></button>
|
|
382
461
|
<button type="button" class="vtab" id="vtReview"><span class="g">⧉</span><span class="b-txt">Review</span></button>
|
|
383
|
-
<button type="button" class="vtab"
|
|
462
|
+
<button type="button" class="vtab" id="vtDocs" title="Docs — 문서·아카이브는 보드 열람실에서 (Part B)"><span class="g">▤</span><span class="b-txt">Docs</span></button>
|
|
384
463
|
</div>
|
|
385
464
|
<div class="right">
|
|
386
465
|
<span class="ver" id="ver" title="로드된 cockpit 버전 (캐시 확인용)">v__COXPIT_VER__</span>
|
|
466
|
+
<button type="button" class="waitchip" id="waitChip" hidden title="입력을 기다리는 에이전트 — 클릭하면 차례로 그 터미널로">◔ <span id="waitN">0</span></button>
|
|
467
|
+
<div class="apwrap" id="apWrap">
|
|
468
|
+
<button type="button" class="toggle" id="attnBtn" aria-haspopup="true" aria-expanded="false" title="알림 — 소리 · 브라우저 알림 · 울릴 전이">◎</button>
|
|
469
|
+
<div class="apop" id="attnPop" hidden role="group" aria-label="알림">
|
|
470
|
+
<div class="lbl"><span>알림</span></div>
|
|
471
|
+
<label class="rchk" title="대기·완료 전이에 짧은 신호음"><input type="checkbox" id="attnSound" /> 소리</label>
|
|
472
|
+
<label class="rchk" title="처음 켤 때 브라우저 권한을 묻습니다"><input type="checkbox" id="attnNotify" /> 브라우저 알림</label>
|
|
473
|
+
<div class="modes" id="attnOn" role="group" aria-label="울릴 전이">
|
|
474
|
+
<button type="button" class="mode" id="attnOnWaiting" title="입력 대기일 때만">대기만</button>
|
|
475
|
+
<button type="button" class="mode" id="attnOnExited" title="완료(종료)일 때만">완료만</button>
|
|
476
|
+
<button type="button" class="mode" id="attnOnBoth" title="대기·완료 둘 다">둘 다</button>
|
|
477
|
+
</div>
|
|
478
|
+
<div class="apop-note">전부 기본 꺼짐. 보고 있는 탭은 자신을 울리지 않습니다.</div>
|
|
479
|
+
</div>
|
|
480
|
+
</div>
|
|
387
481
|
<span class="ws" id="ws"><span class="dot"></span><span id="wstext" class="b-txt">connecting</span></span>
|
|
388
482
|
<button type="button" class="toggle" id="secretsBtn" title="시크릿(API 키) 관리 — 세션에 env 로 주입">∗<span class="b-txt"> Secrets</span></button>
|
|
389
483
|
<a class="toggle" href="/" title="보드(모니터) 뷰로">←<span class="b-txt"> Board</span></a>
|
|
@@ -393,7 +487,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
393
487
|
<div class="scrim" id="scrim"></div>
|
|
394
488
|
<div class="layout" id="layout">
|
|
395
489
|
<aside class="rail" id="rail">
|
|
396
|
-
<div class="lbl"><span>Workspace</span><span id="machName" style="color:var(--faint)">local</span></div>
|
|
490
|
+
<div class="lbl"><span>Workspace</span><span class="lacts"><span class="lnk" id="reapBtn" title="고아 터미널 — run 기록이 없는 coxpit-r* tmux 세션을 찾아 정리합니다(유지보수: 보드의 Reclaim 과 같은 가족)">↻ 고아 터미널</span><span id="machName" style="color:var(--faint)">local</span></span></div>
|
|
397
491
|
<div id="tree"></div>
|
|
398
492
|
<div class="railfoot">클릭한 run·세션은 <b>탭</b>으로 열립니다 · split 으로 페인을 나란히 배치</div>
|
|
399
493
|
</aside>
|
|
@@ -501,6 +595,117 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
501
595
|
</div>
|
|
502
596
|
</div>
|
|
503
597
|
|
|
598
|
+
<div class="modal" id="workModal">
|
|
599
|
+
<div class="pick" style="width:min(460px,92vw)">
|
|
600
|
+
<div class="pick-h"><span class="t">새 작업</span><button class="x" id="workClose" title="닫기">×</button></div>
|
|
601
|
+
<div class="pick-path" id="workRepoName">…</div>
|
|
602
|
+
<div class="sheet-note">작업을 만들면 그 <b>repo 체크아웃</b>에서 <b>main</b> 터미널이 바로 열립니다 — 에이전트도 worktree 도 없는 내 손 터미널입니다. 에이전트는 작업 아래에서 따로 추가합니다.</div>
|
|
603
|
+
<div class="pick-f">
|
|
604
|
+
<input class="pick-name" id="workName" placeholder="작업 이름 (예: 기능 업데이트 6.0)" autocomplete="off" />
|
|
605
|
+
<button class="go" id="workGo">만들고 열기</button>
|
|
606
|
+
</div>
|
|
607
|
+
</div>
|
|
608
|
+
</div>
|
|
609
|
+
|
|
610
|
+
<div class="modal" id="agentModal">
|
|
611
|
+
<div class="pick" style="width:min(460px,92vw)">
|
|
612
|
+
<div class="pick-h"><span class="t">+ 에이전트</span><button class="x" id="agentClose" title="닫기">×</button></div>
|
|
613
|
+
<div class="pick-path" id="agentWorkName">…</div>
|
|
614
|
+
<div class="sheet-body">
|
|
615
|
+
<div class="flabel">역할 이름</div>
|
|
616
|
+
<input class="pick-name" id="agentRole" placeholder="예: 구현 (비우면 에이전트 이름)" maxlength="60" autocomplete="off" />
|
|
617
|
+
<div class="flabel">에이전트</div>
|
|
618
|
+
<div class="modes" id="agentProv"></div>
|
|
619
|
+
<div class="flabel">모델</div>
|
|
620
|
+
<input class="pick-name" id="agentModel" placeholder="비우면 CLI 기본 (예: opus)" autocomplete="off" spellcheck="false" />
|
|
621
|
+
<div class="flabel">둘 곳</div>
|
|
622
|
+
<div class="modes" id="agentPlace">
|
|
623
|
+
<button type="button" class="mode on" data-place="worktree">worktree</button>
|
|
624
|
+
<button type="button" class="mode" data-place="inplace">in-place</button>
|
|
625
|
+
</div>
|
|
626
|
+
<div class="sheet-tradeoff">in-place = 체크아웃 공유, 순차 · worktree = 병렬, 나중에 머지</div>
|
|
627
|
+
<div class="sheet-err" id="agentErr" hidden></div>
|
|
628
|
+
</div>
|
|
629
|
+
<div class="pick-f">
|
|
630
|
+
<label class="rchk" title="실제 CLI 실행 (기본=드라이런)"><input type="checkbox" id="agentReal" /> real</label>
|
|
631
|
+
<span class="shint" id="agentPlaceHint" style="flex:1">worktree 로 격리해 띄웁니다 — 나란히 비교하고 승자만 머지</span>
|
|
632
|
+
<button class="go" id="agentGo">에이전트 추가</button>
|
|
633
|
+
</div>
|
|
634
|
+
</div>
|
|
635
|
+
</div>
|
|
636
|
+
|
|
637
|
+
<div class="modal" id="scrubModal">
|
|
638
|
+
<div class="pick" style="width:min(540px,92vw)">
|
|
639
|
+
<div class="pick-h"><span class="t">Scratch 정리</span><button class="x" id="scrubClose" title="닫기">×</button></div>
|
|
640
|
+
<div class="sheet-note">체크한 세션은 <b>터미널만 종료</b>됩니다 — <b>폴더와 파일은 언제나 그대로 보존</b>됩니다. 터미널이 없는 것만 미리 체크해 뒀고, 접어 둔 오래된 세션은 <b>선택되지도 지워지지도 않습니다</b>.</div>
|
|
641
|
+
<div class="pick-list" id="scrubList"></div>
|
|
642
|
+
<div class="pick-f">
|
|
643
|
+
<span id="scrubHint" style="flex:1;font-size:11px;color:var(--faint)">…</span>
|
|
644
|
+
<button class="go" id="scrubGo">선택 삭제</button>
|
|
645
|
+
</div>
|
|
646
|
+
</div>
|
|
647
|
+
</div>
|
|
648
|
+
|
|
649
|
+
<div class="modal" id="promoModal">
|
|
650
|
+
<div class="pick" style="width:min(540px,92vw)">
|
|
651
|
+
<div class="pick-h"><span class="t">⇧ 프로젝트로</span><button class="x" id="promoClose" title="닫기">×</button></div>
|
|
652
|
+
<div class="pick-path" id="promoPath">…</div>
|
|
653
|
+
<div class="sheet-body">
|
|
654
|
+
<div class="modes" id="promoMode">
|
|
655
|
+
<button type="button" class="mode on" data-promo="register">프로젝트로 등록</button>
|
|
656
|
+
<button type="button" class="mode" data-promo="move">프로젝트로 이동</button>
|
|
657
|
+
</div>
|
|
658
|
+
<div class="sheet-tradeoff" id="promoWhat">이 폴더를 repo 로 등록하고 이 작업을 그 아래로 옮깁니다</div>
|
|
659
|
+
<div class="sheet-err" id="promoErr" hidden></div>
|
|
660
|
+
</div>
|
|
661
|
+
<div class="pick-list" id="promoRepos" hidden></div>
|
|
662
|
+
<div class="pick-f">
|
|
663
|
+
<span id="promoWarn" style="flex:1;font-size:11px;color:var(--blocked);line-height:1.45;white-space:normal" hidden></span>
|
|
664
|
+
<button class="go" id="promoGo">등록하고 옮기기</button>
|
|
665
|
+
</div>
|
|
666
|
+
</div>
|
|
667
|
+
</div>
|
|
668
|
+
|
|
669
|
+
<div class="modal" id="tidyModal">
|
|
670
|
+
<div class="pick" style="width:min(560px,92vw)">
|
|
671
|
+
<div class="pick-h"><span class="t">묵은 작업 정리</span><button class="x" id="tidyClose" title="닫기">×</button></div>
|
|
672
|
+
<div class="pick-path" id="tidyRepoName">…</div>
|
|
673
|
+
<div class="sheet-note">더 돌고 있지 않은(정착한) 작업만 올라옵니다 — <b>터미널이 살아 있는 작업은 목록에 없습니다</b>. 닫으면 그 작업의 <b>run worktree 가 제거</b>되고, <b>repo 체크아웃과 그 파일은 그대로</b>입니다. <b>미머지 표시가 붙은 것은 미리 체크하지 않습니다</b>.</div>
|
|
674
|
+
<div class="pick-list" id="tidyList"></div>
|
|
675
|
+
<div class="sheet-err" id="tidyErr" hidden></div>
|
|
676
|
+
<div class="pick-f">
|
|
677
|
+
<span id="tidyHint" style="flex:1;font-size:11px;color:var(--faint)">…</span>
|
|
678
|
+
<button class="go" id="tidyGo">선택 닫기</button>
|
|
679
|
+
</div>
|
|
680
|
+
</div>
|
|
681
|
+
</div>
|
|
682
|
+
|
|
683
|
+
<div class="modal" id="unregModal">
|
|
684
|
+
<div class="pick" style="width:min(520px,92vw)">
|
|
685
|
+
<div class="pick-h"><span class="t">등록 해제</span><button class="x" id="unregClose" title="닫기">×</button></div>
|
|
686
|
+
<div class="pick-path" id="unregPath">…</div>
|
|
687
|
+
<div class="sheet-note"><b>목록에서만 뺍니다 — 디스크의 폴더와 파일은 손대지 않습니다.</b> 다시 등록하는 데는 클릭 한 번이면 됩니다(같은 경로를 그대로 고르면 됩니다). 지워지는 것은 coxpit 의 등록 기록뿐입니다.</div>
|
|
688
|
+
<div class="sheet-body"><div class="sheet-tradeoff" id="unregRisk">…</div></div>
|
|
689
|
+
<div class="sheet-err" id="unregErr" hidden></div>
|
|
690
|
+
<div class="pick-f">
|
|
691
|
+
<span style="flex:1;font-size:11px;color:var(--faint)">폴더는 그대로 · 다시 등록하면 그만입니다</span>
|
|
692
|
+
<button class="go" id="unregGo">등록 해제</button>
|
|
693
|
+
</div>
|
|
694
|
+
</div>
|
|
695
|
+
</div>
|
|
696
|
+
|
|
697
|
+
<div class="modal" id="reapModal">
|
|
698
|
+
<div class="pick" style="width:min(560px,92vw)">
|
|
699
|
+
<div class="pick-h"><span class="t">↻ 고아 터미널</span><button class="x" id="reapClose" title="닫기">×</button></div>
|
|
700
|
+
<div class="sheet-note">run 기록이 사라졌는데 남아 있는 <b>coxpit-r* tmux 세션</b>들입니다 — <b>살아 있는 run 의 세션은 여기 오르지 않습니다</b>. 빈 셸만 미리 체크했고, <b>무언가 돌고 있는 세션은 표시만 하고 절대 미리 고르지 않습니다</b>. 종료되는 것은 터미널뿐, 폴더·파일은 그대로입니다.</div>
|
|
701
|
+
<div class="pick-list" id="reapList"></div>
|
|
702
|
+
<div class="pick-f">
|
|
703
|
+
<span id="reapHint" style="flex:1;font-size:11px;color:var(--faint)">…</span>
|
|
704
|
+
<button class="go" id="reapGo">선택 종료</button>
|
|
705
|
+
</div>
|
|
706
|
+
</div>
|
|
707
|
+
</div>
|
|
708
|
+
|
|
504
709
|
<div class="modal" id="fpickModal">
|
|
505
710
|
<div class="pick">
|
|
506
711
|
<div class="pick-h"><span class="t">파일 보기</span><button class="x" id="fpClose" title="닫기">×</button></div>
|
|
@@ -594,6 +799,144 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
594
799
|
function repoOfRun(runId){ var r=runById[runId]; var t=r&&taskById[r.taskId]; return t?t.repoId:null; }
|
|
595
800
|
function runIsSession(runId){ var r=runById[runId]; var t=r&&taskById[r.taskId]; var rp=t&&repoById[t.repoId]; return !!(rp&&rp.kind==='sessions'); }
|
|
596
801
|
|
|
802
|
+
// ── 에이전트 상태 (v5.28 A4) ──
|
|
803
|
+
// 서버가 터미널 출력에서 읽어 보내준 거친 상태만 담는다. 여기 없는 run 은 점이 없다 —
|
|
804
|
+
// 클라이언트는 상태를 짐작하지도, 전이를 지어내지도 않는다(spec A6). 'unknown'(첫 출력 전)도 담지 않는다.
|
|
805
|
+
var agentState = {}; // runId(문자열 키) -> 'working'|'waiting'|'idle'|'exited'
|
|
806
|
+
var AS_CLASS = { working:'as-working', waiting:'as-waiting', idle:'as-idle', exited:'as-exited' };
|
|
807
|
+
function asClass(s){ return AS_CLASS[s]||''; }
|
|
808
|
+
function agentStateOf(runId){ return agentState[runId]||''; }
|
|
809
|
+
// 대기 중인 run — 칩의 숫자이자 순회 대상. id 순으로 고정해 클릭할 때마다 순서가 흔들리지 않게.
|
|
810
|
+
function waitingRunIds(){
|
|
811
|
+
var out=[]; Object.keys(agentState).forEach(function(k){ if(agentState[k]==='waiting') out.push(k); });
|
|
812
|
+
out.sort(function(a,b){ return Number(a)-Number(b); });
|
|
813
|
+
return out;
|
|
814
|
+
}
|
|
815
|
+
// 델타 한 건 = 그 run 자리만 칠한다(리하이드레이트 없음). 탭 점 · 트리 run 행 점 · 대기 칩.
|
|
816
|
+
function paintAgentState(runId, state){
|
|
817
|
+
var prev = agentState[runId]||''; // 덮어쓰기 **전**의 상태 — 전이를 아는 유일한 지점
|
|
818
|
+
if (state && state!=='unknown') agentState[runId]=state; else delete agentState[runId];
|
|
819
|
+
var cls=asClass(state);
|
|
820
|
+
var tabDot=$('tabs').querySelector('.tab[data-tab="'+runId+'"] [data-role=asdot]');
|
|
821
|
+
if (tabDot) tabDot.className='as-dot '+cls;
|
|
822
|
+
// 트리 run 행(세션 행 포함) — 기존 run 상태 점을 에이전트 상태로 덮어쓴다. 순서는 건드리지 않는다.
|
|
823
|
+
var row=$('tree').querySelector('.tnode[data-run="'+runId+'"] .st');
|
|
824
|
+
if (row){ var r=runById[runId]; row.className='st '+((r&&r.status)||'')+(cls?' '+cls:''); }
|
|
825
|
+
updateWaitChip();
|
|
826
|
+
raiseAttention(runId, prev, state); // 점·칩은 항상 켜져 있고, 소리/알림만 취향을 탄다
|
|
827
|
+
}
|
|
828
|
+
function updateWaitChip(){
|
|
829
|
+
var n=waitingRunIds().length;
|
|
830
|
+
$('waitN').textContent=String(n);
|
|
831
|
+
$('waitChip').hidden = n===0; // N>0 일 때만 존재한다
|
|
832
|
+
}
|
|
833
|
+
// 칩 클릭 = 다음 대기 터미널로. 이미 열린 탭이면 그 페인에 포커스, 아니면 탭으로 연다.
|
|
834
|
+
var waitCycle=0;
|
|
835
|
+
function jumpNextWaiting(){
|
|
836
|
+
var ids=waitingRunIds(); if(!ids.length) return;
|
|
837
|
+
if (waitCycle>=ids.length) waitCycle=0;
|
|
838
|
+
var id=Number(ids[waitCycle]); waitCycle=(waitCycle+1)%ids.length;
|
|
839
|
+
if(!runById[id]){ toast('대기 중인 run 을 찾을 수 없습니다 · r'+id); return; }
|
|
840
|
+
openTab(id);
|
|
841
|
+
}
|
|
842
|
+
$('waitChip').addEventListener('click', jumpNextWaiting);
|
|
843
|
+
|
|
844
|
+
// ── 주의 환기 (v5.28 A5) ──
|
|
845
|
+
// 부름은 opt-in 이고, 정직하며, 그것을 부른 사건보다 결코 시끄럽지 않다.
|
|
846
|
+
// 시각(탭 점·트리 점·◔ 칩)은 항상 켜져 있고 — 소리와 브라우저 알림만 여기 취향을 탄다.
|
|
847
|
+
// 세 취향은 코크핏에 설정 화면이 없어서 헤더 버튼 하나 밑 작은 판에 산다(localStorage 기억).
|
|
848
|
+
function lsGet(k, d){ try{ var v=localStorage.getItem(k); return v==null?d:v; }catch(e){ return d; } }
|
|
849
|
+
function lsSet(k, v){ try{ localStorage.setItem(k, v); }catch(e){} }
|
|
850
|
+
var attn = {
|
|
851
|
+
sound: lsGet('coxpit.sound','0')==='1',
|
|
852
|
+
// 권한은 브라우저가 SSOT — 기억된 '켜짐'이라도 권한이 없으면 꺼진 것이다(지어낸 준비 상태 없음).
|
|
853
|
+
notify: lsGet('coxpit.notify','0')==='1' && ('Notification' in window) && Notification.permission==='granted',
|
|
854
|
+
on: (function(v){ return (v==='waiting'||v==='exited')?v:'both'; })(lsGet('coxpit.pingOn','both')),
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
// 두 음짜리 짧은 신호음 — WebAudio 로 그 자리에서 만든다(오디오 에셋도, 새 파일도 없다).
|
|
858
|
+
var actx=null;
|
|
859
|
+
function blip(){
|
|
860
|
+
try{
|
|
861
|
+
var AC = window.AudioContext||window.webkitAudioContext; if(!AC) return;
|
|
862
|
+
if(!actx) actx=new AC();
|
|
863
|
+
if(actx.state==='suspended') actx.resume();
|
|
864
|
+
var t0=actx.currentTime;
|
|
865
|
+
[[660,0],[880,0.11]].forEach(function(p){
|
|
866
|
+
var o=actx.createOscillator(), g=actx.createGain();
|
|
867
|
+
o.type='sine'; o.frequency.value=p[0];
|
|
868
|
+
g.gain.setValueAtTime(0.0001, t0+p[1]);
|
|
869
|
+
g.gain.exponentialRampToValueAtTime(0.06, t0+p[1]+0.015); // 작게 — 터미널이 부르는 소리지 경보가 아니다
|
|
870
|
+
g.gain.exponentialRampToValueAtTime(0.0001, t0+p[1]+0.10);
|
|
871
|
+
o.connect(g); g.connect(actx.destination);
|
|
872
|
+
o.start(t0+p[1]); o.stop(t0+p[1]+0.12);
|
|
873
|
+
});
|
|
874
|
+
}catch(e){}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// 브라우저 알림 — 보드와 같은 패턴(권한 요청 + localStorage + 탭하면 그 자리로).
|
|
878
|
+
// 본문은 **세션 이름과 상태뿐**이다. 터미널 내용은 한 글자도 싣지 않는다.
|
|
879
|
+
function notifyAttn(runId, state){
|
|
880
|
+
if (!attn.notify || !('Notification' in window) || Notification.permission!=='granted') return;
|
|
881
|
+
try{
|
|
882
|
+
var n = new Notification('coxpit', {
|
|
883
|
+
body: runLabel(runId) + (state==='waiting' ? ' · 입력 대기' : ' · 완료'),
|
|
884
|
+
tag: 'coxpit-as-'+runId,
|
|
885
|
+
});
|
|
886
|
+
n.onclick = function(){ try{ window.focus(); }catch(e){} jumpToRun(Number(runId)); n.close(); };
|
|
887
|
+
}catch(e){}
|
|
888
|
+
}
|
|
889
|
+
// 칩의 순회와 같은 착지 — 열린 탭이면 그 페인 포커스, 아니면 탭으로 연다.
|
|
890
|
+
function jumpToRun(id){ if(!runById[id]){ toast('run 을 찾을 수 없습니다 · r'+id); return; } openTab(id); }
|
|
891
|
+
|
|
892
|
+
function raiseAttention(runId, prev, next){
|
|
893
|
+
if (next!=='waiting' && next!=='exited') return; // 부를 만한 전이가 아니다
|
|
894
|
+
if (prev===next) return; // 같은 상태 재통보는 전이가 아니다
|
|
895
|
+
if (attn.on!=='both' && attn.on!==next) return; // 울릴 전이 필터(대기만 / 완료만 / 둘 다)
|
|
896
|
+
var away = document.hidden || String(focusedRunId())!==String(runId);
|
|
897
|
+
if (!away) return; // 보고 있는 탭은 절대 자신을 울리지 않는다
|
|
898
|
+
if (attn.sound) blip();
|
|
899
|
+
notifyAttn(runId, next);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// 팝오버 — 행은 기존 컴포넌트(.rchk · .modes/.mode) 그대로. 새 설정 시스템이 아니다.
|
|
903
|
+
var AT_ON = { waiting:'attnOnWaiting', exited:'attnOnExited', both:'attnOnBoth' };
|
|
904
|
+
function paintAttn(){
|
|
905
|
+
$('attnSound').checked = attn.sound;
|
|
906
|
+
$('attnNotify').checked = attn.notify;
|
|
907
|
+
Object.keys(AT_ON).forEach(function(k){ $(AT_ON[k]).classList.toggle('on', attn.on===k); });
|
|
908
|
+
$('attnBtn').classList.toggle('on', attn.sound||attn.notify);
|
|
909
|
+
}
|
|
910
|
+
function setAttnPop(open){
|
|
911
|
+
$('attnPop').hidden = !open;
|
|
912
|
+
$('attnBtn').setAttribute('aria-expanded', open?'true':'false');
|
|
913
|
+
if (open) $('attnSound').focus(); // 키보드로 열어도 바로 첫 행에 선다
|
|
914
|
+
}
|
|
915
|
+
$('attnBtn').addEventListener('click', function(e){ e.stopPropagation(); setAttnPop($('attnPop').hidden); });
|
|
916
|
+
document.addEventListener('click', function(e){ if(!$('attnPop').hidden && !$('apWrap').contains(e.target)) setAttnPop(false); });
|
|
917
|
+
document.addEventListener('keydown', function(e){ if(e.key==='Escape' && !$('attnPop').hidden){ e.preventDefault(); setAttnPop(false); $('attnBtn').focus(); } });
|
|
918
|
+
$('apWrap').addEventListener('focusout', function(){
|
|
919
|
+
setTimeout(function(){ if(!$('apWrap').contains(document.activeElement)) setAttnPop(false); }, 0);
|
|
920
|
+
});
|
|
921
|
+
$('attnSound').addEventListener('change', function(){
|
|
922
|
+
attn.sound = this.checked; lsSet('coxpit.sound', attn.sound?'1':'0'); paintAttn();
|
|
923
|
+
if (attn.sound) blip(); // 켜는 순간 한 번 들려준다(겸사겸사 사용자 제스처로 오디오 잠금 해제)
|
|
924
|
+
});
|
|
925
|
+
$('attnNotify').addEventListener('change', async function(){
|
|
926
|
+
if (this.checked){
|
|
927
|
+
if(!('Notification' in window)){ this.checked=false; toast('이 브라우저는 알림을 지원하지 않습니다'); return; }
|
|
928
|
+
var perm = Notification.permission;
|
|
929
|
+
if (perm!=='granted') perm = await Notification.requestPermission();
|
|
930
|
+
if (perm!=='granted'){ this.checked=false; attn.notify=false; lsSet('coxpit.notify','0'); paintAttn(); toast('알림 권한이 거부됐습니다'); return; }
|
|
931
|
+
}
|
|
932
|
+
attn.notify = this.checked; lsSet('coxpit.notify', attn.notify?'1':'0'); paintAttn();
|
|
933
|
+
});
|
|
934
|
+
Object.keys(AT_ON).forEach(function(k){
|
|
935
|
+
// this.focus() — Safari 는 버튼 클릭에 포커스를 안 옮겨서 focusout 이 판을 닫아버린다
|
|
936
|
+
$(AT_ON[k]).addEventListener('click', function(){ attn.on=k; lsSet('coxpit.pingOn', k); paintAttn(); this.focus(); });
|
|
937
|
+
});
|
|
938
|
+
paintAttn();
|
|
939
|
+
|
|
597
940
|
async function hydrate(){
|
|
598
941
|
try{
|
|
599
942
|
var d = await (await fetch('/api/fleet?view=all')).json();
|
|
@@ -602,8 +945,13 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
602
945
|
(d.tasks||[]).forEach(function(t){ taskById[t.id]=t; });
|
|
603
946
|
(d.repos||[]).forEach(function(r){ repoById[r.id]=r; });
|
|
604
947
|
if (d.machines && d.machines[0]) { $('mach').textContent = d.machines[0].slug; $('machName').textContent = d.machines[0].slug; }
|
|
948
|
+
// 에이전트 상태 씨앗 — 갓 뜬(또는 재연결한) 코크핏이 다음 델타를 기다리지 않게. 맵은 서버가 준 것으로 통째 교체한다
|
|
949
|
+
// (터미널이 떨어진 run 은 서버 맵에서 빠지므로 여기서 자연히 사라진다).
|
|
950
|
+
agentState={}; var asm=d.agentStates||{};
|
|
951
|
+
Object.keys(asm).forEach(function(k){ var s=asm[k]&&asm[k].state; if(s && s!=='unknown') agentState[k]=s; });
|
|
605
952
|
renderTree();
|
|
606
953
|
syncPanes();
|
|
954
|
+
restoreSession(); // 첫 hydrate 로 runById 가 채워진 뒤 마지막 세션 탭을 되살린다(1회)
|
|
607
955
|
populateReq();
|
|
608
956
|
if (reviewOn) renderReviewPicker();
|
|
609
957
|
}catch(e){ /* 재시도는 WS 재연결 or 다음 hydrate */ }
|
|
@@ -625,7 +973,24 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
625
973
|
else if (!$('reqAgent').options.length){ var o=document.createElement('option'); o.value='claude-code'; o.textContent='claude-code'; $('reqAgent').appendChild(o); }
|
|
626
974
|
}
|
|
627
975
|
|
|
628
|
-
// ──
|
|
976
|
+
// ── 표시 이름 (v6.0 T4) ──
|
|
977
|
+
// ① 역할 이름(run.title) ② 프로젝트 아래 root 세션 = main(내 손 터미널)
|
|
978
|
+
// ③ Sessions 버킷은 지금까지처럼 태스크 이름 ④ 그 외는 프로바이더 이름.
|
|
979
|
+
function runLabel(runId){
|
|
980
|
+
var r=runById[runId]; if(!r) return 'r'+runId;
|
|
981
|
+
if (r.title) return r.title;
|
|
982
|
+
var task=taskById[r.taskId]; var rp=task&&repoById[task.repoId];
|
|
983
|
+
if (rp && rp.kind==='sessions') return (task&&task.title)||('session r'+runId);
|
|
984
|
+
if (r.agent==='session') return 'main';
|
|
985
|
+
return r.agent||('r'+runId);
|
|
986
|
+
}
|
|
987
|
+
// 트리 행은 역할 옆에 모델까지 — 스펙의 "구현 · opus" 모양.
|
|
988
|
+
function runTreeName(runId){
|
|
989
|
+
var r=runById[runId]; var nm=runLabel(runId);
|
|
990
|
+
return (r && r.model) ? (nm+' · '+r.model) : nm;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// ── 트리 렌더: Scratch ▸ (프로젝트=Repo 섹션 ▸ Goal 띠 ▸ 작업=Task ▸ 세션=Run) ──
|
|
629
994
|
function renderTree(){
|
|
630
995
|
var el = $('tree');
|
|
631
996
|
var repos = fleet.repos||[], tasks = fleet.tasks||[], groups = fleet.groups||[], runs = fleet.runs||[];
|
|
@@ -640,16 +1005,22 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
640
1005
|
sessRuns.sort(function(a,b){ return b.run.id-a.run.id; });
|
|
641
1006
|
|
|
642
1007
|
var html = '';
|
|
643
|
-
// ──
|
|
644
|
-
|
|
1008
|
+
// ── SCRATCH (자유 세션 = 아직 프로젝트가 아닌 것들) ──
|
|
1009
|
+
// v6.0 S1: 데이터는 그대로(kind='sessions') — 바뀐 건 이름과 어포던스다. 여기서 바로
|
|
1010
|
+
// 정리(S1b)하고, 여기서 바로 프로젝트로 졸업(S2)시킨다. 어지러워지는 자리에서 치울 수 있어야 한다.
|
|
1011
|
+
html += '<div class="lbl"><span>Scratch</span><span class="lacts">'
|
|
1012
|
+
+ '<span class="lnk" data-scrub="1" title="정리 — 터미널이 없는 세션을 한 번에 지웁니다(폴더는 보존)">정리…</span>'
|
|
1013
|
+
+ '<span class="lnk" data-newsession="1">+ 새 세션</span></span></div>';
|
|
1014
|
+
html += '<div class="tree-note">프로젝트가 되기 전의 생각들 — 자라면 ⇧ 로 프로젝트가 됩니다</div>';
|
|
645
1015
|
if (sessRuns.length){
|
|
646
1016
|
sessRuns.forEach(function(s){
|
|
647
1017
|
var r=s.run; var open = tabs[r.id] ? ' open' : '';
|
|
648
1018
|
var sPath=(r.worktreePath||'');
|
|
649
1019
|
var sTail=sPath.replace(/^.*\\/([^/]+)$/,'$1'); // 마지막 폴더명만(끝 조금)
|
|
650
|
-
html += '<div class="tnode session'+open+'" data-run="'+r.id+'" title="'+esc(sPath)+'"><span class="st '+esc(r.status)+'"></span>'
|
|
1020
|
+
html += '<div class="tnode session'+open+'" data-run="'+r.id+'" title="'+esc(sPath)+'"><span class="st '+esc(r.status)+' '+asClass(agentStateOf(r.id))+'"></span>'
|
|
651
1021
|
+ '<span class="n" title="'+esc(s.title||'session')+'">'+esc(s.title||'session')+'</span>'
|
|
652
1022
|
+ '<span class="p" title="'+esc(sPath)+'">'+esc(sTail)+'</span>'
|
|
1023
|
+
+ '<button class="pr" data-promote="'+r.id+'" title="프로젝트로 — 이 폴더를 등록하거나 기존 프로젝트로 옮깁니다(터미널은 이 폴더 그대로)">⇧</button>'
|
|
653
1024
|
+ '<button class="del" data-delsession="'+r.id+'" title="세션 삭제 — 터미널만 종료, 폴더·파일은 보존">×</button></div>';
|
|
654
1025
|
});
|
|
655
1026
|
} else {
|
|
@@ -658,12 +1029,19 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
658
1029
|
html += '<div class="tree-sep"></div>';
|
|
659
1030
|
html += '<div class="lbl"><span>Projects</span></div>';
|
|
660
1031
|
if (!realRepos.length){ html += '<div class="tnode empty">등록된 repo 가 없습니다 — 보드에서 추가하세요.</div>'; }
|
|
1032
|
+
// 프로젝트(repo) 하나 = 섹션 하나. 그 안에 작업(task) 들이 있고, 작업 아래에 세션(run) 이 달린다.
|
|
661
1033
|
realRepos.forEach(function(repo){
|
|
662
1034
|
var rk = 'repo'+repo.id;
|
|
663
1035
|
var rTasks = (tasksByRepo[repo.id]||[]).filter(function(t){ return t.status!=='closed'; });
|
|
664
1036
|
var runCount = rTasks.reduce(function(n,t){ return n+((runsByTask[t.id]||[]).length); }, 0);
|
|
1037
|
+
// 프로젝트 행의 어포던스 셋: 만들기 하나(+ 새 작업) + 치우기 둘(v6.0 T6).
|
|
1038
|
+
// 치우는 둘은 **디스크의 파일을 건드리지 않는다** — 하나는 작업을 닫고, 하나는 등록만 뺀다.
|
|
665
1039
|
html += '<div class="tnode repo" data-fold="'+rk+'"><span class="car">'+(isFold(rk)?'▸':'▾')+'</span>'
|
|
666
|
-
+ '<span class="n"
|
|
1040
|
+
+ '<span class="n" title="'+esc(repo.path||repo.name)+'">'+esc(repo.name)+'</span>'
|
|
1041
|
+
+ '<button class="tact" data-newwork="'+repo.id+'" title="새 작업 — 작업을 만들고 repo 체크아웃에서 main 터미널을 엽니다">+ 새 작업</button>'
|
|
1042
|
+
+ '<button class="tact" data-tidy="'+repo.id+'" title="묵은 작업 정리 — 더 안 도는(정착한) 작업들을 한 번에 닫습니다. 미머지 산출물은 표로 한 번에 보여줍니다">정리…</button>'
|
|
1043
|
+
+ '<button class="tact" data-unreg="'+repo.id+'" title="등록 해제 — 목록에서만 뺍니다. 디스크의 폴더는 그대로이고, 다시 등록하는 데 클릭 한 번입니다">등록 해제</button>'
|
|
1044
|
+
+ '<span class="meta">'+runCount+' run'+(runCount===1?'':'s')+'</span></div>';
|
|
667
1045
|
if (isFold(rk)) return;
|
|
668
1046
|
// goal(group) 로 묶기
|
|
669
1047
|
var byGroup = {}, ungrouped = [];
|
|
@@ -679,20 +1057,42 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
679
1057
|
});
|
|
680
1058
|
el.innerHTML = html;
|
|
681
1059
|
}
|
|
1060
|
+
// 작업(task) 한 줄 + 그 아래 세션(run) 들. 이름은 작업이 갖고, run 은 역할로 읽힌다.
|
|
682
1061
|
function taskHTML(t, rns){
|
|
683
1062
|
var tk='task'+t.id;
|
|
684
1063
|
var s = '<div class="tnode task" data-fold="'+tk+'"><span class="car">'+(rns.length?(isFold(tk)?'▸':'▾'):' ')+'</span>'
|
|
685
|
-
+ '<span class="n">'+esc(t.title)+'</span
|
|
1064
|
+
+ '<span class="n" title="'+esc(t.title)+'">'+esc(t.title)+'</span>'
|
|
1065
|
+
+ '<button class="tact" data-workmd="'+t.id+'" title="WORK.md — 이 작업의 공유 컨텍스트(목표·결정·제약). 저장한 내용은 다음 발사·steer 부터 에이전트에게 전달됩니다(돌고 있는 턴에는 반영되지 않습니다).">▤ WORK.md</button>'
|
|
1066
|
+
+ '<button class="tact" data-newagent="'+t.id+'" title="에이전트 추가 — 이 작업 아래에 역할 세션 하나(worktree 격리)">+ 에이전트</button></div>';
|
|
686
1067
|
if (!isFold(tk)) rns.sort(function(a,b){return a.id-b.id;}).forEach(function(r){
|
|
687
1068
|
var open = tabs[r.id] ? ' open' : '';
|
|
688
1069
|
var act=latestActivity(r.id);
|
|
689
|
-
|
|
690
|
-
|
|
1070
|
+
// in-place 는 브랜치 칩이 없다 — 격리가 없으니 보여줄 브랜치도 없다. 대신 어디서 일하는지를 적는다.
|
|
1071
|
+
var ip = r.inPlace ? ' · in-place' : '';
|
|
1072
|
+
var tip = 'r'+r.id+' · '+r.status+(r.inPlace?' · repo 체크아웃에서 직접 작업(격리 없음)':'');
|
|
1073
|
+
// 점 하나에 두 층 — 에이전트 상태가 살아 있으면 그게 이기고, 없으면 지금까지의 run 상태 그대로.
|
|
1074
|
+
s += '<div class="tnode run'+open+'" data-run="'+r.id+'" title="'+esc(tip)+'"><span class="st '+esc(r.status)+' '+asClass(agentStateOf(r.id))+'"></span>'
|
|
1075
|
+
+ '<span class="n">'+esc(runTreeName(r.id))+'</span>'+(act?'<span class="ract">'+esc(act)+'</span>':'')
|
|
1076
|
+
+ '<span class="meta">r'+r.id+esc(ip)+'</span></div>';
|
|
691
1077
|
});
|
|
692
1078
|
return s;
|
|
693
1079
|
}
|
|
694
1080
|
$('tree').addEventListener('click', function(e){
|
|
695
1081
|
if (e.target.closest('[data-newsession]')){ openSession(); return; }
|
|
1082
|
+
if (e.target.closest('[data-scrub]')){ openScrub(); return; }
|
|
1083
|
+
var pm = e.target.closest('[data-promote]');
|
|
1084
|
+
if (pm){ e.stopPropagation(); openPromote(+pm.dataset.promote); return; }
|
|
1085
|
+
// 노드 액션은 접기(data-fold)·열기(data-run) 보다 먼저 가로챈다 — 같은 행 안에 있으므로.
|
|
1086
|
+
var nw = e.target.closest('[data-newwork]');
|
|
1087
|
+
if (nw){ e.stopPropagation(); openNewWork(+nw.dataset.newwork); return; }
|
|
1088
|
+
var td = e.target.closest('[data-tidy]');
|
|
1089
|
+
if (td){ e.stopPropagation(); openTidy(+td.dataset.tidy); return; }
|
|
1090
|
+
var ur = e.target.closest('[data-unreg]');
|
|
1091
|
+
if (ur){ e.stopPropagation(); openUnreg(+ur.dataset.unreg); return; }
|
|
1092
|
+
var na = e.target.closest('[data-newagent]');
|
|
1093
|
+
if (na){ e.stopPropagation(); openAddAgent(+na.dataset.newagent); return; }
|
|
1094
|
+
var wm = e.target.closest('[data-workmd]');
|
|
1095
|
+
if (wm){ e.stopPropagation(); openWorkDoc(+wm.dataset.workmd); return; }
|
|
696
1096
|
var del = e.target.closest('[data-delsession]');
|
|
697
1097
|
if (del){ e.stopPropagation(); deleteSession(+del.dataset.delsession); return; }
|
|
698
1098
|
var run = e.target.closest('[data-run]');
|
|
@@ -736,12 +1136,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
736
1136
|
function focusRun(){ return focusedRunId(); } // 기존 호출부(steer/review) 호환
|
|
737
1137
|
function copyInto(dst,src){ Object.keys(dst).forEach(function(k){delete dst[k];}); Object.keys(src).forEach(function(k){dst[k]=src[k];}); }
|
|
738
1138
|
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
var task=taskById[r.taskId]; var rp=task&&repoById[task.repoId];
|
|
742
|
-
if (rp && rp.kind==='sessions') return (task&&task.title)||('session r'+runId);
|
|
743
|
-
return 'r'+runId;
|
|
744
|
-
}
|
|
1139
|
+
// 탭 라벨 = 역할 하나(작업 이름은 트리가 이미 보여준다).
|
|
1140
|
+
function tabName(runId){ return runLabel(runId); }
|
|
745
1141
|
|
|
746
1142
|
function ensureTab(runId){
|
|
747
1143
|
if (tabs[runId]) return tabs[runId];
|
|
@@ -817,10 +1213,13 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
817
1213
|
}
|
|
818
1214
|
|
|
819
1215
|
// ── 파일 뷰어 탭(비터미널) — md/html/pdf/이미지/텍스트 보기 + .env 등 편집 ──
|
|
820
|
-
|
|
1216
|
+
// opts.workTaskId — WORK.md 전용(뷰어 루트 밖일 때). 읽기·쓰기만 그 작업의 창구로 바뀌고
|
|
1217
|
+
// 렌더러·편집 UI 는 완전히 같은 것을 쓴다. opts.edit — 열자마자 편집 모드로.
|
|
1218
|
+
function ensureViewer(path, name, opts){
|
|
821
1219
|
var id='v'+(viewerSeq++);
|
|
822
1220
|
var host=document.createElement('div'); host.className='view-host';
|
|
823
|
-
var t={ runId:id, name:name||(path.split('/').pop()||path), kind:'viewer', term:null, ws:null, host:host, path:path, closing:false, opened:false
|
|
1221
|
+
var t={ runId:id, name:name||(path.split('/').pop()||path), kind:'viewer', term:null, ws:null, host:host, path:path, closing:false, opened:false,
|
|
1222
|
+
workTaskId:(opts&&opts.workTaskId)||null, _openEdit:!!(opts&&opts.edit) };
|
|
824
1223
|
tabs[id]=t; tabOrder.push(id);
|
|
825
1224
|
renderViewer(t);
|
|
826
1225
|
return t;
|
|
@@ -852,18 +1251,24 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
852
1251
|
var bar=host.querySelector('.view-bar'), body=host.querySelector('.view-body');
|
|
853
1252
|
bar.querySelector('.vp').textContent=t.path;
|
|
854
1253
|
var rawUrl='/api/fs/raw?path='+encodeURIComponent(t.path);
|
|
855
|
-
|
|
1254
|
+
var readUrl=t.workTaskId ? ('/api/tasks/'+t.workTaskId+'/work') : ('/api/fs/read?path='+encodeURIComponent(t.path));
|
|
1255
|
+
fetch(readUrl).then(function(r){ return r.json(); }).then(function(d){
|
|
856
1256
|
if(d.error){ body.innerHTML='<div class="view-msg">열 수 없습니다: '+esc(d.error)+'</div>'; return; }
|
|
857
|
-
// 공통 액션: 원본 열기(새 탭/시스템)
|
|
858
|
-
var actions='<button data-act="raw" title="원본을 새 탭/시스템 뷰어로">↗ 원본</button>';
|
|
1257
|
+
// 공통 액션: 원본 열기(새 탭/시스템). 뷰어 루트 밖 문서(WORK.md 대체 경로)는 원본 링크가 닿지 않는다.
|
|
1258
|
+
var actions=t.workTaskId ? '' : '<button data-act="raw" title="원본을 새 탭/시스템 뷰어로">↗ 원본</button>';
|
|
1259
|
+
// md 도 편집한다 — WORK.md 가 사는 곳이다(v6.0 W2). 미리보기 ↔ 편집은 같은 페인 안에서 오간다.
|
|
1260
|
+
if(d.kind==='md' && d.editable) actions='<button data-act="edit" class="prim">편집</button>'+actions;
|
|
859
1261
|
if(d.kind==='md' || d.kind==='html' || d.kind==='pdf' || d.kind==='image'){
|
|
860
1262
|
bar.innerHTML='<span class="vp"></span>'+actions; bar.querySelector('.vp').textContent=t.path;
|
|
861
1263
|
}
|
|
862
1264
|
if(d.kind==='md'){
|
|
1265
|
+
t._text=d.text||''; t._editable=!!d.editable;
|
|
863
1266
|
var html='<!doctype html><html><head><meta charset="utf-8"><base target="_blank"><style>'+MD_FRAME_CSS+'</style></head><body>'
|
|
864
1267
|
+ ((window.marked&&window.marked.parse)?window.marked.parse(d.text||''):esc(d.text||'')) + '</body></html>';
|
|
865
1268
|
var f=document.createElement('iframe'); f.setAttribute('sandbox','allow-popups allow-popups-to-escape-sandbox'); f.srcdoc=html;
|
|
866
1269
|
body.innerHTML=''; body.appendChild(f);
|
|
1270
|
+
// 처음 열 때만 바로 편집으로(WORK.md) — 저장·취소 뒤에는 평소대로 미리보기로 돌아온다.
|
|
1271
|
+
if(t._openEdit && d.editable){ t._openEdit=false; startEdit(t); }
|
|
867
1272
|
} else if(d.kind==='html'){
|
|
868
1273
|
// 저장된/임의 HTML 은 신뢰 불가 → same-origin 금지 sandbox(불투명 출처라 쿠키·API 접근 차단)
|
|
869
1274
|
var fh=document.createElement('iframe'); fh.setAttribute('sandbox','allow-scripts allow-popups allow-popups-to-escape-sandbox'); fh.src=rawUrl;
|
|
@@ -883,6 +1288,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
883
1288
|
bar.innerHTML='<span class="vp"></span>'+actions; bar.querySelector('.vp').textContent=t.path;
|
|
884
1289
|
var pre=document.createElement('pre'); pre.className='vtext'; pre.textContent=d.text||''; body.innerHTML=''; body.appendChild(pre);
|
|
885
1290
|
t._text=d.text||''; t._editable=!!d.editable;
|
|
1291
|
+
if(t._openEdit && d.editable){ t._openEdit=false; startEdit(t); }
|
|
886
1292
|
}
|
|
887
1293
|
}).catch(function(){ body.innerHTML='<div class="view-msg">불러오기 실패</div>'; });
|
|
888
1294
|
|
|
@@ -907,7 +1313,9 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
907
1313
|
}
|
|
908
1314
|
function saveEdit(t){
|
|
909
1315
|
var ta=t._ta; if(!ta) return; var content=ta.value;
|
|
910
|
-
|
|
1316
|
+
var wUrl=t.workTaskId ? ('/api/tasks/'+t.workTaskId+'/work') : '/api/fs/write';
|
|
1317
|
+
var wBody=t.workTaskId ? JSON.stringify({content:content}) : JSON.stringify({path:t.path,content:content});
|
|
1318
|
+
fetch(wUrl,{method:(t.workTaskId?'PUT':'POST'),headers:{'content-type':'application/json'},body:wBody})
|
|
911
1319
|
.then(function(r){ return r.json(); }).then(function(d){
|
|
912
1320
|
if(d.error){ toast('저장 실패: '+d.error); return; }
|
|
913
1321
|
t._text=content; toast('저장됨 · '+t.name+' ('+fmtSize(d.size||0)+')'); renderViewer(t);
|
|
@@ -953,12 +1361,15 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
953
1361
|
return;
|
|
954
1362
|
}
|
|
955
1363
|
var r=runById[runId];
|
|
1364
|
+
// 이름 뒤의 작은 점이 에이전트 상태 — 앞의 점(run 상태)과 축이 다르다. 상태가 없으면 뜨지 않는다.
|
|
956
1365
|
html += '<div class="tab'+(shown[runId]?' shown':'')+'" draggable="true" data-tab="'+runId+'" title="더블클릭=이름변경 · 드래그=페인에 배치">'
|
|
957
1366
|
+ '<span class="st '+esc(r?r.status:'')+'"></span>'
|
|
958
1367
|
+ '<span class="nm">'+esc(t.name)+'</span>'
|
|
1368
|
+
+ '<span data-role="asdot" class="as-dot '+asClass(agentStateOf(runId))+'"></span>'
|
|
959
1369
|
+ '<button class="x" title="탭 닫기(터미널 종료)">×</button></div>';
|
|
960
1370
|
});
|
|
961
1371
|
$('tabs').innerHTML = html;
|
|
1372
|
+
updateWaitChip();
|
|
962
1373
|
}
|
|
963
1374
|
function buildNode(node){
|
|
964
1375
|
if (node.leaf){
|
|
@@ -1017,6 +1428,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1017
1428
|
if (typeof reqMode!=='undefined') setMode(reqMode);
|
|
1018
1429
|
requestAnimationFrame(fitAllVisible);
|
|
1019
1430
|
setTimeout(fitAllVisible, 60); // 레이아웃 확정 후 재핏(초기 0-size 보정)
|
|
1431
|
+
persistSession(); // 탭·페인 배치가 바뀔 때마다 마지막 세션 스냅샷 저장(복원 후에만 동작)
|
|
1020
1432
|
}
|
|
1021
1433
|
function setLeafFocus(id){
|
|
1022
1434
|
focusLeaf=id;
|
|
@@ -1057,6 +1469,32 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1057
1469
|
layout=rebuildNode(s.tree); zoomLeaf=null; var f=firstLeaf(); focusLeaf=f?f.id:'L0'; render(); renderTree(); toast('레이아웃 복원 · '+nm); }
|
|
1058
1470
|
function deleteLayout(nm){ var o=loadLayouts(); delete o[nm]; saveLayouts(o); toast('레이아웃 삭제 · '+nm); }
|
|
1059
1471
|
|
|
1472
|
+
// ── 마지막 세션 자동 기억/복원 (localStorage 'coxpit.session', 기기·데몬 origin 별) ──
|
|
1473
|
+
// 탭·페인 배치가 바뀔 때마다 스냅샷을 저장하고, 코크핏을 다시 열면 마지막 모습 그대로 되살린다.
|
|
1474
|
+
// rebuildNode 가 이미 사라진 run 은 걸러내므로 죽은 세션은 자동 제외된다. 별도 조작 불필요.
|
|
1475
|
+
var SESSION_KEY='coxpit.session';
|
|
1476
|
+
var sessionRestoreDone=false;
|
|
1477
|
+
function persistSession(){
|
|
1478
|
+
if(!sessionRestoreDone) return; // 첫 복원 전(초기 빈 render)에 저장하면 스냅샷을 덮어써 버린다
|
|
1479
|
+
try{ localStorage.setItem(SESSION_KEY, JSON.stringify(serializeNode(layout))); }catch(e){}
|
|
1480
|
+
}
|
|
1481
|
+
function restoreSession(){
|
|
1482
|
+
if(sessionRestoreDone) return; // 1회만(이후 hydrate 는 통과)
|
|
1483
|
+
sessionRestoreDone=true;
|
|
1484
|
+
// 이미 탭이 열려 있으면(딥링크 등) 손대지 않는다. 그 외에는 저장분을 되살린다.
|
|
1485
|
+
if(!tabOrder.length){
|
|
1486
|
+
var raw; try{ raw=localStorage.getItem(SESSION_KEY); }catch(e){ raw=null; }
|
|
1487
|
+
var sn=null; if(raw){ try{ sn=JSON.parse(raw); }catch(e){ sn=null; } }
|
|
1488
|
+
if(sn){ try{
|
|
1489
|
+
var rebuilt=rebuildNode(sn); // 살아있는 run·뷰어 탭을 되살린다(죽은 것은 tab=null 로 떨궈짐)
|
|
1490
|
+
if(rebuilt){ layout=rebuilt; zoomLeaf=null; var f=firstLeaf(); focusLeaf=f?f.id:'L0'; }
|
|
1491
|
+
if(!tabOrder.length){ layout={leaf:true,id:'L0',tab:null}; focusLeaf='L0'; } // 되살릴 게 없으면 깔끔한 빈 상태
|
|
1492
|
+
render(); renderTree();
|
|
1493
|
+
}catch(e){} }
|
|
1494
|
+
}
|
|
1495
|
+
persistSession(); // 복원할 게 없거나 이미 탭이 있어도, 지금부터 현재 상태를 마지막-세션으로 기록한다
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1060
1498
|
// 탭 열기 = 포커스 슬롯에 표시(강제 분할 없음). 기존 호출부(openRunPane) 호환.
|
|
1061
1499
|
function openTab(runId){
|
|
1062
1500
|
zoomLeaf=null; // 새 탭은 보여야 하므로 줌 해제
|
|
@@ -1100,6 +1538,9 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1100
1538
|
try{ if(t.ro) t.ro.disconnect(); }catch(e){}
|
|
1101
1539
|
try{ if(t.term) t.term.dispose(); }catch(e){}
|
|
1102
1540
|
eachLeaf(layout,function(l){ if(l.tab===runId) l.tab=null; });
|
|
1541
|
+
// 이 클라이언트의 터미널이 떨어졌으니 로컬 상태도 버린다(대기 칩에 갈 수 없는 곳이 남지 않게).
|
|
1542
|
+
// 다른 클라이언트가 아직 붙어 있어 서버 맵엔 남아 있을 수 있는데, 그 어긋남은 다음 hydrate 가 통째 교체하며 스스로 맞춘다.
|
|
1543
|
+
delete agentState[runId];
|
|
1103
1544
|
delete tabs[runId]; tabOrder=tabOrder.filter(function(x){return x!==runId;});
|
|
1104
1545
|
render(); renderTree();
|
|
1105
1546
|
}
|
|
@@ -1146,12 +1587,21 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1146
1587
|
var val=input.value.trim();
|
|
1147
1588
|
var span=document.createElement('span'); span.className='nm'; span.textContent=(commit&&val)?val:t.name;
|
|
1148
1589
|
input.replaceWith(span);
|
|
1149
|
-
|
|
1590
|
+
// 세션 버킷 탭은 지금까지처럼 태스크(=세션) 이름을, 그 외 run 은 역할 이름을 바꾼다.
|
|
1591
|
+
if(commit && val && val!==t.name){ if(runIsSession(runId)) renameTask(r.taskId, val); else renameRun(runId, val); }
|
|
1150
1592
|
}
|
|
1151
1593
|
input.addEventListener('keydown', function(e){ e.stopPropagation(); if(e.key==='Enter'){ e.preventDefault(); finish(true); } else if(e.key==='Escape'){ finish(false); } });
|
|
1152
1594
|
input.addEventListener('blur', function(){ finish(true); });
|
|
1153
1595
|
input.addEventListener('click', function(e){ e.stopPropagation(); });
|
|
1154
1596
|
}
|
|
1597
|
+
// run 역할 이름(v6.0 T4) — PATCH /api/runs/:id { title }
|
|
1598
|
+
async function renameRun(runId, title){
|
|
1599
|
+
try{
|
|
1600
|
+
var res=await fetch('/api/runs/'+runId,{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({title:title})});
|
|
1601
|
+
if(res.ok){ if(runById[runId]) runById[runId].title=title; toast('이름 변경 · '+title); await hydrate(); }
|
|
1602
|
+
else { var j=await res.json().catch(function(){return{};}); toast('이름 변경 실패: '+(j.error||res.status)); }
|
|
1603
|
+
}catch(e){ toast('이름 변경 실패: '+e); }
|
|
1604
|
+
}
|
|
1155
1605
|
async function renameTask(taskId, title){
|
|
1156
1606
|
try{
|
|
1157
1607
|
var res=await fetch('/api/tasks/'+taskId,{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({title:title})});
|
|
@@ -1266,6 +1716,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1266
1716
|
});
|
|
1267
1717
|
|
|
1268
1718
|
// ── ⌘K 커맨드 팔레트 — 이동(세션·run·프로젝트) + 명령 ──
|
|
1719
|
+
// 보드로 나가는 유일한 길. 헤더의 ← Board 와 같은 이동이고, 뷰만 딥링크로 지정한다.
|
|
1720
|
+
function gotoBoard(view){ location.href = view ? ('/?view='+encodeURIComponent(view)) : '/'; }
|
|
1269
1721
|
var palItems=[], palSel=0;
|
|
1270
1722
|
function paletteItems(){
|
|
1271
1723
|
var items=[
|
|
@@ -1281,7 +1733,11 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1281
1733
|
{k:'cmd', label:'뷰어 / 히스토리', run:openHistory},
|
|
1282
1734
|
{k:'cmd', label:'Review 열기 (비교·머지)', run:showReview},
|
|
1283
1735
|
{k:'cmd', label:'시크릿 (env 주입)', run:openSecrets},
|
|
1284
|
-
|
|
1736
|
+
// 보드는 읽는 방이다 — ⌘K 한 번이면 닿지만, 현관은 아니다(v6.0 Part B).
|
|
1737
|
+
// 뷰 전환은 보드가 이미 가진 setView 를 /?view= 딥링크로 깨울 뿐, 새 길을 내지 않는다.
|
|
1738
|
+
{k:'board', label:'Board — 보드 (리뷰·기록실)', hint:'/', run:function(){ gotoBoard(''); }},
|
|
1739
|
+
{k:'board', label:'Archive — 닫힌 작업 보관함', hint:'/?view=archive', run:function(){ gotoBoard('archive'); }},
|
|
1740
|
+
{k:'board', label:'Workrooms — 골 워크룸', hint:'/?view=goals', run:function(){ gotoBoard('goals'); }}
|
|
1285
1741
|
];
|
|
1286
1742
|
// 세션 → 페인으로 열기
|
|
1287
1743
|
var runs=[]; Object.keys(runById).forEach(function(id){ runs.push(runById[id]); });
|
|
@@ -1406,15 +1862,390 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1406
1862
|
// ── 자유 세션 — 폴더를 지정해 tmux 셸(프로젝트 비소속). ──
|
|
1407
1863
|
var pickPathCur = '';
|
|
1408
1864
|
function machineSlug(){ return (fleet.machines && fleet.machines[0] && fleet.machines[0].slug) || 'local'; }
|
|
1865
|
+
// 세션 삭제의 유일한 길 — 한 건이든 정리 판의 여러 건이든 이 요청 하나를 지난다.
|
|
1866
|
+
// 서버는 sessions 버킷 run 만 받아주고, 폴더는 **어느 경우에도** 건드리지 않는다.
|
|
1867
|
+
async function delSessionReq(runId){
|
|
1868
|
+
try{
|
|
1869
|
+
var res=await fetch('/api/runs/'+runId,{method:'DELETE'});
|
|
1870
|
+
var j=await res.json().catch(function(){return{};});
|
|
1871
|
+
if(res.ok && tabs[runId]) closeTab(runId);
|
|
1872
|
+
return { ok:res.ok, detail:(j.error||j.detail||('HTTP '+res.status)) };
|
|
1873
|
+
}catch(e){ return { ok:false, detail:String(e) }; }
|
|
1874
|
+
}
|
|
1409
1875
|
async function deleteSession(runId){
|
|
1410
1876
|
if(!confirm('이 세션을 삭제할까요?\\n터미널만 종료됩니다 · 폴더와 파일은 그대로 보존됩니다.')) return;
|
|
1877
|
+
var r=await delSessionReq(runId);
|
|
1878
|
+
if(r.ok){ toast('세션 삭제됨 (폴더 보존)'); await hydrate(); }
|
|
1879
|
+
else toast('삭제 실패: '+r.detail);
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
// ── v6.0 S1b — Scratch 정리 ──
|
|
1883
|
+
// 신호는 **이미 들고 있는 /api/fleet** 것만 쓴다(추가 엔드포인트 없음):
|
|
1884
|
+
// tmuxWindow+status = 터미널이 살아 있나 · startedAt/endedAt = 마지막으로 움직인 게 언제였나.
|
|
1885
|
+
// 규칙 둘: ① 터미널 없는 것만 미리 체크 ② 14일 넘게 조용한 것은 접어만 두고 **절대 미리 체크하지 않는다**
|
|
1886
|
+
// (보이지 않는 것을 지우는 판은 청소 도구가 아니라 함정이다).
|
|
1887
|
+
var SCRUB_STALE_DAYS = 14;
|
|
1888
|
+
var scrubSel = {}, scrubOldOpen = false, scrubbing = false;
|
|
1889
|
+
function scratchRows(){
|
|
1890
|
+
var out=[];
|
|
1891
|
+
(fleet.runs||[]).forEach(function(r){
|
|
1892
|
+
var t=taskById[r.taskId]; var rp=t&&repoById[t.repoId];
|
|
1893
|
+
if(!rp || rp.kind!=='sessions') return;
|
|
1894
|
+
var last=Math.max(r.endedAt?new Date(r.endedAt).getTime():0, r.startedAt?new Date(r.startedAt).getTime():0);
|
|
1895
|
+
var days=last?Math.floor((Date.now()-last)/86400000):0;
|
|
1896
|
+
var live=!!r.tmuxWindow && (r.status==='open'||r.status==='running');
|
|
1897
|
+
out.push({ id:r.id, title:(t&&t.title)||('session r'+r.id), path:r.worktreePath||'', live:live, days:days, last:last });
|
|
1898
|
+
});
|
|
1899
|
+
out.sort(function(a,b){ return b.id-a.id; });
|
|
1900
|
+
return out;
|
|
1901
|
+
}
|
|
1902
|
+
function scrubAge(x){ return !x.last ? '시각 모름' : (x.days===0 ? '오늘' : (x.days+'일 전')); }
|
|
1903
|
+
function scrubRowHTML(x){
|
|
1904
|
+
return '<label class="scrub-row" title="'+esc(x.path)+'"><input type="checkbox" data-scrubchk="'+x.id+'"'+(scrubSel[x.id]?' checked':'')+' />'
|
|
1905
|
+
+ '<span class="snm">'+esc(x.title)+'</span>'
|
|
1906
|
+
+ '<span class="slive'+(x.live?'':' dead')+'">'+(x.live?'터미널 살아 있음':'터미널 없음')+'</span>'
|
|
1907
|
+
+ '<span class="sage">r'+x.id+' · '+scrubAge(x)+'</span></label>';
|
|
1908
|
+
}
|
|
1909
|
+
function renderScrub(){
|
|
1910
|
+
var rows=scratchRows(), fresh=[], old=[];
|
|
1911
|
+
rows.forEach(function(x){ ((!x.live && x.days>=SCRUB_STALE_DAYS) ? old : fresh).push(x); });
|
|
1912
|
+
var html = fresh.map(scrubRowHTML).join('');
|
|
1913
|
+
if (old.length){
|
|
1914
|
+
html += '<div class="scrub-fold" data-scrubold="1">'+(scrubOldOpen?'▾':'▸')+' 오래된 세션 '+old.length
|
|
1915
|
+
+ '<span class="sage">'+SCRUB_STALE_DAYS+'일 넘게 조용 · 접힌 것은 지워지지 않습니다</span></div>';
|
|
1916
|
+
if (scrubOldOpen) html += old.map(scrubRowHTML).join('');
|
|
1917
|
+
}
|
|
1918
|
+
if (!rows.length) html = '<div class="pick-row" style="cursor:default;color:var(--faint)">정리할 세션이 없습니다</div>';
|
|
1919
|
+
$('scrubList').innerHTML = html;
|
|
1920
|
+
var n=scrubCount();
|
|
1921
|
+
$('scrubHint').textContent = rows.length
|
|
1922
|
+
? (n+'개 선택 · 세션 '+rows.length+'개 중'+(old.length?(' · 오래된 '+old.length+'개는 접힘'):''))
|
|
1923
|
+
: '';
|
|
1924
|
+
$('scrubGo').disabled = n===0;
|
|
1925
|
+
}
|
|
1926
|
+
function scrubCount(){ var n=0; Object.keys(scrubSel).forEach(function(k){ if(scrubSel[k]) n++; }); return n; }
|
|
1927
|
+
function openScrub(){
|
|
1928
|
+
scrubSel={}; scrubOldOpen=false;
|
|
1929
|
+
scratchRows().forEach(function(x){ if(!x.live && x.days<SCRUB_STALE_DAYS) scrubSel[x.id]=true; });
|
|
1930
|
+
renderScrub(); $('scrubModal').classList.add('on');
|
|
1931
|
+
}
|
|
1932
|
+
function closeScrub(){ $('scrubModal').classList.remove('on'); }
|
|
1933
|
+
async function runScrub(){
|
|
1934
|
+
if (scrubbing) return;
|
|
1935
|
+
var ids=Object.keys(scrubSel).filter(function(k){ return scrubSel[k]; }).map(Number);
|
|
1936
|
+
if (!ids.length){ toast('선택한 세션이 없습니다'); return; }
|
|
1937
|
+
scrubbing=true; $('scrubGo').disabled=true;
|
|
1938
|
+
var okN=0, bad='';
|
|
1939
|
+
for (var i=0;i<ids.length;i++){
|
|
1940
|
+
var r=await delSessionReq(ids[i]);
|
|
1941
|
+
if (r.ok) okN++; else if(!bad) bad=r.detail;
|
|
1942
|
+
}
|
|
1943
|
+
scrubbing=false; scrubSel={};
|
|
1944
|
+
await hydrate();
|
|
1945
|
+
toast('세션 '+okN+'개 삭제됨 (폴더 보존)'+(okN<ids.length?(' · 실패 '+(ids.length-okN)+(bad?(': '+bad):'')):''));
|
|
1946
|
+
if (scratchRows().length) renderScrub(); else closeScrub();
|
|
1947
|
+
}
|
|
1948
|
+
$('scrubClose').addEventListener('click', closeScrub);
|
|
1949
|
+
$('scrubModal').addEventListener('click', function(e){ if(e.target===this) closeScrub(); });
|
|
1950
|
+
$('scrubGo').addEventListener('click', runScrub);
|
|
1951
|
+
$('scrubList').addEventListener('change', function(e){
|
|
1952
|
+
var c=e.target.closest('[data-scrubchk]'); if(!c) return;
|
|
1953
|
+
scrubSel[+c.dataset.scrubchk]=c.checked; renderScrub();
|
|
1954
|
+
});
|
|
1955
|
+
$('scrubList').addEventListener('click', function(e){
|
|
1956
|
+
if (e.target.closest('[data-scrubold]')){ scrubOldOpen=!scrubOldOpen; renderScrub(); }
|
|
1957
|
+
});
|
|
1958
|
+
|
|
1959
|
+
// ── v6.0 S2 — 승격: 생각이 프로젝트가 된다(터미널은 잃지 않고) ──
|
|
1960
|
+
// 길 둘. ① 이 폴더가 자랐다 → 기존 등록 흐름(POST /api/repos)으로 repo 로 만들고 작업을 그 아래로.
|
|
1961
|
+
// ② 이 작업이 저 프로젝트 것이다 → 이미 있는 repo 를 골라 PATCH /api/tasks/:id { repoId }.
|
|
1962
|
+
// 어느 쪽이든 run 은 손대지 않는다 — worktreePath 가 그대로라 터미널은 같은 폴더에서 계속 돈다.
|
|
1963
|
+
var promoTaskId=null, promoPath='', promoMode='register', promoRepoId=null, promoting=false;
|
|
1964
|
+
function promoErr(msg){ var el=$('promoErr'); if(!msg){ el.hidden=true; el.textContent=''; return; } el.textContent=msg; el.hidden=false; }
|
|
1965
|
+
function realRepoList(){ return (fleet.repos||[]).filter(function(x){ return x.kind!=='sessions'; }); }
|
|
1966
|
+
function renderPromoRepos(){
|
|
1967
|
+
var repos=realRepoList();
|
|
1968
|
+
if (!repos.length){ $('promoRepos').innerHTML='<div class="pick-row" style="cursor:default;color:var(--faint)">등록된 프로젝트가 없습니다 — 먼저 등록으로 만드세요</div>'; return; }
|
|
1969
|
+
$('promoRepos').innerHTML = repos.map(function(x){
|
|
1970
|
+
return '<div class="pick-row'+(promoRepoId===x.id?' on':'')+'" data-promorepo="'+x.id+'">'
|
|
1971
|
+
+ '<span class="ic">'+(promoRepoId===x.id?'◉':'◯')+'</span><span>'+esc(x.name)+'</span>'
|
|
1972
|
+
+ '<span class="rel" title="'+esc(x.path)+'">'+esc(x.path)+'</span></div>';
|
|
1973
|
+
}).join('');
|
|
1974
|
+
}
|
|
1975
|
+
// 폴더가 그 프로젝트 밖이면 숨기지 않고 말한다 — 바뀌는 건 소속뿐이고 터미널은 이 폴더에 남는다.
|
|
1976
|
+
function paintPromoWarn(){
|
|
1977
|
+
var w=$('promoWarn');
|
|
1978
|
+
var rp=(promoMode==='move' && promoRepoId!=null) ? repoById[promoRepoId] : null;
|
|
1979
|
+
var base=rp ? String(rp.path||'').replace(/\\/+$/,'') : '';
|
|
1980
|
+
var inside = !!base && !!promoPath && (promoPath===base || promoPath.indexOf(base+'/')===0);
|
|
1981
|
+
if (!rp || inside){ w.hidden=true; w.textContent=''; return; }
|
|
1982
|
+
w.textContent='이 세션 폴더는 '+(rp.name||'그 프로젝트')+' 밖입니다 — 바뀌는 건 소속(정리)뿐이고, 터미널은 이 폴더에서 그대로 돕니다.';
|
|
1983
|
+
w.hidden=false;
|
|
1984
|
+
}
|
|
1985
|
+
function setPromoMode(m){
|
|
1986
|
+
promoMode = (m==='move') ? 'move' : 'register';
|
|
1987
|
+
Array.prototype.forEach.call($('promoMode').querySelectorAll('.mode'), function(x){
|
|
1988
|
+
x.classList.toggle('on', x.getAttribute('data-promo')===promoMode);
|
|
1989
|
+
});
|
|
1990
|
+
var reg = promoMode==='register';
|
|
1991
|
+
$('promoWhat').textContent = reg
|
|
1992
|
+
? '이 폴더를 repo 로 등록하고 이 작업을 그 아래로 옮깁니다 (git 저장소여야 합니다)'
|
|
1993
|
+
: '이미 등록된 프로젝트를 골라 이 작업을 그 아래로 옮깁니다';
|
|
1994
|
+
$('promoRepos').hidden = reg;
|
|
1995
|
+
$('promoGo').textContent = reg ? '등록하고 옮기기' : '여기로 옮기기';
|
|
1996
|
+
if (!reg) renderPromoRepos();
|
|
1997
|
+
promoErr(''); paintPromoWarn();
|
|
1998
|
+
}
|
|
1999
|
+
function openPromote(runId){
|
|
2000
|
+
var r=runById[runId]; if(!r){ toast('세션을 찾을 수 없습니다'); return; }
|
|
2001
|
+
promoTaskId=r.taskId; promoPath=r.worktreePath||''; promoRepoId=null;
|
|
2002
|
+
$('promoPath').textContent = promoPath || '(폴더 없음)';
|
|
2003
|
+
setPromoMode('register');
|
|
2004
|
+
$('promoModal').classList.add('on');
|
|
2005
|
+
}
|
|
2006
|
+
function closePromote(){ $('promoModal').classList.remove('on'); }
|
|
2007
|
+
async function doPromote(){
|
|
2008
|
+
if (promoting || promoTaskId==null) return;
|
|
2009
|
+
promoting=true; $('promoGo').disabled=true; promoErr('');
|
|
1411
2010
|
try{
|
|
1412
|
-
var
|
|
2011
|
+
var repoId=promoRepoId;
|
|
2012
|
+
if (promoMode==='register'){
|
|
2013
|
+
if (!promoPath){ promoErr('이 세션의 폴더를 알 수 없습니다'); return; }
|
|
2014
|
+
// 이미 등록된 폴더면 다시 등록하지 않는다 — 중복 등록은 트리를 어지럽힐 뿐이다.
|
|
2015
|
+
var dup=realRepoList().filter(function(x){ return x.path===promoPath; })[0];
|
|
2016
|
+
if (dup) repoId=dup.id;
|
|
2017
|
+
else {
|
|
2018
|
+
var rr=await fetch('/api/repos',{method:'POST',headers:{'content-type':'application/json'},
|
|
2019
|
+
body:JSON.stringify({machineSlug:machineSlug(), path:promoPath})});
|
|
2020
|
+
var rj=await rr.json().catch(function(){return{};});
|
|
2021
|
+
if (!rr.ok || !rj.repo){ promoErr('등록 실패: '+(rj.detail||rj.hint||rj.error||rr.status)); return; }
|
|
2022
|
+
repoId=rj.repo.id;
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
if (repoId==null){ promoErr('옮길 프로젝트를 고르세요'); return; }
|
|
2026
|
+
var res=await fetch('/api/tasks/'+promoTaskId,{method:'PATCH',headers:{'content-type':'application/json'},
|
|
2027
|
+
body:JSON.stringify({repoId:repoId})});
|
|
1413
2028
|
var j=await res.json().catch(function(){return{};});
|
|
1414
|
-
if(res.ok){
|
|
1415
|
-
|
|
1416
|
-
|
|
2029
|
+
if (!res.ok){ promoErr('옮기기 실패: '+(j.detail||j.error||res.status)); return; }
|
|
2030
|
+
closePromote(); await hydrate();
|
|
2031
|
+
toast('프로젝트로 승격 · 터미널은 이 폴더 그대로입니다');
|
|
2032
|
+
}catch(e){ promoErr('승격 실패: '+e); }
|
|
2033
|
+
finally{ promoting=false; $('promoGo').disabled=false; }
|
|
2034
|
+
}
|
|
2035
|
+
$('promoClose').addEventListener('click', closePromote);
|
|
2036
|
+
$('promoModal').addEventListener('click', function(e){ if(e.target===this) closePromote(); });
|
|
2037
|
+
$('promoGo').addEventListener('click', doPromote);
|
|
2038
|
+
$('promoMode').addEventListener('click', function(e){
|
|
2039
|
+
var b=e.target.closest('[data-promo]'); if(!b) return;
|
|
2040
|
+
setPromoMode(b.getAttribute('data-promo'));
|
|
2041
|
+
});
|
|
2042
|
+
$('promoRepos').addEventListener('click', function(e){
|
|
2043
|
+
var row=e.target.closest('[data-promorepo]'); if(!row) return;
|
|
2044
|
+
promoRepoId=+row.dataset.promorepo; renderPromoRepos(); promoErr(''); paintPromoWarn();
|
|
2045
|
+
});
|
|
2046
|
+
|
|
2047
|
+
// ── v6.0 T6 — 프로젝트 쪽 정리(그리고 고아 터미널) ──
|
|
2048
|
+
// Scratch 가 S1b 로 치워지듯, 프로젝트도 **어질러지는 자리에서** 치워져야 한다. 셋 다 이미 있는
|
|
2049
|
+
// 길만 지난다: 등록 해제=DELETE /api/repos/:id · 묵은 작업 정리=POST /api/tasks/:id/close ·
|
|
2050
|
+
// 고아 터미널=/api/tmux/orphans. 규칙 하나는 셋이 공유한다 —
|
|
2051
|
+
// **살아 있는 것도, 디스크의 폴더도, 놀라서 사라지는 일은 없다.**
|
|
2052
|
+
var LIVE_RUN = { running:true, preparing:true, pending:true, open:true };
|
|
2053
|
+
function taskRuns(taskId){ return (fleet.runs||[]).filter(function(r){ return r.taskId===taskId; }); }
|
|
2054
|
+
function taskSettled(taskId){ return !taskRuns(taskId).some(function(r){ return LIVE_RUN[r.status]===true; }); }
|
|
2055
|
+
function repoOpenTasks(repoId){ return (fleet.tasks||[]).filter(function(t){ return t.repoId===repoId && t.status!=='closed'; }); }
|
|
2056
|
+
// v4.1 close 가드(taskCloseRisk)의 클라이언트 쪽 읽기 — 신호는 **이미 들고 있는 fleet** 것만 쓴다
|
|
2057
|
+
// (정착 + 바뀐 파일 있음 + export·pr 로 빠져나간 적 없음). 최종 판정은 언제나 서버다:
|
|
2058
|
+
// 닫기가 409 로 막으면 서버가 준 목록을 그대로, 한 번에 보여준다.
|
|
2059
|
+
function closeRiskOf(taskId){
|
|
2060
|
+
var out=[];
|
|
2061
|
+
taskRuns(taskId).forEach(function(r){
|
|
2062
|
+
if (['done','failed','stopped'].indexOf(r.status)<0) return;
|
|
2063
|
+
if (!(r.filesChanged>0)) return;
|
|
2064
|
+
if ((r.events||[]).some(function(e){ return e.kind==='export'||e.kind==='pr'; })) return;
|
|
2065
|
+
out.push({ runId:r.id, filesChanged:r.filesChanged });
|
|
2066
|
+
});
|
|
2067
|
+
return out;
|
|
2068
|
+
}
|
|
2069
|
+
function riskText(list){
|
|
2070
|
+
return list.map(function(x){ return 'r'+x.runId+'·'+x.filesChanged+'파일'; }).join(' ');
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
// ── 묵은 작업 정리 — 표 하나, 닫기 한 번 ──
|
|
2074
|
+
var tidyRepoId=null, tidySel={}, tidyForce=null, tidying=false;
|
|
2075
|
+
function tidyErr(msg){ var el=$('tidyErr'); if(!msg){ el.hidden=true; el.textContent=''; return; } el.textContent=msg; el.hidden=false; }
|
|
2076
|
+
function tidyRows(){
|
|
2077
|
+
return repoOpenTasks(tidyRepoId).filter(function(t){ return taskSettled(t.id); }).map(function(t){
|
|
2078
|
+
var risk=closeRiskOf(t.id);
|
|
2079
|
+
return { id:t.id, title:t.title||('task #'+t.id), runN:taskRuns(t.id).length, risk:risk };
|
|
2080
|
+
}).sort(function(a,b){ return b.id-a.id; });
|
|
2081
|
+
}
|
|
2082
|
+
function tidyCount(){ var n=0; Object.keys(tidySel).forEach(function(k){ if(tidySel[k]) n++; }); return n; }
|
|
2083
|
+
function tidyRowHTML(x){
|
|
2084
|
+
return '<label class="scrub-row'+(x.risk.length?' risky':'')+'" title="'+esc(x.title)+'"><input type="checkbox" data-tidychk="'+x.id+'"'+(tidySel[x.id]?' checked':'')+' />'
|
|
2085
|
+
+ '<span class="snm">'+esc(x.title)+'</span>'
|
|
2086
|
+
+ (x.risk.length ? '<span class="swarn">미머지 '+esc(riskText(x.risk))+'</span>' : '')
|
|
2087
|
+
+ '<span class="sage">#'+x.id+' · run '+x.runN+'</span></label>';
|
|
2088
|
+
}
|
|
2089
|
+
function renderTidy(){
|
|
2090
|
+
var rows=tidyRows();
|
|
2091
|
+
$('tidyList').innerHTML = rows.length ? rows.map(tidyRowHTML).join('')
|
|
2092
|
+
: '<div class="pick-row" style="cursor:default;color:var(--faint)">정리할 묵은 작업이 없습니다 — 남은 작업은 아직 돌고 있습니다</div>';
|
|
2093
|
+
var n=tidyCount(), riskN=0;
|
|
2094
|
+
rows.forEach(function(x){ if(x.risk.length && tidySel[x.id]) riskN++; });
|
|
2095
|
+
$('tidyHint').textContent = rows.length
|
|
2096
|
+
? (n+'개 선택 · 정착한 작업 '+rows.length+'개'+(riskN?(' · 그중 미머지 '+riskN+'개'):''))
|
|
2097
|
+
: '';
|
|
2098
|
+
// 409 를 한 번 받았으면 버튼은 "그래도 닫기" 로 바뀐다 — 확인 창이 작업 수만큼 뜨는 일은 없다.
|
|
2099
|
+
if (tidyForce && tidyForce.length){ $('tidyGo').textContent='그래도 닫기 ('+tidyForce.length+')'; $('tidyGo').disabled=false; }
|
|
2100
|
+
else { $('tidyGo').textContent='선택 닫기'; $('tidyGo').disabled=n===0; }
|
|
2101
|
+
}
|
|
2102
|
+
function openTidy(repoId){
|
|
2103
|
+
var rp=repoById[repoId]; if(!rp){ toast('프로젝트를 찾을 수 없습니다'); return; }
|
|
2104
|
+
tidyRepoId=repoId; tidySel={}; tidyForce=null; tidyErr('');
|
|
2105
|
+
$('tidyRepoName').textContent=(rp.name||'')+' — '+(rp.path||'');
|
|
2106
|
+
// 미리 체크하는 것은 **위험 표시가 없는 것만**(S1b 와 같은 규칙: 안 보이는 위험을 대신 삼키지 않는다)
|
|
2107
|
+
tidyRows().forEach(function(x){ if(!x.risk.length) tidySel[x.id]=true; });
|
|
2108
|
+
renderTidy(); $('tidyModal').classList.add('on');
|
|
1417
2109
|
}
|
|
2110
|
+
function closeTidy(){ $('tidyModal').classList.remove('on'); }
|
|
2111
|
+
async function closeTaskReq(taskId, force){
|
|
2112
|
+
var res=await fetch('/api/tasks/'+taskId+'/close',{method:'POST',headers:{'content-type':'application/json'},
|
|
2113
|
+
body:JSON.stringify(force?{force:true}:{})});
|
|
2114
|
+
var j=await res.json().catch(function(){return{};});
|
|
2115
|
+
return { ok:res.ok, status:res.status, atRisk:j.atRisk||[], detail:j.detail||j.error||('HTTP '+res.status) };
|
|
2116
|
+
}
|
|
2117
|
+
async function runTidy(){
|
|
2118
|
+
if (tidying || tidyRepoId==null) return;
|
|
2119
|
+
var force=!!(tidyForce && tidyForce.length);
|
|
2120
|
+
var ids = force ? tidyForce.slice() : Object.keys(tidySel).filter(function(k){ return tidySel[k]; }).map(Number);
|
|
2121
|
+
if (!ids.length){ toast('선택한 작업이 없습니다'); return; }
|
|
2122
|
+
tidying=true; $('tidyGo').disabled=true; tidyErr('');
|
|
2123
|
+
var okN=0, blocked=[], bad='';
|
|
2124
|
+
for (var i=0;i<ids.length;i++){
|
|
2125
|
+
var r=await closeTaskReq(ids[i], force);
|
|
2126
|
+
if (r.ok){ okN++; delete tidySel[ids[i]]; }
|
|
2127
|
+
else if (r.status===409){ blocked.push({ id:ids[i], atRisk:r.atRisk }); }
|
|
2128
|
+
else if (!bad){ bad=r.detail; }
|
|
2129
|
+
}
|
|
2130
|
+
tidying=false; tidyForce=null;
|
|
2131
|
+
await hydrate();
|
|
2132
|
+
if (blocked.length){
|
|
2133
|
+
// 서버 가드가 막은 것들 — 한 판에 모아 한 번만 묻는다(v4.1 그룹 닫기와 같은 규칙).
|
|
2134
|
+
tidyForce = blocked.map(function(b){ return b.id; });
|
|
2135
|
+
blocked.forEach(function(b){ tidySel[b.id]=true; });
|
|
2136
|
+
var lines = blocked.map(function(b){
|
|
2137
|
+
var t=taskById[b.id];
|
|
2138
|
+
return ((t&&t.title)||('#'+b.id))+' — '+(riskText(b.atRisk||[])||'미머지 산출물');
|
|
2139
|
+
}).join(' · ');
|
|
2140
|
+
tidyErr('아직 살릴 곳이 없는 산출물이 있습니다(머지도 export 도 PR 도 아님): '+lines
|
|
2141
|
+
+ ' · 그래도 닫으면 그 변경은 worktree 와 함께 사라집니다.');
|
|
2142
|
+
}
|
|
2143
|
+
if (okN || bad) toast('작업 '+okN+'개 닫음'+(bad?(' · 실패: '+bad):'')+(blocked.length?(' · 확인 필요 '+blocked.length+'개'):''));
|
|
2144
|
+
if (tidyRows().length || blocked.length) renderTidy(); else closeTidy();
|
|
2145
|
+
}
|
|
2146
|
+
$('tidyClose').addEventListener('click', closeTidy);
|
|
2147
|
+
$('tidyModal').addEventListener('click', function(e){ if(e.target===this) closeTidy(); });
|
|
2148
|
+
$('tidyGo').addEventListener('click', runTidy);
|
|
2149
|
+
$('tidyList').addEventListener('change', function(e){
|
|
2150
|
+
var c=e.target.closest('[data-tidychk]'); if(!c) return;
|
|
2151
|
+
tidySel[+c.dataset.tidychk]=c.checked; tidyForce=null; tidyErr(''); renderTidy();
|
|
2152
|
+
});
|
|
2153
|
+
|
|
2154
|
+
// ── 등록 해제 — 목록에서만 뺀다. 디스크는 건드리지 않는다 ──
|
|
2155
|
+
var unregRepoId=null, unregging=false;
|
|
2156
|
+
function unregErr(msg){ var el=$('unregErr'); if(!msg){ el.hidden=true; el.textContent=''; return; } el.textContent=msg; el.hidden=false; }
|
|
2157
|
+
function openUnreg(repoId){
|
|
2158
|
+
var rp=repoById[repoId]; if(!rp){ toast('프로젝트를 찾을 수 없습니다'); return; }
|
|
2159
|
+
unregRepoId=repoId; unregErr('');
|
|
2160
|
+
$('unregPath').textContent=(rp.name||'')+' — '+(rp.path||'');
|
|
2161
|
+
// 열린 작업의 close 가드를 여기서 미리 합산해 보여준다 — 거절당하고 나서야 아는 일이 없게.
|
|
2162
|
+
var open=repoOpenTasks(repoId), riskN=0;
|
|
2163
|
+
open.forEach(function(t){ if(closeRiskOf(t.id).length) riskN++; });
|
|
2164
|
+
$('unregRisk').textContent = open.length
|
|
2165
|
+
? ('열린 작업 '+open.length+'개'+(riskN?(' · 그중 미머지 산출물 '+riskN+'개'):'')+' — 열린 작업이 남아 있으면 해제는 거절됩니다. 「정리…」로 먼저 닫으세요.')
|
|
2166
|
+
: '열린 작업 없음 — 바로 해제됩니다.';
|
|
2167
|
+
$('unregModal').classList.add('on');
|
|
2168
|
+
}
|
|
2169
|
+
function closeUnreg(){ $('unregModal').classList.remove('on'); }
|
|
2170
|
+
async function doUnreg(){
|
|
2171
|
+
if (unregging || unregRepoId==null) return;
|
|
2172
|
+
unregging=true; $('unregGo').disabled=true; unregErr('');
|
|
2173
|
+
try{
|
|
2174
|
+
var res=await fetch('/api/repos/'+unregRepoId,{method:'DELETE'});
|
|
2175
|
+
var j=await res.json().catch(function(){return{};});
|
|
2176
|
+
if (!res.ok){
|
|
2177
|
+
unregErr('해제 실패: '+(j.detail||j.error||('HTTP '+res.status))+(res.status===409?' — 「정리…」로 먼저 닫으세요.':''));
|
|
2178
|
+
return;
|
|
2179
|
+
}
|
|
2180
|
+
closeUnreg(); await hydrate();
|
|
2181
|
+
toast('등록 해제됨 · 폴더와 파일은 그대로입니다');
|
|
2182
|
+
}catch(e){ unregErr('해제 실패: '+e); }
|
|
2183
|
+
finally{ unregging=false; $('unregGo').disabled=false; }
|
|
2184
|
+
}
|
|
2185
|
+
$('unregClose').addEventListener('click', closeUnreg);
|
|
2186
|
+
$('unregModal').addEventListener('click', function(e){ if(e.target===this) closeUnreg(); });
|
|
2187
|
+
$('unregGo').addEventListener('click', doUnreg);
|
|
2188
|
+
|
|
2189
|
+
// ── 고아 터미널(↻ — 보드의 Reclaim worktrees 와 같은 유지보수 가족) ──
|
|
2190
|
+
// 2026-09-17 에 손으로 13개를 걷어낸 그 일. 목록은 서버가 판정한다(DB 에 run 이 없는 coxpit-r* 만).
|
|
2191
|
+
// 빈 셸만 미리 체크하고, 무언가 돌고 있는 세션은 **표시만** 한다 — 지우는 판이 보여주지 않은 것을
|
|
2192
|
+
// 지우는 순간 그건 빗자루가 아니라 함정이 된다.
|
|
2193
|
+
var reapRows=[], reapSel={}, reaping=false;
|
|
2194
|
+
function reapRowHTML(x){
|
|
2195
|
+
return '<label class="scrub-row'+(x.idle?'':' risky')+'" title="'+esc(x.name)+'"><input type="checkbox" data-reapchk="'+esc(x.name)+'"'+(reapSel[x.name]?' checked':'')+' />'
|
|
2196
|
+
+ '<span class="snm">'+esc(x.name)+'</span>'
|
|
2197
|
+
+ (x.idle ? '<span class="slive dead">빈 셸</span>' : '<span class="swarn">돌고 있음: '+esc(x.command||'?')+'</span>')
|
|
2198
|
+
+ '<span class="sage">r'+x.runId+' · run 기록 없음</span></label>';
|
|
2199
|
+
}
|
|
2200
|
+
function reapCount(){ var n=0; Object.keys(reapSel).forEach(function(k){ if(reapSel[k]) n++; }); return n; }
|
|
2201
|
+
function renderReap(){
|
|
2202
|
+
$('reapList').innerHTML = reapRows.length ? reapRows.map(reapRowHTML).join('')
|
|
2203
|
+
: '<div class="pick-row" style="cursor:default;color:var(--faint)">고아 터미널이 없습니다 — 남은 tmux 세션은 모두 아는 run 의 것입니다</div>';
|
|
2204
|
+
var n=reapCount(), busy=0;
|
|
2205
|
+
reapRows.forEach(function(x){ if(!x.idle) busy++; });
|
|
2206
|
+
$('reapHint').textContent = reapRows.length
|
|
2207
|
+
? (n+'개 선택 · 고아 '+reapRows.length+'개'+(busy?(' · 돌고 있는 '+busy+'개는 미선택'):''))
|
|
2208
|
+
: '';
|
|
2209
|
+
$('reapGo').disabled = n===0;
|
|
2210
|
+
}
|
|
2211
|
+
async function loadReap(){
|
|
2212
|
+
$('reapList').innerHTML='<div class="pick-row" style="cursor:default;color:var(--faint)">tmux 세션을 읽는 중…</div>';
|
|
2213
|
+
$('reapHint').textContent=''; $('reapGo').disabled=true;
|
|
2214
|
+
reapRows=[]; reapSel={};
|
|
2215
|
+
try{
|
|
2216
|
+
var res=await fetch('/api/tmux/orphans');
|
|
2217
|
+
var j=await res.json();
|
|
2218
|
+
reapRows=j.sessions||[];
|
|
2219
|
+
reapRows.forEach(function(x){ if(x.idle) reapSel[x.name]=true; }); // 빈 셸만 미리 체크
|
|
2220
|
+
}catch(e){ reapRows=[]; }
|
|
2221
|
+
renderReap();
|
|
2222
|
+
}
|
|
2223
|
+
function openReap(){ $('reapModal').classList.add('on'); loadReap(); }
|
|
2224
|
+
function closeReap(){ $('reapModal').classList.remove('on'); }
|
|
2225
|
+
async function runReap(){
|
|
2226
|
+
if (reaping) return;
|
|
2227
|
+
var names=Object.keys(reapSel).filter(function(k){ return reapSel[k]; });
|
|
2228
|
+
if (!names.length){ toast('선택한 세션이 없습니다'); return; }
|
|
2229
|
+
reaping=true; $('reapGo').disabled=true;
|
|
2230
|
+
try{
|
|
2231
|
+
var res=await fetch('/api/tmux/orphans/kill',{method:'POST',headers:{'content-type':'application/json'},
|
|
2232
|
+
body:JSON.stringify({sessions:names})});
|
|
2233
|
+
var j=await res.json().catch(function(){return{};});
|
|
2234
|
+
if (!res.ok){ toast('종료 실패: '+(j.detail||j.error||('HTTP '+res.status))); }
|
|
2235
|
+
else { toast('고아 터미널 '+(j.count||0)+'개 종료됨'+((j.skipped&&j.skipped.length)?(' · 건너뜀 '+j.skipped.length+'개'):'')); }
|
|
2236
|
+
}catch(e){ toast('종료 실패: '+e); }
|
|
2237
|
+
finally{ reaping=false; }
|
|
2238
|
+
await loadReap();
|
|
2239
|
+
}
|
|
2240
|
+
$('reapBtn').addEventListener('click', openReap);
|
|
2241
|
+
$('reapClose').addEventListener('click', closeReap);
|
|
2242
|
+
$('reapModal').addEventListener('click', function(e){ if(e.target===this) closeReap(); });
|
|
2243
|
+
$('reapGo').addEventListener('click', runReap);
|
|
2244
|
+
$('reapList').addEventListener('change', function(e){
|
|
2245
|
+
var c=e.target.closest('[data-reapchk]'); if(!c) return;
|
|
2246
|
+
reapSel[c.dataset.reapchk]=c.checked; renderReap();
|
|
2247
|
+
});
|
|
2248
|
+
|
|
1418
2249
|
function openSession(){ $('pickModal').classList.add('on'); $('pickName').value=''; browseTo(''); }
|
|
1419
2250
|
function closePicker(){ $('pickModal').classList.remove('on'); }
|
|
1420
2251
|
async function browseTo(p){
|
|
@@ -1456,6 +2287,117 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1456
2287
|
$('pickName').addEventListener('keydown', function(e){ if(e.key==='Enter'){ e.preventDefault(); $('pickGo').click(); } });
|
|
1457
2288
|
$('sessionBtn').addEventListener('click', openSession);
|
|
1458
2289
|
|
|
2290
|
+
// ── v6.0 Part T — 새 작업(+ 새 작업) / 에이전트 추가(+ 에이전트) ──
|
|
2291
|
+
// 작업 = task(이름이 사는 곳), 그 아래 세션 = run. 작업을 만들면 repo 체크아웃에
|
|
2292
|
+
// main 터미널(root 세션)이 바로 열리고, 에이전트는 그 작업 아래에 worktree 로 붙는다.
|
|
2293
|
+
// v6.0 Part P — 격리는 강제가 아니라 선택이다. 'worktree'(기본, 지금까지의 손버릇 그대로)
|
|
2294
|
+
// 아니면 'inplace'(repo 체크아웃 공유 · 순차). 도구는 대가를 그대로 말하고, 한 체크아웃에
|
|
2295
|
+
// 에이전트 둘은 절대 안 돌린다(서버가 409 로 막고, 그 이유는 시트 안에서 보인다).
|
|
2296
|
+
var workRepoId=null, agentTaskId=null, agentProvId='', agentPlace='worktree';
|
|
2297
|
+
var PLACE_HINT={ worktree:'worktree 로 격리해 띄웁니다 — 나란히 비교하고 승자만 머지',
|
|
2298
|
+
inplace:'repo 체크아웃에서 그대로 띄웁니다 — main 터미널에서 편집이 바로 보입니다' };
|
|
2299
|
+
function setAgentPlace(p){
|
|
2300
|
+
agentPlace = (p==='inplace') ? 'inplace' : 'worktree';
|
|
2301
|
+
Array.prototype.forEach.call($('agentPlace').querySelectorAll('.mode'), function(x){
|
|
2302
|
+
x.classList.toggle('on', x.getAttribute('data-place')===agentPlace);
|
|
2303
|
+
});
|
|
2304
|
+
$('agentPlaceHint').textContent = PLACE_HINT[agentPlace];
|
|
2305
|
+
}
|
|
2306
|
+
function agentErr(msg){ var el=$('agentErr'); if(!msg){ el.hidden=true; el.textContent=''; return; } el.textContent=msg; el.hidden=false; }
|
|
2307
|
+
function openNewWork(repoId){
|
|
2308
|
+
var rp=repoById[repoId]; if(!rp){ toast('프로젝트를 찾을 수 없습니다'); return; }
|
|
2309
|
+
workRepoId=repoId;
|
|
2310
|
+
$('workRepoName').textContent=rp.name+(rp.path?(' · '+rp.path):'');
|
|
2311
|
+
$('workName').value=''; $('workModal').classList.add('on');
|
|
2312
|
+
setTimeout(function(){ try{ $('workName').focus(); }catch(e){} }, 30);
|
|
2313
|
+
}
|
|
2314
|
+
function closeNewWork(){ $('workModal').classList.remove('on'); }
|
|
2315
|
+
var creatingWork=false;
|
|
2316
|
+
async function createWork(){
|
|
2317
|
+
if(creatingWork || workRepoId==null) return;
|
|
2318
|
+
var title=$('workName').value.trim();
|
|
2319
|
+
if(!title){ toast('작업 이름을 적어주세요'); $('workName').focus(); return; }
|
|
2320
|
+
creatingWork=true; $('workGo').disabled=true;
|
|
2321
|
+
try{
|
|
2322
|
+
var res=await fetch('/api/workbench',{method:'POST',headers:{'content-type':'application/json'},
|
|
2323
|
+
body:JSON.stringify({repoId:workRepoId, title:title, root:true})});
|
|
2324
|
+
var j=await res.json().catch(function(){return{};});
|
|
2325
|
+
if(res.ok && j.runId){ closeNewWork(); await hydrate(); openRunPane(j.runId); toast('작업 · '+title+' — main 터미널 열림'); }
|
|
2326
|
+
else toast('작업 생성 실패: '+(j.detail||j.error||res.status));
|
|
2327
|
+
}catch(e){ toast('작업 생성 실패: '+e); }
|
|
2328
|
+
finally{ creatingWork=false; $('workGo').disabled=false; }
|
|
2329
|
+
}
|
|
2330
|
+
// WORK.md — 이 작업의 공유 컨텍스트(목표·결정·제약). 서버가 없으면 빈 파일로 만들어 주고,
|
|
2331
|
+
// 그 경로가 파일 뷰어 루트(기본=홈, ~/.coxpit 이 그 안) 안이면 **평소 쓰던 뷰어 페인** 그대로 연다.
|
|
2332
|
+
// 밖이면(COXPIT_DB 를 홈 밖으로 옮긴 경우) 같은 페인이 전용 창구로 읽고 쓴다 — 어포던스는 깨지지 않는다.
|
|
2333
|
+
// [주의] 저장은 **다음 발사·steer** 부터 닿는다. 돌고 있는 턴에 끼어드는 마법은 없다.
|
|
2334
|
+
async function openWorkDoc(taskId){
|
|
2335
|
+
var t=taskById[taskId]; if(!t){ toast('작업을 찾을 수 없습니다'); return; }
|
|
2336
|
+
try{
|
|
2337
|
+
var res=await fetch('/api/tasks/'+taskId+'/work',{method:'POST'});
|
|
2338
|
+
var j=await res.json().catch(function(){return{};});
|
|
2339
|
+
if(!res.ok || !j.path){ toast('WORK.md 를 열 수 없습니다: '+(j.detail||j.error||res.status)); return; }
|
|
2340
|
+
var v=ensureViewer(j.path, 'WORK.md · '+(t.title||('task '+taskId)), { edit:true, workTaskId:(j.inRoot?null:taskId) });
|
|
2341
|
+
openTab(v.runId); if (isMobile()) setDrawer(false);
|
|
2342
|
+
toast('WORK.md · 저장한 내용은 다음 발사·steer 부터 전달됩니다');
|
|
2343
|
+
}catch(e){ toast('WORK.md 를 열 수 없습니다: '+e); }
|
|
2344
|
+
}
|
|
2345
|
+
function openAddAgent(taskId){
|
|
2346
|
+
var t=taskById[taskId]; if(!t){ toast('작업을 찾을 수 없습니다'); return; }
|
|
2347
|
+
agentTaskId=taskId;
|
|
2348
|
+
var rp=repoById[t.repoId];
|
|
2349
|
+
$('agentWorkName').textContent=(rp?rp.name+' ▸ ':'')+t.title;
|
|
2350
|
+
$('agentRole').value='';
|
|
2351
|
+
var provs=(fleet.providers||[]).slice();
|
|
2352
|
+
if(!provs.length) provs=[{id:'claude-code',label:'claude-code'}];
|
|
2353
|
+
if(!agentProvId || !provs.some(function(p){ return p.id===agentProvId; })) agentProvId=provs[0].id;
|
|
2354
|
+
$('agentProv').innerHTML=provs.map(function(p){
|
|
2355
|
+
return '<button type="button" class="mode'+(p.id===agentProvId?' on':'')+'" data-prov="'+esc(p.id)+'">'+esc(p.label||p.id)+'</button>';
|
|
2356
|
+
}).join('');
|
|
2357
|
+
agentErr(''); setAgentPlace(agentPlace);
|
|
2358
|
+
$('agentModal').classList.add('on');
|
|
2359
|
+
setTimeout(function(){ try{ $('agentRole').focus(); }catch(e){} }, 30);
|
|
2360
|
+
}
|
|
2361
|
+
function closeAddAgent(){ $('agentModal').classList.remove('on'); }
|
|
2362
|
+
var addingAgent=false;
|
|
2363
|
+
async function addAgent(){
|
|
2364
|
+
if(addingAgent || agentTaskId==null) return;
|
|
2365
|
+
addingAgent=true; $('agentGo').disabled=true; agentErr('');
|
|
2366
|
+
try{
|
|
2367
|
+
var role=$('agentRole').value.trim();
|
|
2368
|
+
var res=await fetch('/api/tasks/'+agentTaskId+'/run',{method:'POST',headers:{'content-type':'application/json'},
|
|
2369
|
+
body:JSON.stringify({agent:agentProvId, count:1, real:$('agentReal').checked, model:$('agentModel').value.trim(), title:role,
|
|
2370
|
+
inPlace:(agentPlace==='inplace')})});
|
|
2371
|
+
var j=await res.json().catch(function(){return{};});
|
|
2372
|
+
var ids=(j&&j.runs||[]).map(function(x){ return x.id; });
|
|
2373
|
+
if(res.ok && ids.length){ closeAddAgent(); await hydrate(); openRunPane(ids[0]);
|
|
2374
|
+
toast('에이전트 추가 · '+(role||agentProvId)+' · '+(agentPlace==='inplace'?'in-place':'worktree')); }
|
|
2375
|
+
// 체크아웃이 이미 물려 있으면(409) 시트를 닫지 않는다 — 여기서 이유를 읽고 바로 고를 수 있게.
|
|
2376
|
+
else if(res.status===409){ agentErr(j.detail||'이 체크아웃에서 이미 다른 에이전트가 일하고 있습니다 — steer 하거나 worktree 로 띄우세요'); }
|
|
2377
|
+
else agentErr('에이전트 추가 실패: '+(j.detail||j.error||res.status));
|
|
2378
|
+
}catch(e){ agentErr('에이전트 추가 실패: '+e); }
|
|
2379
|
+
finally{ addingAgent=false; $('agentGo').disabled=false; }
|
|
2380
|
+
}
|
|
2381
|
+
$('workClose').addEventListener('click', closeNewWork);
|
|
2382
|
+
$('workModal').addEventListener('click', function(e){ if(e.target===this) closeNewWork(); });
|
|
2383
|
+
$('workGo').addEventListener('click', createWork);
|
|
2384
|
+
$('workName').addEventListener('keydown', function(e){ e.stopPropagation(); if(e.key==='Enter' && !e.isComposing){ e.preventDefault(); createWork(); } });
|
|
2385
|
+
$('agentClose').addEventListener('click', closeAddAgent);
|
|
2386
|
+
$('agentModal').addEventListener('click', function(e){ if(e.target===this) closeAddAgent(); });
|
|
2387
|
+
$('agentGo').addEventListener('click', addAgent);
|
|
2388
|
+
$('agentProv').addEventListener('click', function(e){
|
|
2389
|
+
var b=e.target.closest('[data-prov]'); if(!b) return;
|
|
2390
|
+
agentProvId=b.getAttribute('data-prov');
|
|
2391
|
+
Array.prototype.forEach.call(this.querySelectorAll('.mode'), function(x){ x.classList.toggle('on', x.getAttribute('data-prov')===agentProvId); });
|
|
2392
|
+
});
|
|
2393
|
+
$('agentPlace').addEventListener('click', function(e){
|
|
2394
|
+
var b=e.target.closest('[data-place]'); if(!b) return;
|
|
2395
|
+
setAgentPlace(b.getAttribute('data-place')); agentErr('');
|
|
2396
|
+
});
|
|
2397
|
+
Array.prototype.forEach.call([$('agentRole'),$('agentModel')], function(el){
|
|
2398
|
+
el.addEventListener('keydown', function(e){ e.stopPropagation(); if(e.key==='Enter' && !e.isComposing){ e.preventDefault(); addAgent(); } });
|
|
2399
|
+
});
|
|
2400
|
+
|
|
1459
2401
|
// ── 파일 피커(뷰어로 열기) — 폴더=이동, 파일=뷰어 ──
|
|
1460
2402
|
var fpDirCur = '';
|
|
1461
2403
|
function openFilePicker(){ $('fpickModal').classList.add('on'); $('fpSearch').value=''; fpTo(fpDirCur||''); setTimeout(function(){ try{ $('fpSearch').focus(); }catch(e){} },30); }
|
|
@@ -1631,6 +2573,9 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1631
2573
|
function openReviewForRun(runId){ var r=runById[runId]; if(!r){ toast('run 정보 없음'); return; } showReview(); rvTaskId=r.taskId; try{ $('rvTask').value=String(r.taskId); }catch(e){} syncVcmd(); loadCompare(r.taskId); }
|
|
1632
2574
|
$('vtTerm').addEventListener('click', showTerminal);
|
|
1633
2575
|
$('vtReview').addEventListener('click', showReview);
|
|
2576
|
+
// Docs = 보드 열람실(문서 스냅샷은 아카이브 뷰에 산다). Part B 원칙 그대로 gotoBoard 로 연다 —
|
|
2577
|
+
// 죽은 버튼을 코크핏 안에 문서뷰를 다시 그리지 않고, 이미 있는 보드로 보낸다.
|
|
2578
|
+
$('vtDocs').addEventListener('click', function(){ gotoBoard('archive'); });
|
|
1634
2579
|
function reviewableTasks(){
|
|
1635
2580
|
var byTask = {}; (fleet.runs||[]).forEach(function(r){ byTask[r.taskId]=(byTask[r.taskId]||0)+1; });
|
|
1636
2581
|
return (fleet.tasks||[]).filter(function(t){
|
|
@@ -1677,10 +2622,12 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1677
2622
|
rns.forEach(function(r){
|
|
1678
2623
|
var col=document.createElement('div'); col.className='rv-col';
|
|
1679
2624
|
var st = typeof r.stat==='string' ? r.stat.split('\\n').pop().trim() : '';
|
|
1680
|
-
|
|
2625
|
+
// 브랜치가 없는 run(in-place · main 손터미널)은 머지할 것이 없다 — 편집이 이미 체크아웃에 있다.
|
|
2626
|
+
var noIso = !r.branch;
|
|
2627
|
+
var mergeable = !noIso && (r.status==='done'||r.status==='open'||r.status==='merged') && r.filesChanged>0;
|
|
1681
2628
|
// green-gate: verifyCmd 있는데 pass 아니면 "merge anyway"(주의색) — 막지 않고 경고
|
|
1682
|
-
var gated = hasVerify && r.verifyStatus!=='pass';
|
|
1683
|
-
var mlabel = r.status==='merged' ? 'merged' : (gated ? 'merge anyway' : 'merge ▸');
|
|
2629
|
+
var gated = !noIso && hasVerify && r.verifyStatus!=='pass';
|
|
2630
|
+
var mlabel = noIso ? 'in-place' : (r.status==='merged' ? 'merged' : (gated ? 'merge anyway' : 'merge ▸'));
|
|
1684
2631
|
var vline = '';
|
|
1685
2632
|
if (hasVerify){
|
|
1686
2633
|
var vs = r.verifyStatus||''; var out = (r.verifyOutput||'').split('\\n').pop();
|
|
@@ -1693,7 +2640,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1693
2640
|
+ '<span class="rid">r'+r.id+'</span><span class="chip '+esc(r.status)+'">'+esc(r.status)+'</span>'
|
|
1694
2641
|
+ '<span class="stat">'+esc(st)+'</span>'
|
|
1695
2642
|
+ '<button class="rv-merge'+(gated?' caution':'')+'" data-merge="'+r.id+'"'+((r.status==='merged'||!mergeable)?' disabled':'')
|
|
1696
|
-
+ (gated?' title="검증 미통과 — 그래도 머지"':'')+'>'+mlabel+'</button></div>'
|
|
2643
|
+
+ (noIso?' title="체크아웃에서 직접 작업 — 머지할 것이 없습니다(아래는 커밋 안 한 변경)"':(gated?' title="검증 미통과 — 그래도 머지"':''))+'>'+mlabel+'</button></div>'
|
|
1697
2644
|
+ vline
|
|
1698
2645
|
+ '<div class="rv-diff">'+diffHTML(r.diff)+'</div>';
|
|
1699
2646
|
col._vout = { text: r.verifyOutput||'' };
|
|
@@ -1746,7 +2693,13 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1746
2693
|
var ws = new WebSocket(proto+'://'+location.host+'/ws');
|
|
1747
2694
|
ws.onopen = function(){ $('ws').classList.add('on'); $('wstext').textContent='live'; };
|
|
1748
2695
|
ws.onclose = function(){ $('ws').classList.remove('on'); $('wstext').textContent='reconnecting'; setTimeout(wsConnect, 1500); };
|
|
1749
|
-
|
|
2696
|
+
/* 델타는 종류가 많아 단순히 전체 리하이드레이트(디바운스).
|
|
2697
|
+
단 agentstate 는 초당 여러 번 올 수 있어 리하이드레이트를 걸지 않는다 — 그 run 자리만 표적으로 칠한다(A4). */
|
|
2698
|
+
ws.onmessage = function(m){
|
|
2699
|
+
var ev=null; try{ ev = JSON.parse(m.data); }catch(e){}
|
|
2700
|
+
if (ev && ev.type==='agentstate'){ paintAgentState(ev.runId, ev.state); return; }
|
|
2701
|
+
scheduleHydrate();
|
|
2702
|
+
};
|
|
1750
2703
|
}
|
|
1751
2704
|
var hydT=null;
|
|
1752
2705
|
function scheduleHydrate(){ if (hydT) return; hydT = setTimeout(function(){ hydT=null; hydrate(); }, 400); }
|