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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 = `${p.emoji} `;
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 = '복잡한 작업은 헤더의 [기업 모드] 로 여러 전문 에이전트에게 분배할 수 있습니다. · 입력 후 Cmd/Ctrl + Enter 로 전송.';
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(`${escAttr(tag)} `);
}
if (s.model && s.model.name) {
const loaded = s.model.loaded;
segs.push(`${escAttr(shortModel(s.model.name))} `);
}
if (s.brain && typeof s.brain.files === 'number') {
segs.push(`Brain ${s.brain.files} `);
}
if (typeof s.contextLength === 'number') {
if (s.cappedForSmallModel) {
segs.push(`ctx ${fmtK(s.contextLength)} · 소형모델 제한 `);
} else {
segs.push(`ctx ${fmtK(s.contextLength)} `);
}
}
segs.push(`메모리 ${s.memory ? 'On' : 'Off'} `);
if (s.multiAgent) segs.push(`멀티에이전트 `);
rbContent.innerHTML = segs.join('· ');
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 ? `
사유: ${escAttr(String(v.reason))}
` : '';
// 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 =
`${escAttr(titleText)}
` +
reasonLine +
`📝 교훈 기록 무시
`;
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 = `🔎 참조 지식 없음 — 모델 자체 지식으로 답변 `;
} 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 ? ` · 파일 ${files.length} ` : '';
const layerTag = layers.length ? ` · 메모리 ${escAttr(layers.join('·'))} ` : '';
const lessonTag = lessons.length ? ` · ⚠ 교훈 ${lessons.length} ` : '';
const titleAttr = files.length ? `사용된 브레인 파일:\n${files.join('\n')}` : '에이전트↔지식 매핑 편집';
footer.innerHTML = `🔎 참조: ${agentTag}${escAttr(scopeLabel)} ${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}% (${escAttr(srcLabel)}) `;
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(' ');
const w = document.createElement('div');
w.className = 'scope-unaddressed';
w.innerHTML = `⚠ 답변에서 안 보이는 교훈 체크리스트 항목: ${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 0–100 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 ? 'U
You' : '✦
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 = '
';
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 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 = `${s.title}
${new Date(s.timestamp).toLocaleString()} · ${s.messageCount} msgs
`;
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 = 'No Agent ';
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 = `${escAttr(v.label || '💬 대화')} ` +
(v.reason ? ` ${escAttr(v.reason)} ` : '');
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 'devilRebuttal': {
// Devil Agent (도현) 카드. main assistant 답변 직후 chat 하단에 한 장 추가.
// 사용자가 '재반박' 누르면 그 텍스트가 다음 user prompt 로 가서 main turn 한 번 더 돌고,
// 재반박-입장 hint 가 prompt 에 prepend 됨.
const v = msg.value || {};
const chatEl = document.getElementById('chat');
if (!chatEl) break;
const card = document.createElement('div');
card.className = 'devil-rebuttal-card';
const persona = String(v.persona || '도현');
const text = String(v.text || '');
card.innerHTML = `
🎭 ${escAttr(persona)} 이(가) 반박합니다
재반박
넘기기
`;
// body 는 textContent 로 안전하게.
card.querySelector('.devil-rebuttal-body').textContent = text;
chatEl.appendChild(card);
chatEl.scrollTop = chatEl.scrollHeight;
card.querySelector('.devil-dismiss').addEventListener('click', () => card.remove());
card.querySelector('.devil-reply').addEventListener('click', () => {
// 입력창에 prefix 채워서 사용자가 자기 반박 입력하게.
const input = document.getElementById('input');
if (input) {
input.value = `[${persona}의 반박에 답변] `;
input.focus();
// 커서 끝으로.
try { input.setSelectionRange(input.value.length, input.value.length); } catch {}
}
card.remove();
});
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 = '🛑 의도 정렬 취소됨 — 작업이 시작되지 않았습니다
';
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 = `${escAttr(kindLabel)} `;
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 = `${escAttr(label)} ${val ? fmt(val) : '(미정) '} `;
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 =
`${head}
` +
`${parts.join(' · ')}
`;
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 = '🛡️ 작업 승인 대기 중 (Action Approval Required)
' +
'위의 변경 사항을 프로젝트에 반영할까요?
' +
'' +
' 승인 (Approve) ' +
' 롤백 (Rollback) ' +
'
';
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 = `📎 ${f.name} ✕ `;
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 0–100 = 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 means "I picked something" → close after its own handler runs; a
// (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 = '템플릿으로 시작 ';
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 = '대표가 알아서 분배 ';
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 = `${escAttr(p.name || p.id)} ${(p.stages || []).length}단계${p.id === activeId ? ' · 현재 사용 중' : ''} `;
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 = `${pipelineLabel} ${escAttr(progress)} ${escAttr(when)} ${escAttr(why)} `;
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 `` 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 = `${escAttr(title)} ${escAttr(hint)} `;
_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 = `${escAttr(a.name)} ${escAttr(a.role)} `;
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 = `${labelText} ` +
(overridden
? '조정됨 '
: '기본값 ');
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 = '🧭 CEO
작업 분배 중…
';
} else if (ev.phase === 'plan-ready') {
const tasks = (ev.plan?.tasks || []).map((t, i) => `${i + 1}. ${escAttr(t.agent)} — ${escAttr(t.task)}`).join(' ');
card.innerHTML = `🧭 CEO 브리프
${escAttr(ev.plan?.brief || '(brief 없음)')}
${tasks || '(no tasks — chat reply)'}
`;
} else if (ev.phase === 'agent-start') {
card.innerHTML = `${escAttr(ev.agentId)} 작업 수행 중…
${escAttr(ev.task)} (${ev.index + 1}/${ev.total})
`;
} else if (ev.phase === 'agent-done') {
const o = ev.output || {};
const body = (o.response || '').slice(0, 4000);
card.innerHTML = `${escAttr(ev.agentId)} 완료 ${(o.durationMs/1000).toFixed(1)}s${o.error ? ' · ⚠️ ' + escAttr(o.error) : ''}
${fmt(body)}
`;
} else if (ev.phase === 'stage-loop') {
card.innerHTML = `🔁 Stage 재시도
${escAttr(ev.from)} → ${escAttr(ev.to)} (반복 ${ev.iteration}회)
`;
} 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 = `🔍 ${escAttr(ev.stageLabel || ev.stageId)} 검수 사이클 시작 검수자: ${escAttr(ev.inspectorAgentId)} · 최대 ${ev.maxRounds}라운드
`;
} 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 = `라운드 ${ev.round} ${(ev.durationMs/1000).toFixed(1)}s
${inspIcon} 검수 ${fmt((ev.inspectorText || '').slice(0, 1500))}
${ceoIcon} CEO ${fmt((ev.ceoText || '').slice(0, 1000))}
`;
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 = `✋ ${escAttr(ev.stageLabel || ev.stageId)} 완료 — 검토 후 승인해 주세요`;
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 = '🧭 CEO 종합 보고서 작성 중…
';
} else if (ev.phase === 'report-done') {
card.className += ' report';
card.innerHTML = `🧭 CEO 보고서${ev.ok ? '' : ' (fallback)'}
${fmt(ev.report || '')}
`;
} 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 = '📱 영숙이 텔레그램에 보고 완료
';
} else if (ev.ok === false) {
card.innerHTML = `⚠️ 텔레그램 보고 실패${ev.reason ? ` — ${escAttr(ev.reason)}` : ''}
`;
} else {
// null → not configured. Skip rendering entirely to keep chat clean.
return;
}
} else if (ev.phase === 'session-saved') {
card.innerHTML = `세션 저장 완료 — 클릭하여 열기
`;
card.style.cursor = 'pointer';
card.onclick = () => vscode.postMessage({ type: 'openCompanySession', sessionDir: ev.sessionDir });
} else if (ev.phase === 'aborted') {
card.innerHTML = `⛔ 회사 모드 중단
${escAttr(ev.reason)}
`;
}
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');