feat: v2.2.3 - Stability, Self-Reflector & Intent Alignment
- 버전 2.2.3 상향 및 PATCHNOTES.md 업데이트 - [신규] src/features/selfReflector/ - 성찰 실행/검증/프롬프트 모듈 추가 - [신규] intentAlignment.ts, intentClassifier.ts - 의도 정렬 시스템 추가 - [신규] pixelOfficeState.ts - 픽셀 오피스 상태 관리 추가 - sidebarProvider, dispatcher, chatHandlers 핵심 로직 최적화 - astra-2.2.3.vsix 패키지 생성 완료 (298 tests PASS)
This commit is contained in:
@@ -919,6 +919,382 @@
|
||||
}
|
||||
.company-phase-card.approval button:disabled { opacity: 0.55; cursor: default; }
|
||||
|
||||
/* 3-way 합의 검수 사이클 카드. review-start이 컨테이너를 만들고
|
||||
review-round 이벤트가 .rev-rounds 안에 라운드 한 줄씩 누적. */
|
||||
.company-phase-card.review {
|
||||
border-color: var(--accent-glow, rgba(99,102,241,0.4));
|
||||
}
|
||||
.company-phase-card.review .rev-rounds {
|
||||
display: flex; flex-direction: column; gap: 8px; margin-top: 8px;
|
||||
}
|
||||
.company-phase-card.review .rev-round {
|
||||
border-left: 2px solid var(--border);
|
||||
padding: 4px 8px;
|
||||
background: rgba(99,102,241,0.04);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.company-phase-card.review .rev-round-head {
|
||||
font-size: 10.5px; color: var(--text-bright); font-weight: 600; margin-bottom: 4px;
|
||||
}
|
||||
.company-phase-card.review .rev-line {
|
||||
display: flex; gap: 6px; align-items: flex-start; margin-top: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.company-phase-card.review .rev-actor {
|
||||
flex-shrink: 0; min-width: 64px;
|
||||
color: var(--text-dim); font-weight: 600;
|
||||
}
|
||||
.company-phase-card.review .rev-body { flex: 1; min-width: 0; }
|
||||
.company-phase-card.review .rev-body p { margin: 0 0 4px 0; }
|
||||
.company-phase-card.review .rev-end {
|
||||
margin-top: 8px; padding: 4px 8px;
|
||||
font-size: 11px; font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
/* Intent Alignment 카드 — new_task 요청 직후 C-G-C-F 분석 결과를 보여주고
|
||||
질문 / 확인 버튼을 띄움. 다른 phase 카드보다 살짝 무게감을 주려고
|
||||
accent 테두리. */
|
||||
.company-alignment-card {
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--surface);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
margin: 6px 0;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.company-alignment-card.cancelled {
|
||||
border-color: var(--border);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.company-alignment-card .cph-head {
|
||||
color: var(--text-bright); margin-bottom: 6px;
|
||||
}
|
||||
.company-alignment-card .cal-summary {
|
||||
display: grid; grid-template-columns: auto 1fr; gap: 4px 10px;
|
||||
margin: 6px 0; padding: 6px 8px;
|
||||
background: rgba(99,102,241,0.05);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.company-alignment-card .cal-row {
|
||||
display: contents;
|
||||
}
|
||||
.company-alignment-card .cal-key {
|
||||
font-weight: 700; color: var(--text-bright);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.company-alignment-card .cal-val {
|
||||
color: var(--text-primary); word-break: break-word;
|
||||
}
|
||||
.company-alignment-card .cal-val em { color: var(--text-dim); font-style: italic; }
|
||||
.company-alignment-card .cal-questions {
|
||||
margin-top: 6px; padding: 6px 8px;
|
||||
border-left: 2px solid var(--accent);
|
||||
background: rgba(99,102,241,0.04);
|
||||
}
|
||||
.company-alignment-card .cal-q-head {
|
||||
font-weight: 600; color: var(--text-bright); margin-bottom: 4px;
|
||||
}
|
||||
.company-alignment-card .cal-questions ul {
|
||||
margin: 4px 0 4px 16px; padding: 0;
|
||||
}
|
||||
.company-alignment-card .cal-questions li {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.company-alignment-card .cal-hint {
|
||||
margin-top: 4px; font-size: 10.5px;
|
||||
color: var(--text-dim); font-style: italic;
|
||||
}
|
||||
.company-alignment-card .cal-conf {
|
||||
margin-top: 6px; font-size: 10.5px; font-weight: 600;
|
||||
}
|
||||
.company-alignment-card .cal-conf-high { color: #10B981; }
|
||||
.company-alignment-card .cal-conf-medium { color: #F5C518; }
|
||||
.company-alignment-card .cal-conf-low { color: var(--error); }
|
||||
.company-alignment-card .cal-actions {
|
||||
display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap;
|
||||
}
|
||||
.company-alignment-card .cal-actions button {
|
||||
font-size: 11px; padding: 4px 10px; border-radius: 5px; cursor: pointer;
|
||||
}
|
||||
.company-alignment-card .cal-actions button:disabled {
|
||||
opacity: 0.55; cursor: default;
|
||||
}
|
||||
|
||||
/* 고급: 작업 흐름 편집 영역. 기본 접힘 — 일반 사용자는 만질 일 X. */
|
||||
details.pipeline-advanced {
|
||||
border-style: dashed;
|
||||
opacity: 0.85;
|
||||
}
|
||||
details.pipeline-advanced:not([open]) { padding: 8px 12px; }
|
||||
details.pipeline-advanced summary.pipeline-advanced-summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
user-select: none;
|
||||
outline: none;
|
||||
}
|
||||
details.pipeline-advanced summary::-webkit-details-marker { display: none; }
|
||||
details.pipeline-advanced summary .pa-icon { font-size: 13px; }
|
||||
details.pipeline-advanced summary .pa-title {
|
||||
font-size: 11px; font-weight: 700; color: var(--text-bright);
|
||||
}
|
||||
details.pipeline-advanced summary .pa-hint {
|
||||
font-size: 10px; color: var(--text-dim); flex: 1;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
details.pipeline-advanced[open] summary { margin-bottom: 4px; }
|
||||
details.pipeline-advanced[open] {
|
||||
opacity: 1;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
/* ─────────────── Pixel Office ─────────────── */
|
||||
.pixel-office {
|
||||
display: none;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
margin: 0 12px 6px 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pixel-office[data-enabled="true"] { display: block; }
|
||||
.pixel-office[data-collapsed="true"] .po-body { display: none; }
|
||||
.po-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
background: rgba(99,102,241,0.08);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.pixel-office[data-collapsed="true"] .po-head { border-bottom: none; }
|
||||
.po-head-left { display: flex; align-items: center; gap: 8px; }
|
||||
.po-title { font-size: 11px; font-weight: 700; color: var(--text-bright); }
|
||||
.po-status-label {
|
||||
font-size: 10px; padding: 2px 8px; border-radius: 9999px;
|
||||
background: var(--bg); color: var(--text-dim);
|
||||
font-weight: 600; letter-spacing: 0.02em;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
/* 상태별 색상 강조 — webview JS가 po-status-{status} 클래스를 셋팅. */
|
||||
.po-status-label.po-status-idle { color: var(--text-dim); }
|
||||
.po-status-label.po-status-intake { color: #60A5FA; border-color: #60A5FA; }
|
||||
.po-status-label.po-status-analyzing { color: #A78BFA; border-color: #A78BFA; }
|
||||
.po-status-label.po-status-need_clarification { color: #F5C518; border-color: #F5C518; }
|
||||
.po-status-label.po-status-contract_ready { color: #10B981; border-color: #10B981; }
|
||||
.po-status-label.po-status-planning { color: #22D3EE; border-color: #22D3EE; }
|
||||
.po-status-label.po-status-executing { color: var(--accent); border-color: var(--accent); }
|
||||
.po-status-label.po-status-reviewing { color: #FB923C; border-color: #FB923C; }
|
||||
.po-status-label.po-status-waiting_approval { color: #F472B6; border-color: #F472B6; }
|
||||
.po-status-label.po-status-error { color: var(--error); border-color: var(--error); }
|
||||
.po-status-label.po-status-done { color: #10B981; border-color: #10B981; }
|
||||
|
||||
.po-head-actions { display: flex; gap: 4px; align-items: center; }
|
||||
.po-collapse, .po-expand {
|
||||
background: transparent; border: none; cursor: pointer;
|
||||
color: var(--text-dim); font-size: 12px; line-height: 1;
|
||||
padding: 2px 6px; border-radius: 4px;
|
||||
}
|
||||
.po-collapse:hover, .po-expand:hover {
|
||||
color: var(--accent); background: rgba(99,102,241,0.1);
|
||||
}
|
||||
.pixel-office[data-collapsed="true"] .po-collapse { transform: rotate(-90deg); }
|
||||
|
||||
.po-body {
|
||||
display: grid; grid-template-columns: minmax(140px, 180px) 1fr;
|
||||
gap: 8px; padding: 8px 10px;
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.po-body { grid-template-columns: 1fr; }
|
||||
}
|
||||
/* 좌측 — 픽셀 오피스 씬 (캐릭터 + 책상 + 진행률) */
|
||||
.po-scene {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
.po-char-wrap {
|
||||
position: relative;
|
||||
width: 100%; min-height: 90px;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: flex-end;
|
||||
padding: 4px 0;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, rgba(99,102,241,0.05) 0%, rgba(99,102,241,0.0) 70%);
|
||||
}
|
||||
.po-char {
|
||||
position: relative;
|
||||
width: 48px; height: 48px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--bg);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 8px;
|
||||
transition: transform 0.18s, border-color 0.18s;
|
||||
}
|
||||
.po-char-emoji {
|
||||
font-size: 28px; line-height: 1;
|
||||
}
|
||||
/* 상태에 따른 캐릭터 동작 */
|
||||
.pixel-office[data-status="executing"] .po-char { animation: po-bob 1.6s ease-in-out infinite; }
|
||||
.pixel-office[data-status="analyzing"] .po-char { animation: po-tilt 2.4s ease-in-out infinite; }
|
||||
.pixel-office[data-status="reviewing"] .po-char { animation: po-tilt 1.8s ease-in-out infinite; }
|
||||
.pixel-office[data-status="need_clarification"] .po-char { border-color: #F5C518; }
|
||||
.pixel-office[data-status="waiting_approval"] .po-char { border-color: #F472B6; }
|
||||
.pixel-office[data-status="error"] .po-char {
|
||||
border-color: var(--error);
|
||||
animation: po-shake 0.4s ease-in-out infinite;
|
||||
}
|
||||
.pixel-office[data-status="done"] .po-char { border-color: #10B981; }
|
||||
@keyframes po-bob {
|
||||
0%,100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-3px); }
|
||||
}
|
||||
@keyframes po-tilt {
|
||||
0%,100% { transform: rotate(0deg); }
|
||||
50% { transform: rotate(-4deg); }
|
||||
}
|
||||
@keyframes po-shake {
|
||||
0%,100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-2px); }
|
||||
75% { transform: translateX(2px); }
|
||||
}
|
||||
|
||||
/* 캐릭터 옆 소품 — 상태에 따라 작은 이모지 (돋보기·체크리스트·코드 등) */
|
||||
.po-char-prop {
|
||||
position: absolute;
|
||||
right: -22px; top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 16px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.po-desk {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
margin-top: -4px;
|
||||
background: linear-gradient(180deg, var(--border) 0%, var(--bg) 100%);
|
||||
border-radius: 0 0 6px 6px;
|
||||
}
|
||||
.po-progress {
|
||||
width: 100%; height: 4px;
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.po-progress-bar {
|
||||
height: 100%; background: var(--accent);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* 말풍선 영역 */
|
||||
.po-bubbles {
|
||||
position: absolute;
|
||||
left: 50%; transform: translateX(-50%);
|
||||
bottom: 56px;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||
pointer-events: none;
|
||||
width: max-content; max-width: 220px;
|
||||
}
|
||||
.po-bubble {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
font-size: 10.5px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
|
||||
position: relative;
|
||||
animation: po-bubble-in 0.25s ease-out;
|
||||
max-width: 220px;
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
}
|
||||
.po-bubble::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%; bottom: -4px;
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
width: 6px; height: 6px;
|
||||
background: var(--bg);
|
||||
border-right: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.po-bubble.po-bubble-warning { border-color: #F5C518; color: #F5C518; }
|
||||
.po-bubble.po-bubble-warning::after { border-color: #F5C518; }
|
||||
.po-bubble.po-bubble-error { border-color: var(--error); color: var(--error); }
|
||||
.po-bubble.po-bubble-error::after { border-color: var(--error); }
|
||||
.po-bubble.po-bubble-success { border-color: #10B981; color: #10B981; }
|
||||
.po-bubble.po-bubble-success::after { border-color: #10B981; }
|
||||
.po-bubble.po-bubble-fading { animation: po-bubble-out 0.3s ease-in forwards; }
|
||||
@keyframes po-bubble-in {
|
||||
from { opacity: 0; transform: translateY(4px) scale(0.92); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes po-bubble-out {
|
||||
to { opacity: 0; transform: translateY(-4px) scale(0.92); }
|
||||
}
|
||||
|
||||
/* 우측 — 정보 패널 */
|
||||
.po-panel {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
font-size: 10.5px;
|
||||
min-width: 0;
|
||||
}
|
||||
.po-row {
|
||||
display: grid; grid-template-columns: 60px 1fr;
|
||||
gap: 6px; align-items: baseline;
|
||||
}
|
||||
.po-key {
|
||||
color: var(--text-dim); font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.po-val {
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.po-val-status { font-weight: 700; text-transform: lowercase; }
|
||||
.po-section {
|
||||
margin-top: 4px; padding-top: 4px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
.po-section-head {
|
||||
font-size: 10px; font-weight: 700; color: var(--text-bright);
|
||||
margin-bottom: 3px;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
.po-need-input { margin: 0 0 0 14px; padding: 0; color: var(--text-primary); }
|
||||
.po-need-input li { margin-bottom: 2px; }
|
||||
.po-approval {
|
||||
color: #F472B6; font-style: italic;
|
||||
}
|
||||
.po-contract {
|
||||
display: grid; grid-template-columns: 40px 1fr;
|
||||
gap: 2px 6px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.po-contract-key { color: var(--text-dim); font-weight: 600; }
|
||||
.po-contract-val { color: var(--text-primary); word-break: break-word; }
|
||||
.po-logs {
|
||||
font-size: 9.5px; line-height: 1.4;
|
||||
color: var(--text-dim);
|
||||
max-height: 90px; overflow-y: auto;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
}
|
||||
.po-logs div { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* 의도 분류기가 chat/followup 으로 판정했을 때 채팅에 끼우는 작은 라벨.
|
||||
파이프라인 카드는 절대 만들지 않고 한 줄짜리 메타 텍스트만 — 사용자가
|
||||
"왜 파이프라인이 안 돌았지?" 의문을 즉시 해소할 수 있게 한다. */
|
||||
.company-intent-note {
|
||||
font-size: 10.5px; color: var(--text-dim);
|
||||
padding: 4px 8px; margin: 4px 0;
|
||||
border-left: 2px solid var(--border);
|
||||
}
|
||||
.company-intent-note .cin-label {
|
||||
color: var(--text-bright); font-weight: 600; margin-right: 6px;
|
||||
}
|
||||
.company-intent-note .cin-reason { font-style: italic; }
|
||||
|
||||
/* Project Architecture chip — three-state surface above the input. */
|
||||
.arch-chip {
|
||||
display: none;
|
||||
|
||||
+75
-7
@@ -227,11 +227,19 @@
|
||||
<div id="addAgentError" class="map-status" style="color:var(--error); margin-top:6px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Work Pipeline editor. The active pipeline (if any) drives the
|
||||
dispatcher instead of the CEO planner. Empty list / "기본 (CEO
|
||||
자유 분배)" → legacy planner behaviour. -->
|
||||
<div class="map-section">
|
||||
<div class="map-section-head">
|
||||
<!--
|
||||
Work Pipeline editor. 기본은 *접혀 있음* — CEO가 사용자 의도를 보고
|
||||
자동으로 적합한 작업 흐름을 선택하므로 일반 사용자는 만질 일이 없다.
|
||||
"고급: 작업 흐름 직접 편집" 토글을 열어야만 보이는 영역으로 옮김.
|
||||
기능과 데이터는 그대로 유지 (롤백 안전 · 고급 사용자용).
|
||||
-->
|
||||
<details class="map-section pipeline-advanced">
|
||||
<summary class="pipeline-advanced-summary">
|
||||
<span class="pa-icon">🔧</span>
|
||||
<span class="pa-title">고급: 작업 흐름 직접 편집</span>
|
||||
<span class="pa-hint">평소엔 대표가 자동 분배 — 직접 정의하고 싶을 때만 펼치세요.</span>
|
||||
</summary>
|
||||
<div class="map-section-head" style="margin-top:8px;">
|
||||
<div>
|
||||
<div class="map-section-title">작업 흐름</div>
|
||||
<div class="map-section-hint">대표에게 맡기거나, 사용자가 정한 순서대로 팀원이 이어서 작업하게 만듭니다.</div>
|
||||
@@ -247,11 +255,11 @@
|
||||
<div class="control-row" style="margin-top:8px; gap:8px; align-items:center;">
|
||||
<label style="font-size:10px; color:var(--text-dim);">현재 흐름:</label>
|
||||
<select id="activePipelineSel" style="flex:1; padding:4px 8px; background:var(--bg); color:var(--text-primary); border:1px solid var(--border); border-radius:6px; font-size:11px;">
|
||||
<option value="">대표가 알아서 분배</option>
|
||||
<option value="">대표가 알아서 분배 (권장)</option>
|
||||
</select>
|
||||
</div>
|
||||
<ul id="companyPipelineList" class="map-list" style="margin-top:8px;"></ul>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Pipeline editor — 카드형 단계 에디터.
|
||||
각 단계는 역할 그룹 → 담당 cascading + 지시 텍스트 + 재시도 옵션을
|
||||
@@ -387,6 +395,66 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Pixel Office — Agent Work Pipeline 상태 시각화 패널.
|
||||
백엔드의 SidebarChatProvider.pixelOfficeOn*() 콜이 push하는 `pixelOfficeUpdate`
|
||||
메시지를 받아 그대로 그린다. dispatcher / agent 로직은 절대 건드리지 않는
|
||||
read-only 시각화 레이어. data-enabled 속성으로 보이기/숨기기 토글.
|
||||
-->
|
||||
<div id="pixelOffice" class="pixel-office" data-enabled="false" data-collapsed="false">
|
||||
<div class="po-head">
|
||||
<div class="po-head-left">
|
||||
<span class="po-title">🏢 Pixel Office</span>
|
||||
<span class="po-status-label" id="poStatusLabel">idle</span>
|
||||
</div>
|
||||
<div class="po-head-actions">
|
||||
<button class="po-expand" id="poExpandBtn" title="전체 사무실 뷰 열기">⛶</button>
|
||||
<button class="po-collapse" id="poCollapseBtn" title="접기/펼치기">▾</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="po-body" id="poBody">
|
||||
<div class="po-scene">
|
||||
<!-- 캐릭터 영역: 단일 캐릭터 + 머리 위 말풍선 슬롯. -->
|
||||
<div class="po-char-wrap">
|
||||
<div class="po-bubbles" id="poBubbles"></div>
|
||||
<div class="po-char" id="poChar">
|
||||
<div class="po-char-emoji" id="poCharEmoji">🧑💼</div>
|
||||
<div class="po-char-prop" id="poCharProp"></div>
|
||||
</div>
|
||||
<div class="po-desk"></div>
|
||||
</div>
|
||||
<!-- 진행률 막대. -->
|
||||
<div class="po-progress" id="poProgress">
|
||||
<div class="po-progress-bar" id="poProgressBar" style="width:0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="po-panel" id="poPanel">
|
||||
<div class="po-row"><span class="po-key">Agent</span><span class="po-val" id="poAgentName">—</span></div>
|
||||
<div class="po-row"><span class="po-key">Status</span><span class="po-val po-val-status" id="poStatusVal">idle</span></div>
|
||||
<div class="po-row"><span class="po-key">Task</span><span class="po-val" id="poTask">—</span></div>
|
||||
<div class="po-row"><span class="po-key">Step</span><span class="po-val" id="poStep">—</span></div>
|
||||
<div class="po-row" id="poNextStepRow" style="display:none;"><span class="po-key">Next</span><span class="po-val" id="poNextStep">—</span></div>
|
||||
<div class="po-row" id="poMessageRow" style="display:none;"><span class="po-key">Note</span><span class="po-val" id="poMessage">—</span></div>
|
||||
<div class="po-section" id="poNeedInputSection" style="display:none;">
|
||||
<div class="po-section-head">Need User Input</div>
|
||||
<ol class="po-need-input" id="poNeedInputList"></ol>
|
||||
</div>
|
||||
<div class="po-section" id="poApprovalSection" style="display:none;">
|
||||
<div class="po-section-head">Awaiting Approval</div>
|
||||
<div class="po-approval" id="poApprovalText">—</div>
|
||||
</div>
|
||||
<div class="po-section" id="poContractSection" style="display:none;">
|
||||
<div class="po-section-head">Requirement Contract</div>
|
||||
<div class="po-contract" id="poContract"></div>
|
||||
</div>
|
||||
<div class="po-section" id="poLogsSection" style="display:none;">
|
||||
<div class="po-section-head">Recent Logs</div>
|
||||
<div class="po-logs" id="poLogs"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat" id="chat">
|
||||
<!-- Dynamic welcome panel — JS의 _renderWelcome()이 두뇌/모델 상태에 맞춰
|
||||
시작 체크리스트 또는 예시 질문을 채워 넣음. 첫 메시지가 가면 사라짐. -->
|
||||
|
||||
+485
-12
@@ -947,6 +947,142 @@
|
||||
renderCompanyChip(!!v.enabled, v.summary || '');
|
||||
break;
|
||||
}
|
||||
case 'companyIntentDecision': {
|
||||
// 1인 기업 모드에서 의도 분류기가 "이건 새 업무가 아님"으로
|
||||
// 판정했을 때 화면에 작은 라벨 한 줄을 띄워, 사용자가 왜
|
||||
// 파이프라인이 안 돌았는지 알 수 있게 한다. new_task로 갈
|
||||
// 땐 라벨 없이 그냥 파이프라인 카드들이 떠서 명확하므로
|
||||
// 별도 알림 필요 없다.
|
||||
const v = msg.value || {};
|
||||
const chatEl = document.getElementById('chat');
|
||||
if (!chatEl) break;
|
||||
const note = document.createElement('div');
|
||||
note.className = 'company-intent-note';
|
||||
note.innerHTML = `<span class="cin-label">${escAttr(v.label || '💬 대화')}</span>` +
|
||||
(v.reason ? ` <span class="cin-reason">${escAttr(v.reason)}</span>` : '');
|
||||
chatEl.appendChild(note);
|
||||
chatEl.scrollTop = chatEl.scrollHeight;
|
||||
break;
|
||||
}
|
||||
case 'pixelOfficeUpdate': {
|
||||
if (typeof window.__pixelOfficeApply === 'function') {
|
||||
window.__pixelOfficeApply(msg.value || {});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'companyAlignmentCard': {
|
||||
// Intent Alignment 카드. kind에 따라 4가지 모드:
|
||||
// - 'auto-proceed' : confidence high → 자동 진행 안내(읽기 전용)
|
||||
// - 'questions' : 답해야 할 질문 + 채팅으로 답변 안내
|
||||
// - 'confirm' : 질문 없음, ✅진행 / 🛑취소 버튼
|
||||
// - 'cancelled' : 사용자가 취소 — 카드 한 줄로 종료 표시
|
||||
const v = msg.value || {};
|
||||
const chatEl = document.getElementById('chat');
|
||||
if (!chatEl) break;
|
||||
const card = document.createElement('div');
|
||||
card.className = 'company-alignment-card';
|
||||
if (v.kind === 'cancelled') {
|
||||
card.classList.add('cancelled');
|
||||
card.innerHTML = '<div class="cph-meta">🛑 의도 정렬 취소됨 — 작업이 시작되지 않았습니다</div>';
|
||||
chatEl.appendChild(card);
|
||||
chatEl.scrollTop = chatEl.scrollHeight;
|
||||
break;
|
||||
}
|
||||
const c = v.contract || {};
|
||||
const kindLabel = v.kind === 'auto-proceed' ? '✅ 의도 분석 완료 — 곧장 진행'
|
||||
: v.kind === 'confirm' ? '🧭 요청 정리 — 확인 후 진행'
|
||||
: '🤔 추가 정보 필요';
|
||||
const head = document.createElement('div');
|
||||
head.className = 'cph-head';
|
||||
head.innerHTML = `<strong>${escAttr(kindLabel)}</strong>`;
|
||||
if (typeof v.roundsAsked === 'number' && typeof v.roundsLimit === 'number') {
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'cph-meta';
|
||||
meta.style.marginLeft = '8px';
|
||||
meta.textContent = `라운드 ${v.roundsAsked + 1}/${v.roundsLimit}`;
|
||||
head.appendChild(meta);
|
||||
}
|
||||
card.appendChild(head);
|
||||
|
||||
// ── C-G-C-F summary block ──
|
||||
const summary = document.createElement('div');
|
||||
summary.className = 'cal-summary';
|
||||
const dl = (label, val) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'cal-row';
|
||||
row.innerHTML = `<span class="cal-key">${escAttr(label)}</span><span class="cal-val">${val ? fmt(val) : '<em>(미정)</em>'}</span>`;
|
||||
return row;
|
||||
};
|
||||
summary.appendChild(dl('맥락', c.context));
|
||||
summary.appendChild(dl('목표', c.goal));
|
||||
if (Array.isArray(c.criteria) && c.criteria.length > 0) {
|
||||
const ul = c.criteria.map((x) => `- ${x}`).join('\n');
|
||||
summary.appendChild(dl('기준', ul));
|
||||
} else {
|
||||
summary.appendChild(dl('기준', ''));
|
||||
}
|
||||
summary.appendChild(dl('형식', c.format));
|
||||
card.appendChild(summary);
|
||||
|
||||
// ── 미해결 질문 ──
|
||||
if (Array.isArray(c.openQuestions) && c.openQuestions.length > 0 && v.kind === 'questions') {
|
||||
const qBlock = document.createElement('div');
|
||||
qBlock.className = 'cal-questions';
|
||||
const qHead = document.createElement('div');
|
||||
qHead.className = 'cal-q-head';
|
||||
qHead.textContent = '아래 질문에 한 메시지로 답해 주세요 (다 답해도, 일부만 답해도 OK):';
|
||||
qBlock.appendChild(qHead);
|
||||
const ul = document.createElement('ul');
|
||||
for (const q of c.openQuestions) {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = q;
|
||||
ul.appendChild(li);
|
||||
}
|
||||
qBlock.appendChild(ul);
|
||||
const hint = document.createElement('div');
|
||||
hint.className = 'cal-hint';
|
||||
hint.textContent = '답하지 않고 지금 그대로 시작하려면 아래 "그대로 진행" 버튼을 누르세요.';
|
||||
qBlock.appendChild(hint);
|
||||
card.appendChild(qBlock);
|
||||
}
|
||||
|
||||
// ── 신뢰도 + 액션 버튼 ──
|
||||
const confLabel = c.confidence === 'high' ? '신뢰도: high'
|
||||
: c.confidence === 'medium' ? '신뢰도: medium' : '신뢰도: low';
|
||||
const confEl = document.createElement('div');
|
||||
confEl.className = 'cal-conf cal-conf-' + (c.confidence || 'low');
|
||||
confEl.textContent = confLabel;
|
||||
card.appendChild(confEl);
|
||||
|
||||
if (v.kind !== 'auto-proceed') {
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'cal-actions';
|
||||
const proceedBtn = document.createElement('button');
|
||||
proceedBtn.className = 'send-btn';
|
||||
proceedBtn.textContent = '✅ 그대로 진행';
|
||||
proceedBtn.onclick = () => {
|
||||
vscode.postMessage({ type: 'respondCompanyAlignment', decision: 'proceed' });
|
||||
actions.querySelectorAll('button').forEach((b) => b.disabled = true);
|
||||
proceedBtn.textContent = '✅ 진행 중...';
|
||||
};
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.className = 'secondary-btn';
|
||||
cancelBtn.textContent = '🛑 취소';
|
||||
cancelBtn.title = '이 작업을 시작하지 않음';
|
||||
cancelBtn.onclick = () => {
|
||||
vscode.postMessage({ type: 'respondCompanyAlignment', decision: 'cancel' });
|
||||
actions.querySelectorAll('button').forEach((b) => b.disabled = true);
|
||||
cancelBtn.textContent = '🛑 취소됨';
|
||||
};
|
||||
actions.appendChild(proceedBtn);
|
||||
actions.appendChild(cancelBtn);
|
||||
card.appendChild(actions);
|
||||
}
|
||||
|
||||
chatEl.appendChild(card);
|
||||
chatEl.scrollTop = chatEl.scrollHeight;
|
||||
break;
|
||||
}
|
||||
case 'companyAgents': {
|
||||
renderCompanyAgentCards(msg.value || {});
|
||||
break;
|
||||
@@ -1854,6 +1990,8 @@
|
||||
agentId: '',
|
||||
modelOverride: '',
|
||||
requiresApproval: false,
|
||||
reviewWith: '',
|
||||
reviewMaxRounds: 3,
|
||||
instructionTemplate: '',
|
||||
loopBackPattern: '',
|
||||
loopBackTo: '',
|
||||
@@ -1990,31 +2128,45 @@
|
||||
const agentSel = document.createElement('select');
|
||||
const _refillAgentSel = () => {
|
||||
agentSel.innerHTML = '';
|
||||
// "⚙️ CEO 자동 선택" 옵션을 항상 맨 위에 추가 — value=''로 빈
|
||||
// agentId 저장됨. dispatcher가 보고 직군 후보 중 매번 CEO에게
|
||||
// 적임자 결정 요청. 사용자 의도 "CEO가 배분"의 정공법.
|
||||
const autoOpt = document.createElement('option');
|
||||
autoOpt.value = '';
|
||||
autoOpt.textContent = '⚙️ CEO 자동 선택 (이 직군 중)';
|
||||
agentSel.appendChild(autoOpt);
|
||||
const list = _activeAgentsByCategory[roleSel.value] || [];
|
||||
if (list.length === 0) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
opt.value = '__no_agents__';
|
||||
opt.textContent = '(이 직군의 활성 에이전트 없음)';
|
||||
opt.disabled = true;
|
||||
agentSel.appendChild(opt);
|
||||
agentSel.disabled = true;
|
||||
// CEO 자동 선택은 여전히 가능 — 활성 후보가 없으면 dispatcher에서
|
||||
// no-active-agent-in-role 에러로 사용자에게 알린다.
|
||||
} else {
|
||||
agentSel.disabled = false;
|
||||
for (const a of list) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = a.id;
|
||||
opt.textContent = `${a.emoji} ${a.name}`;
|
||||
agentSel.appendChild(opt);
|
||||
}
|
||||
// 현재 stage의 agentId가 이 직군에 속하면 유지, 아니면 첫 번째.
|
||||
const inList = list.some((a) => a.id === stage.agentId);
|
||||
agentSel.value = inList ? stage.agentId : list[0].id;
|
||||
stage.agentId = agentSel.value;
|
||||
}
|
||||
// 현재 stage.agentId가 빈값이면 CEO 자동 선택(default). 활성 후보에 있으면
|
||||
// 그 사람, 없으면 다시 CEO 자동으로 되돌림 — stale agentId 박제 방지.
|
||||
const aid = stage.agentId || '';
|
||||
if (aid && list.some((a) => a.id === aid)) {
|
||||
agentSel.value = aid;
|
||||
} else {
|
||||
agentSel.value = '';
|
||||
stage.agentId = '';
|
||||
}
|
||||
};
|
||||
_refillAgentSel();
|
||||
roleSel.onchange = () => {
|
||||
stage.roleCategory = roleSel.value;
|
||||
stage.agentId = _firstAgentOfCategory(roleSel.value);
|
||||
// 직군 바뀌면 명시적 담당자 박힌 게 더 이상 의미 없음 — CEO 자동 선택으로 리셋.
|
||||
stage.agentId = '';
|
||||
_refillAgentSel();
|
||||
};
|
||||
agentSel.onchange = () => { stage.agentId = agentSel.value; };
|
||||
@@ -2053,6 +2205,71 @@
|
||||
approvalWrap.appendChild(approvalText);
|
||||
body.appendChild(approvalWrap);
|
||||
|
||||
// ── 3-way 합의 검수 사이클 ──
|
||||
// 작업자 산출물 → 검수자 의견 → CEO 메타-판단을 라운드로 반복해
|
||||
// "작업자 + 검수자 + 대표" 셋이 모두 만족할 때까지 진행. 작은
|
||||
// 모델에선 라운드를 많이 돌 수 있으므로 maxRounds 안전망 필요.
|
||||
const reviewWrap = document.createElement('label');
|
||||
reviewWrap.style.cssText = 'display:flex; gap:6px; align-items:center; font-size:10px; color:var(--text-dim); cursor:pointer; margin-top:4px;';
|
||||
const reviewCb = document.createElement('input');
|
||||
reviewCb.type = 'checkbox';
|
||||
reviewCb.checked = !!stage.reviewWith;
|
||||
const reviewText = document.createElement('span');
|
||||
reviewText.textContent = '🔍 검수 사이클 (작업자 + 검수자 + CEO 합의)';
|
||||
reviewText.title = '체크하면 작업자 산출물 직후 검수자 + CEO 메타-판단을 라운드로 돌려 셋이 모두 만족할 때만 통과';
|
||||
reviewWrap.appendChild(reviewCb);
|
||||
reviewWrap.appendChild(reviewText);
|
||||
body.appendChild(reviewWrap);
|
||||
|
||||
// 검수자 선택 + 최대 라운드 (체크박스 ON일 때만 노출)
|
||||
const reviewDetail = document.createElement('div');
|
||||
reviewDetail.style.cssText = 'display:none; gap:8px; flex-wrap:wrap; align-items:center; font-size:10px; color:var(--text-dim); margin: 2px 0 4px 22px;';
|
||||
const inspLbl = document.createElement('label'); inspLbl.textContent = '검수자:';
|
||||
const inspSel = document.createElement('select');
|
||||
// 옵션: 직군 inspector 자동 + 활성 inspector 직군 에이전트 + 'any active agent of given category' 정도면 충분
|
||||
const inspectorOpts = (_activeAgentsByCategory['inspector'] || []);
|
||||
const autoOpt = document.createElement('option');
|
||||
autoOpt.value = 'inspector';
|
||||
autoOpt.textContent = '⚙️ 감리 직군 자동';
|
||||
inspSel.appendChild(autoOpt);
|
||||
for (const a of inspectorOpts) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = `agent:${a.id}`;
|
||||
opt.textContent = `${a.emoji} ${a.name}`;
|
||||
inspSel.appendChild(opt);
|
||||
}
|
||||
// 현재값 적용
|
||||
inspSel.value = stage.reviewWith || 'inspector';
|
||||
inspSel.onchange = () => {
|
||||
stage.reviewWith = inspSel.value;
|
||||
};
|
||||
const roundLbl = document.createElement('label'); roundLbl.textContent = '최대 라운드:';
|
||||
const roundInput = document.createElement('input');
|
||||
roundInput.type = 'number'; roundInput.min = '1'; roundInput.max = '10';
|
||||
roundInput.style.cssText = 'width:55px; padding:2px 4px; background:var(--bg); color:var(--text-primary); border:1px solid var(--border); border-radius:4px;';
|
||||
roundInput.value = String(stage.reviewMaxRounds || 3);
|
||||
roundInput.oninput = () => {
|
||||
const v = parseInt(roundInput.value, 10);
|
||||
stage.reviewMaxRounds = Number.isFinite(v) ? Math.max(1, Math.min(10, v)) : 3;
|
||||
};
|
||||
reviewDetail.appendChild(inspLbl); reviewDetail.appendChild(inspSel);
|
||||
reviewDetail.appendChild(roundLbl); reviewDetail.appendChild(roundInput);
|
||||
body.appendChild(reviewDetail);
|
||||
|
||||
const _syncReviewDetail = () => {
|
||||
reviewDetail.style.display = reviewCb.checked ? 'flex' : 'none';
|
||||
};
|
||||
_syncReviewDetail();
|
||||
reviewCb.onchange = () => {
|
||||
if (reviewCb.checked) {
|
||||
stage.reviewWith = inspSel.value || 'inspector';
|
||||
if (!stage.reviewMaxRounds) stage.reviewMaxRounds = 3;
|
||||
} else {
|
||||
stage.reviewWith = '';
|
||||
}
|
||||
_syncReviewDetail();
|
||||
};
|
||||
|
||||
// 지시 텍스트 + 토큰 버튼
|
||||
const instrLabelDiv = document.createElement('div');
|
||||
instrLabelDiv.className = 'psc-field-label';
|
||||
@@ -2175,6 +2392,8 @@
|
||||
agentId: s.agentId || '',
|
||||
modelOverride: s.modelOverride || '',
|
||||
requiresApproval: !!s.requiresApproval,
|
||||
reviewWith: s.reviewWith || '',
|
||||
reviewMaxRounds: s.reviewMaxRounds || 3,
|
||||
instructionTemplate: s.instructionTemplate || '',
|
||||
loopBackPattern: s.loopBackPattern || '',
|
||||
loopBackTo: s.loopBackTo || '',
|
||||
@@ -2241,15 +2460,16 @@
|
||||
if (_pipelineEditError) _pipelineEditError.textContent = 'id는 필수입니다 (소문자로 시작, /-/_).';
|
||||
return;
|
||||
}
|
||||
// 각 stage 검증: 라벨 + 담당 에이전트 필수.
|
||||
// 각 stage 검증: 라벨 + (담당자 또는 직군) 중 하나는 반드시.
|
||||
// CEO 자동 선택 모드(agentId 비어 있음)면 직군이 필수.
|
||||
for (let i = 0; i < _editStages.length; i++) {
|
||||
const s = _editStages[i];
|
||||
if (!s.label?.trim()) {
|
||||
if (_pipelineEditError) _pipelineEditError.textContent = `${i + 1}번 단계: 이름이 비어 있습니다.`;
|
||||
return;
|
||||
}
|
||||
if (!s.agentId) {
|
||||
if (_pipelineEditError) _pipelineEditError.textContent = `${i + 1}번 단계: 담당 에이전트를 지정하세요. 해당 직군에 활성 에이전트가 없으면 관리 패널에서 활성화 먼저.`;
|
||||
if (!s.agentId && !s.roleCategory) {
|
||||
if (_pipelineEditError) _pipelineEditError.textContent = `${i + 1}번 단계: 담당자(또는 직군)을 지정하세요.`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -2260,14 +2480,22 @@
|
||||
const out = {
|
||||
id: s.id,
|
||||
label: s.label.trim(),
|
||||
agentId: s.agentId,
|
||||
// 빈 agentId(= CEO 자동 선택) 일 땐 필드 자체를 빼서 backend에
|
||||
// optional로 전달. dispatcher가 roleCategory 보고 실시간 결정.
|
||||
roleCategory: s.roleCategory,
|
||||
instructionTemplate: s.instructionTemplate || '',
|
||||
};
|
||||
if (s.agentId && s.agentId.trim()) {
|
||||
out.agentId = s.agentId.trim();
|
||||
}
|
||||
if (s.modelOverride && s.modelOverride.trim()) {
|
||||
out.modelOverride = s.modelOverride.trim();
|
||||
}
|
||||
if (s.requiresApproval) out.requiresApproval = true;
|
||||
if (s.reviewWith && s.reviewWith.trim()) {
|
||||
out.reviewWith = s.reviewWith.trim();
|
||||
out.reviewMaxRounds = s.reviewMaxRounds || 3;
|
||||
}
|
||||
if (s.loopBackPattern && s.loopBackTo) {
|
||||
out.loopBackPattern = s.loopBackPattern;
|
||||
out.loopBackTo = s.loopBackTo;
|
||||
@@ -2377,6 +2605,214 @@
|
||||
window.__renderCompanyPipelines = renderCompanyPipelines;
|
||||
window.__closePipelineEditor = _closePipelineEditor;
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Pixel Office 렌더러 — 백엔드의 pixelOfficeUpdate 메시지를 받아 캐릭터,
|
||||
// 정보 패널, 말풍선 큐를 갱신. 어떤 상태도 그대로 그리기만 함 (read-only).
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
(function setupPixelOffice() {
|
||||
const root = document.getElementById('pixelOffice');
|
||||
if (!root) return;
|
||||
const collapseBtn = document.getElementById('poCollapseBtn');
|
||||
const expandBtn = document.getElementById('poExpandBtn');
|
||||
const head = document.querySelector('#pixelOffice .po-head');
|
||||
const charEl = document.getElementById('poChar');
|
||||
const charEmoji = document.getElementById('poCharEmoji');
|
||||
const charProp = document.getElementById('poCharProp');
|
||||
const bubblesEl = document.getElementById('poBubbles');
|
||||
const progressBar = document.getElementById('poProgressBar');
|
||||
const statusLabel = document.getElementById('poStatusLabel');
|
||||
const statusVal = document.getElementById('poStatusVal');
|
||||
const agentName = document.getElementById('poAgentName');
|
||||
const taskEl = document.getElementById('poTask');
|
||||
const stepEl = document.getElementById('poStep');
|
||||
const nextStepRow = document.getElementById('poNextStepRow');
|
||||
const nextStepEl = document.getElementById('poNextStep');
|
||||
const messageRow = document.getElementById('poMessageRow');
|
||||
const messageEl = document.getElementById('poMessage');
|
||||
const needInputSection = document.getElementById('poNeedInputSection');
|
||||
const needInputList = document.getElementById('poNeedInputList');
|
||||
const approvalSection = document.getElementById('poApprovalSection');
|
||||
const approvalText = document.getElementById('poApprovalText');
|
||||
const contractSection = document.getElementById('poContractSection');
|
||||
const contractEl = document.getElementById('poContract');
|
||||
const logsSection = document.getElementById('poLogsSection');
|
||||
const logsEl = document.getElementById('poLogs');
|
||||
|
||||
// 상태별 캐릭터·소품 매핑 — 픽셀아트 대신 이모지 조합으로.
|
||||
const STATUS_VIS = {
|
||||
idle: { emoji: '🧑💼', prop: '' },
|
||||
intake: { emoji: '🧑💼', prop: '📨' },
|
||||
analyzing: { emoji: '🧐', prop: '🔍' },
|
||||
need_clarification: { emoji: '🤔', prop: '❓' },
|
||||
contract_ready: { emoji: '🧑💼', prop: '📋' },
|
||||
planning: { emoji: '🧑💼', prop: '📝' },
|
||||
executing: { emoji: '🧑💻', prop: '⚙️' },
|
||||
reviewing: { emoji: '🧐', prop: '✅' },
|
||||
waiting_approval: { emoji: '🧑💼', prop: '🛑' },
|
||||
error: { emoji: '😵', prop: '⚠️' },
|
||||
done: { emoji: '😎', prop: '☕' },
|
||||
};
|
||||
|
||||
// ── 말풍선 큐 ──
|
||||
// 큐 크기 제한 + 자동 사라짐 타이머. 같은 텍스트 연속 등장 시 1개로 합침.
|
||||
let cfg = { enabled: true, bubblesEnabled: true, maxVisibleBubbles: 3, bubbleDurationMs: 4500 };
|
||||
const bubbleQueue = []; // { el, timer }
|
||||
let lastBubbleText = '';
|
||||
|
||||
const collapseToggle = () => {
|
||||
const cur = root.getAttribute('data-collapsed') === 'true';
|
||||
root.setAttribute('data-collapsed', cur ? 'false' : 'true');
|
||||
};
|
||||
if (collapseBtn) collapseBtn.onclick = (e) => { e.stopPropagation(); collapseToggle(); };
|
||||
if (expandBtn) expandBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
// 백엔드에 전체보기 panel 열기 요청.
|
||||
vscode.postMessage({ type: 'openPixelOfficePanel' });
|
||||
};
|
||||
// head 영역 자체 클릭으로도 토글 (버튼 외 영역).
|
||||
if (head) head.addEventListener('click', (e) => {
|
||||
if (e.target instanceof HTMLElement && (e.target.closest('.po-collapse') || e.target.closest('.po-expand'))) return;
|
||||
collapseToggle();
|
||||
});
|
||||
|
||||
const dropOldestBubble = () => {
|
||||
const first = bubbleQueue.shift();
|
||||
if (!first) return;
|
||||
if (first.timer) clearTimeout(first.timer);
|
||||
first.el.classList.add('po-bubble-fading');
|
||||
setTimeout(() => { try { first.el.remove(); } catch {} }, 300);
|
||||
};
|
||||
|
||||
const pushBubble = (b) => {
|
||||
if (!cfg.bubblesEnabled) return;
|
||||
if (!b || !b.text) return;
|
||||
if (b.text === lastBubbleText) return; // 연속 중복 차단
|
||||
lastBubbleText = b.text;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'po-bubble po-bubble-' + (b.type || 'status');
|
||||
el.textContent = b.text;
|
||||
bubblesEl.appendChild(el);
|
||||
const duration = b.durationMs || cfg.bubbleDurationMs || 4500;
|
||||
const timer = setTimeout(() => {
|
||||
const idx = bubbleQueue.findIndex((x) => x.el === el);
|
||||
if (idx >= 0) {
|
||||
bubbleQueue.splice(idx, 1);
|
||||
el.classList.add('po-bubble-fading');
|
||||
setTimeout(() => { try { el.remove(); } catch {} }, 300);
|
||||
}
|
||||
}, duration);
|
||||
bubbleQueue.push({ el, timer });
|
||||
while (bubbleQueue.length > Math.max(1, cfg.maxVisibleBubbles)) {
|
||||
dropOldestBubble();
|
||||
}
|
||||
};
|
||||
|
||||
const setText = (el, val) => { if (el) el.textContent = (val == null || val === '') ? '—' : String(val); };
|
||||
|
||||
const apply = (payload) => {
|
||||
cfg = Object.assign(cfg, payload && payload.config ? payload.config : {});
|
||||
root.setAttribute('data-enabled', cfg.enabled ? 'true' : 'false');
|
||||
if (!cfg.enabled) return;
|
||||
const state = payload && payload.state;
|
||||
if (state) {
|
||||
const vis = STATUS_VIS[state.status] || STATUS_VIS.idle;
|
||||
if (charEmoji) charEmoji.textContent = vis.emoji;
|
||||
if (charProp) charProp.textContent = vis.prop;
|
||||
root.setAttribute('data-status', state.status || 'idle');
|
||||
// 상태 라벨 색상 클래스 새로.
|
||||
if (statusLabel) {
|
||||
statusLabel.className = 'po-status-label po-status-' + (state.status || 'idle');
|
||||
statusLabel.textContent = state.status || 'idle';
|
||||
}
|
||||
setText(statusVal, state.status);
|
||||
setText(agentName, state.agentName);
|
||||
setText(taskEl, state.currentTask);
|
||||
setText(stepEl, state.currentStep);
|
||||
if (state.nextStep) {
|
||||
nextStepRow.style.display = '';
|
||||
setText(nextStepEl, state.nextStep);
|
||||
} else {
|
||||
nextStepRow.style.display = 'none';
|
||||
}
|
||||
if (state.message) {
|
||||
messageRow.style.display = '';
|
||||
setText(messageEl, state.message);
|
||||
} else {
|
||||
messageRow.style.display = 'none';
|
||||
}
|
||||
// Progress
|
||||
if (progressBar) {
|
||||
const pct = typeof state.progress === 'number'
|
||||
? Math.round(Math.max(0, Math.min(1, state.progress)) * 100)
|
||||
: 0;
|
||||
progressBar.style.width = pct + '%';
|
||||
}
|
||||
// Need input
|
||||
if (Array.isArray(state.needUserInput) && state.needUserInput.length > 0) {
|
||||
needInputSection.style.display = '';
|
||||
needInputList.innerHTML = '';
|
||||
for (const q of state.needUserInput) {
|
||||
const li = document.createElement('li'); li.textContent = q;
|
||||
needInputList.appendChild(li);
|
||||
}
|
||||
} else {
|
||||
needInputSection.style.display = 'none';
|
||||
}
|
||||
// Approval
|
||||
if (state.awaitingApproval) {
|
||||
approvalSection.style.display = '';
|
||||
setText(approvalText, state.awaitingApproval);
|
||||
} else {
|
||||
approvalSection.style.display = 'none';
|
||||
}
|
||||
// Contract
|
||||
const c = state.requirementContract;
|
||||
if (c && (c.goal || c.context || (c.criteria && c.criteria.length) || c.format)) {
|
||||
contractSection.style.display = '';
|
||||
contractEl.innerHTML = '';
|
||||
const addRow = (k, v) => {
|
||||
if (!v || (Array.isArray(v) && v.length === 0)) return;
|
||||
const ke = document.createElement('div');
|
||||
ke.className = 'po-contract-key'; ke.textContent = k;
|
||||
const ve = document.createElement('div');
|
||||
ve.className = 'po-contract-val';
|
||||
ve.textContent = Array.isArray(v) ? v.map((x) => '• ' + x).join('\n') : String(v);
|
||||
ve.style.whiteSpace = 'pre-line';
|
||||
contractEl.appendChild(ke);
|
||||
contractEl.appendChild(ve);
|
||||
};
|
||||
addRow('Goal', c.goal);
|
||||
addRow('Ctx', c.context);
|
||||
addRow('Crit', c.criteria);
|
||||
addRow('Fmt', c.format);
|
||||
if (c.confidence) addRow('Conf', c.confidence);
|
||||
} else {
|
||||
contractSection.style.display = 'none';
|
||||
}
|
||||
// Recent logs
|
||||
if (Array.isArray(state.recentLogs) && state.recentLogs.length > 0) {
|
||||
logsSection.style.display = '';
|
||||
logsEl.innerHTML = '';
|
||||
for (const line of state.recentLogs) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = line;
|
||||
logsEl.appendChild(d);
|
||||
}
|
||||
} else {
|
||||
logsSection.style.display = 'none';
|
||||
}
|
||||
}
|
||||
// Bubbles
|
||||
if (Array.isArray(payload?.bubbles)) {
|
||||
for (const b of payload.bubbles) pushBubble(b);
|
||||
}
|
||||
};
|
||||
window.__pixelOfficeApply = apply;
|
||||
|
||||
// webview 로드 직후 백엔드 캐시 상태 요청.
|
||||
try { vscode.postMessage({ type: 'getPixelOfficeState' }); } catch {}
|
||||
})();
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// 이어서 진행 가능 세션 렌더링.
|
||||
//
|
||||
@@ -3063,6 +3499,43 @@
|
||||
} else if (ev.phase === 'stage-loop') {
|
||||
card.innerHTML = `<div class="cph-head">🔁 Stage 재시도</div>
|
||||
<div class="cph-meta">${escAttr(ev.from)} → ${escAttr(ev.to)} (반복 ${ev.iteration}회)</div>`;
|
||||
} else if (ev.phase === 'review-start') {
|
||||
// 검수 사이클 시작 — 같은 stageId에 라운드를 누적할 컨테이너 카드.
|
||||
// 이후 review-round 이벤트가 이 카드의 .rev-rounds 자식 안에 한 줄씩 append.
|
||||
card.className += ' review';
|
||||
card.dataset.stageId = ev.stageId;
|
||||
card.dataset.reviewMaxRounds = String(ev.maxRounds || 3);
|
||||
card.innerHTML = `<div class="cph-head">🔍 <strong>${escAttr(ev.stageLabel || ev.stageId)}</strong> 검수 사이클 시작 <span class="cph-meta">검수자: ${escAttr(ev.inspectorAgentId)} · 최대 ${ev.maxRounds}라운드</span></div>
|
||||
<div class="rev-rounds"></div>`;
|
||||
} else if (ev.phase === 'review-round') {
|
||||
// 이미 그려둔 review-start 카드 안에 라운드 한 줄을 누적.
|
||||
const target = chatEl.querySelector(`.company-phase-card.review[data-stage-id="${CSS.escape(ev.stageId)}"] .rev-rounds`);
|
||||
if (target) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'rev-round';
|
||||
const inspIcon = ev.inspectorVerdict === 'pass' ? '✅' : ev.inspectorVerdict === 'revise' ? '❌' : '❓';
|
||||
const ceoIcon = ev.ceoVerdict === 'pass' ? '✅' : ev.ceoVerdict === 'abort' ? '🛑' : ev.ceoVerdict === 'revise' ? '🔁' : '❓';
|
||||
row.innerHTML = `<div class="rev-round-head">라운드 ${ev.round} <span class="cph-meta">${(ev.durationMs/1000).toFixed(1)}s</span></div>
|
||||
<div class="rev-line"><span class="rev-actor">${inspIcon} 검수</span><span class="rev-body">${fmt((ev.inspectorText || '').slice(0, 1500))}</span></div>
|
||||
<div class="rev-line"><span class="rev-actor">${ceoIcon} CEO</span><span class="rev-body">${fmt((ev.ceoText || '').slice(0, 1000))}</span></div>`;
|
||||
target.appendChild(row);
|
||||
}
|
||||
return; // 새 카드 만들지 않음
|
||||
} else if (ev.phase === 'review-end') {
|
||||
// 사이클 종료 라벨을 컨테이너 카드 끝에 붙임 — 별도 카드 X.
|
||||
const target = chatEl.querySelector(`.company-phase-card.review[data-stage-id="${CSS.escape(ev.stageId)}"]`);
|
||||
if (target) {
|
||||
const tail = document.createElement('div');
|
||||
tail.className = 'rev-end';
|
||||
const label = ev.final === 'pass'
|
||||
? `✅ 합의 통과 (${ev.rounds}라운드)`
|
||||
: ev.final === 'maxed-out'
|
||||
? `⚠️ 최대 라운드 도달 — 현 결과로 진행 (${ev.rounds}라운드)`
|
||||
: `🛑 사이클 중단 (${ev.rounds}라운드)`;
|
||||
tail.textContent = label;
|
||||
target.appendChild(tail);
|
||||
}
|
||||
return;
|
||||
} else if (ev.phase === 'awaiting-approval') {
|
||||
// 승인 게이트 카드 — 사용자가 클릭할 때까지 대기.
|
||||
card.className += ' approval';
|
||||
|
||||
Reference in New Issue
Block a user