/**
* Chat Panel — 구조화된 메시지 렌더링 (Antigravity 스타일)
*/
class ChatPanel {
constructor() {
this.containerEl = document.getElementById('chatContainer');
this.emptyEl = document.getElementById('emptyState');
this.messagesEl = document.getElementById('chatMessages');
this.inputEl = document.getElementById('chatInput');
this.sendBtn = document.getElementById('sendBtn');
this.sessionNameEl = document.getElementById('chatSessionName');
this.sessionStatusEl = document.getElementById('chatSessionStatus');
this.onSendMessage = null; // callback(text)
this.onActionClick = null; // callback(button) - {label, x, y}
this.activeSession = null;
this._lastHash = '';
this._setupInput();
}
_setupInput() {
this.inputEl.addEventListener('input', () => {
this.inputEl.style.height = 'auto';
this.inputEl.style.height = Math.min(this.inputEl.scrollHeight, 120) + 'px';
});
this.inputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this._sendMessage();
}
});
this.sendBtn.addEventListener('click', () => this._sendMessage());
}
_sendMessage() {
const text = this.inputEl.value.trim();
if (!text) return;
if (this.onSendMessage) this.onSendMessage(text);
this.inputEl.value = '';
this.inputEl.style.height = 'auto';
}
showSession(session) {
this.activeSession = session;
this.emptyEl.style.display = 'none';
this.containerEl.style.display = 'flex';
this.sessionNameEl.textContent = session.name;
this.sessionStatusEl.textContent = session.status === 'connected' ? '● 연결됨' : session.status;
this.messagesEl.innerHTML = '
';
this.inputEl.focus();
}
showEmpty() {
this.activeSession = null;
this.emptyEl.style.display = 'flex';
this.containerEl.style.display = 'none';
}
updateChat(messages, hasMore = false, scrollToBottom = true) {
if (!messages || !Array.isArray(messages) || messages.length === 0) {
this.messagesEl.innerHTML = `
⚠️ 채팅 데이터를 가져올 수 없습니다.
Antigravity에서 채팅을 시작해주세요.
`;
return;
}
// 변경 감지 — 실제 내용 기반 hash
const contentKey = messages.map(m => (m.type || '') + (m.text || '') + (m.title || '') + (m.html || '').substring(0, 30)).join('|');
const hash = contentKey.length + ':' + messages.length + ':' + contentKey.substring(0, 200);
if (hash === this._lastHash) return;
this._lastHash = hash;
this._shownCount = messages.length;
const prevScrollTop = this.messagesEl.scrollTop;
const prevScrollHeight = this.messagesEl.scrollHeight;
const wasAtBottom = this._isScrolledToBottom();
const frag = document.createDocumentFragment();
// 상단 "더 보기" 영역
if (hasMore) {
const loader = document.createElement('div');
loader.className = 'load-more-indicator';
loader.id = 'loadMoreIndicator';
loader.textContent = '⬆ 스크롤하여 이전 메시지 로드';
frag.appendChild(loader);
}
for (const msg of messages) {
const el = this._renderMessage(msg);
if (el) frag.appendChild(el);
}
this.messagesEl.innerHTML = '';
this.messagesEl.appendChild(frag);
// 스크롤 이벤트: 최상단 → 이전 메시지 로드
this.messagesEl.onscroll = () => {
if (this.messagesEl.scrollTop < 5 && this.onLoadMore) {
this.onLoadMore();
}
};
// 스크롤 위치 결정
if (scrollToBottom || wasAtBottom) {
this._scrollToBottom();
} else {
// 기존 스크롤 위치 유지
this.messagesEl.scrollTop = prevScrollTop;
}
}
/**
* 이전 메시지를 상단에 추가 (스크롤 위치 보존)
*/
prependMessages(msgs) {
if (!msgs || msgs.length === 0) return;
const prevHeight = this.messagesEl.scrollHeight;
const frag = document.createDocumentFragment();
for (const msg of msgs) {
const el = this._renderMessage(msg);
if (el) frag.appendChild(el);
}
// 로딩 인디케이터 다음에 삽입
const indicator = document.getElementById('loadMoreIndicator');
if (indicator) {
indicator.after(frag);
} else {
this.messagesEl.prepend(frag);
}
this._shownCount = (this._shownCount || 0) + msgs.length;
// 스크롤 위치 보존
this.messagesEl.scrollTop = this.messagesEl.scrollHeight - prevHeight;
// 더 이상 로드할 게 없으면 인디케이터 숨기기
if (indicator && this.messagesEl.children.length - 1 >= this._shownCount) {
indicator.remove();
}
}
getShownCount() {
return this._shownCount || 0;
}
/**
* 메시지 타입별 렌더러
*/
_renderMessage(msg) {
switch (msg.type) {
case 'task': return this._renderTask(msg);
case 'text': return this._renderText(msg);
case 'thought': return this._renderThought(msg);
case 'code': return this._renderCode(msg);
case 'image': return this._renderImage(msg);
case 'actions': return this._renderActions(msg);
case 'user': return this._renderUser(msg);
case 'status': return this._renderStatus(msg);
default: return null;
}
}
/**
* 작업 카드 — Antigravity의 task boundary 카드
*/
_renderTask(msg) {
const card = document.createElement('div');
card.className = 'msg-card';
// 헤더
const header = document.createElement('div');
header.className = 'msg-card-header';
const toggle = document.createElement('span');
toggle.className = 'msg-toggle';
toggle.textContent = '▾';
const title = document.createElement('span');
title.className = 'msg-card-title';
title.textContent = msg.title || 'Task';
header.appendChild(toggle);
header.appendChild(title);
// 모드 뱃지
if (msg.mode) {
const badge = document.createElement('span');
badge.className = 'msg-mode-badge';
const modeMap = { PLANNING: '📋', EXECUTION: '⚙️', VERIFICATION: '✅' };
badge.textContent = modeMap[msg.mode] || msg.mode;
badge.title = msg.mode;
header.appendChild(badge);
}
// 요약
if (msg.summary) {
const summary = document.createElement('div');
summary.className = 'msg-card-summary';
summary.textContent = msg.summary;
header.appendChild(summary);
}
card.appendChild(header);
// 도구 호출 목록 (tools)
const toolList = msg.tools || msg.steps || [];
if (toolList.length > 0) {
const body = document.createElement('div');
body.className = 'msg-card-body';
for (const tool of toolList) {
const row = document.createElement('div');
row.className = 'msg-step';
const icon = document.createElement('span');
icon.className = 'msg-step-icon';
icon.textContent = tool.icon || '•';
const text = document.createElement('span');
text.className = 'msg-step-text';
text.textContent = tool.label || tool.text || '';
const check = document.createElement('span');
check.className = 'msg-step-check';
check.textContent = tool.done ? '✓' : '…';
check.style.color = tool.done ? '#22c55e' : '#94a3b8';
row.appendChild(icon);
row.appendChild(text);
row.appendChild(check);
body.appendChild(row);
}
card.appendChild(body);
// 접기/펴기 토글
header.style.cursor = 'pointer';
header.addEventListener('click', () => {
const isHidden = body.style.display === 'none';
body.style.display = isHidden ? '' : 'none';
toggle.textContent = isHidden ? '▾' : '▸';
});
}
// 카드 내부 액션 버튼
if (msg.actions && msg.actions.length > 0) {
const actionsDiv = document.createElement('div');
actionsDiv.className = 'msg-card-actions';
for (const btn of msg.actions) {
const el = document.createElement('button');
el.className = 'msg-action-btn';
el.textContent = btn.label;
if (['Proceed', 'Approve', 'Accept', 'Yes', 'Allow'].some(k => btn.label.includes(k))) {
el.classList.add('msg-action-primary');
}
if (btn.x && btn.y) {
el.style.cursor = 'pointer';
el.addEventListener('click', (e) => {
e.stopPropagation();
if (this.onActionClick) {
this.onActionClick({ label: btn.label, x: btn.x, y: btn.y });
}
});
}
actionsDiv.appendChild(el);
}
card.appendChild(actionsDiv);
}
return card;
}
/**
* 텍스트 메시지 — 마크다운 렌더링
*/
_renderText(msg) {
const div = document.createElement('div');
div.className = 'msg-text';
if (msg.html) {
// Antigravity에서 가져온 렌더링된 HTML (sanitized subset)
div.innerHTML = this._sanitizeHtml(msg.html);
} else {
div.textContent = msg.content || '';
}
return div;
}
/**
* Thought Process — 접을 수 있는 블록
*/
_renderThought(msg) {
const div = document.createElement('div');
div.className = 'msg-thought';
const btn = document.createElement('button');
btn.className = 'msg-thought-btn';
btn.innerHTML = `💭 ${this._escapeHtml(msg.label || 'Thought')}`;
div.appendChild(btn);
return div;
}
/**
* 코드 블록
*/
_renderCode(msg) {
const wrapper = document.createElement('div');
wrapper.className = 'msg-code-block';
if (msg.language) {
const lang = document.createElement('div');
lang.className = 'msg-code-lang';
lang.textContent = msg.language;
wrapper.appendChild(lang);
}
const pre = document.createElement('pre');
const code = document.createElement('code');
code.textContent = msg.content || '';
pre.appendChild(code);
wrapper.appendChild(pre);
return wrapper;
}
/**
* 이미지 — 크기 제한 적용
*/
_renderImage(msg) {
const div = document.createElement('div');
div.className = 'msg-image';
const img = document.createElement('img');
img.src = msg.src;
img.alt = msg.alt || '';
img.loading = 'lazy';
// 최대 크기 제한
img.style.maxWidth = '320px';
img.style.maxHeight = '200px';
div.appendChild(img);
return div;
}
/**
* 액션 버튼 영역
*/
_renderActions(msg) {
const div = document.createElement('div');
div.className = 'msg-actions';
// 라벨(알림 문구) 표시
if (msg.label) {
const labelEl = document.createElement('div');
labelEl.className = 'msg-actions-label';
labelEl.textContent = msg.label;
div.appendChild(labelEl);
}
for (const btn of (msg.buttons || [])) {
const el = document.createElement('button');
el.className = btn.variant === 'danger' ? 'msg-action-btn msg-action-danger' : 'msg-action-btn msg-action-primary';
el.textContent = btn.label || btn;
el.style.cursor = 'pointer';
// api_call: 직접 API 호출 (승인/거절 등)
if (btn.action === 'api_call' && btn.endpoint) {
el.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
el.disabled = true;
el.textContent = '처리 중...';
this.actionInProgress = true; // polling에 의한 DOM 교체 방지
try {
const res = await fetch(btn.endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(btn.body || {}),
});
if (res.ok) {
el.textContent = '✓ 완료';
// 모든 형제 버튼 비활성화
div.querySelectorAll('button').forEach(b => { b.disabled = true; });
} else {
const err = await res.json().catch(() => ({}));
el.textContent = `실패: ${err.error || res.status}`;
}
} catch (e) {
el.textContent = `오류: ${e.message}`;
} finally {
this.actionInProgress = false;
}
});
} else if (btn.action === 'switch_mirror') {
el.addEventListener('click', () => {
document.getElementById('tabMirror')?.click();
});
} else if (btn.x && btn.y) {
el.addEventListener('click', () => {
if (this.onActionClick) {
this.onActionClick({ label: btn.label, x: btn.x, y: btn.y });
}
});
}
div.appendChild(el);
}
return div;
}
/**
* HTML sanitize (간단히 위험 태그 제거)
*/
_sanitizeHtml(html) {
// script, iframe, object, embed, on* 속성 제거
return html
.replace(/