feat(chat): redesign chat UI with structured rendering and interactive action buttons

This commit is contained in:
2026-03-07 21:08:08 +09:00
parent 4b855c9e57
commit 507324f78e
5 changed files with 826 additions and 107 deletions

View File

@@ -44,9 +44,16 @@
}
/* ─── Reset ──────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
html,
body {
height: 100%;
font-family: var(--font-sans);
font-size: 14px;
@@ -57,13 +64,22 @@ html, body {
}
/* ─── Scrollbar ──────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--bg-active);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* ─── Header ─────────────────────────────────────────── */
.header {
@@ -77,7 +93,11 @@ html, body {
z-index: 100;
}
.header-left { display: flex; align-items: center; gap: 12px; }
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.logo {
display: flex;
@@ -94,7 +114,11 @@ html, body {
-webkit-text-fill-color: transparent;
}
.header-right { display: flex; align-items: center; gap: 16px; }
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.connection-status {
display: flex;
@@ -112,8 +136,14 @@ html, body {
transition: background var(--transition-normal);
}
.status-dot.connected { background: var(--success); box-shadow: 0 0 6px var(--success); }
.status-dot.error { background: var(--error); }
.status-dot.connected {
background: var(--success);
box-shadow: 0 0 6px var(--success);
}
.status-dot.error {
background: var(--error);
}
/* ─── Main Layout ────────────────────────────────────── */
.main-layout {
@@ -181,14 +211,34 @@ html, body {
flex-shrink: 0;
}
.session-indicator.connected { background: var(--success); box-shadow: 0 0 4px rgba(52, 211, 153, 0.4); }
.session-indicator.connecting { background: var(--warning); animation: pulse 1.5s infinite; }
.session-indicator.disconnected { background: var(--text-muted); }
.session-indicator.error { background: var(--error); }
.session-indicator.connected {
background: var(--success);
box-shadow: 0 0 4px rgba(52, 211, 153, 0.4);
}
.session-indicator.connecting {
background: var(--warning);
animation: pulse 1.5s infinite;
}
.session-indicator.disconnected {
background: var(--text-muted);
}
.session-indicator.error {
background: var(--error);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
.session-info {
@@ -223,8 +273,14 @@ html, body {
font-size: 14px;
}
.session-card:hover .session-remove { opacity: 1; }
.session-remove:hover { color: var(--error); background: rgba(248, 113, 113, 0.1); }
.session-card:hover .session-remove {
opacity: 1;
}
.session-remove:hover {
color: var(--error);
background: rgba(248, 113, 113, 0.1);
}
/* ─── Chat Area ──────────────────────────────────────── */
.chat-area {
@@ -317,32 +373,265 @@ html, body {
font-size: 13px;
}
/* Antigravity 에서 가져온 HTML을 표시하는 컨테이너 */
.chat-messages .ag-content {
font-family: var(--font-sans);
line-height: 1.6;
/* Antigravity 에서 가져온 구조화 메시지를 렌더링하는 컴포넌트 */
/* --- 메시지 공통 --- */
.chat-messages>* {
margin-bottom: 8px;
}
/* --- 작업 카드 (Task boundary) --- */
.msg-card {
border: 1px solid var(--border-subtle);
border-radius: 10px;
overflow: hidden;
background: var(--bg-secondary);
transition: border-color var(--transition-fast);
}
.msg-card:hover {
border-color: var(--border-medium);
}
.msg-card-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 10px 14px;
}
.msg-toggle {
font-size: 11px;
color: var(--text-muted);
width: 14px;
text-align: center;
flex-shrink: 0;
transition: color var(--transition-fast);
}
.msg-card-header:hover .msg-toggle {
color: var(--text-primary);
}
.chat-messages .ag-content pre,
.chat-messages .ag-content code {
font-family: var(--font-mono);
.msg-card-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.chat-messages .ag-content pre {
.msg-card-summary {
width: 100%;
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
padding-left: 22px;
}
.msg-card-body {
border-top: 1px solid var(--border-subtle);
padding: 8px 14px 10px;
}
.msg-step {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 3px 0;
font-size: 12px;
color: var(--text-secondary);
line-height: 1.4;
}
.msg-step-icon {
width: 16px;
text-align: center;
flex-shrink: 0;
font-size: 11px;
}
.msg-step-text {
flex: 1;
min-width: 0;
word-break: break-word;
}
/* --- 텍스트 메시지 --- */
.msg-text {
font-size: 13px;
line-height: 1.6;
color: var(--text-primary);
padding: 4px 0;
word-break: break-word;
}
.msg-text p {
margin: 4px 0;
}
.msg-text ul,
.msg-text ol {
padding-left: 18px;
margin: 4px 0;
}
.msg-text li {
margin: 2px 0;
}
.msg-text strong {
font-weight: 600;
}
.msg-text a {
color: var(--text-accent);
text-decoration: none;
}
.msg-text a:hover {
text-decoration: underline;
}
.msg-text code:not(pre code) {
font-family: var(--font-mono);
font-size: 12px;
background: var(--bg-tertiary);
padding: 1px 5px;
border-radius: 4px;
color: var(--text-accent);
}
.msg-text pre {
background: var(--bg-tertiary);
border: 1px solid var(--border-subtle);
border-radius: 8px;
padding: 14px;
padding: 10px 12px;
overflow-x: auto;
margin: 10px 0;
margin: 6px 0;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.5;
}
.chat-messages .ag-content code {
.msg-text img {
max-width: 320px;
max-height: 200px;
border-radius: 8px;
margin: 4px 0;
}
/* --- Thought Process --- */
.msg-thought {
margin: 4px 0;
}
.msg-thought-btn {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 6px 10px;
background: none;
border: none;
border-radius: 6px;
color: var(--text-muted);
font-family: var(--font-sans);
font-size: 12px;
cursor: pointer;
transition: all var(--transition-fast);
text-align: left;
}
.msg-thought-btn:hover {
background: var(--bg-hover);
color: var(--text-secondary);
}
.msg-thought-icon {
font-size: 14px;
}
/* --- 코드 블록 --- */
.msg-code-block {
border: 1px solid var(--border-subtle);
border-radius: 8px;
overflow: hidden;
margin: 4px 0;
}
.msg-code-lang {
padding: 4px 12px;
background: var(--bg-tertiary);
padding: 2px 6px;
border-radius: 4px;
border-bottom: 1px solid var(--border-subtle);
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-muted);
text-transform: lowercase;
}
.msg-code-block pre {
margin: 0;
padding: 10px 12px;
background: var(--bg-primary);
overflow-x: auto;
font-size: 12px;
line-height: 1.5;
}
.msg-code-block code {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-primary);
}
/* --- 이미지 --- */
.msg-image {
margin: 4px 0;
}
.msg-image img {
max-width: 320px;
max-height: 200px;
border-radius: 8px;
border: 1px solid var(--border-subtle);
object-fit: contain;
}
/* --- 액션 버튼 --- */
.msg-actions {
display: flex;
gap: 6px;
padding: 4px 0;
flex-wrap: wrap;
}
.msg-action-btn {
padding: 4px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border-subtle);
border-radius: 6px;
color: var(--text-secondary);
font-family: var(--font-sans);
font-size: 12px;
cursor: default;
transition: all var(--transition-fast);
}
.msg-action-btn[style*="cursor: pointer"]:hover {
background: var(--bg-hover);
border-color: var(--border-medium);
color: var(--text-primary);
}
.msg-action-btn.msg-action-primary {
background: var(--accent-primary);
color: white;
border-color: var(--accent-primary);
}
.msg-action-btn.msg-action-primary:hover {
background: var(--accent-secondary);
transform: scale(1.02);
}
/* Chat Input */
@@ -371,8 +660,13 @@ html, body {
transition: border-color var(--transition-fast);
}
.chat-input-area textarea::placeholder { color: var(--text-muted); }
.chat-input-area textarea:focus { border-color: var(--accent-primary); }
.chat-input-area textarea::placeholder {
color: var(--text-muted);
}
.chat-input-area textarea:focus {
border-color: var(--accent-primary);
}
/* ─── Buttons ────────────────────────────────────────── */
.btn-icon {
@@ -430,7 +724,9 @@ html, body {
transform: scale(1.05);
}
.btn-send:active { transform: scale(0.95); }
.btn-send:active {
transform: scale(0.95);
}
.btn {
padding: 8px 16px;
@@ -448,7 +744,9 @@ html, body {
color: white;
}
.btn-primary:hover { background: var(--accent-secondary); }
.btn-primary:hover {
background: var(--accent-secondary);
}
.btn-secondary {
background: var(--bg-tertiary);
@@ -456,7 +754,9 @@ html, body {
border: 1px solid var(--border-subtle);
}
.btn-secondary:hover { background: var(--bg-hover); }
.btn-secondary:hover {
background: var(--bg-hover);
}
/* ─── Modal ──────────────────────────────────────────── */
.modal-backdrop {
@@ -471,7 +771,15 @@ html, body {
animation: fadeIn 0.15s ease;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal {
background: var(--bg-elevated);
@@ -483,7 +791,17 @@ html, body {
animation: slideUp 0.2s ease;
}
@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: none; opacity: 1; } }
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: none;
opacity: 1;
}
}
.modal-header {
display: flex;
@@ -533,7 +851,9 @@ html, body {
transition: border-color var(--transition-fast);
}
.form-group input:focus { border-color: var(--accent-primary); }
.form-group input:focus {
border-color: var(--accent-primary);
}
.form-hint {
font-size: 11px;
@@ -605,11 +925,32 @@ html, body {
animation-fill-mode: forwards;
}
@keyframes slideInRight { from { transform: translateX(100px); opacity: 0; } to { transform: none; opacity: 1; } }
@keyframes fadeOut { to { opacity: 0; transform: translateY(10px); } }
@keyframes slideInRight {
from {
transform: translateX(100px);
opacity: 0;
}
.toast.error { border-color: var(--error); }
.toast.success { border-color: var(--success); }
to {
transform: none;
opacity: 1;
}
}
@keyframes fadeOut {
to {
opacity: 0;
transform: translateY(10px);
}
}
.toast.error {
border-color: var(--error);
}
.toast.success {
border-color: var(--success);
}
/* ─── View Tabs (Chat ↔ Mirror) ──────────────────────── */
.view-tabs {
@@ -677,5 +1018,4 @@ html, body {
font-size: 14px;
pointer-events: none;
animation: pulse 1.5s infinite;
}
}

View File

@@ -98,7 +98,7 @@
case 'chat_update':
if (msg.sessionId === sessionPanel.activeSessionId) {
chatPanel.updateChat(msg.html);
chatPanel.updateChat(msg.messages);
}
break;
@@ -129,6 +129,10 @@
case 'screencast_stopped':
break;
case 'action_clicked':
// 서버에서의 확인 응답 (토스트는 이미 프론트엔드에서 표시)
break;
case 'error':
showToast(msg.message, 'error');
break;
@@ -164,6 +168,17 @@
});
};
chatPanel.onActionClick = (button) => {
sendWs({
type: 'click_action',
sessionId: sessionPanel.activeSessionId,
label: button.label,
x: button.x,
y: button.y,
});
showToast(`"${button.label}" 클릭`, 'success');
};
// ─── 미러 패널 이벤트 ─────────────────────────────
mirrorPanel.onStartScreencast = (sessionId) => {
sendWs({ type: 'start_screencast', sessionId });

View File

@@ -1,5 +1,5 @@
/**
* Chat Panel — 채팅 표시/입력 UI 관리
* Chat Panel — 구조화된 메시지 렌더링 (Antigravity 스타일)
*/
class ChatPanel {
@@ -13,22 +13,19 @@ class ChatPanel {
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';
});
// Enter로 전송 (Shift+Enter는 줄바꿈)
this.inputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
@@ -36,47 +33,27 @@ class ChatPanel {
}
});
this.sendBtn.addEventListener('click', () => {
this._sendMessage();
});
this.sendBtn.addEventListener('click', () => this._sendMessage());
}
_sendMessage() {
const text = this.inputEl.value.trim();
if (!text) return;
if (this.onSendMessage) {
this.onSendMessage(text);
}
// 입력창 초기화
if (this.onSendMessage) this.onSendMessage(text);
this.inputEl.value = '';
this.inputEl.style.height = 'auto';
}
/**
* 세션 선택 시 UI 표시
*/
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 = `
<div class="chat-welcome">
<p>채팅 데이터를 불러오는 중...</p>
</div>
`;
this.messagesEl.innerHTML = '<div class="chat-welcome"><p>채팅 데이터를 불러오는 중...</p></div>';
this.inputEl.focus();
}
/**
* 빈 상태 표시
*/
showEmpty() {
this.activeSession = null;
this.emptyEl.style.display = 'flex';
@@ -84,42 +61,258 @@ class ChatPanel {
}
/**
* 채팅 내용 업데이트 (Antigravity DOM HTML)
* 구조화된 메시지 배열로 채팅 렌더링
*/
updateChat(html) {
if (!html || html.includes('chat container not found')) {
updateChat(messages) {
if (!messages || !Array.isArray(messages) || messages.length === 0) {
this.messagesEl.innerHTML = `
<div class="chat-welcome">
<p>⚠️ 채팅 컨테이너를 찾을 수 없습니다.<br>
Antigravity에서 채팅을 시작해주세요.</p>
</div>
`;
<div class="chat-welcome">
<p>⚠️ 채팅 데이터를 가져올 수 없습니다.<br>
Antigravity에서 채팅을 시작해주세요.</p>
</div>`;
return;
}
// HTML 삽입 (Antigravity에서 가져온 DOM)
// 변경 감지 — 같은 내용이면 리렌더 안 함
const hash = JSON.stringify(messages).length + ':' + messages.length;
if (hash === this._lastHash) return;
this._lastHash = hash;
const wasAtBottom = this._isScrolledToBottom();
const frag = document.createDocumentFragment();
this.messagesEl.innerHTML = `<div class="ag-content">${html}</div>`;
for (const msg of messages) {
const el = this._renderMessage(msg);
if (el) frag.appendChild(el);
}
// 스크롤 유지
if (wasAtBottom) {
this._scrollToBottom();
this.messagesEl.innerHTML = '';
this.messagesEl.appendChild(frag);
if (wasAtBottom) this._scrollToBottom();
}
/**
* 메시지 타입별 렌더러
*/
_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);
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 = msg.collapsed ? '▸' : '▾';
const title = document.createElement('span');
title.className = 'msg-card-title';
title.textContent = msg.title || 'Task';
header.appendChild(toggle);
header.appendChild(title);
// 요약
if (msg.summary) {
const summary = document.createElement('div');
summary.className = 'msg-card-summary';
summary.textContent = msg.summary;
header.appendChild(summary);
}
card.appendChild(header);
// 하위 항목
if (msg.steps && msg.steps.length > 0) {
const body = document.createElement('div');
body.className = 'msg-card-body';
if (msg.collapsed) body.style.display = 'none';
for (const step of msg.steps) {
const row = document.createElement('div');
row.className = 'msg-step';
const icon = document.createElement('span');
icon.className = 'msg-step-icon';
icon.textContent = step.icon || '•';
const text = document.createElement('span');
text.className = 'msg-step-text';
text.textContent = step.text;
row.appendChild(icon);
row.appendChild(text);
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 ? '▾' : '▸';
});
}
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 = `<span class="msg-thought-icon">💭</span> ${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';
for (const btn of (msg.buttons || [])) {
const el = document.createElement('button');
el.className = 'msg-action-btn';
el.textContent = btn.label || btn;
// Proceed/Review 등 주요 액션은 강조
const label = btn.label || btn;
if (['Proceed', 'Approve', 'Accept', 'Yes', 'Allow'].some(k => label.includes(k))) {
el.classList.add('msg-action-primary');
}
// 좌표가 있으면 클릭 가능
if (btn.x && btn.y) {
el.style.cursor = 'pointer';
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(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/<iframe\b[^>]*>[\s\S]*?<\/iframe>/gi, '')
.replace(/<object\b[^>]*>[\s\S]*?<\/object>/gi, '')
.replace(/<embed\b[^>]*>/gi, '')
.replace(/\bon\w+\s*=\s*"[^"]*"/gi, '')
.replace(/\bon\w+\s*=\s*'[^']*'/gi, '')
// 이미지 크기 제한 인라인 추가
.replace(/<img\b/gi, '<img style="max-width:320px;max-height:200px;border-radius:8px;" ');
}
_escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
updateSessionStatus(status) {
if (!this.activeSession) return;
const statusText = {
connected: '● 연결됨',
disconnected: '○ 연결 끊김',
error: '⚠ 오류',
};
this.sessionStatusEl.textContent = statusText[status] || status;
}