Files
connectai/media/sidebar.js
T

3738 lines
207 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const vscode = acquireVsCodeApi();
const chat = document.getElementById('chat');
const input = document.getElementById('input');
// [State Persistence - Tier 0] 즉시 복원 (Instant Restore from WebView State)
const previousState = vscode.getState();
if (previousState && previousState.history && previousState.history.length > 0) {
console.log('[Astra] Restoring from Webview State...');
renderHistory(previousState.history);
}
function saveWebviewState(history) {
const current = vscode.getState() || {};
vscode.setState({ ...current, history });
}
function saveUiState() {
const current = vscode.getState() || {};
vscode.setState({ ...current, secondBrainTraceEnabled, secondBrainTraceDebug });
}
function renderHistory(history) {
if (!history || history.length === 0) return;
chat.innerHTML = '';
history.forEach(m => {
if (!m) return;
// Only skip truly internal system messages, keep assistant thoughts
if (m.role === 'system' && m.internal) return;
addMsg(m.content, m.role === 'assistant' ? 'assistant' : 'user', m.rationale);
});
chat.scrollTop = chat.scrollHeight;
}
const sendBtn = document.getElementById('sendBtn');
const stopBtn = document.getElementById('stopBtn');
const cancelBtn = document.getElementById('cancelBtn');
const toastNotif = document.getElementById('toastNotif');
const thinkingBar = document.getElementById('thinkingBar');
const statusLabel = document.getElementById('statusLabel');
const stepper = document.getElementById('stepper');
// --- Draft State Management ---
let isDraftActive = false;
let _toastTimer = null;
function showToast(msg, type = 'info') {
toastNotif.textContent = msg;
toastNotif.className = 'toast-notif toast-' + type + ' toast-visible';
if (_toastTimer) clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => {
toastNotif.classList.remove('toast-visible');
}, 2500);
}
function setDraftActive(active) {
isDraftActive = active;
cancelBtn.style.display = active ? 'inline-flex' : 'none';
}
// 생성 중/완료 시 Send ⇔ Stop 전환
function setGenerating(generating) {
if (generating) {
sendBtn.style.display = 'none';
stopBtn.style.display = 'inline-flex';
// 생성 중에는 Clear 버튼 숨김
cancelBtn.style.display = 'none';
} else {
stopBtn.style.display = 'none';
sendBtn.style.display = 'inline-flex';
sendBtn.disabled = false;
// Draft 상태에 따라 Clear 버튼 복원
if (isDraftActive) cancelBtn.style.display = 'inline-flex';
}
}
function clearDraft() {
// Step 1: 상태 초기화 (Draft State Reset)
setDraftActive(false);
// Step 2: UI 반영 (Input + Attachments 초기화)
input.value = '';
input.style.height = 'auto';
pendingFiles = [];
renderAttachments();
input.focus();
// Step 3: Toast 알림으로 즉각적 피드백
showToast('✕ 작성 내용이 초기화되었습니다.', 'warn');
Sound.warn();
}
// --- Sound Manager ---
const Sound = {
ctx: null,
init() { if (!this.ctx) this.ctx = new (window.AudioContext || window.webkitAudioContext)(); },
play(freq, type, dur) {
try {
this.init();
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, this.ctx.currentTime);
gain.gain.setValueAtTime(0.05, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + dur);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start();
osc.stop(this.ctx.currentTime + dur);
} catch(e) {}
},
success() { this.play(880, 'sine', 0.1); setTimeout(() => this.play(1109, 'sine', 0.15), 80); },
warn() { this.play(440, 'triangle', 0.3); }
};
function setStep(stepId, state = 'active') {
stepper.classList.add('active');
const step = document.getElementById('step-' + stepId);
if (step) {
if (state === 'active') {
document.querySelectorAll('.step').forEach(s => s.classList.remove('active'));
step.classList.add('active');
} else if (state === 'complete') {
step.classList.remove('active');
step.classList.add('complete');
}
}
}
function resetStepper() {
stepper.classList.remove('active');
document.querySelectorAll('.step').forEach(s => {
s.classList.remove('active');
s.classList.remove('complete');
});
}
const modelSel = document.getElementById('modelSel');
const brainSel = document.getElementById('brainSel');
const historyOverlay = document.getElementById('historyOverlay');
const historyList = document.getElementById('historyList');
const statusDot = document.getElementById('statusDot');
const engineStatusText = document.getElementById('engineStatusText');
const attachBtn = document.getElementById('attachBtn');
const fileInput = document.getElementById('fileInput');
const attachPreview = document.getElementById('attachPreview');
const agentSel = document.getElementById('agentSel');
const designerSel = document.getElementById('designerSel');
const chronicleRecordSel = document.getElementById('chronicleRecordSel');
const editAgentBtn = document.getElementById('editAgentBtn');
const addAgentBtn = document.getElementById('addAgentBtn');
const deleteAgentBtn = document.getElementById('deleteAgentBtn');
const knowledgeScopeSel = document.getElementById('knowledgeScopeSel');
const editKnowledgeMapBtn = document.getElementById('editKnowledgeMapBtn');
const reloadKnowledgeMapBtn = document.getElementById('reloadKnowledgeMapBtn');
const addBrainBtn = document.getElementById('addBrainBtn');
const editBrainBtn = document.getElementById('editBrainBtn');
const deleteBrainBtn = document.getElementById('deleteBrainBtn');
const saveWikiRawBtn = document.getElementById('saveWikiRawBtn');
const agentConfigPanel = document.getElementById('agentConfigPanel');
const agentPrompt = document.getElementById('agentPrompt');
const negativePrompt = document.getElementById('negativePrompt');
const updateAgentBtn = document.getElementById('updateAgentBtn');
const agentMapOverlay = document.getElementById('agentMapOverlay');
const closeAgentMapBtn = document.getElementById('closeAgentMapBtn');
const cancelAgentMapBtn = document.getElementById('cancelAgentMapBtn');
const saveAgentMapBtn = document.getElementById('saveAgentMapBtn');
const editAgentMapJsonBtn = document.getElementById('editAgentMapJsonBtn');
const addKnowledgeFolderBtn = document.getElementById('addKnowledgeFolderBtn');
const addSkillFolderBtn = document.getElementById('addSkillFolderBtn');
const addSkillFileBtn = document.getElementById('addSkillFileBtn');
const knowledgeFolderList = document.getElementById('knowledgeFolderList');
const skillFolderList = document.getElementById('skillFolderList');
const agentMapAgentName = document.getElementById('agentMapAgentName');
const agentMapStatus = document.getElementById('agentMapStatus');
const readyBar = document.getElementById('readyBar');
const rbDot = document.getElementById('rbDot');
const rbContent = document.getElementById('rbContent');
const ctxBadge = document.getElementById('ctxBadge');
const ctxBrainName = document.getElementById('ctxBrainName');
const ctxAgentName = document.getElementById('ctxAgentName');
const ctxProjectName = document.getElementById('ctxProjectName');
const recordsLatest = document.getElementById('recordsLatest');
function escAttr(t) { return String(t == null ? '' : t).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
function fmtMixHint(w) { return `모델 ${100 - w}% · 두뇌 ${w}%`; }
/** Compact relative-time formatter for chips (e.g. "2m ago", "just now"). */
function formatRelativeTime(iso) {
try {
const then = new Date(iso).getTime();
if (!Number.isFinite(then)) return iso;
const diffSec = Math.max(0, Math.floor((Date.now() - then) / 1000));
if (diffSec < 45) return 'just now';
if (diffSec < 90) return '1m ago';
const m = Math.floor(diffSec / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
return `${d}d ago`;
} catch { return iso; }
}
function fmtK(n) {
if (typeof n !== 'number' || !isFinite(n)) return '?';
if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1).replace(/\.0$/, '') + 'k';
return String(n);
}
function shortModel(m) { m = String(m || ''); const i = m.lastIndexOf('/'); return i >= 0 ? m.slice(i + 1) : m; }
function selText(sel) { try { return sel && sel.selectedIndex >= 0 ? (sel.options[sel.selectedIndex].text || '').trim() : ''; } catch { return ''; } }
function truncMid(s, n) { s = String(s || ''); if (s.length <= n) return s; const h = Math.max(4, Math.floor((n - 1) / 2)); return s.slice(0, h) + '…' + s.slice(-h); }
// ── Context Bar (Brain / Agent / Project summary) + Records line ──────
function syncContextBar() {
if (ctxBrainName) ctxBrainName.textContent = selText(brainSel) || '—';
if (ctxAgentName) { const t = selText(agentSel); ctxAgentName.textContent = (!t || /no agent/i.test(t)) ? '기본' : t; }
if (ctxProjectName) ctxProjectName.textContent = selText(designerSel) || '—';
// welcome 패널이 떠 있으면 두뇌/프로젝트 변경이 즉시 반영되도록 같이 호출.
try { _renderWelcome(); } catch { /* 초기화 전 호출은 무시 */ }
}
// ──────────────────────────────────────────────────────────────────────
// Welcome panel — 빈 채팅 상태에서 보이는 동적 시작 가이드.
//
// 첫 사용자(두뇌 미설정·모델 미선택)에게는 "시작하기 3단계" 체크리스트를,
// 이미 준비가 끝난 사용자에게는 예시 질문 chip을 보여준다. 같은 슬롯에서
// 상태에 따라 내용만 바뀌므로 노이즈가 안 쌓인다. 첫 메시지가 가면
// sender(`if (document.querySelector('.welcome')) ... .remove()`)가
// 패널 자체를 제거하므로 dismiss 로직은 별도로 둘 필요 없음.
// ──────────────────────────────────────────────────────────────────────
const _SAMPLE_PROMPTS = [
{ emoji: '📋', text: '지금 열린 프로젝트의 구조를 분석하고 핵심 모듈을 알려줘' },
{ emoji: '🐞', text: '이 코드에서 잠재적인 버그·엣지 케이스가 있는지 검토해줘' },
{ emoji: '✍️', text: '이 함수에 사용자 입장에서 이해하기 쉬운 주석을 달아줘' },
{ emoji: '🧭', text: '오늘 무엇부터 하면 좋을지 우선순위를 짜줘 (1인 기업 모드 추천)' },
];
function _renderWelcome() {
const panel = document.getElementById('welcomePanel');
if (!panel) return;
// 패널이 한 번 제거된 상태(첫 메시지 이후)면 다시 만들지 않는다.
if (!panel.isConnected) return;
const brainName = (ctxBrainName && ctxBrainName.textContent || '').trim();
const hasBrain = brainName && brainName !== '—';
const modelVal = (modelSel && modelSel.value || '').trim()
|| (document.getElementById('modelInlineSel') && document.getElementById('modelInlineSel').value || '').trim();
const hasModel = !!modelVal;
const ready = hasBrain && hasModel;
panel.innerHTML = '';
const logo = document.createElement('div');
logo.className = 'welcome-logo';
logo.textContent = '✦';
const title = document.createElement('div');
title.className = 'welcome-title';
title.textContent = ready
? '준비 완료. 무엇을 도와드릴까요?'
: 'Astra에 오신 것을 환영합니다';
const lead = document.createElement('p');
lead.className = 'welcome-lead';
lead.textContent = ready
? '아래 예시 중 하나를 눌러 시작하거나, 입력창에 직접 적어보세요.'
: '로컬에서 동작하는 개인 AI 비서입니다. 시작하기 전에 두 가지만 확인해주세요.';
panel.appendChild(logo);
panel.appendChild(title);
panel.appendChild(lead);
if (!ready) {
// ── 시작 체크리스트 (3단계) ──
const steps = document.createElement('div');
steps.className = 'welcome-checklist';
const mkStep = (n, done, label, hint, actionLabel, onAction) => {
const row = document.createElement('div');
row.className = 'wc-step' + (done ? ' done' : '');
const bullet = document.createElement('div');
bullet.className = 'wc-bullet';
bullet.textContent = done ? '✓' : String(n);
const txt = document.createElement('div');
txt.className = 'wc-text';
const t1 = document.createElement('div');
t1.className = 'wc-label';
t1.textContent = label;
const t2 = document.createElement('div');
t2.className = 'wc-hint';
t2.textContent = hint;
txt.appendChild(t1);
txt.appendChild(t2);
row.appendChild(bullet);
row.appendChild(txt);
if (!done && actionLabel && onAction) {
const btn = document.createElement('button');
btn.className = 'wc-action';
btn.textContent = actionLabel;
btn.onclick = onAction;
row.appendChild(btn);
}
return row;
};
steps.appendChild(mkStep(
1, hasBrain,
'두뇌(지식 폴더) 연결',
'자주 쓰는 노트·문서를 모아둔 로컬 폴더입니다. Astra가 답변할 때 이 폴더의 내용을 참고합니다.',
'두뇌 추가',
() => {
const editBtn = document.getElementById('contextEditBtn');
if (editBtn) editBtn.click();
const addBrainBtn = document.getElementById('addBrainBtn');
if (addBrainBtn) setTimeout(() => addBrainBtn.click(), 120);
},
));
steps.appendChild(mkStep(
2, hasModel,
'사용할 모델 선택',
'LM Studio 또는 Ollama에 로드되어 있는 로컬 모델을 고릅니다.',
'모델 열기',
() => {
const editBtn = document.getElementById('contextEditBtn');
if (editBtn) editBtn.click();
setTimeout(() => { if (modelSel) modelSel.focus(); }, 120);
},
));
steps.appendChild(mkStep(
3, false,
'첫 질문 적어보기',
'아래 입력창에 자연어로 무엇이든 적어보세요. 코드·문서·아이디어 모두 가능합니다.',
'입력창으로',
() => { try { input && input.focus(); } catch {} },
));
panel.appendChild(steps);
return;
}
// ── 준비 완료 상태: 예시 질문 chip ──
const chips = document.createElement('div');
chips.className = 'welcome-chips';
for (const p of _SAMPLE_PROMPTS) {
const chip = document.createElement('button');
chip.className = 'welcome-chip';
chip.innerHTML = `<span class="welcome-chip-emoji">${p.emoji}</span><span class="welcome-chip-text"></span>`;
chip.querySelector('.welcome-chip-text').textContent = p.text;
chip.title = '클릭하면 입력창에 채워집니다 (자동 전송 안 함).';
chip.onclick = () => {
if (!input) return;
input.value = p.text;
input.style.height = 'auto';
input.style.height = input.scrollHeight + 'px';
input.focus();
};
chips.appendChild(chip);
}
panel.appendChild(chips);
// ── 하단 부드러운 안내 (1인 기업 모드, 단축키) ──
const tips = document.createElement('div');
tips.className = 'welcome-tips';
tips.innerHTML = '복잡한 작업은 헤더의 <b>[기업 모드]</b>로 여러 전문 에이전트에게 분배할 수 있습니다. · 입력 후 <b>Cmd/Ctrl + Enter</b>로 전송.';
panel.appendChild(tips);
}
// 모델 selector가 바뀌면 welcome도 즉시 갱신.
document.addEventListener('change', (e) => {
if (e.target && (e.target.id === 'modelSel' || e.target.id === 'modelInlineSel')) {
try { _renderWelcome(); } catch {}
}
});
function syncRecordsLine() {
if (!recordsLatest) return;
const opt = chronicleRecordSel && chronicleRecordSel.value ? selText(chronicleRecordSel) : '';
recordsLatest.textContent = opt ? '· ' + truncMid(opt, 38) : '';
}
// ── Ready-status bar (Engine / Model / Brain count / Context / Memory) ──
let readyState = {};
function renderReadyBar() {
if (!readyBar || !rbContent) return;
const s = readyState;
const segs = [];
if (s.engine) {
const on = s.engine.online;
const tag = on === true ? 'Online' : on === false ? 'Offline' : '확인 중';
segs.push(`<span class="rb-seg ${on === false ? 'bad' : on === true ? 'ok' : ''}">${escAttr(tag)}</span>`);
}
if (s.model && s.model.name) {
const loaded = s.model.loaded;
segs.push(`<span class="rb-seg" title="${escAttr(s.model.name)}${loaded === true ? ' — 메모리에 로드됨' : loaded === false ? ' — 아직 로드 안 됨' : ''}">${escAttr(shortModel(s.model.name))}</span>`);
}
if (s.brain && typeof s.brain.files === 'number') {
segs.push(`<span class="rb-seg" title="${escAttr('Brain: ' + (s.brain.name || ''))}">Brain ${s.brain.files}</span>`);
}
if (typeof s.contextLength === 'number') {
if (s.cappedForSmallModel) {
segs.push(`<span class="rb-seg rb-warn" title="${escAttr('작은 모델(≤4B) 감지 — 예산을 ' + fmtK(s.contextLength) + ' tokens 로 축소 (설정 g1nation.contextLength = ' + fmtK(s.nominalContextLength) + '). g1nation.smallModelContextCap 으로 조절.')}">ctx ${fmtK(s.contextLength)} <span class="rb-dim">· 소형모델 제한</span></span>`);
} else {
segs.push(`<span class="rb-seg" title="모델 context window (g1nation.contextLength). 실제 로드된 값과 맞춰주세요.">ctx ${fmtK(s.contextLength)}</span>`);
}
}
segs.push(`<span class="rb-seg ${s.memory ? '' : 'rb-dim'}">메모리 ${s.memory ? 'On' : 'Off'}</span>`);
if (s.multiAgent) segs.push(`<span class="rb-seg">멀티에이전트</span>`);
rbContent.innerHTML = segs.join('<span class="rb-sep">·</span>');
if (rbDot) {
const on = s.engine && s.engine.online;
rbDot.className = 'rb-dot ' + (on === true ? 'ok' : on === false ? 'bad' : 'warn');
}
}
// ── Context-budget badge (직전 요청 기준) ────────────────────────────
function renderCtxBadge(b) {
if (!ctxBadge) return;
if (!b || typeof b.inputTokens !== 'number') { ctxBadge.textContent = ''; ctxBadge.className = 'ctx-badge'; ctxBadge.title = ''; return; }
const parts = [`${fmtK(b.inputTokens)} in / ${fmtK(b.maxOutputTokens)} out`];
if (typeof b.contextLength === 'number') {
parts.push(b.cappedForSmallModel ? `ctx ${fmtK(b.contextLength)}` : `ctx ${fmtK(b.contextLength)}`);
}
if (typeof b.brainFiles === 'number' && b.brainFiles > 0) parts.push(`Brain ${b.brainFiles}`);
if (b.includesOpenFile) parts.push('📄 열린 파일');
if (b.imageCount > 0) parts.push(`🖼 ${b.imageCount}`);
if (b.droppedHistory > 0) parts.push(`기록 ${b.droppedHistory}`);
if (b.systemTruncated) parts.push('컨텍스트 일부 생략');
if (b.cappedForSmallModel) parts.push('🔻 작은 모델 모드');
if (b.tight) parts.push('⚠ 컨텍스트 거의 가득');
const warn = b.tight || b.systemTruncated;
ctxBadge.textContent = parts.join(' · ');
ctxBadge.className = 'ctx-badge' + (warn ? ' warn' : ' ok');
ctxBadge.title = `model: ${b.model || ''}${b.paramB != null ? ' (~' + b.paramB + 'B)' : ''}\n입력 ≈ ${b.inputTokens} tokens (시스템 ${b.systemTokens}, 기록 ${b.historyKept}개)\n출력 상한 ${b.maxOutputTokens} tokens / 유효 context window ${b.contextLength} tokens${b.cappedForSmallModel ? ' (작은 모델용 축소; 설정값 ' + b.nominalContextLength + ')' : ''}`;
}
if (readyBar) {
readyBar.addEventListener('click', e => {
const t = e.target;
if (t && t.dataset && t.dataset.act === 'map') vscode.postMessage({ type: 'editKnowledgeMap' });
});
}
// ── "Record a lesson?" prompt (after a rollback / rejected change / repeated complaint) ──
function renderLessonCandidate(v) {
const t = v && v.trigger;
const titleText = t === 'rejected'
? '⚠ 방금 변경을 거부하셨네요 — 같은 실수를 반복하지 않게 교훈으로 기록할까요?'
: t === 'qa-feedback'
? '⚠ 같은 문제가 반복되는 것 같습니다 — 교훈으로 기록해두면 다음 작업 전에 자동으로 체크합니다.'
: '⚠ 방금 작업이 롤백됐습니다 — 같은 실수를 반복하지 않게 교훈으로 기록할까요?';
const reasonLine = v && v.reason ? `<div class="lc-reason">사유: ${escAttr(String(v.reason))}</div>` : '';
// Reuse the active (or last) assistant bubble as the anchor; fall back to appending to the chat.
let anchor = streamBody && streamBody._parent;
if (!anchor) { const all = chat.querySelectorAll('.msg.msg-ai'); anchor = all[all.length - 1]; }
const old = (anchor || chat).querySelector('.lesson-candidate-box');
if (old) old.remove();
const box = document.createElement('div');
box.className = 'lesson-candidate-box';
box.innerHTML =
`<div class="lc-title">${escAttr(titleText)}</div>` +
reasonLine +
`<div class="lc-btns"><button class="lc-rec">📝 교훈 기록</button><button class="lc-skip">무시</button></div>`;
box.querySelector('.lc-rec').onclick = () => { vscode.postMessage({ type: 'createLessonFromConversation' }); box.remove(); };
box.querySelector('.lc-skip').onclick = () => box.remove();
if (anchor) {
const actions = anchor.querySelector('.msg-actions');
if (actions) anchor.insertBefore(box, actions); else anchor.appendChild(box);
} else {
chat.appendChild(box);
}
chat.scrollTop = chat.scrollHeight;
}
// ── Per-answer "scope used" footer ──────────────────────────────────
const MEMORY_LAYER_LABELS = {
'long-term-memory': '장기기억',
'project-memory': '프로젝트기억',
'procedural-memory': '절차기억',
'episodic-memory': '에피소드기억',
'project-scan': '프로젝트스캔',
'recent-knowledge': '최근지식',
};
function dirOf(rel) {
const i = Math.max(rel.lastIndexOf('/'), rel.lastIndexOf('\\'));
return i > 0 ? rel.slice(0, i) : '(루트)';
}
function renderScopeFooter(target, v) {
if (!target) return;
const old = target.querySelector('.msg-scope-footer');
if (old) old.remove();
const footer = document.createElement('div');
footer.className = 'msg-scope-footer';
const files = Array.isArray(v.usedBrainFiles) ? v.usedBrainFiles : [];
const layers = (Array.isArray(v.usedMemoryLayers) ? v.usedMemoryLayers : []).map(s => MEMORY_LAYER_LABELS[s] || s);
const lessons = Array.isArray(v.lessonFiles) ? v.lessonFiles : [];
if (files.length === 0 && layers.length === 0 && lessons.length === 0) {
footer.innerHTML = `<span class="scope-link" data-act="map" title="에이전트↔지식 매핑 편집">🔎 참조 지식 없음</span> <span class="scope-dim">— 모델 자체 지식으로 답변</span>`;
} else {
const dirs = Array.from(new Set(files.map(dirOf)));
let scopeLabel;
if (v.scoped && Array.isArray(v.configuredFolders) && v.configuredFolders.length) {
scopeLabel = v.configuredFolders.join(', ');
} else if (dirs.length) {
scopeLabel = dirs.slice(0, 4).join(', ') + (dirs.length > 4 ? `${dirs.length - 4}` : '');
} else if (files.length === 0) {
scopeLabel = '브레인 파일 없음';
} else {
scopeLabel = '전체 브레인';
}
const agentTag = v.agentName ? `[${escAttr(v.agentName)}] ` : '';
const fileTag = files.length ? ` <span class="scope-dim">· 파일 ${files.length}</span>` : '';
const layerTag = layers.length ? ` <span class="scope-dim">· 메모리 ${escAttr(layers.join('·'))}</span>` : '';
const lessonTag = lessons.length ? ` <span class="scope-lesson" data-act="lessons" title="${escAttr('적용된 교훈 (클릭 → 교훈 관리):\n' + lessons.join('\n'))}">· ⚠ 교훈 ${lessons.length}</span>` : '';
const titleAttr = files.length ? `사용된 브레인 파일:\n${files.join('\n')}` : '에이전트↔지식 매핑 편집';
footer.innerHTML = `<span class="scope-link" data-act="map" title="${escAttr(titleAttr)}">🔎 참조: ${agentTag}${escAttr(scopeLabel)}</span>${fileTag}${lessonTag}${layerTag}`;
}
// Knowledge Mix indicator — shows the policy that actually drove this turn so the
// user can see *why* the answer leaned the way it did.
if (v.knowledgeMix && typeof v.knowledgeMix.weight === 'number') {
const w = Math.max(0, Math.min(100, v.knowledgeMix.weight));
const src = v.knowledgeMix.source;
const srcLabel = src === 'agent'
? `agent: ${v.knowledgeMix.agent || v.agentName || ''}`
: src === 'global' ? 'global' : 'default';
const mix = document.createElement('div');
mix.className = 'scope-mix';
mix.innerHTML = `🎚 Knowledge Mix · Model ${100 - w}% / Brain ${w}% <span class="scope-dim">(${escAttr(srcLabel)})</span>`;
footer.appendChild(mix);
}
// Non-blocking flag: lesson Prevention-Checklist items the answer doesn't visibly address.
const unaddressed = Array.isArray(v.unaddressedChecklist) ? v.unaddressedChecklist : [];
if (unaddressed.length) {
const list = unaddressed.map(it => `· ${escAttr(it)}`).join('<br>');
const w = document.createElement('div');
w.className = 'scope-unaddressed';
w.innerHTML = `⚠ 답변에서 안 보이는 교훈 체크리스트 항목:<br>${list}`;
footer.appendChild(w);
}
footer.addEventListener('click', e => {
const act = e.target && e.target.dataset && e.target.dataset.act;
if (act === 'map') vscode.postMessage({ type: 'editKnowledgeMap' });
else if (act === 'lessons') vscode.postMessage({ type: 'manageLessons' });
});
const actions = target.querySelector('.msg-actions');
if (actions) target.insertBefore(footer, actions); else target.appendChild(footer);
}
// `model: ''` means "Use current model" (i.e. no per-agent override).
// `secondBrainWeight: null` means "Use global setting"; a number 0100 overrides it.
let agentMapDraft = { agentPath: '', name: '', knowledgeFolders: [], skillFolders: [], model: '', secondBrainWeight: null };
/**
* Sync the per-agent Knowledge Mix UI (checkbox + slider + hint) to whatever
* is currently in `agentMapDraft.secondBrainWeight`. Called whenever the
* modal opens or the backend ships fresh data.
*/
function syncAgentMapMixUi() {
const cb = document.getElementById('agentMapMixUseGlobal');
const slider = document.getElementById('agentMapMixSlider');
const hint = document.getElementById('agentMapMixHint');
if (!cb || !slider || !hint) return;
const useGlobal = agentMapDraft.secondBrainWeight === null || agentMapDraft.secondBrainWeight === undefined;
cb.checked = useGlobal;
slider.disabled = useGlobal;
const value = useGlobal
? (parseInt((document.getElementById('knowledgeMixSlider') || {}).value, 10) || 50)
: Math.max(0, Math.min(100, agentMapDraft.secondBrainWeight | 0));
slider.value = String(value);
hint.textContent = useGlobal
? `전역 설정 사용 · ${fmtMixHint(value)}`
: fmtMixHint(value);
}
/**
* Rebuild the per-agent model dropdown using whatever model list the top-bar
* #modelSel currently has. Called whenever the modal opens OR the model list
* is refreshed by the extension host. Preserves the current draft selection.
*/
function refreshAgentMapModelOptions() {
const sel = document.getElementById('agentMapModelSel');
if (!sel) return;
const desired = agentMapDraft.model || '';
sel.innerHTML = '';
const useDefault = document.createElement('option');
useDefault.value = '';
useDefault.innerText = 'Use current model';
sel.appendChild(useDefault);
const seen = new Set();
// Source the available models from the populated top-bar dropdown so we don't
// need an additional round-trip; if a model is selected for this agent but
// is no longer in the list, we still surface it so the user sees the value.
for (const opt of modelSel.options) {
if (!opt.value || seen.has(opt.value)) continue;
seen.add(opt.value);
const o = document.createElement('option');
o.value = opt.value;
o.innerText = opt.innerText;
sel.appendChild(o);
}
if (desired && !seen.has(desired)) {
const o = document.createElement('option');
o.value = desired;
o.innerText = `${desired} (saved)`;
sel.appendChild(o);
}
sel.value = desired;
}
function renderAgentMapLists() {
const renderList = (listEl, items, kind) => {
listEl.innerHTML = '';
items.forEach((p, idx) => {
const li = document.createElement('li');
li.className = 'map-item';
const icon = document.createElement('span');
icon.className = 'map-item-icon';
icon.textContent = kind === 'knowledge' ? '📁' : (p.endsWith('.md') || p.endsWith('.markdown') ? '📄' : '📁');
const pathEl = document.createElement('span');
pathEl.className = 'map-item-path';
pathEl.textContent = p;
pathEl.title = p;
const removeBtn = document.createElement('button');
removeBtn.className = 'map-item-remove';
removeBtn.textContent = '✕';
removeBtn.title = '연결 해제';
removeBtn.onclick = () => {
items.splice(idx, 1);
renderAgentMapLists();
};
li.appendChild(icon);
li.appendChild(pathEl);
li.appendChild(removeBtn);
listEl.appendChild(li);
});
};
renderList(knowledgeFolderList, agentMapDraft.knowledgeFolders, 'knowledge');
renderList(skillFolderList, agentMapDraft.skillFolders, 'skill');
}
function openAgentMapModal() {
if (!agentSel || agentSel.value === 'none' || !agentSel.value) {
showToast('에이전트를 먼저 선택하세요.');
return;
}
agentMapStatus.className = 'map-status';
agentMapStatus.textContent = '불러오는 중...';
agentMapDraft = { agentPath: agentSel.value, name: agentSel.options[agentSel.selectedIndex]?.text || '', knowledgeFolders: [], skillFolders: [], model: '', secondBrainWeight: null };
agentMapAgentName.textContent = agentMapDraft.name;
knowledgeFolderList.innerHTML = '';
skillFolderList.innerHTML = '';
refreshAgentMapModelOptions();
syncAgentMapMixUi();
agentMapOverlay.classList.add('visible');
vscode.postMessage({ type: 'getAgentMap', agentPath: agentSel.value });
}
function closeAgentMapModal() {
agentMapOverlay.classList.remove('visible');
agentMapStatus.textContent = '';
agentMapStatus.className = 'map-status';
}
let streamBody = null;
let internetEnabled = false;
let secondBrainTraceEnabled = true;
let secondBrainTraceDebug = false;
let pendingFiles = [];
let editMode = false;
if (previousState && typeof previousState.secondBrainTraceEnabled === 'boolean') {
secondBrainTraceEnabled = previousState.secondBrainTraceEnabled;
}
if (previousState && typeof previousState.secondBrainTraceDebug === 'boolean') {
secondBrainTraceDebug = previousState.secondBrainTraceDebug;
}
const initialTraceBtn = document.getElementById('brainTraceBtn');
initialTraceBtn.classList.toggle('active', secondBrainTraceEnabled);
initialTraceBtn.setAttribute('data-tooltip', secondBrainTraceEnabled ? 'Second Brain Trace Mode: On' : 'Second Brain Trace Mode: Off');
const initialTraceDebugBtn = document.getElementById('brainTraceDebugBtn');
initialTraceDebugBtn.classList.toggle('active', secondBrainTraceDebug);
initialTraceDebugBtn.setAttribute('data-tooltip', secondBrainTraceDebug ? 'Second Brain Debug JSON: On' : 'Second Brain Debug JSON: Off');
function fmt(text) { return marked.parse(text || ''); }
function copyToClipboard(text, btn) {
const textarea = document.createElement('textarea');
textarea.value = text; textarea.style.position = 'fixed'; textarea.style.opacity = '0';
document.body.appendChild(textarea); textarea.select();
try {
if (document.execCommand('copy')) {
btn.innerText = '✅ Copied!'; setTimeout(() => { btn.innerText = '📋 Copy'; }, 2000);
}
} catch (err) { console.error('Copy failed', err); }
document.body.removeChild(textarea);
}
window.approve = () => {
const box = document.querySelector('.approval-box');
if (box) box.remove();
vscode.postMessage({ type: 'approveAction' });
};
window.reject = () => {
const box = document.querySelector('.approval-box');
if (box) box.remove();
vscode.postMessage({ type: 'rejectAction' });
};
function exportToMD(text) {
vscode.postMessage({ type: 'exportResponse', text: text });
}
function addMsg(text, role, rationale) {
const isUser = role === 'user';
const msgEl = document.createElement('div');
msgEl.className = 'msg ' + (isUser ? 'msg-user' : 'msg-ai');
msgEl._raw = text;
const head = document.createElement('div');
head.className = 'msg-head';
head.innerHTML = isUser ? '<div class="av av-user">U</div> You' : '<div class="av av-ai">✦</div> Astra';
const body = document.createElement('div');
body.className = 'msg-body markdown-body';
if (isUser) {
body.innerText = text;
} else {
body.innerHTML = fmt(text);
}
const actions = document.createElement('div');
actions.className = 'msg-actions';
const copyBtn = document.createElement('button');
copyBtn.className = 'action-btn'; copyBtn.innerText = '📋 Copy';
copyBtn.onclick = (e) => { e.stopPropagation(); copyToClipboard(msgEl._raw, copyBtn); };
const exportBtn = document.createElement('button');
exportBtn.className = 'action-btn'; exportBtn.innerText = '💾 Export';
exportBtn.onclick = (e) => { e.stopPropagation(); exportToMD(msgEl._raw); };
actions.appendChild(copyBtn);
actions.appendChild(exportBtn);
msgEl.appendChild(head); msgEl.appendChild(body);
msgEl.appendChild(actions);
chat.appendChild(msgEl); chat.scrollTop = chat.scrollHeight;
return { body, msgEl };
}
window.addEventListener('message', e => {
const msg = e.data;
switch(msg.type) {
case 'addMessage':
addMsg(msg.value, msg.role, msg.rationale);
// Update state for non-streamed messages
const s = vscode.getState() || { history: [] };
s.history.push({ role: msg.role === 'assistant' ? 'assistant' : 'user', content: msg.value, rationale: msg.rationale });
saveWebviewState(s.history);
break;
case 'streamStart':
thinkingBar.classList.remove('active');
if (document.querySelector('.welcome')) document.querySelector('.welcome').remove();
const res = addMsg('', 'assistant');
streamBody = res.body; streamBody._parent = res.msgEl; streamBody._parent._raw = '';
streamBody.classList.add('stream-active');
break;
case 'streamChunk':
if (streamBody) {
streamBody._parent._raw += msg.value;
streamBody.innerHTML = fmt(streamBody._parent._raw);
chat.scrollTop = chat.scrollHeight;
}
break;
case 'streamReplace':
// Progressive answering: the backend streamed raw tokens
// live (including hidden reasoning, pre-sanitize text);
// once everything is finalized it sends the cleaned full
// text via streamReplace so the bubble ends up correct
// regardless of what slipped through during streaming.
if (streamBody) {
streamBody._parent._raw = String(msg.value ?? '');
streamBody.innerHTML = fmt(streamBody._parent._raw);
chat.scrollTop = chat.scrollHeight;
}
break;
case 'streamEnd':
if (streamBody) {
streamBody.classList.remove('stream-active');
// Update state after stream finishes
const state = vscode.getState() || { history: [] };
state.history.push({ role: 'assistant', content: streamBody._parent._raw });
saveWebviewState(state.history);
}
streamBody = null;
// 생성 완료 시 Stop 버튼 숨기고 Send 복구
setGenerating(false);
resetStepper();
// 1인 기업 모드는 streamStart를 거치지 않아 thinkingBar가
// 그대로 남으므로 streamEnd에서 명시적으로 끄는 게 안전.
thinkingBar.classList.remove('active');
Sound.success();
vscode.postMessage({ type: 'getReadyStatus' });
break;
case 'restoreHistory':
case 'sessionLoaded':
const historyPayload = msg.type === 'sessionLoaded' ? msg.value : msg.value;
const history = Array.isArray(historyPayload)
? historyPayload
: (Array.isArray(historyPayload?.history) ? historyPayload.history : []);
if (history && history.length > 0) {
renderHistory(history);
saveWebviewState(history);
}
if (historyPayload?.negativePrompt !== undefined) {
negativePrompt.value = historyPayload.negativePrompt;
}
historyOverlay.classList.remove('visible');
break;
case 'clearChat':
// welcomePanel을 빈 div로 다시 만들고 _renderWelcome으로 상태에 맞는
// 내용을 채워 넣는다 — 신규 사용자에게는 체크리스트, 준비된 사용자에게는
// 예시 질문 chip이 나옴.
chat.innerHTML = '<div class="welcome" id="welcomePanel"></div>';
try { _renderWelcome(); } catch {}
break;
case 'focusInput':
input.focus();
break;
case 'modelsList': {
modelSel.innerHTML = '';
const inlineModelSel = document.getElementById('modelInlineSel');
if (inlineModelSel) inlineModelSel.innerHTML = '';
// [State Persistence - Tier 2] LocalStorage에서 마지막 선택 모델 복원 시도
const _savedModel = localStorage.getItem('g1nation_last_model');
// 서버 추천 모델 vs 로컬 저장 모델 중 우선순위 결정
// LocalStorage에 저장된 값이 현재 목록에 있으면 그것을 우선 사용 (Tier 2 우선)
const _preferredModel = (_savedModel && msg.value.models.includes(_savedModel))
? _savedModel
: msg.value.selected;
const _loadedSet = new Set(Array.isArray(msg.value.loadedModels) ? msg.value.loadedModels : []);
const _models = Array.isArray(msg.value.models) ? msg.value.models.slice() : [];
// Fallback: server returned nothing but we still know the configured model.
if (_models.length === 0 && _preferredModel) _models.push(_preferredModel);
_models.forEach(m => {
const label = _loadedSet.has(m) ? `${m}` : m;
const o1 = document.createElement('option');
o1.value = m; o1.innerText = label;
if (m === _preferredModel) o1.selected = true;
modelSel.appendChild(o1);
if (inlineModelSel) {
const o2 = document.createElement('option');
o2.value = m; o2.innerText = label;
if (m === _preferredModel) o2.selected = true;
inlineModelSel.appendChild(o2);
}
});
// LocalStorage에 저장된 모델이 실제로 적용된 경우, 백엔드 설정도 동기화
if (_savedModel && _savedModel !== msg.value.selected && msg.value.models.includes(_savedModel)) {
vscode.postMessage({ type: 'model', value: _savedModel });
}
// The model name is now visible inside the footer pill itself,
// so statusLabel is reserved for actual status (autoContinue
// progress, etc.). Keep it empty in steady state.
statusLabel.innerText = '';
// Refresh per-agent model dropdown options (if currently visible) so it stays in sync.
if (typeof refreshAgentMapModelOptions === 'function') refreshAgentMapModelOptions();
// If the company manage overlay is open with cached agent data,
// re-render its cards so each per-agent model <select> picks up
// any newly-discovered models from the master list.
if (typeof _lastCompanyAgentsPayload !== 'undefined' && _lastCompanyAgentsPayload
&& document.getElementById('companyOverlay')?.classList.contains('visible')) {
renderCompanyAgentCards(_lastCompanyAgentsPayload);
}
// 모델 목록이 채워졌고 default 선택이 정해졌으니 welcome 패널의
// "사용할 모델 선택" 체크 표시도 즉시 업데이트.
try { _renderWelcome(); } catch {}
break;
}
case 'brainProfiles':
brainSel.innerHTML = '';
msg.value.profiles.forEach(p => {
const o = document.createElement('option'); o.value = p.id; o.innerText = p.name;
if (p.id === msg.value.activeBrainId) o.selected = true;
brainSel.appendChild(o);
});
const addOpt = document.createElement('option');
addOpt.value = 'new'; addOpt.innerText = '+ Add New Brain...';
brainSel.appendChild(addOpt);
syncContextBar();
break;
case 'sessionList':
historyList.innerHTML = '';
msg.value.forEach(s => {
const el = document.createElement('div'); el.className = 'history-item';
el.setAttribute('role', 'button');
el.tabIndex = 0;
el.dataset.sessionId = s.id;
el.innerHTML = `<div style="font-weight:600; color:var(--text-bright); margin-bottom:2px;">${s.title}</div><div style="font-size:10px; color:var(--text-dim)">${new Date(s.timestamp).toLocaleString()} · ${s.messageCount} msgs</div>`;
const load = () => {
if (!el.dataset.sessionId) return;
vscode.postMessage({ type: 'loadSession', id: el.dataset.sessionId });
};
el.addEventListener('click', load);
el.addEventListener('keydown', event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
load();
}
});
historyList.appendChild(el);
});
break;
case 'engineStatus':
statusDot.style.background = msg.value.online ? 'var(--success)' : 'var(--error)';
engineStatusText.innerText = msg.value.online ? 'Online' : 'Offline';
readyState.engine = Object.assign({}, readyState.engine, { online: !!msg.value.online });
renderReadyBar();
break;
case 'readyStatus':
readyState = Object.assign({}, readyState, msg.value || {});
renderReadyBar();
break;
case 'contextBudget':
renderCtxBadge(msg.value);
break;
case 'usedScope': {
let target = streamBody && streamBody._parent;
if (!target) {
const all = chat.querySelectorAll('.msg.msg-ai');
target = all[all.length - 1];
}
renderScopeFooter(target, msg.value || {});
break;
}
case 'lessonCandidate':
renderLessonCandidate(msg.value || {});
break;
case 'autoContinue':
statusLabel.innerText = msg.value; thinkingBar.classList.add('active');
if (msg.value.includes('Analyzing')) setStep('analyze');
if (msg.value.includes('Planning')) setStep('plan');
if (msg.value.includes('Executing')) setStep('execute');
setTimeout(() => { thinkingBar.classList.remove('active'); }, 3000);
break;
case 'agentsList':
agentSel.innerHTML = '<option value="none">No Agent</option>';
msg.value.forEach(a => {
const o = document.createElement('option'); o.value = a.path; o.innerText = a.name;
if (a.path === msg.selected) o.selected = true;
agentSel.appendChild(o);
});
if (msg.selected && msg.selected !== 'none') {
vscode.postMessage({ type: 'getAgentContent', path: msg.selected });
}
vscode.postMessage({ type: 'getKnowledgeScope', agentPath: msg.selected });
syncContextBar();
break;
case 'companyStatus': {
const v = msg.value || {};
renderCompanyChip(!!v.enabled, v.summary || '');
renderScopeSeg(v.activePipelineId || null);
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': {
// 새 path (officeSnapshot) 가 한 번이라도 도착했다면 옛 message 는 무시.
if (window.__officeSnapshotSeen) break;
if (typeof window.__pixelOfficeApply === 'function') {
window.__pixelOfficeApply(msg.value || {});
}
break;
}
case 'officeSnapshot': {
// refactor #E — mini view 도 OfficeSnapshot 수신.
// OfficeSnapshot 을 옛 {state, bubbles, config} payload 모양으로 변환 후
// 기존 __pixelOfficeApply 재사용. dual-mode 안전 전환.
window.__officeSnapshotSeen = true;
const snap = msg.value;
if (!snap || typeof window.__pixelOfficeApply !== 'function') break;
const roster = Array.isArray(snap.roster) ? snap.roster : [];
const active = (snap.activeAgentId && roster.find((a) => a.agentId === snap.activeAgentId)) || roster[0];
const phaseToStatus = (p) => {
if (p === 'awaiting-approval') return 'waiting_approval';
if (p === 'reporting') return 'done';
if (p === 'intake') return 'analyzing';
return p || 'idle';
};
const synthetic = {
agentId: snap.activeAgentId || (active && active.agentId) || 'main',
agentName: (active && active.agentName) || 'Agent',
status: (active && active.status) || phaseToStatus(snap.phase),
currentTask: snap.task && snap.task.goal,
currentStep: active && active.currentStep,
message: snap.activeAgentId || '',
recentLogs: (active && active.lastLog) ? [active.lastLog] : [],
progress: snap.pipeline ? (snap.pipeline.index / Math.max(1, snap.pipeline.stages.length)) : 0,
pipelineStages: snap.pipeline && snap.pipeline.stages,
needUserInput: (snap.awaiting && snap.awaiting.kind === 'clarification') ? snap.awaiting.questions : undefined,
awaitingApproval: (snap.awaiting && snap.awaiting.kind === 'approval') ? snap.awaiting.questions[0] : undefined,
requirementContract: snap.task,
updatedAt: snap.updatedAt,
};
window.__pixelOfficeApply({
state: synthetic,
bubbles: Array.isArray(snap.newBubbles) ? snap.newBubbles : [],
// config 는 그대로 유지 — snapshot 에는 enabled 만 함의적, 옛 cfg 가 살아있음.
config: undefined,
});
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;
}
case 'addCompanyAgentResult': {
const v = msg.value || {};
const errEl = document.getElementById('addAgentError');
const form = document.getElementById('addCompanyAgentForm');
if (v.ok) {
if (errEl) errEl.textContent = '';
// 폼 닫고 필드 초기화 — companyAgents 메시지가 목록을 다시 그려줌
if (form) form.setAttribute('data-open', 'false');
const fids = ['newAgentId','newAgentName','newAgentRole','newAgentEmoji','newAgentColor','newAgentTagline','newAgentSpecialty','newAgentPersona'];
for (const fid of fids) { const el = document.getElementById(fid); if (el) el.value = ''; }
showToast(`✅ '${v.agentId}' 에이전트 추가 완료`, 'info');
} else {
if (errEl) errEl.textContent = v.reason || '에이전트 추가 실패.';
}
break;
}
case 'deleteCompanyAgentResult': {
const v = msg.value || {};
if (v.ok) {
const msg2 = v.kind === 'builtin-hidden'
? `🗑 기본 에이전트 '${v.agentId}' 숨김 처리 (목록 하단에서 복원 가능)`
: `🗑 '${v.agentId}' 에이전트 삭제됨`;
showToast(msg2, 'warn');
} else {
showToast(`삭제 실패: ${v.reason || '알 수 없음'}`, 'warn');
}
break;
}
case 'restoreHiddenAgentResult': {
const v = msg.value || {};
if (v.ok) {
showToast(`↩ '${v.agentId}' 에이전트 복원 완료`, 'info');
} else {
showToast(`복원 실패: ${v.reason || '알 수 없음'}`, 'warn');
}
break;
}
case 'companyPipelines': {
if (typeof window.__renderCompanyPipelines === 'function') {
window.__renderCompanyPipelines(msg.value || {});
}
break;
}
case 'companyResumable': {
if (typeof window.__renderCompanyResumable === 'function') {
window.__renderCompanyResumable(msg.value || {});
}
break;
}
case 'companyPipelineTemplateContent': {
const tpl = msg.value;
if (!tpl) { showToast('템플릿을 찾을 수 없습니다.', 'warn'); break; }
// 에디터를 미리 채워서 연다 — 사용자는 ID/name/지시문만 다듬으면 됨.
if (typeof window.__openPipelineEditorWithTemplate === 'function') {
window.__openPipelineEditorWithTemplate(tpl);
}
break;
}
case 'upsertCompanyPipelineResult': {
const v = msg.value || {};
const errEl = document.getElementById('pipelineEditError');
if (v.ok) {
if (errEl) errEl.textContent = '';
if (typeof window.__closePipelineEditor === 'function') window.__closePipelineEditor();
showToast('작업 흐름 저장 완료', 'info');
} else {
if (errEl) errEl.textContent = v.reason || '작업 흐름 저장 실패.';
}
break;
}
case 'deleteCompanyPipelineResult': {
const v = msg.value || {};
if (v.ok) showToast(`작업 흐름 '${v.pipelineId}' 삭제됨`, 'warn');
else showToast(`삭제 실패: ${v.reason || '알 수 없음'}`, 'warn');
break;
}
case 'setActiveCompanyPipelineResult': {
const v = msg.value || {};
if (v.ok) {
showToast(v.pipelineId ? `작업 흐름 '${v.pipelineId}' 사용 중` : '대표가 알아서 분배하도록 변경', 'info');
} else {
showToast(`활성 설정 실패: ${v.reason || '알 수 없음'}`, 'warn');
}
break;
}
case 'openCompanyManageOverlay': {
// Triggered by the Command Palette `Manage 1인 기업 Agents`.
document.getElementById('companyOverlay')?.classList.add('visible');
vscode.postMessage({ type: 'getCompanyAgents' });
vscode.postMessage({ type: 'getCompanyResumable' });
break;
}
case 'companyTurnUpdate': {
if (msg.value) renderCompanyPhase(msg.value);
break;
}
case 'architectureStatus': {
// Three-state chip:
// active — full info + Open/Refresh/Detach
// inactive — name + [Attach] button (user previously detached, OR doc not yet generated)
// hidden — no project + no workspace
const chip = document.getElementById('archChip');
const title = document.getElementById('archChipTitle');
const meta = document.getElementById('archChipMeta');
if (!chip || !title || !meta) break;
const v = msg.value || {};
if (v.active) {
chip.setAttribute('data-state', 'active');
title.textContent = `${v.projectName || 'Project'} architecture`;
const updatedLabel = v.lastUpdated ? `updated ${formatRelativeTime(v.lastUpdated)}` : 'just attached';
const autoLabel = v.autoUpdate === false ? 'Auto-update Off' : 'Auto-update On';
meta.textContent = `${updatedLabel} · ${autoLabel}`;
} else if (v.canAttach && v.projectName) {
chip.setAttribute('data-state', 'inactive');
title.textContent = `${v.projectName} architecture`;
meta.textContent = v.detached ? 'detached — click Attach to re-enable' : 'not yet activated';
} else {
chip.setAttribute('data-state', 'hidden');
}
break;
}
case 'architectureRefreshFailed': {
const reason = msg.value && msg.value.reason;
if (reason === 'no-active-project') {
showToast('활성 프로젝트가 없습니다. 먼저 프로젝트를 선택하세요.', 'warn');
} else {
showToast('Architecture 갱신 실패', 'warn');
}
break;
}
case 'architectureRefreshResult': {
// Trust-building stats card: shows exactly what the
// refresh did so users don't have to guess whether the
// 0.1s click actually accomplished anything.
const v = msg.value || {};
const card = document.createElement('div');
card.className = 'arch-refresh-card';
const noChanges = (v.newlyAnalyzed | 0) === 0 && (v.deleted | 0) === 0;
if (noChanges) card.classList.add('no-changes');
const head = noChanges
? `📋 ${escAttr(v.projectName || 'Project')} architecture — 변경 사항 없음`
: `📋 ${escAttr(v.projectName || 'Project')} architecture refreshed`;
const parts = [
`${v.newlyAnalyzed | 0} newly analysed`,
`${v.cached | 0} cached`,
];
if ((v.deleted | 0) > 0) parts.push(`${v.deleted | 0} deleted`);
parts.push(`${v.durationMs | 0}ms`);
card.innerHTML =
`<div class="arc-head">${head}</div>` +
`<div class="arc-meta">${parts.join(' · ')}</div>`;
const chatEl = document.getElementById('chat');
if (chatEl) {
chatEl.appendChild(card);
chatEl.scrollTop = chatEl.scrollHeight;
setTimeout(() => {
card.classList.add('fading');
card.addEventListener('transitionend', () => card.remove(), { once: true });
}, 3000);
}
break;
}
case 'knowledgeMix': {
// Initial sync: reflect whatever weight is currently in settings.
if (msg.value && typeof msg.value.weight === 'number') {
const w = Math.max(0, Math.min(100, msg.value.weight));
const slider = document.getElementById('knowledgeMixSlider');
if (slider) slider.value = String(w);
const hint = document.getElementById('knowledgeMixHint');
if (hint) hint.textContent = fmtMixHint(w);
}
break;
}
case 'agentModelOverride': {
// The extension chose a different model than what the dropdowns show
// (per-agent pinned model). Reflect that in the UI without persisting
// it as the new global default — selecting a different agent or
// clearing the override should restore the previous selection.
const pinned = msg.value && msg.value.model;
if (pinned) {
const inlineSel = document.getElementById('modelInlineSel');
// Add an option if it isn't already known so the value can stick.
const ensureOption = (sel) => {
if (!sel) return;
const has = Array.from(sel.options).some(o => o.value === pinned);
if (!has) {
const o = document.createElement('option');
o.value = pinned;
o.innerText = `${pinned} (agent)`;
sel.appendChild(o);
}
sel.value = pinned;
};
ensureOption(modelSel);
ensureOption(inlineSel);
// The pill shows the model directly; surface the override as a tooltip
// instead of a duplicate status string.
if (inlineSel) inlineSel.title = `Model pinned by current agent: ${pinned}`;
}
break;
}
case 'agentMapData':
if (msg.value) {
agentMapDraft = {
agentPath: agentMapDraft.agentPath,
name: agentMapDraft.name,
knowledgeFolders: Array.isArray(msg.value.knowledgeFolders) ? msg.value.knowledgeFolders.slice() : [],
skillFolders: Array.isArray(msg.value.skillFolders) ? msg.value.skillFolders.slice() : [],
model: typeof msg.value.model === 'string' ? msg.value.model : '',
secondBrainWeight: (typeof msg.value.secondBrainWeight === 'number'
&& Number.isFinite(msg.value.secondBrainWeight))
? Math.max(0, Math.min(100, Math.round(msg.value.secondBrainWeight)))
: null,
};
renderAgentMapLists();
refreshAgentMapModelOptions();
syncAgentMapMixUi();
agentMapStatus.textContent = msg.value.exists ? '' : '새 매핑입니다. 저장하면 생성됩니다.';
agentMapStatus.className = 'map-status';
}
break;
case 'pickedPath':
if (msg.value && msg.value.path && agentMapOverlay.classList.contains('visible')) {
const target = (msg.value.kind === 'knowledgeFolder')
? agentMapDraft.knowledgeFolders
: agentMapDraft.skillFolders;
if (!target.includes(msg.value.path)) {
target.push(msg.value.path);
renderAgentMapLists();
}
}
break;
case 'agentMapSaved':
if (msg.value && msg.value.ok) {
agentMapStatus.className = 'map-status ok';
agentMapStatus.textContent = '저장되었습니다.';
vscode.postMessage({ type: 'getKnowledgeScope', agentPath: agentMapDraft.agentPath });
setTimeout(closeAgentMapModal, 700);
} else {
agentMapStatus.className = 'map-status error';
agentMapStatus.textContent = '저장 실패: ' + (msg.value?.error || '알 수 없는 오류');
}
break;
case 'knowledgeScope':
if (knowledgeScopeSel) {
knowledgeScopeSel.innerHTML = '';
const folders = (msg.value && msg.value.folders) || [];
if (folders.length === 0) {
const o = document.createElement('option');
o.value = '';
const label = (msg.value && msg.value.agent)
? `매핑된 폴더 없음 (agent: ${msg.value.agent})`
: '매핑 없음 — 전체 브레인 검색';
o.innerText = label;
knowledgeScopeSel.appendChild(o);
knowledgeScopeSel.disabled = true;
} else {
knowledgeScopeSel.disabled = false;
folders.forEach(f => {
const o = document.createElement('option');
o.value = f.absolute;
o.innerText = f.relative || f.absolute;
o.title = f.absolute;
knowledgeScopeSel.appendChild(o);
});
}
}
break;
case 'chronicleProjects':
designerSel.innerHTML = '';
msg.value.projects.forEach(p => {
const o = document.createElement('option');
o.value = p.id;
o.innerText = p.name;
o.title = p.recordRoot;
if (p.id === msg.value.activeProjectId) o.selected = true;
designerSel.appendChild(o);
});
const newDesignerOpt = document.createElement('option');
newDesignerOpt.value = 'new';
newDesignerOpt.innerText = '+ Add Designer Project...';
designerSel.appendChild(newDesignerOpt);
syncContextBar();
vscode.postMessage({ type: 'getChronicleRecords' });
break;
case 'chronicleRecords':
chronicleRecordSel.innerHTML = '';
if (!msg.value || msg.value.length === 0) {
const emptyRecordOpt = document.createElement('option');
emptyRecordOpt.value = '';
emptyRecordOpt.innerText = 'No records yet';
chronicleRecordSel.appendChild(emptyRecordOpt);
syncRecordsLine();
break;
}
msg.value.forEach(record => {
const o = document.createElement('option');
o.value = record.path;
o.innerText = record.relativePath;
o.title = record.path;
chronicleRecordSel.appendChild(o);
});
syncRecordsLine();
break;
case 'agentContent':
agentPrompt.value = msg.value;
negativePrompt.value = msg.negativePrompt || '';
break;
case 'agentDeleted':
agentConfigPanel.style.display = 'none';
editMode = false;
editAgentBtn.classList.remove('active');
agentPrompt.value = '';
negativePrompt.value = '';
break;
case 'error':
thinkingBar.classList.remove('active'); sendBtn.disabled = false;
addMsg(msg.value, 'error');
break;
case 'lmStudioError':
showToast('LM Studio: ' + msg.value, 'warn');
break;
case 'requiresApproval':
const box = document.createElement('div');
box.className = 'approval-box';
box.innerHTML = '<div class="approval-title"><span>🛡️</span> 작업 승인 대기 중 (Action Approval Required)</div>' +
'<div style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">위의 변경 사항을 프로젝트에 반영할까요?</div>' +
'<div class="approval-btns">' +
' <button class="btn-approve" onclick="approve()">승인 (Approve)</button>' +
' <button class="btn-reject" onclick="reject()">롤백 (Rollback)</button>' +
'</div>';
chat.appendChild(box);
chat.scrollTop = chat.scrollHeight;
break;
}
});
function renderAttachments() {
attachPreview.innerHTML = '';
if (pendingFiles.length === 0) { attachPreview.classList.remove('visible'); return; }
attachPreview.classList.add('visible');
pendingFiles.forEach((f, i) => {
const chip = document.createElement('div'); chip.className = 'file-chip';
chip.innerHTML = `<span>📎</span> ${f.name} <span class="remove" onclick="removeFile(${i})">✕</span>`;
attachPreview.appendChild(chip);
});
}
window.removeFile = (i) => {
pendingFiles.splice(i, 1);
renderAttachments();
// 파일 삭제 후 Draft State 재평가
setDraftActive(input.value.trim().length > 0 || pendingFiles.length > 0);
};
function processFiles(files) {
if (!files || files.length === 0) return;
Array.from(files).forEach(file => {
const reader = new FileReader();
reader.onload = () => {
const base64 = reader.result.split(',')[1];
pendingFiles.push({ name: file.name, type: file.type, data: base64 });
renderAttachments();
setDraftActive(true);
};
reader.readAsDataURL(file);
});
showToast(`${files.length}개의 파일이 추가되었습니다.`, 'success');
Sound.success();
}
attachBtn.onclick = () => fileInput.click();
fileInput.onchange = () => {
processFiles(fileInput.files);
fileInput.value = '';
};
// --- Drag and Drop Implementation ---
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
document.body.addEventListener(eventName, e => {
e.preventDefault();
e.stopPropagation();
}, false);
});
['dragenter', 'dragover'].forEach(eventName => {
document.body.addEventListener(eventName, () => {
document.body.classList.add('drag-over');
}, false);
});
['dragleave', 'drop'].forEach(eventName => {
document.body.addEventListener(eventName, () => {
document.body.classList.remove('drag-over');
}, false);
});
document.body.addEventListener('drop', e => {
const dt = e.dataTransfer;
const files = dt.files;
// ⭐ Kodari PD 가이드 반영: Input 요소의 상태를 드롭된 파일로 강제 동기화
if (files && files.length > 0) {
fileInput.files = files; // Input의 files 속성 업데이트
console.log(`✅ [DnD] Input 상태 동기화 성공: ${files[0].name}${files.length - 1}`);
}
processFiles(files);
}, false);
function send() {
const val = input.value.trim();
if (!val && pendingFiles.length === 0) return;
addMsg(val || (pendingFiles.length > 0 ? `[Sent ${pendingFiles.length} files]` : ''), 'user');
vscode.postMessage({
type: 'prompt',
value: val,
model: modelSel.value,
internet: internetEnabled,
files: pendingFiles.length > 0 ? pendingFiles : undefined,
agentFile: agentSel.value === 'none' ? undefined : agentSel.value,
brainProfileId: brainSel.value && brainSel.value !== 'new' ? brainSel.value : undefined,
negativePrompt: negativePrompt.value.trim() || undefined,
secondBrainTrace: secondBrainTraceEnabled,
secondBrainTraceDebug
});
input.value = ''; input.style.height = 'auto'; pendingFiles = []; renderAttachments();
// 전송 완료 후 Draft State 리셋 + Stop 버튼 표시
setDraftActive(false);
setGenerating(true);
thinkingBar.classList.add('active');
// Save state after sending
const currentState = vscode.getState() || { history: [] };
currentState.history.push({ role: 'user', content: val });
saveWebviewState(currentState.history);
}
sendBtn.onclick = send;
input.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
if (e.isComposing) return;
e.preventDefault();
send();
}
});
let _lastActivityBump = 0;
const ACTIVITY_BUMP_INTERVAL_MS = 5000;
const bumpActivity = () => {
const now = Date.now();
if (now - _lastActivityBump < ACTIVITY_BUMP_INTERVAL_MS) return;
_lastActivityBump = now;
vscode.postMessage({ type: 'activity' });
};
input.addEventListener('input', () => {
input.style.height = 'auto';
input.style.height = input.scrollHeight + 'px';
// Draft State: 내용이 있으면 cancelBtn 표시
setDraftActive(input.value.trim().length > 0 || pendingFiles.length > 0);
bumpActivity();
});
cancelBtn.onclick = () => clearDraft();
stopBtn.onclick = () => {
vscode.postMessage({ type: 'stopGeneration' });
setGenerating(false);
thinkingBar.classList.remove('active');
showToast('■ 생성이 중단되었습니다.', 'warn');
Sound.warn();
};
const startNewChat = () => { Sound.play(660, 'sine', 0.1); vscode.postMessage({ type: 'newChat' }); };
document.getElementById('newChatBtn').onclick = startNewChat;
// Note: input-footer "New Chat" / "Sync Knowledge" buttons were removed.
// Both actions remain available in the top toolbar (newChatBtn / brainBtn / Tools menu).
document.getElementById('settingsBtn').onclick = () => vscode.postMessage({ type: 'openSettings' });
document.getElementById('internetBtn').onclick = () => {
internetEnabled = !internetEnabled; document.getElementById('internetBtn').classList.toggle('active', internetEnabled);
};
document.getElementById('brainTraceBtn').onclick = () => {
secondBrainTraceEnabled = !secondBrainTraceEnabled;
const btn = document.getElementById('brainTraceBtn');
btn.classList.toggle('active', secondBrainTraceEnabled);
btn.setAttribute('data-tooltip', secondBrainTraceEnabled ? 'Second Brain Trace Mode: On' : 'Second Brain Trace Mode: Off');
saveUiState();
};
document.getElementById('brainTraceDebugBtn').onclick = () => {
secondBrainTraceDebug = !secondBrainTraceDebug;
const btn = document.getElementById('brainTraceDebugBtn');
btn.classList.toggle('active', secondBrainTraceDebug);
btn.setAttribute('data-tooltip', secondBrainTraceDebug ? 'Second Brain Debug JSON: On' : 'Second Brain Debug JSON: Off');
saveUiState();
};
const syncBrain = () => { Sound.play(550, 'sine', 0.1); vscode.postMessage({ type: 'syncBrain' }); };
document.getElementById('brainBtn').onclick = syncBrain;
saveWikiRawBtn.onclick = () => vscode.postMessage({ type: 'saveWikiRaw' });
addBrainBtn.onclick = () => vscode.postMessage({ type: 'addBrain' });
editBrainBtn.onclick = () => {
if (!brainSel.value || brainSel.value === 'new') return;
vscode.postMessage({ type: 'editBrain', id: brainSel.value });
};
deleteBrainBtn.onclick = () => {
if (!brainSel.value || brainSel.value === 'new') return;
vscode.postMessage({ type: 'deleteBrain', id: brainSel.value });
};
// (inputSyncBtn removed — Sync Knowledge is reachable via the top brainBtn / Tools menu.)
document.getElementById('historyBtn').onclick = () => vscode.postMessage({ type: 'getSessions' });
document.getElementById('historyBtn').addEventListener('click', () => historyOverlay.classList.add('visible'));
document.getElementById('closeHistoryBtn').onclick = () => historyOverlay.classList.remove('visible');
// The input placeholder is now a constant brand label — the model name
// lives in the footer pill itself, so we don't repeat it here.
const updateInputPlaceholder = () => {
if (typeof input !== 'undefined' && input) input.placeholder = 'Ask Astra...';
};
// Shared handler so the header dropdown and the footer pill dropdown
// always commit the same way and stay visually synced.
const applyModelSelection = (selectedModel, originEl) => {
if (!selectedModel) return;
// [State Persistence - Tier 2] 모델 변경 시 LocalStorage에 즉시 저장 (클라이언트 측 지속성)
try {
localStorage.setItem('g1nation_last_model', selectedModel);
} catch(e) {
console.warn('[Astra] LocalStorage 저장 실패:', e);
}
// [State Persistence - Tier 1] VS Code 전역 설정에 동기화 (영구 저장)
vscode.postMessage({ type: 'model', value: selectedModel });
// Mirror the value to the *other* dropdown so both pickers reflect reality.
const inlineSel = document.getElementById('modelInlineSel');
if (originEl !== modelSel && modelSel.value !== selectedModel) modelSel.value = selectedModel;
if (inlineSel && originEl !== inlineSel && inlineSel.value !== selectedModel) inlineSel.value = selectedModel;
};
modelSel.onchange = () => applyModelSelection(modelSel.value, modelSel);
const _inlineModelSelEl = document.getElementById('modelInlineSel');
if (_inlineModelSelEl) {
_inlineModelSelEl.onchange = () => applyModelSelection(_inlineModelSelEl.value, _inlineModelSelEl);
}
brainSel.onchange = () => {
if (brainSel.value === 'new') {
vscode.postMessage({ type: 'addBrain' });
} else {
vscode.postMessage({ type: 'setBrainProfile', id: brainSel.value });
}
};
designerSel.onchange = () => {
if (designerSel.value === 'new') {
vscode.postMessage({ type: 'createChronicleProject' });
} else {
vscode.postMessage({ type: 'setChronicleProject', id: designerSel.value });
vscode.postMessage({ type: 'getChronicleRecords' });
}
};
agentSel.onchange = () => {
if (agentSel.value !== 'none') {
vscode.postMessage({ type: 'getAgentContent', path: agentSel.value });
// [State Persistence Fix] 에이전트 선택값을 즉시 백엔드에 저장
vscode.postMessage({ type: 'saveAgentSelection', path: agentSel.value });
if (editMode) agentConfigPanel.style.display = 'flex';
} else {
agentConfigPanel.style.display = 'none';
editMode = false;
editAgentBtn.classList.remove('active');
agentPrompt.value = '';
negativePrompt.value = '';
// [State Persistence Fix] 에이전트 해제도 즉시 저장
vscode.postMessage({ type: 'saveAgentSelection', path: 'none' });
}
vscode.postMessage({ type: 'getKnowledgeScope', agentPath: agentSel.value });
};
if (editKnowledgeMapBtn) {
editKnowledgeMapBtn.onclick = () => openAgentMapModal();
}
if (reloadKnowledgeMapBtn) {
reloadKnowledgeMapBtn.onclick = () => vscode.postMessage({ type: 'getKnowledgeScope', agentPath: agentSel.value });
}
if (closeAgentMapBtn) closeAgentMapBtn.onclick = closeAgentMapModal;
if (cancelAgentMapBtn) cancelAgentMapBtn.onclick = closeAgentMapModal;
if (editAgentMapJsonBtn) {
editAgentMapJsonBtn.onclick = () => vscode.postMessage({ type: 'editKnowledgeMap' });
}
if (addKnowledgeFolderBtn) {
addKnowledgeFolderBtn.onclick = () => vscode.postMessage({ type: 'pickPath', kind: 'knowledgeFolder' });
}
if (addSkillFolderBtn) {
addSkillFolderBtn.onclick = () => vscode.postMessage({ type: 'pickPath', kind: 'skillFolder' });
}
if (addSkillFileBtn) {
addSkillFileBtn.onclick = () => vscode.postMessage({ type: 'pickPath', kind: 'skillFile' });
}
if (saveAgentMapBtn) {
saveAgentMapBtn.onclick = () => {
agentMapStatus.className = 'map-status';
agentMapStatus.textContent = '저장 중...';
vscode.postMessage({
type: 'saveAgentMap',
agentPath: agentMapDraft.agentPath,
knowledgeFolders: agentMapDraft.knowledgeFolders,
skillFolders: agentMapDraft.skillFolders,
// Empty string = "Use current model" (override removed).
model: agentMapDraft.model || '',
// null = "Use global setting" (override removed); number 0100 = pinned.
secondBrainWeight: agentMapDraft.secondBrainWeight,
});
};
}
// Track changes to the per-agent model dropdown so the draft stays in sync.
const _agentMapModelSelEl = document.getElementById('agentMapModelSel');
if (_agentMapModelSelEl) {
_agentMapModelSelEl.onchange = () => {
agentMapDraft.model = _agentMapModelSelEl.value || '';
};
}
// ── Per-agent Knowledge Mix slider + "Use global" checkbox ────────────
const _agentMapMixCb = document.getElementById('agentMapMixUseGlobal');
const _agentMapMixSlider = document.getElementById('agentMapMixSlider');
if (_agentMapMixCb && _agentMapMixSlider) {
_agentMapMixCb.addEventListener('change', () => {
if (_agentMapMixCb.checked) {
agentMapDraft.secondBrainWeight = null;
} else {
// Snap to whatever the slider currently shows so the user has a starting point.
agentMapDraft.secondBrainWeight = parseInt(_agentMapMixSlider.value, 10) || 50;
}
syncAgentMapMixUi();
});
_agentMapMixSlider.addEventListener('input', () => {
if (_agentMapMixCb.checked) return; // disabled state, but guard anyway
const w = Math.max(0, Math.min(100, parseInt(_agentMapMixSlider.value, 10) || 50));
agentMapDraft.secondBrainWeight = w;
const hint = document.getElementById('agentMapMixHint');
if (hint) hint.textContent = fmtMixHint(w);
});
}
editAgentBtn.onclick = () => {
if (agentSel.value === 'none') return;
editMode = !editMode;
editAgentBtn.classList.toggle('active', editMode);
agentConfigPanel.style.display = editMode ? 'flex' : 'none';
};
updateAgentBtn.onclick = () => {
if (agentSel.value !== 'none') {
vscode.postMessage({
type: 'updateAgent',
path: agentSel.value,
content: agentPrompt.value,
negativePrompt: negativePrompt.value.trim()
});
}
};
addAgentBtn.onclick = () => vscode.postMessage({ type: 'createAgent' });
deleteAgentBtn.onclick = () => {
if (agentSel.value === 'none') return;
vscode.postMessage({ type: 'deleteAgent', path: agentSel.value });
};
document.getElementById('addDesignerBtn').onclick = () => vscode.postMessage({ type: 'createChronicleProject' });
document.getElementById('openDesignerBtn').onclick = () => vscode.postMessage({ type: 'openChronicleFolder' });
document.getElementById('refreshChronicleRecordsBtn').onclick = () => vscode.postMessage({ type: 'getChronicleRecords' });
document.getElementById('openChronicleRecordBtn').onclick = () => {
if (!chronicleRecordSel.value) return;
vscode.postMessage({ type: 'openChronicleRecord', path: chronicleRecordSel.value });
};
// ── Header dropdowns (Tools ▾ / Edit ▾ / Records ▾) ──────────────────
function closeAllDropdowns(except) {
document.querySelectorAll('.hdr-menu.open').forEach(m => { if (m !== except) m.classList.remove('open'); });
}
document.querySelectorAll('[data-dd]').forEach(dd => {
const trigger = dd.querySelector('[data-dd-trigger]');
const menu = dd.querySelector('[data-dd-menu]');
if (!trigger || !menu) return;
trigger.addEventListener('click', e => {
e.stopPropagation();
const willOpen = !menu.classList.contains('open');
closeAllDropdowns(menu);
menu.classList.toggle('open', willOpen);
});
// Clicks inside the menu shouldn't bubble to the document (which would close it). A click
// on a <button> means "I picked something" → close after its own handler runs; a <select>
// (or label/spacer) keeps the menu open so the user can change several things.
menu.addEventListener('click', e => {
e.stopPropagation();
if (e.target && e.target.closest && e.target.closest('button')) {
setTimeout(() => menu.classList.remove('open'), 0);
}
});
});
document.addEventListener('click', () => closeAllDropdowns());
// Keep the Context Bar / Records line in sync with the (now-collapsed) selectors.
[brainSel, agentSel, designerSel].forEach(s => s && s.addEventListener('change', syncContextBar));
if (chronicleRecordSel) chronicleRecordSel.addEventListener('change', syncRecordsLine);
syncContextBar();
syncRecordsLine();
vscode.postMessage({ type: 'getModels' });
vscode.postMessage({ type: 'getAgents' });
vscode.postMessage({ type: 'getChronicleProjects' });
vscode.postMessage({ type: 'getChronicleRecords' });
vscode.postMessage({ type: 'getKnowledgeMix' });
vscode.postMessage({ type: 'getArchitectureStatus' });
vscode.postMessage({ type: 'getCompanyStatus' });
vscode.postMessage({ type: 'ready' });
// ── Project Architecture chip buttons ─────────────────────────────────
const _archOpenBtn = document.getElementById('archOpenBtn');
const _archRefreshBtn = document.getElementById('archRefreshBtn');
const _archDetachBtn = document.getElementById('archDetachBtn');
if (_archOpenBtn) _archOpenBtn.onclick = () => vscode.postMessage({ type: 'openArchitectureDoc' });
if (_archRefreshBtn) _archRefreshBtn.onclick = () => vscode.postMessage({ type: 'refreshArchitecture' });
if (_archDetachBtn) _archDetachBtn.onclick = () => vscode.postMessage({ type: 'detachArchitecture' });
// [Attach] is visible only in the inactive chip state; clicking it
// re-enables architecture mode for the current workspace's project.
const _archAttachBtn = document.getElementById('archAttachBtn');
if (_archAttachBtn) _archAttachBtn.onclick = () => vscode.postMessage({ type: 'attachArchitecture' });
// ── 1인 기업 (Company) Mode chip + manage overlay ─────────────────────
// The chip itself toggles enabled/disabled. The ▾ button opens the
// manage overlay where the user picks active agents + per-agent
// model overrides. State round-trips through `companyStatus` /
// `companyAgents` messages so the webview and extension stay in sync.
const _companyChip = document.getElementById('companyChip');
const _companyManageBtn = document.getElementById('companyManageBtn');
const _companyOverlay = document.getElementById('companyOverlay');
const _closeCompanyBtns = [
document.getElementById('closeCompanyOverlayBtn'),
document.getElementById('closeCompanyOverlayBtn2'),
].filter(Boolean);
const _companyNameInput = document.getElementById('companyNameInput');
const _saveCompanyNameBtn = document.getElementById('saveCompanyNameBtn');
const _companyAgentList = document.getElementById('companyAgentList');
const _companyStatusEl = document.getElementById('companyStatus');
/**
* Chip lives in the main header toolbar now, so it uses the same
* `icon-btn.active` styling as `brainTraceBtn` / `internetBtn`.
* Detail (company name + agent count) goes in the tooltip — the
* label stays a constant "Corp" so the toolbar tone-and-manner
* isn't broken by a wildly varying-width chip.
*/
const renderCompanyChip = (active, summary) => {
if (!_companyChip) return;
_companyChip.classList.toggle('active', !!active);
_companyChip.setAttribute(
'data-tooltip',
active
? `1인 기업 ON · ${summary || ''}`.trim()
: '1인 기업 모드 OFF — 클릭해서 켜기',
);
// 스코프 프리셋 segmented control 도 기업 모드 ON 일 때만 노출.
const scopeSeg = document.getElementById('companyScopeSeg');
if (scopeSeg) scopeSeg.hidden = !active;
};
// 활성 pipeline 의 id 가 어느 SCOPE 프리셋의 suggestedPipelineId 와 매칭되는지로 active 표시.
// companyStatus 메시지가 activePipelineId 를 보낼 때마다 호출.
const SCOPE_PRESET_TO_PIPELINE_ID = {
'plan-only': 'plan-only',
'dev-only': 'dev-only',
'full-product-dev': 'product-dev',
};
const renderScopeSeg = (activePipelineId) => {
const scopeSeg = document.getElementById('companyScopeSeg');
if (!scopeSeg) return;
for (const btn of scopeSeg.querySelectorAll('.scope-seg-btn')) {
const tplId = btn.getAttribute('data-scope');
const expected = SCOPE_PRESET_TO_PIPELINE_ID[tplId];
btn.classList.toggle('active', !!activePipelineId && activePipelineId === expected);
}
};
// Wire up clicks once.
const _scopeSeg = document.getElementById('companyScopeSeg');
if (_scopeSeg && !_scopeSeg.dataset.wired) {
_scopeSeg.dataset.wired = '1';
_scopeSeg.addEventListener('click', (e) => {
const btn = e.target && e.target.closest && e.target.closest('.scope-seg-btn');
if (!btn) return;
const tplId = btn.getAttribute('data-scope');
if (!tplId) return;
// Optimistic visual flip — backend ack 가 companyStatus 갱신으로 결과 확정.
for (const b of _scopeSeg.querySelectorAll('.scope-seg-btn')) {
b.classList.toggle('active', b === btn);
}
vscode.postMessage({ type: 'setCompanyScopePreset', templateId: tplId });
});
}
if (_companyChip) {
_companyChip.onclick = () => {
const isActive = _companyChip.classList.contains('active');
// Optimistic flip — backend echoes the canonical state back.
renderCompanyChip(!isActive, '');
vscode.postMessage({ type: 'setCompanyEnabled', value: !isActive });
};
}
if (_companyManageBtn) {
_companyManageBtn.onclick = () => {
if (!_companyOverlay) return;
_companyOverlay.classList.add('visible');
_companyStatusEl.textContent = '불러오는 중...';
vscode.postMessage({ type: 'getCompanyAgents' });
vscode.postMessage({ type: 'getCompanyPipelines' });
vscode.postMessage({ type: 'getCompanyResumable' });
};
}
for (const btn of _closeCompanyBtns) {
btn.onclick = () => _companyOverlay?.classList.remove('visible');
}
if (_saveCompanyNameBtn && _companyNameInput) {
_saveCompanyNameBtn.onclick = () => {
vscode.postMessage({ type: 'setCompanyName', value: _companyNameInput.value });
};
}
// ── Add-agent form: toggle, clear, submit ──
const _addCompanyAgentBtn = document.getElementById('addCompanyAgentBtn');
const _addAgentForm = document.getElementById('addCompanyAgentForm');
const _cancelAddAgentBtn = document.getElementById('cancelAddAgentBtn');
const _saveAddAgentBtn = document.getElementById('saveAddAgentBtn');
const _addAgentError = document.getElementById('addAgentError');
const _addAgentFields = () => ({
id: document.getElementById('newAgentId'),
name: document.getElementById('newAgentName'),
role: document.getElementById('newAgentRole'),
emoji: document.getElementById('newAgentEmoji'),
color: document.getElementById('newAgentColor'),
roleCategory: document.getElementById('newAgentRoleCategory'),
tagline: document.getElementById('newAgentTagline'),
specialty: document.getElementById('newAgentSpecialty'),
persona: document.getElementById('newAgentPersona'),
});
const _populateAddAgentRoleSelect = () => {
const sel = document.getElementById('newAgentRoleCategory');
if (!sel) return;
sel.innerHTML = '';
for (const cat of _roleCategoryOrder) {
if (cat === 'ceo') continue; // ceo는 빌트인 전용
const opt = document.createElement('option');
opt.value = cat;
opt.textContent = _roleCategoryLabels[cat] || cat;
sel.appendChild(opt);
}
sel.value = 'support'; // 기본은 가장 무해한 직군
};
const _clearAddAgentForm = () => {
const f = _addAgentFields();
for (const k of Object.keys(f)) {
if (!f[k]) continue;
if (k === 'roleCategory') continue; // select는 별도 populate
f[k].value = '';
}
if (_addAgentError) _addAgentError.textContent = '';
_populateAddAgentRoleSelect();
};
if (_addCompanyAgentBtn && _addAgentForm) {
_addCompanyAgentBtn.onclick = () => {
const open = _addAgentForm.getAttribute('data-open') === 'true';
_addAgentForm.setAttribute('data-open', open ? 'false' : 'true');
if (!open) {
_clearAddAgentForm();
_addAgentFields().id?.focus();
}
};
}
if (_cancelAddAgentBtn && _addAgentForm) {
_cancelAddAgentBtn.onclick = () => {
_addAgentForm.setAttribute('data-open', 'false');
_clearAddAgentForm();
};
}
if (_saveAddAgentBtn) {
_saveAddAgentBtn.onclick = () => {
const f = _addAgentFields();
const def = {
id: (f.id?.value || '').trim().toLowerCase(),
name: (f.name?.value || '').trim(),
role: (f.role?.value || '').trim(),
emoji: (f.emoji?.value || '').trim(),
color: (f.color?.value || '').trim(),
roleCategory: (f.roleCategory?.value || 'support'),
tagline: (f.tagline?.value || '').trim(),
specialty: (f.specialty?.value || '').trim(),
persona: (f.persona?.value || '').trim(),
};
if (!def.id || !def.name || !def.role) {
if (_addAgentError) _addAgentError.textContent = 'id · 이름 · 역할은 필수입니다.';
return;
}
if (_addAgentError) _addAgentError.textContent = '';
vscode.postMessage({ type: 'addCompanyAgent', def });
};
}
// ── Work Pipeline editor (card-based) ──
const _activePipelineSel = document.getElementById('activePipelineSel');
const _pipelineList = document.getElementById('companyPipelineList');
const _addPipelineBtn = document.getElementById('addCompanyPipelineBtn');
const _pipelineEditForm = document.getElementById('pipelineEditForm');
const _pipelineEditId = document.getElementById('pipelineEditId');
const _pipelineEditName = document.getElementById('pipelineEditName');
const _pipelineStageList = document.getElementById('pipelineStageList');
const _addStageBtn = document.getElementById('addStageBtn');
const _pipelineEditError = document.getElementById('pipelineEditError');
const _cancelPipelineEditBtn = document.getElementById('cancelPipelineEditBtn');
const _savePipelineEditBtn = document.getElementById('savePipelineEditBtn');
// pipeline 페이로드에 같이 오는 직군별 활성 에이전트 캐시.
// 카드의 "직군 → 담당" cascading dropdown이 이걸 참조한다.
let _activeAgentsByCategory = {};
// 현재 편집 중인 stages의 in-memory 표현. 카드 UI는 이 배열만 보고
// 다시 그려지므로 텍스트 입력 외 모든 상태 변경(추가/삭제/순서변경/
// 직군 변경)이 즉시 re-render를 통해 화면과 데이터를 동기화한다.
let _editStages = [];
// 드래그 중인 stage의 원본 인덱스. -1 = 드래그 중 아님.
// 한 페이지에 에디터 인스턴스는 하나뿐이라 모듈 스코프로 충분.
let _draggedStageIndex = -1;
const _genStageId = (taken) => {
const used = new Set(taken);
let n = 1;
while (used.has(`stage-${n}`)) n++;
return `stage-${n}`;
};
const _emptyStage = () => ({
id: _genStageId(_editStages.map((s) => s.id)),
label: '',
roleCategory: 'planner',
agentId: '',
modelOverride: '',
requiresApproval: false,
reviewWith: '',
reviewMaxRounds: 3,
instructionTemplate: '',
loopBackPattern: '',
loopBackTo: '',
maxIterations: 3,
});
const _firstAgentOfCategory = (cat) => {
const list = _activeAgentsByCategory[cat] || [];
return list[0]?.id || '';
};
const _buildStageCard = (stage, index, total) => {
const li = document.createElement('li');
li.className = 'pipeline-stage-card';
li.dataset.stageId = stage.id;
li.dataset.index = String(index);
// ── head: number + label input + ↑/↓/🗑 ──
const head = document.createElement('div');
head.className = 'psc-head';
const num = document.createElement('span');
num.className = 'psc-num';
num.textContent = String(index + 1);
num.title = '드래그하여 순서 변경 (↑/↓ 버튼도 가능)';
// ── 드래그 핸들: 번호 칩만 드래그 시작점이 되도록.
// textarea·input 영역에서 드래그가 시작되면 텍스트 선택과 충돌함.
// li 자체는 드래그 가능하되 dragstart는 핸들 클릭 후에만 허용.
li.draggable = false;
let _dragArmed = false;
num.addEventListener('mousedown', () => {
li.draggable = true;
_dragArmed = true;
});
// mouseup이 어디서든 일어나면 드래그 해제 (drop 후엔 dragend가 처리).
const _disarm = () => {
if (_dragArmed) { _dragArmed = false; li.draggable = false; }
};
li.addEventListener('mouseup', _disarm);
li.addEventListener('mouseleave', _disarm);
li.addEventListener('dragstart', (e) => {
if (!_dragArmed) { e.preventDefault(); return; }
e.dataTransfer.effectAllowed = 'move';
// 텍스트 페이로드는 필요 없지만 일부 브라우저가 비어 있으면 무시.
try { e.dataTransfer.setData('text/plain', stage.id); } catch {}
li.classList.add('dragging');
// li reference를 모듈 스코프 변수에 기록 — dragover/drop이 참조.
_draggedStageIndex = index;
});
li.addEventListener('dragend', () => {
li.classList.remove('dragging');
li.draggable = false;
_dragArmed = false;
_draggedStageIndex = -1;
// 모든 카드의 drop indicator 제거.
_pipelineStageList?.querySelectorAll('.drop-above, .drop-below')
.forEach((el) => el.classList.remove('drop-above', 'drop-below'));
});
li.addEventListener('dragover', (e) => {
if (_draggedStageIndex < 0 || _draggedStageIndex === index) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
// 카드의 위/아래 절반에 따라 indicator 위치 결정.
const rect = li.getBoundingClientRect();
const above = (e.clientY - rect.top) < rect.height / 2;
li.classList.toggle('drop-above', above);
li.classList.toggle('drop-below', !above);
});
li.addEventListener('dragleave', () => {
li.classList.remove('drop-above', 'drop-below');
});
li.addEventListener('drop', (e) => {
e.preventDefault();
li.classList.remove('drop-above', 'drop-below');
if (_draggedStageIndex < 0 || _draggedStageIndex === index) return;
const rect = li.getBoundingClientRect();
const above = (e.clientY - rect.top) < rect.height / 2;
const from = _draggedStageIndex;
let to = above ? index : index + 1;
// splice 보정: from < to 인 경우 to를 한 칸 당겨야 의도한 위치.
if (from < to) to -= 1;
if (from === to) return;
const [moved] = _editStages.splice(from, 1);
_editStages.splice(to, 0, moved);
_draggedStageIndex = -1;
_renderStages();
});
const lbl = document.createElement('input');
lbl.type = 'text';
lbl.className = 'psc-label';
lbl.placeholder = '단계 이름 (예: 기획 논의)';
lbl.value = stage.label || '';
lbl.oninput = () => { stage.label = lbl.value; };
const ctrls = document.createElement('div');
ctrls.className = 'psc-controls';
const upBtn = document.createElement('button');
upBtn.textContent = '↑'; upBtn.title = '위로'; upBtn.disabled = index === 0;
upBtn.onclick = () => { if (index > 0) { [_editStages[index - 1], _editStages[index]] = [_editStages[index], _editStages[index - 1]]; _renderStages(); } };
const downBtn = document.createElement('button');
downBtn.textContent = '↓'; downBtn.title = '아래로'; downBtn.disabled = index === total - 1;
downBtn.onclick = () => { if (index < _editStages.length - 1) { [_editStages[index + 1], _editStages[index]] = [_editStages[index], _editStages[index + 1]]; _renderStages(); } };
const delBtn = document.createElement('button');
delBtn.className = 'del'; delBtn.textContent = '🗑'; delBtn.title = '단계 삭제';
delBtn.onclick = () => {
if (!confirm(`'${stage.label || stage.id}' 단계를 삭제할까요?`)) return;
_editStages.splice(index, 1);
_renderStages();
};
ctrls.appendChild(upBtn); ctrls.appendChild(downBtn); ctrls.appendChild(delBtn);
head.appendChild(num); head.appendChild(lbl); head.appendChild(ctrls);
li.appendChild(head);
// ── body ──
const body = document.createElement('div');
body.className = 'psc-body';
// row: 직군 → 담당
const row = document.createElement('div');
row.className = 'psc-row';
const roleLabel = document.createElement('label');
roleLabel.textContent = '직군:';
const roleSel = document.createElement('select');
for (const cat of _roleCategoryOrder) {
if (cat === 'ceo') continue; // stage agent로 CEO는 안 씀
const opt = document.createElement('option');
opt.value = cat;
opt.textContent = _roleCategoryLabels[cat] || cat;
roleSel.appendChild(opt);
}
roleSel.value = stage.roleCategory || 'planner';
const agentLabel = document.createElement('label');
agentLabel.textContent = '담당:';
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 = '__no_agents__';
opt.textContent = '(이 직군의 활성 에이전트 없음)';
opt.disabled = true;
agentSel.appendChild(opt);
// CEO 자동 선택은 여전히 가능 — 활성 후보가 없으면 dispatcher에서
// no-active-agent-in-role 에러로 사용자에게 알린다.
} else {
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가 빈값이면 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;
// 직군 바뀌면 명시적 담당자 박힌 게 더 이상 의미 없음 — CEO 자동 선택으로 리셋.
stage.agentId = '';
_refillAgentSel();
};
agentSel.onchange = () => { stage.agentId = agentSel.value; };
row.appendChild(roleLabel); row.appendChild(roleSel);
row.appendChild(agentLabel); row.appendChild(agentSel);
// 모델 select — 비워두면 에이전트의 모델 override → 글로벌 default 사용.
const modelLbl = document.createElement('label'); modelLbl.textContent = '모델:';
const stageModelSel = document.createElement('select');
stageModelSel.title = '비워두면 담당 에이전트의 모델 설정(또는 글로벌 기본)을 사용';
populateAgentModelSelect(stageModelSel, stage.modelOverride || '');
// populateAgentModelSelect는 첫 옵션 라벨이 "default (global)"인데
// stage 맥락에선 "기본 (에이전트 설정 사용)"이 더 정확. 첫 옵션 텍스트만 교체.
if (stageModelSel.options.length > 0 && stageModelSel.options[0].value === '') {
stageModelSel.options[0].text = '담당자 설정 사용';
}
stageModelSel.onchange = () => { stage.modelOverride = stageModelSel.value || ''; };
row.appendChild(modelLbl); row.appendChild(stageModelSel);
body.appendChild(row);
// ── 승인 게이트 체크박스 ──
// 한 stage가 끝난 뒤 사용자가 직접 ✅승인 / ✎수정요청 / 🛑중단 을
// 누를 때까지 dispatcher가 대기. 검토 stage(inspector)나 중요 결정
// 직전에 켜두면 자동 진행을 막을 수 있다.
const approvalWrap = document.createElement('label');
approvalWrap.style.cssText = 'display:flex; gap:6px; align-items:center; font-size:10px; color:var(--text-dim); cursor:pointer;';
const approvalCb = document.createElement('input');
approvalCb.type = 'checkbox';
approvalCb.checked = !!stage.requiresApproval;
approvalCb.onchange = () => { stage.requiresApproval = approvalCb.checked; };
approvalWrap.appendChild(approvalCb);
const approvalText = document.createElement('span');
approvalText.textContent = '이 단계 후 내 승인 받기';
approvalText.title = '체크하면 단계 완료 후 자동 진행하지 않고 승인/수정요청/중단 버튼이 채팅창에 뜹니다';
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';
instrLabelDiv.textContent = '담당자에게 전달할 요청';
body.appendChild(instrLabelDiv);
const tokens = document.createElement('div');
tokens.className = 'psc-tokens';
const instr = document.createElement('textarea');
instr.className = 'psc-instr';
instr.placeholder = '예: {{userPrompt}} 에 대한 기획서 초안을 작성해주세요. 시장 조사 결과({{stage.research}})를 참고하세요.';
instr.value = stage.instructionTemplate || '';
instr.oninput = () => { stage.instructionTemplate = instr.value; };
const _insertToken = (token) => {
const start = instr.selectionStart ?? instr.value.length;
const end = instr.selectionEnd ?? instr.value.length;
instr.value = instr.value.slice(0, start) + token + instr.value.slice(end);
stage.instructionTemplate = instr.value;
instr.focus();
instr.selectionStart = instr.selectionEnd = start + token.length;
};
const mkTokenBtn = (label, token) => {
const b = document.createElement('button');
b.textContent = label;
b.onclick = (e) => { e.preventDefault(); _insertToken(token); };
return b;
};
tokens.appendChild(mkTokenBtn('+ 사용자 요청', '{{userPrompt}}'));
tokens.appendChild(mkTokenBtn('+ CEO 브리프', '{{brief}}'));
// 이전 stages의 출력을 참조하는 토큰들 — 자기 자신 이후는 제외.
for (let i = 0; i < index; i++) {
const prev = _editStages[i];
const label = prev.label || prev.id;
tokens.appendChild(mkTokenBtn(`+ ${label}`, `{{stage.${prev.id}}}`));
}
body.appendChild(tokens);
body.appendChild(instr);
// ── loop-back details ──
const loop = document.createElement('details');
loop.className = 'psc-loop';
if (stage.loopBackPattern || stage.loopBackTo) loop.open = true;
const summary = document.createElement('summary');
const hasLoop = !!(stage.loopBackPattern && stage.loopBackTo);
summary.textContent = hasLoop
? `재작업 활성: "${stage.loopBackPattern}" 발견 시 → ${_editStages.find((s) => s.id === stage.loopBackTo)?.label || stage.loopBackTo}`
: '문제가 있으면 이전 단계로 되돌리기 (선택)';
loop.appendChild(summary);
const grid = document.createElement('div');
grid.className = 'psc-loop-grid';
// condition
const condLbl = document.createElement('label'); condLbl.textContent = '감지할 표현:';
const condInput = document.createElement('input');
condInput.type = 'text';
condInput.placeholder = '예: 버그|오류|fail|재작업';
condInput.value = stage.loopBackPattern || '';
condInput.oninput = () => { stage.loopBackPattern = condInput.value; };
// target
const tgtLbl = document.createElement('label'); tgtLbl.textContent = '돌아갈 단계:';
const tgtSel = document.createElement('select');
const emptyOpt = document.createElement('option');
emptyOpt.value = ''; emptyOpt.textContent = '(선택 안 함)';
tgtSel.appendChild(emptyOpt);
for (let i = 0; i < index; i++) {
const prev = _editStages[i];
const opt = document.createElement('option');
opt.value = prev.id;
opt.textContent = `${i + 1}. ${prev.label || prev.id}`;
tgtSel.appendChild(opt);
}
tgtSel.value = (stage.loopBackTo && index > 0) ? stage.loopBackTo : '';
tgtSel.onchange = () => { stage.loopBackTo = tgtSel.value; };
// max
const maxLbl = document.createElement('label'); maxLbl.textContent = '최대 반복:';
const maxInput = document.createElement('input');
maxInput.type = 'number'; maxInput.min = '1'; maxInput.max = '10';
maxInput.value = String(stage.maxIterations || 3);
maxInput.oninput = () => {
const v = parseInt(maxInput.value, 10);
stage.maxIterations = Number.isFinite(v) ? Math.max(1, Math.min(10, v)) : 3;
};
grid.appendChild(condLbl); grid.appendChild(condInput);
grid.appendChild(tgtLbl); grid.appendChild(tgtSel);
grid.appendChild(maxLbl); grid.appendChild(maxInput);
if (index === 0) {
const note = document.createElement('div');
note.style.cssText = 'grid-column:1/-1; font-style:italic; opacity:0.7;';
note.textContent = '첫 단계는 되돌아갈 곳이 없어 재시도 설정이 적용되지 않습니다.';
grid.appendChild(note);
}
loop.appendChild(grid);
body.appendChild(loop);
li.appendChild(body);
return li;
};
const _renderStages = () => {
if (!_pipelineStageList) return;
_pipelineStageList.innerHTML = '';
for (let i = 0; i < _editStages.length; i++) {
_pipelineStageList.appendChild(_buildStageCard(_editStages[i], i, _editStages.length));
}
};
const _openPipelineEditor = (pipeline) => {
if (!_pipelineEditForm) return;
_pipelineEditForm.setAttribute('data-open', 'true');
if (_pipelineEditError) _pipelineEditError.textContent = '';
if (pipeline) {
if (_pipelineEditId) { _pipelineEditId.value = pipeline.id; _pipelineEditId.disabled = true; }
if (_pipelineEditName) _pipelineEditName.value = pipeline.name || '';
// stages 데이터를 깊은 복사 — 사용자 수정이 cancel 시 안 새도록.
_editStages = (pipeline.stages || []).map((s) => ({
id: s.id,
label: s.label || '',
// 기존 데이터에 roleCategory가 없으면 담당 agentId의 직군으로 추정.
roleCategory: s.roleCategory || _deriveRoleFromAgent(s.agentId) || 'planner',
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 || '',
maxIterations: s.maxIterations || 3,
}));
} else {
if (_pipelineEditId) { _pipelineEditId.value = ''; _pipelineEditId.disabled = false; }
if (_pipelineEditName) _pipelineEditName.value = '';
_editStages = [];
}
_renderStages();
};
// 빌트인 + 커스텀 에이전트의 직군 매핑 캐시 (lastCompanyAgentsPayload 활용)
const _deriveRoleFromAgent = (agentId) => {
if (!agentId || !_lastCompanyAgentsPayload) return null;
const agent = (_lastCompanyAgentsPayload.agents || []).find((a) => a.id === agentId);
return agent?.roleCategory || null;
};
const _closePipelineEditor = () => {
if (_pipelineEditForm) _pipelineEditForm.setAttribute('data-open', 'false');
if (_pipelineEditId) _pipelineEditId.disabled = false;
_editStages = [];
};
if (_addPipelineBtn) {
_addPipelineBtn.onclick = () => {
_openPipelineEditor(null);
_pipelineEditId?.focus();
};
}
// 템플릿에서 새 pipeline 만들기. 선택 시 백엔드에 stages를 요청 →
// companyPipelineTemplateContent 응답이 오면 에디터를 미리 채워 연다.
const _pipelineTemplateSel = document.getElementById('pipelineTemplateSel');
if (_pipelineTemplateSel) {
_pipelineTemplateSel.onchange = () => {
const tplId = _pipelineTemplateSel.value;
if (!tplId) return;
vscode.postMessage({ type: 'getCompanyPipelineTemplate', templateId: tplId });
// 선택 후 즉시 초기화 — 사용자가 같은 템플릿을 다시 찍어도 onchange가 다시 발화하도록.
_pipelineTemplateSel.value = '';
};
}
if (_addStageBtn) {
_addStageBtn.onclick = () => {
const ns = _emptyStage();
ns.agentId = _firstAgentOfCategory(ns.roleCategory);
_editStages.push(ns);
_renderStages();
// 새로 추가한 카드의 라벨 입력으로 포커스 — 이름부터 적게 유도.
const newCard = _pipelineStageList?.lastElementChild;
newCard?.querySelector('.psc-label')?.focus();
};
}
if (_cancelPipelineEditBtn) {
_cancelPipelineEditBtn.onclick = _closePipelineEditor;
}
if (_savePipelineEditBtn) {
_savePipelineEditBtn.onclick = () => {
const id = (_pipelineEditId?.value || '').trim().toLowerCase();
if (!id) {
if (_pipelineEditError) _pipelineEditError.textContent = 'id는 필수입니다 (소문자로 시작, /-/_).';
return;
}
// 각 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 && !s.roleCategory) {
if (_pipelineEditError) _pipelineEditError.textContent = `${i + 1}번 단계: 담당자(또는 직군)을 지정하세요.`;
return;
}
}
const def = {
id,
name: (_pipelineEditName?.value || '').trim() || id,
stages: _editStages.map((s) => {
const out = {
id: s.id,
label: s.label.trim(),
// 빈 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;
out.maxIterations = s.maxIterations || 3;
}
return out;
}),
};
vscode.postMessage({ type: 'upsertCompanyPipeline', def });
};
}
if (_activePipelineSel) {
_activePipelineSel.onchange = () => {
vscode.postMessage({
type: 'setActiveCompanyPipeline',
pipelineId: _activePipelineSel.value || null,
});
};
}
let _lastCompanyPipelinesPayload = null;
const renderCompanyPipelines = (payload) => {
if (!_pipelineList || !_activePipelineSel) return;
_lastCompanyPipelinesPayload = payload || null;
const pipelines = (payload && payload.pipelines && typeof payload.pipelines === 'object') ? payload.pipelines : {};
const activeId = payload && payload.activePipelineId ? payload.activePipelineId : '';
// 직군별 에이전트 + 라벨 캐시 갱신. 에디터가 열려 있는 동안
// 새 페이로드가 오면 카드의 담당 dropdown도 새 목록 반영.
if (payload && payload.activeAgentsByCategory) {
_activeAgentsByCategory = payload.activeAgentsByCategory;
}
if (payload && payload.roleCategoryLabels) _roleCategoryLabels = payload.roleCategoryLabels;
if (payload && Array.isArray(payload.roleCategoryOrder)) _roleCategoryOrder = payload.roleCategoryOrder;
// 템플릿 드롭다운 채우기.
const tplSel = document.getElementById('pipelineTemplateSel');
if (tplSel && payload && Array.isArray(payload.templates)) {
tplSel.innerHTML = '<option value="">템플릿으로 시작</option>';
for (const t of payload.templates) {
const opt = document.createElement('option');
opt.value = t.templateId;
opt.textContent = `${t.name} · ${t.stageCount}단계`;
opt.title = t.description || '';
tplSel.appendChild(opt);
}
}
// 에디터가 열려 있으면 stages 다시 그려서 새로운 담당 옵션 반영.
if (_pipelineEditForm?.getAttribute('data-open') === 'true' && _editStages.length > 0) {
_renderStages();
}
// active dropdown
_activePipelineSel.innerHTML = '<option value="">대표가 알아서 분배</option>';
for (const p of Object.values(pipelines)) {
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = `${p.name || p.id} · ${(p.stages || []).length}단계`;
_activePipelineSel.appendChild(opt);
}
_activePipelineSel.value = activeId;
// list
_pipelineList.innerHTML = '';
const entries = Object.values(pipelines);
if (entries.length === 0) {
const li = document.createElement('li');
li.className = 'pipeline-empty-state';
li.textContent = '아직 저장된 작업 흐름이 없습니다. 템플릿으로 시작하거나 직접 만들어보세요.';
_pipelineList.appendChild(li);
return;
}
for (const p of entries) {
const li = document.createElement('li');
li.className = 'pipeline-summary-card';
const head = document.createElement('div');
head.className = 'pipeline-summary-head';
const title = document.createElement('div');
title.className = 'pipeline-summary-title';
title.innerHTML = `<strong>${escAttr(p.name || p.id)}</strong><span>${(p.stages || []).length}단계${p.id === activeId ? ' · 현재 사용 중' : ''}</span>`;
const actions = document.createElement('div');
actions.className = 'pipeline-summary-actions';
const editBtn = document.createElement('button');
editBtn.className = 'company-agent-edit';
editBtn.textContent = '편집';
editBtn.onclick = () => _openPipelineEditor(p);
const delBtn = document.createElement('button');
delBtn.className = 'company-agent-edit';
delBtn.textContent = '삭제';
delBtn.title = '작업 흐름 삭제';
delBtn.onclick = () => {
if (!confirm(`'${p.name || p.id}' 작업 흐름을 삭제할까요?`)) return;
vscode.postMessage({ type: 'deleteCompanyPipeline', pipelineId: p.id });
};
actions.appendChild(editBtn);
actions.appendChild(delBtn);
head.appendChild(title);
head.appendChild(actions);
li.appendChild(head);
const flow = document.createElement('div');
flow.className = 'pipeline-summary-flow';
const stages = Array.isArray(p.stages) ? p.stages : [];
flow.textContent = stages.length > 0
? stages.map((s) => s.label || s.id).join(' → ')
: '단계가 없습니다';
li.appendChild(flow);
_pipelineList.appendChild(li);
}
};
// expose for the message handler below
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 {}
})();
// ──────────────────────────────────────────────────────────────────────
// 이어서 진행 가능 세션 렌더링.
//
// 백엔드가 보낸 items 배열을 카드 목록으로 그린다. 비어 있으면 섹션 자체를
// data-empty="true"로 숨겨 평소 시야에서 사라지게 만든다. 카드는 두 액션:
// - 이어서 진행 → resumeCompanyTurn 메시지
// - 버리기 → discardResumableSession (resume 파일을 'failed'로 마킹)
// ──────────────────────────────────────────────────────────────────────
const _formatRelative = (iso) => {
if (!iso) return '';
const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso;
const diff = Date.now() - d.getTime();
const m = Math.round(diff / 60000);
if (m < 1) return '방금 전';
if (m < 60) return `${m}분 전`;
const h = Math.round(m / 60);
if (h < 24) return `${h}시간 전`;
const days = Math.round(h / 24);
return `${days}일 전`;
};
const _formatAbortReason = (reason) => {
if (!reason) return '도중에 멈춤';
const map = {
'signal-aborted': '시작 직전에 중단',
'aborted-after-plan': '계획 직후 중단',
'aborted-mid-dispatch': '실행 중에 중단',
'aborted-mid-pipeline': '단계 진행 중 중단',
'aborted-mid-approval': '승인 대기 중 중단',
'aborted-by-user-at-approval': '승인 단계에서 중단',
'aborted-before-report': '보고서 직전 중단',
};
return map[reason] || reason;
};
const renderCompanyResumable = (payload) => {
const section = document.getElementById('companyResumableSection');
const list = document.getElementById('companyResumableList');
if (!section || !list) return;
const items = (payload && Array.isArray(payload.items)) ? payload.items : [];
list.innerHTML = '';
if (items.length === 0) {
section.setAttribute('data-empty', 'true');
return;
}
section.setAttribute('data-empty', 'false');
for (const it of items) {
const li = document.createElement('li');
li.className = 'company-resumable-card';
li.dataset.timestamp = it.timestamp;
const head = document.createElement('div');
head.className = 'company-resumable-head';
const prompt = document.createElement('div');
prompt.className = 'company-resumable-prompt';
prompt.textContent = it.userPrompt || '(빈 요청)';
prompt.title = it.userPrompt || '';
const actions = document.createElement('div');
actions.className = 'company-resumable-actions';
const resumeBtn = document.createElement('button');
resumeBtn.className = 'primary company-resumable-resume';
resumeBtn.textContent = '이어서 진행';
resumeBtn.title = '이 작업을 멈췄던 다음 단계부터 같은 세션에 이어 기록합니다.';
resumeBtn.onclick = () => {
// 사용자에게 곧 시작될 거라는 시각 피드백.
resumeBtn.disabled = true;
resumeBtn.textContent = '재개 중…';
vscode.postMessage({ type: 'resumeCompanyTurn', timestamp: it.timestamp });
// overlay를 닫아 채팅 화면이 보이게 — 사용자가 진행 상황 즉시 확인.
document.getElementById('companyOverlay')?.classList.remove('visible');
};
const discardBtn = document.createElement('button');
discardBtn.className = 'company-resumable-discard';
discardBtn.textContent = '버리기';
discardBtn.title = '이 작업을 더 이상 이어가지 않습니다. 목록에서만 빠지고 기존 산출물 파일은 그대로 남습니다.';
discardBtn.onclick = () => {
if (!confirm('이 미완 작업을 목록에서 버릴까요? 이미 만들어진 산출물 파일은 사라지지 않습니다.')) return;
vscode.postMessage({ type: 'discardResumableSession', timestamp: it.timestamp });
};
actions.appendChild(resumeBtn);
actions.appendChild(discardBtn);
head.appendChild(prompt);
head.appendChild(actions);
li.appendChild(head);
const meta = document.createElement('div');
meta.className = 'company-resumable-meta';
const pipelineLabel = it.pipelineName
? `📋 ${escAttr(it.pipelineName)}`
: '🧭 대표 분배 모드';
const progress = (it.totalCount > 0)
? `${it.completedCount}/${it.totalCount} 단계 완료`
: '진행도 정보 없음';
const when = _formatRelative(it.lastUpdatedAt);
const why = it.status === 'aborted'
? `· ${_formatAbortReason(it.abortReason)}`
: (it.status === 'in-progress' ? '· 프로세스 중단 추정' : '');
meta.innerHTML = `<span>${pipelineLabel}</span><span>${escAttr(progress)}</span><span>${escAttr(when)} ${escAttr(why)}</span>`;
li.appendChild(meta);
list.appendChild(li);
}
};
window.__renderCompanyResumable = renderCompanyResumable;
// 템플릿 stamp 시 호출 — id/name 제안값 + stages를 카드 에디터에 미리 채움.
window.__openPipelineEditorWithTemplate = (tpl) => {
if (!tpl) return;
// suggested id가 이미 존재하면 -2, -3 식으로 충돌 회피.
const taken = new Set(Object.keys((_lastCompanyPipelinesPayload?.pipelines) || {}));
let id = tpl.suggestedPipelineId || 'pipeline';
if (taken.has(id)) {
let n = 2;
while (taken.has(`${id}-${n}`)) n++;
id = `${id}-${n}`;
}
_openPipelineEditor({
id,
name: tpl.suggestedPipelineName || id,
stages: tpl.stages || [],
});
// suggested id는 새로 만드는 것이므로 잠가두지 않고 사용자가 바꿀 수 있게.
if (_pipelineEditId) _pipelineEditId.disabled = false;
};
/**
* Keep the last payload around so we can re-render whenever the
* model list refreshes (the top `#modelSel` is the source of truth
* for available models — see `populateAgentModelSelect`).
*/
let _lastCompanyAgentsPayload = null;
/**
* Populate one agent's model `<select>` from the master `#modelSel`
* options. Mirrors the pattern used by `refreshAgentMapModelOptions`
* so the user picks from the same canonical list everywhere.
* Empty value = "default (global)". Saved overrides not in the list
* are preserved as a "(saved)" option so the value never gets lost.
*/
function populateAgentModelSelect(sel, current) {
sel.innerHTML = '';
const useDefault = document.createElement('option');
useDefault.value = '';
useDefault.innerText = '기본 모델 사용';
sel.appendChild(useDefault);
const seen = new Set();
for (const opt of modelSel.options) {
if (!opt.value || seen.has(opt.value)) continue;
seen.add(opt.value);
const o = document.createElement('option');
o.value = opt.value;
o.innerText = opt.innerText;
sel.appendChild(o);
}
if (current && !seen.has(current)) {
const o = document.createElement('option');
o.value = current;
o.innerText = `${current} (저장됨)`;
sel.appendChild(o);
}
sel.value = current || '';
}
/**
* Render the agent cards in the manage overlay. Each card has:
* - a model dropdown (default + every loaded model)
* - an ON/OFF toggle (CEO always-on)
* - an Edit button that toggles an inline prompt editor with
* tagline / specialty / persona textareas + Reset/Save/Cancel.
*/
// 직군 라벨/순서 캐시. 페이로드에 같이 오므로 첫 렌더 후엔 항상 채워져 있음.
// pipeline 에디터(다음 phase)도 같은 캐시를 본다.
let _roleCategoryLabels = {
ceo: 'CEO', planner: '기획', researcher: '리서치', designer: '디자인',
developer: '개발', qa: 'QA', inspector: '감리', support: '지원',
};
let _roleCategoryOrder = ['ceo','planner','researcher','designer','developer','qa','inspector','support'];
window.__getRoleLabels = () => _roleCategoryLabels;
window.__getRoleOrder = () => _roleCategoryOrder;
function renderCompanyAgentCards(payload) {
if (!_companyAgentList) return;
_lastCompanyAgentsPayload = payload;
_companyAgentList.innerHTML = '';
if (_companyNameInput && payload && typeof payload.companyName === 'string') {
_companyNameInput.value = payload.companyName;
}
if (payload && payload.roleCategoryLabels) _roleCategoryLabels = payload.roleCategoryLabels;
if (payload && Array.isArray(payload.roleCategoryOrder)) _roleCategoryOrder = payload.roleCategoryOrder;
const rawAgents = (payload && Array.isArray(payload.agents)) ? payload.agents : [];
// 활성/비활성 정렬: CEO는 항상 최상단(alwaysOn), 그 다음 활성(켜짐) 에이전트,
// 마지막으로 비활성. 같은 그룹 내에서는 백엔드가 보낸 원래 순서를 유지(stable).
const agents = rawAgents
.map((a, i) => ({ a, i }))
.sort((x, y) => {
// CEO/alwaysOn 최우선
const xLocked = x.a.alwaysOn ? 0 : 1;
const yLocked = y.a.alwaysOn ? 0 : 1;
if (xLocked !== yLocked) return xLocked - yLocked;
// 그 다음 active 여부
const xAct = x.a.active ? 0 : 1;
const yAct = y.a.active ? 0 : 1;
if (xAct !== yAct) return xAct - yAct;
// tiebreak: 원래 순서
return x.i - y.i;
})
.map((p) => p.a);
let sectionKey = '';
const appendTeamSection = (key, title, hint) => {
if (sectionKey === key) return;
sectionKey = key;
const section = document.createElement('li');
section.className = 'company-agent-section-label';
// 시각적 그룹핑용 라벨 — 스크린리더는 카드 갯수만 세도록 presentation 처리.
section.setAttribute('role', 'presentation');
section.setAttribute('aria-hidden', 'true');
section.innerHTML = `<span>${escAttr(title)}</span><small>${escAttr(hint)}</small>`;
_companyAgentList.appendChild(section);
};
for (const a of agents) {
if (a.alwaysOn || a.active) {
appendTeamSection('active', '참여 중', '이번 작업에 함께 응답합니다');
} else {
appendTeamSection('standby', '대기 중', '필요할 때 팀에 합류시킬 수 있습니다');
}
const li = document.createElement('li');
li.className = 'company-agent-card';
li.setAttribute('data-active', a.active ? 'true' : 'false');
if (a.alwaysOn) li.setAttribute('data-locked', 'true');
li.dataset.agentId = a.id;
li.style.setProperty('--agent-color', a.color || 'var(--accent)');
// ── Row 1: emoji + name/tagline + controls ──
// CSS handles layout via `.company-agent-head` (flex-wrap,
// gap, etc.) so we don't repeat inline styles here.
const row = document.createElement('div');
row.className = 'company-agent-head';
const emoji = document.createElement('span');
emoji.className = 'company-agent-emoji';
emoji.textContent = a.emoji;
const body = document.createElement('div');
body.className = 'company-agent-body';
const name = document.createElement('div');
name.className = 'company-agent-name';
name.innerHTML = `<span>${escAttr(a.name)}</span><span class="company-agent-role">${escAttr(a.role)}</span>`;
const tag = document.createElement('div');
tag.className = 'company-agent-tagline';
tag.textContent = a.tagline || '';
tag.title = a.specialty || '';
body.appendChild(name);
body.appendChild(tag);
const meta = document.createElement('div');
meta.className = 'company-agent-meta';
const roleChip = document.createElement('span');
roleChip.className = 'company-agent-chip';
roleChip.textContent = _roleCategoryLabels[a.roleCategory || a.defaultRoleCategory] || a.roleCategory || a.defaultRoleCategory || '역할';
const modelChip = document.createElement('span');
modelChip.className = 'company-agent-chip';
modelChip.textContent = a.modelOverride ? '전용 모델' : '기본 모델';
meta.appendChild(roleChip);
meta.appendChild(modelChip);
if (a.personaOverridden || a.specialtyOverridden || a.taglineOverridden || a.nameOverridden || a.roleOverridden) {
const tunedChip = document.createElement('span');
tunedChip.className = 'company-agent-chip tuned';
tunedChip.textContent = '커스텀';
meta.appendChild(tunedChip);
}
body.appendChild(meta);
const controls = document.createElement('div');
controls.className = 'company-agent-controls';
// 직군 select — CEO는 잠금. role 라벨 보다 먼저 와서 시선 흐름이
// "이 사람은 [직군] 직군"으로 자연스럽게 읽히도록 controls 첫번째에.
const roleSelEl = document.createElement('select');
roleSelEl.className = 'company-agent-role-select';
roleSelEl.title = '이 에이전트의 직군. 파이프라인 에디터에서 직군별로 담당자를 고를 때 사용됩니다.';
if (a.id === 'ceo') {
const opt = document.createElement('option');
opt.value = 'ceo'; opt.textContent = _roleCategoryLabels.ceo || 'CEO';
roleSelEl.appendChild(opt);
roleSelEl.value = 'ceo';
roleSelEl.disabled = true;
} else {
for (const cat of _roleCategoryOrder) {
if (cat === 'ceo') continue;
const opt = document.createElement('option');
opt.value = cat;
opt.textContent = _roleCategoryLabels[cat] || cat;
roleSelEl.appendChild(opt);
}
roleSelEl.value = a.roleCategory || a.defaultRoleCategory || 'support';
if (a.roleCategoryOverridden) roleSelEl.classList.add('overridden');
roleSelEl.onchange = () => {
const v = roleSelEl.value;
const sendValue = v === a.defaultRoleCategory ? null : v;
vscode.postMessage({
type: 'setCompanyAgentRoleCategory',
agentId: a.id,
value: sendValue,
});
};
}
const modelSelEl = document.createElement('select');
modelSelEl.className = 'company-agent-model';
modelSelEl.title = '비워두면 글로벌 기본 모델 사용';
populateAgentModelSelect(modelSelEl, a.modelOverride || '');
modelSelEl.onchange = () => {
vscode.postMessage({
type: 'setCompanyAgentModel',
agentId: a.id,
model: modelSelEl.value || '',
});
};
const editBtn = document.createElement('button');
editBtn.className = 'company-agent-edit';
editBtn.textContent = '프로필';
if (a.personaOverridden || a.specialtyOverridden || a.taglineOverridden) {
editBtn.classList.add('dirty');
editBtn.title = '프로필과 응답 방식이 사용자에 의해 조정되었습니다';
} else {
editBtn.title = '이름, 역할, 잘하는 일, 응답 스타일 편집';
}
editBtn.onclick = () => {
const expanded = li.getAttribute('data-expanded') === 'true';
li.setAttribute('data-expanded', expanded ? 'false' : 'true');
// 카드가 갑자기 두 배로 길어지지 않게 다른 패널은 자동으로 접는다.
li.setAttribute('data-settings-expanded', 'false');
};
const settingsBtn = document.createElement('button');
settingsBtn.className = 'company-agent-edit company-agent-settings-btn';
settingsBtn.textContent = '설정';
settingsBtn.title = '역할 그룹, 모델, 참고 지식 반영 조정';
settingsBtn.onclick = () => {
const expanded = li.getAttribute('data-settings-expanded') === 'true';
li.setAttribute('data-settings-expanded', expanded ? 'false' : 'true');
li.setAttribute('data-expanded', 'false');
};
const toggle = document.createElement('button');
toggle.className = 'company-agent-toggle';
toggle.textContent = a.active ? '참여 중' : '+ 참여';
if (a.alwaysOn) {
toggle.disabled = true;
toggle.textContent = '항상 참여';
} else {
toggle.onclick = () => {
const wantActive = !(li.getAttribute('data-active') === 'true');
// ── Optimistic update ──
// 백엔드 응답이 오기 전에 카드를 옳은 섹션(참여 중 / 대기 중)으로
// 즉시 이동시켜야 라벨과 카드 위치가 어긋난 상태로 머무르지 않는다.
// 캐시된 페이로드의 active 플래그를 갱신한 뒤 같은 페이로드로 한 번 더
// 렌더하면 sort + 섹션 라벨이 새 상태대로 재계산됨.
if (_lastCompanyAgentsPayload && Array.isArray(_lastCompanyAgentsPayload.agents)) {
const found = _lastCompanyAgentsPayload.agents.find((x) => x.id === a.id);
if (found) found.active = wantActive;
}
const nextIds = (_lastCompanyAgentsPayload?.agents || [])
.filter((x) => x.active || x.alwaysOn)
.map((x) => x.id)
.filter(Boolean);
// 백엔드 알림은 먼저 보내고 (네트워크 latency 흡수), 그 직후 동기 재렌더로
// UI를 정렬. 백엔드 응답이 오면 같은 결과로 한 번 더 그려지지만 idempotent.
vscode.postMessage({ type: 'setCompanyActiveAgents', value: nextIds });
if (_lastCompanyAgentsPayload) renderCompanyAgentCards(_lastCompanyAgentsPayload);
};
}
controls.appendChild(editBtn);
controls.appendChild(settingsBtn);
// 삭제 버튼 — CEO만 빼고 빌트인/커스텀 모두 노출. 단, 어떤 워크 파이프라인의
// stage라도 이 에이전트를 참조하고 있으면 disabled로 처리하고 tooltip에
// 사용 중인 파이프라인을 적어, 사용자가 어디로 가서 빼야 하는지 보이게 한다.
if (!a.alwaysOn) {
const delBtn = document.createElement('button');
delBtn.className = 'company-agent-edit company-agent-delete';
delBtn.textContent = a.custom ? '삭제' : '숨김';
const usedIn = Array.isArray(a.usedInPipelines) ? a.usedInPipelines : [];
if (usedIn.length > 0) {
delBtn.disabled = true;
delBtn.classList.add('disabled');
delBtn.title = `숨길 수 없음 — 다음 작업 흐름에서 사용 중: ${usedIn.map((n) => `'${n}'`).join(', ')}. 작업 흐름에서 먼저 빼거나 다른 팀원으로 교체하세요.`;
} else {
delBtn.title = a.custom
? `'${a.name}' 팀원 삭제 (모든 설정 함께 제거)`
: `'${a.name}' 팀원 숨기기 (기본 팀원이라 복원 가능)`;
delBtn.onclick = () => {
const confirmMsg = a.custom
? `'${a.name}' 팀원을 삭제할까요? 이 팀원의 모든 설정(모델·응답 방식·지식 반영)도 함께 삭제됩니다.`
: `기본 팀원 '${a.name}'을(를) 목록에서 숨길까요? 나중에 [목록 맨 아래 → 숨긴 기본 팀원] 영역에서 복원할 수 있습니다.`;
if (!confirm(confirmMsg)) return;
vscode.postMessage({ type: 'deleteCompanyAgent', agentId: a.id });
};
}
controls.appendChild(delBtn);
}
controls.appendChild(toggle);
row.appendChild(emoji);
row.appendChild(body);
row.appendChild(controls);
li.appendChild(row);
const settings = document.createElement('div');
settings.className = 'company-agent-settings';
const settingsGrid = document.createElement('div');
settingsGrid.className = 'company-agent-settings-grid';
const roleWrap = document.createElement('label');
roleWrap.textContent = '역할 그룹';
roleWrap.appendChild(roleSelEl);
const modelWrap = document.createElement('label');
modelWrap.textContent = '모델';
modelWrap.appendChild(modelSelEl);
settingsGrid.appendChild(roleWrap);
settingsGrid.appendChild(modelWrap);
settings.appendChild(settingsGrid);
// CEO doesn't dispatch agents itself, it only synthesises,
// so the brain mix for CEO turns is governed by specialists.
if (a.id !== 'ceo') {
settings.appendChild(_buildAgentKnowledgeMixSlider(a, payload.globalKnowledgeMixWeight));
}
li.appendChild(settings);
// ── Row 2 (collapsed by default): prompt editor ──
li.appendChild(_buildAgentPromptEditor(a));
_companyAgentList.appendChild(li);
}
// ── 숨겨진 기본 에이전트 복원 영역 ──
// 사용자가 "삭제"한 빌트인 에이전트를 다시 꺼낼 수 있는 출구. 빈 배열이면
// 섹션 자체를 그리지 않아 평소엔 시야에서 사라진다.
const hidden = Array.isArray(payload && payload.hiddenBuiltins) ? payload.hiddenBuiltins : [];
if (hidden.length > 0) {
const restoreLi = document.createElement('li');
restoreLi.className = 'company-agent-hidden-section';
const head = document.createElement('div');
head.className = 'company-agent-hidden-head';
head.textContent = `숨긴 기본 팀원 (${hidden.length}명)`;
restoreLi.appendChild(head);
const hint = document.createElement('div');
hint.className = 'company-agent-hidden-hint';
hint.textContent = '복원하면 목록에 다시 추가됩니다. 설정은 모두 디폴트로 시작합니다.';
restoreLi.appendChild(hint);
const list = document.createElement('div');
list.className = 'company-agent-hidden-list';
for (const h of hidden) {
const chip = document.createElement('button');
chip.className = 'company-agent-hidden-chip';
chip.textContent = `${h.emoji || '🙂'} ${h.name} · ${h.role} ↩ 복원`;
chip.title = `'${h.name}' 복원`;
chip.onclick = () => {
vscode.postMessage({ type: 'restoreHiddenAgent', agentId: h.id });
};
list.appendChild(chip);
}
restoreLi.appendChild(list);
_companyAgentList.appendChild(restoreLi);
}
if (_companyStatusEl) _companyStatusEl.textContent = '';
}
/**
* Inline Knowledge Mix slider for one agent. Empty (override = null)
* means "use the global slider value" — shown as a hint label so the
* user knows what they'll fall back to. Slider commits on `change`
* (not `input`) so dragging doesn't spam writes.
*/
function _buildAgentKnowledgeMixSlider(a, globalWeight) {
const row = document.createElement('div');
row.className = 'company-agent-mix-row';
const usingOverride = a.knowledgeMixOverride !== null && a.knowledgeMixOverride !== undefined;
const effective = a.effectiveKnowledgeMixWeight;
const label = document.createElement('span');
label.className = 'company-agent-mix-label';
label.textContent = '참고 지식 반영';
const sourceBadge = document.createElement('span');
sourceBadge.className = 'company-agent-mix-source' + (usingOverride ? ' override' : '');
sourceBadge.textContent = usingOverride ? '직접 조정' : '전체 설정';
const slider = document.createElement('input');
slider.type = 'range';
slider.min = '0'; slider.max = '100'; slider.step = '5';
slider.value = String(effective);
slider.disabled = !usingOverride;
slider.className = 'company-agent-mix-slider';
const hint = document.createElement('span');
hint.className = 'company-agent-mix-hint';
const renderHint = () => {
const w = parseInt(slider.value, 10) || 50;
hint.textContent = `${w}%`;
};
const cb = document.createElement('input');
cb.type = 'checkbox'; cb.checked = !usingOverride;
cb.className = 'company-agent-mix-cb';
cb.title = '전체 참고 지식 설정 사용';
cb.onchange = () => {
if (cb.checked) {
// Reset to global.
slider.disabled = true;
slider.value = String(globalWeight ?? 50);
sourceBadge.textContent = '전체 설정';
sourceBadge.classList.remove('override');
renderHint();
vscode.postMessage({
type: 'setCompanyAgentKnowledgeMix',
agentId: a.id, value: null,
});
} else {
// Take ownership at the current displayed value.
slider.disabled = false;
sourceBadge.textContent = '직접 조정';
sourceBadge.classList.add('override');
const w = parseInt(slider.value, 10) || 50;
vscode.postMessage({
type: 'setCompanyAgentKnowledgeMix',
agentId: a.id, value: w,
});
}
};
slider.addEventListener('input', renderHint);
slider.addEventListener('change', () => {
if (cb.checked) return; // safety: shouldn't fire when disabled
const w = parseInt(slider.value, 10) || 50;
vscode.postMessage({
type: 'setCompanyAgentKnowledgeMix',
agentId: a.id, value: w,
});
});
renderHint();
const cbWrap = document.createElement('label');
cbWrap.className = 'company-agent-mix-cbwrap';
cbWrap.appendChild(cb);
cbWrap.appendChild(document.createTextNode(' 전체 설정 사용'));
row.appendChild(label);
row.appendChild(sourceBadge);
row.appendChild(slider);
row.appendChild(hint);
row.appendChild(cbWrap);
return row;
}
/**
* Build the per-agent prompt editor. Hidden until the user clicks
* the Edit button. Three fields (tagline / specialty / persona);
* Save sends whichever fields actually changed; Reset sends `null`
* to wipe all overrides at once.
*/
function _buildAgentPromptEditor(a) {
const editor = document.createElement('div');
editor.className = 'company-agent-editor';
const _field = (key, labelText, isTextarea, current, defaultVal, overridden) => {
const lbl = document.createElement('label');
lbl.className = 'field-label';
lbl.innerHTML = `<span>${labelText}</span>` +
(overridden
? '<span class="field-flag">조정됨</span>'
: '<span class="field-flag" style="color:var(--text-dim)">기본값</span>');
editor.appendChild(lbl);
const el = isTextarea
? document.createElement('textarea')
: document.createElement('input');
if (!isTextarea) el.type = 'text';
el.value = current || '';
el.placeholder = defaultVal || '';
editor.appendChild(el);
return el;
};
// ── 디스플레이 필드 (이름·역할·이모지·색상) ──
// 빌트인이든 커스텀이든 사용자가 자유롭게 리네이밍 가능. 변경 후
// CEO 보고서·planner enumeration·세션 로그 등 모든 표시 지점에
// 즉시 반영된다 — resolveAgent가 override를 머지하므로.
const nameInput = _field('name', '이름', false, a.name, a.defaultName, a.nameOverridden);
const roleInput = _field('role', '역할 소개', false, a.role, a.defaultRole, a.roleOverridden);
// 이모지·색상은 한 줄에 나란히 — CSS는 grid 없이 inline flex로 처리.
const visualWrap = document.createElement('div');
visualWrap.style.cssText = 'display:flex; gap:8px;';
const emojiInput = _field('emoji', '이모지', false, a.emoji, a.defaultEmoji, a.emojiOverridden);
const colorInput = _field('color', '테마 색상', false, a.color, a.defaultColor, a.colorOverridden);
// 위에서 만든 두 필드는 editor에 이미 append됨. 한 줄로 묶고 싶으면
// 부모에서 분리해 visualWrap에 다시 넣는다 — label은 직전 sibling.
// 더 단순하게: emoji/color 입력 후 reflow는 그냥 두고 max-width만 줄임.
emojiInput.style.maxWidth = '80px';
colorInput.style.maxWidth = '120px';
const tagInput = _field('tagline', '한 줄 소개', false, a.tagline, a.defaultTagline, a.taglineOverridden);
const specInput = _field('specialty', '잘하는 일', true, a.specialty, a.defaultSpecialty, a.specialtyOverridden);
const persInput = _field('persona', '응답 스타일', true, a.persona, a.defaultPersona, a.personaOverridden);
specInput.rows = 3;
persInput.rows = 5;
const actions = document.createElement('div');
actions.className = 'editor-actions';
const resetBtn = document.createElement('button');
resetBtn.className = 'danger';
resetBtn.textContent = '기본값으로';
resetBtn.title = '이 에이전트의 모든 사용자 변경 사항 제거 → 디폴트로 복귀 (이름·역할·프롬프트 전부)';
resetBtn.onclick = () => {
vscode.postMessage({ type: 'setCompanyAgentPrompt', agentId: a.id, override: null });
vscode.postMessage({ type: 'setCompanyAgentDisplay', agentId: a.id, override: null });
};
const cancelBtn = document.createElement('button');
cancelBtn.textContent = '취소';
cancelBtn.onclick = () => {
const card = editor.closest('.company-agent-card');
if (card) card.setAttribute('data-expanded', 'false');
};
const saveBtn = document.createElement('button');
saveBtn.className = 'primary';
saveBtn.textContent = '저장';
saveBtn.onclick = () => {
// 두 개의 message로 분리 전송 — display(name/role/emoji/color)와
// prompt(tagline/specialty/persona)는 백엔드에서 서로 다른
// override 테이블을 쓴다. 빈 문자열은 그 필드만 reset, 디폴트와
// 같은 값은 override 안 함(원본 그대로 두기) — 사용자가 일부러
// 디폴트 값을 다시 입력해 "override 박제"하는 케이스는 드물고,
// 안 박는 게 다음 코드 업데이트 시 새 디폴트를 자동 흡수해서
// 유리하다.
vscode.postMessage({
type: 'setCompanyAgentDisplay',
agentId: a.id,
override: {
name: nameInput.value === a.defaultName ? '' : nameInput.value,
role: roleInput.value === a.defaultRole ? '' : roleInput.value,
emoji: emojiInput.value === a.defaultEmoji ? '' : emojiInput.value,
color: colorInput.value === a.defaultColor ? '' : colorInput.value,
},
});
vscode.postMessage({
type: 'setCompanyAgentPrompt',
agentId: a.id,
override: {
tagline: tagInput.value === a.defaultTagline ? '' : tagInput.value,
specialty: specInput.value === a.defaultSpecialty ? '' : specInput.value,
persona: persInput.value === a.defaultPersona ? '' : persInput.value,
},
});
};
actions.appendChild(resetBtn);
actions.appendChild(cancelBtn);
actions.appendChild(saveBtn);
editor.appendChild(actions);
return editor;
}
/**
* Render one phase event from the dispatcher. The chat gets a
* card per phase so the user can follow progress in real time —
* "🧭 CEO 작업 분배 중..." → "📺 레오 작업 수행 중..." → final report.
*/
function renderCompanyPhase(ev) {
const chatEl = document.getElementById('chat');
if (!chatEl) return;
const card = document.createElement('div');
card.className = 'company-phase-card';
if (ev.phase === 'plan-start') {
card.innerHTML = '<div class="cph-head">🧭 CEO</div><div class="cph-meta">작업 분배 중…</div>';
} else if (ev.phase === 'plan-ready') {
const tasks = (ev.plan?.tasks || []).map((t, i) => `${i + 1}. <strong>${escAttr(t.agent)}</strong> — ${escAttr(t.task)}`).join('<br>');
card.innerHTML = `<div class="cph-head">🧭 CEO 브리프</div>
<div>${escAttr(ev.plan?.brief || '(brief 없음)')}</div>
<div class="cph-meta" style="margin-top:6px">${tasks || '(no tasks — chat reply)'}</div>`;
} else if (ev.phase === 'agent-start') {
card.innerHTML = `<div class="cph-head">${escAttr(ev.agentId)} 작업 수행 중…</div>
<div class="cph-meta">${escAttr(ev.task)} <em>(${ev.index + 1}/${ev.total})</em></div>`;
} else if (ev.phase === 'agent-done') {
const o = ev.output || {};
const body = (o.response || '').slice(0, 4000);
card.innerHTML = `<div class="cph-head">${escAttr(ev.agentId)} 완료 <span class="cph-meta">${(o.durationMs/1000).toFixed(1)}s${o.error ? ' · ⚠️ ' + escAttr(o.error) : ''}</span></div>
<div class="markdown-body">${fmt(body)}</div>`;
} 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';
card.dataset.stageId = ev.stageId;
const head = document.createElement('div');
head.className = 'cph-head';
head.innerHTML = `✋ <strong>${escAttr(ev.stageLabel || ev.stageId)}</strong> 완료 — 검토 후 승인해 주세요`;
const actions = document.createElement('div');
actions.style.cssText = 'display:flex; gap:6px; margin-top:8px; flex-wrap:wrap;';
const approveBtn = document.createElement('button');
approveBtn.className = 'send-btn';
approveBtn.textContent = '✅ 승인 (다음으로)';
approveBtn.onclick = () => {
vscode.postMessage({ type: 'respondCompanyApproval', stageId: ev.stageId, decision: 'approve' });
actions.querySelectorAll('button').forEach((b) => b.disabled = true);
approveBtn.textContent = '✅ 승인됨';
};
const reviseBtn = document.createElement('button');
reviseBtn.className = 'secondary-btn';
reviseBtn.textContent = '✎ 수정 요청';
reviseBtn.onclick = () => {
const comment = prompt('어떤 부분을 수정하면 좋을까요? (담당 에이전트에게 전달됩니다)', '');
if (comment === null) return; // 취소
vscode.postMessage({ type: 'respondCompanyApproval', stageId: ev.stageId, decision: 'revise', comment });
actions.querySelectorAll('button').forEach((b) => b.disabled = true);
reviseBtn.textContent = '✎ 수정 요청 전송됨';
};
const abortBtn = document.createElement('button');
abortBtn.className = 'secondary-btn';
abortBtn.textContent = '🛑 중단';
abortBtn.title = '여기서 파이프라인 중단';
abortBtn.onclick = () => {
if (!confirm('이 라운드를 여기서 중단할까요? 이미 완료된 stage 결과는 보존됩니다.')) return;
vscode.postMessage({ type: 'respondCompanyApproval', stageId: ev.stageId, decision: 'abort' });
actions.querySelectorAll('button').forEach((b) => b.disabled = true);
abortBtn.textContent = '🛑 중단 요청';
};
actions.appendChild(approveBtn);
actions.appendChild(reviseBtn);
actions.appendChild(abortBtn);
card.innerHTML = '';
card.appendChild(head);
card.appendChild(actions);
} else if (ev.phase === 'approval-resolved') {
// 이전에 그려둔 awaiting-approval 카드를 갱신 — 별도 카드를 또 만들지 않음.
const prev = chatEl.querySelector(`.company-phase-card.approval[data-stage-id="${CSS.escape(ev.stageId)}"]`);
if (prev) {
const tail = document.createElement('div');
tail.className = 'cph-meta';
tail.style.marginTop = '6px';
const label = ev.decision === 'approve' ? '✅ 승인됨' : ev.decision === 'revise' ? '✎ 수정 요청됨' : '🛑 사용자가 중단';
tail.textContent = label;
prev.appendChild(tail);
}
return; // 새 카드 안 만듦
} else if (ev.phase === 'report-start') {
card.innerHTML = '<div class="cph-head">🧭 CEO 종합 보고서 작성 중…</div>';
} else if (ev.phase === 'report-done') {
card.className += ' report';
card.innerHTML = `<div class="cph-head">🧭 CEO 보고서${ev.ok ? '' : ' (fallback)'}</div>
<div class="markdown-body">${fmt(ev.report || '')}</div>`;
} else if (ev.phase === 'telegram-mirror') {
// Reflect whether the secretary actually mirrored the round
// to Telegram. `ok === null` = the user hasn't opted in to
// Telegram at all (no token / chat id / enabled). We render
// that as a quiet line instead of an error to avoid nagging.
if (ev.ok === true) {
card.innerHTML = '<div class="cph-meta">📱 영숙이 텔레그램에 보고 완료</div>';
} else if (ev.ok === false) {
card.innerHTML = `<div class="cph-meta">⚠️ 텔레그램 보고 실패${ev.reason ? `${escAttr(ev.reason)}` : ''}</div>`;
} else {
// null → not configured. Skip rendering entirely to keep chat clean.
return;
}
} else if (ev.phase === 'session-saved') {
card.innerHTML = `<div class="cph-meta">세션 저장 완료 — 클릭하여 열기</div>`;
card.style.cursor = 'pointer';
card.onclick = () => vscode.postMessage({ type: 'openCompanySession', sessionDir: ev.sessionDir });
} else if (ev.phase === 'aborted') {
card.innerHTML = `<div class="cph-head">⛔ 회사 모드 중단</div><div class="cph-meta">${escAttr(ev.reason)}</div>`;
}
chatEl.appendChild(card);
chatEl.scrollTop = chatEl.scrollHeight;
}
// ── Knowledge Mix: global slider ──────────────────────────────────────
// Mirrors `g1nation.knowledgeMix.secondBrainWeight`. The hint label updates
// live as the user drags; the value is committed (postMessage) on `change`
// so we don't spam settings updates while scrubbing.
const knowledgeMixSlider = document.getElementById('knowledgeMixSlider');
const knowledgeMixHint = document.getElementById('knowledgeMixHint');
const renderGlobalMixHint = () => {
if (!knowledgeMixSlider || !knowledgeMixHint) return;
knowledgeMixHint.textContent = fmtMixHint(parseInt(knowledgeMixSlider.value, 10) || 50);
};
if (knowledgeMixSlider) {
knowledgeMixSlider.addEventListener('input', renderGlobalMixHint);
knowledgeMixSlider.addEventListener('change', () => {
const w = Math.max(0, Math.min(100, parseInt(knowledgeMixSlider.value, 10) || 50));
vscode.postMessage({ type: 'setKnowledgeMix', value: w });
});
renderGlobalMixHint();
}
// --- Proactive Behavioral Tracking ---
let hoverTimer = null;
const trackBehavior = (elementId, context) => {
const el = document.getElementById(elementId);
if (!el) return;
el.addEventListener('mouseenter', () => {
hoverTimer = setTimeout(() => {
vscode.postMessage({ type: 'proactiveTrigger', context: context });
}, 5000); // 5 seconds threshold
});
el.addEventListener('mouseleave', () => {
if (hoverTimer) clearTimeout(hoverTimer);
});
};
trackBehavior('settingsBtn', 'settings_exploration');
trackBehavior('brainBtn', 'brain_sync_exploration');
trackBehavior('agentSel', 'agent_selection_exploration');