feat(server,frontend): real-time sync architecture with message accumulator

- Add message-accumulator.js: cascades diff-based message accumulation
- Add 3-second cascade polling with broadcastToAll (was undefined!)
- Add /api/bridge/approve endpoint: tries accept/reject Step→Command→Terminal
- Add persistent approve/reject buttons in chat header toolbar
- Frontend: loadSessionMessages (trajectory + accumulated), applyNewMessages (WS push)
- Status change detection: _prevStatusKey tracking prevents unnecessary re-renders
- actionInProgress flag prevents DOM replacement during button fetch
- Known issues: Trajectory 341 hard limit, Cascade no command-approval state
This commit is contained in:
2026-03-08 14:05:59 +09:00
parent e7521433cb
commit 1060476113
16 changed files with 940 additions and 209 deletions

View File

@@ -182,6 +182,37 @@ body {
padding: 0 8px 8px;
}
/* 프로젝트 그룹 헤더 */
.project-group-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px 4px;
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.project-group-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
/* 이전 메시지 로더 */
.load-more-indicator {
text-align: center;
color: var(--text-muted);
font-size: 11px;
padding: 12px 0;
opacity: 0.6;
border-bottom: 1px solid var(--border-subtle);
margin-bottom: 8px;
}
/* Session Card */
.session-card {
display: flex;
@@ -392,6 +423,56 @@ body {
.chat-actions {
display: flex;
gap: 6px;
align-items: center;
}
.approve-controls {
display: flex;
gap: 4px;
margin-left: 8px;
padding-left: 8px;
border-left: 1px solid var(--border);
}
.btn-approve,
.btn-reject {
font-size: 12px;
padding: 4px 10px;
border: 1px solid transparent;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.btn-approve {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
border-color: rgba(34, 197, 94, 0.3);
}
.btn-approve:hover {
background: rgba(34, 197, 94, 0.3);
}
.btn-approve:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-reject {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
border-color: rgba(239, 68, 68, 0.3);
}
.btn-reject:hover {
background: rgba(239, 68, 68, 0.3);
}
.btn-reject:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Chat Messages */

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -7,9 +8,12 @@
<meta name="description" content="Antigravity IDE 원격 제어 대시보드">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div id="app">
<!-- Header -->
@@ -17,12 +21,12 @@
<div class="header-left">
<div class="logo">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="url(#grad)" stroke-width="2"/>
<circle cx="12" cy="12" r="4" fill="url(#grad)"/>
<circle cx="12" cy="12" r="10" stroke="url(#grad)" stroke-width="2" />
<circle cx="12" cy="12" r="4" fill="url(#grad)" />
<defs>
<linearGradient id="grad" x1="0" y1="0" x2="24" y2="24">
<stop offset="0%" stop-color="#818cf8"/>
<stop offset="100%" stop-color="#6366f1"/>
<stop offset="0%" stop-color="#818cf8" />
<stop offset="100%" stop-color="#6366f1" />
</linearGradient>
</defs>
</svg>
@@ -45,7 +49,8 @@
<h2>세션</h2>
<button class="btn-icon" id="addSessionBtn" title="세션 추가">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
</div>
@@ -59,8 +64,9 @@
<!-- No session selected state -->
<div class="empty-state" id="emptyState">
<div class="empty-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" opacity="0.3">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"
opacity="0.3">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</div>
<h3>세션을 선택하세요</h3>
@@ -79,6 +85,10 @@
<button class="view-tab active" data-view="chat" id="tabChat">💬 채팅</button>
<button class="view-tab" data-view="mirror" id="tabMirror">🖥️ 미러</button>
</div>
<div class="approve-controls" id="approveControls">
<button class="btn-approve" id="btnApprove" title="현재 요청 승인">✅ 승인</button>
<button class="btn-reject" id="btnReject" title="현재 요청 거절">❌ 거절</button>
</div>
<button class="btn-sm" id="screenshotBtn" title="스크린샷">📷</button>
<button class="btn-sm" id="reconnectBtn" title="재연결">🔄</button>
</div>
@@ -89,14 +99,11 @@
</div>
</div>
<div class="chat-input-area">
<textarea
id="chatInput"
placeholder="Antigravity에 보낼 메시지를 입력하세요..."
rows="1"
></textarea>
<textarea id="chatInput" placeholder="Antigravity에 보낼 메시지를 입력하세요..." rows="1"></textarea>
<button class="btn-send" id="sendBtn" title="전송">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
<line x1="22" y1="2" x2="11" y2="13" />
<polygon points="22 2 15 22 11 13 2 9 22 2" />
</svg>
</button>
</div>
@@ -115,7 +122,7 @@
<span>스크린샷 미리보기</span>
<button class="btn-icon" id="closeScreenshot"></button>
</div>
<img id="screenshotImage" alt="Antigravity Screenshot"/>
<img id="screenshotImage" alt="Antigravity Screenshot" />
</div>
</div>
</section>
@@ -158,4 +165,5 @@
<script src="js/mirror-panel.js"></script>
<script src="js/app.js"></script>
</body>
</html>
</html>

View File

@@ -97,47 +97,73 @@
fetch('/api/bridge/sessions'),
fetch('/api/bridge/cascades'),
]);
if (!sessRes.ok) return;
const sessData = await sessRes.json();
const sessData = sessRes.ok ? await sessRes.json() : { sessions: [] };
let cascadeMap = {};
if (cascRes.ok) {
const cascData = await cascRes.json();
cascadeMap = cascData.cascades || cascData;
}
bridgeSessions = (sessData.sessions || []).map(s => {
const cascade = cascadeMap[s.id] || {};
// 워크스페이스에서 프로젝트명 추출 (computedName 우선)
const repoName = cascade.workspaces?.[0]?.repository?.computedName || '';
const wsUri = cascade.workspaces?.[0]?.workspaceFolderAbsoluteUri || '';
// 세션 → 맵으로 변환 (id 기준)
const sessionIds = new Set();
const sessionList = (sessData.sessions || []);
function buildSession(id, sessionData, cascade) {
const s = sessionData || {};
const c = cascade || {};
// 워크스페이스에서 프로젝트명 추출
const repoName = c.workspaces?.[0]?.repository?.computedName || '';
const wsUri = c.workspaces?.[0]?.workspaceFolderAbsoluteUri || '';
const project = repoName
? repoName.split('/').pop() // "Variet/gravity_web" → "gravity_web"
? repoName.split('/').pop()
: (wsUri ? decodeURIComponent(wsUri.split('/').pop()) : '');
// 대화 이름: cascade summary > task name > title (Conversation N은 무시)
const rawTitle = s.title || '';
// 대화 이름: cascade summary > task name > title
const rawTitle = s.title || c.summary || '';
const isGeneric = /^Conversation \d+$/.test(rawTitle);
const summary = cascade.summary || '';
const taskName = cascade.latestTaskBoundaryStep?.step?.taskBoundary?.taskName || '';
const displayName = summary || taskName || (isGeneric ? '' : rawTitle) || s.id.substring(0, 8);
const summary = c.summary || '';
const taskName = c.latestTaskBoundaryStep?.step?.taskBoundary?.taskName || '';
const displayName = summary || taskName || (isGeneric ? '' : rawTitle) || id.substring(0, 8);
// 상태
const runStatus = cascade.status || '';
const runStatus = c.status || '';
const isRunning = runStatus.includes('RUNNING');
return {
id: s.id,
id: id,
name: displayName,
host: 'bridge',
cdpPort: 0,
status: isRunning ? 'running' : 'connected',
title: displayName,
stepCount: cascade.stepCount || s.stepCount || 0,
lastModified: cascade.lastModifiedTime || s.lastModifiedTime,
stepCount: c.stepCount || s.stepCount || 0,
lastModified: c.lastModifiedTime || s.lastModifiedTime,
project: project,
isRunning: isRunning,
isBridge: true,
};
}
// 1) Sessions API 기반 목록
bridgeSessions = sessionList.map(s => {
sessionIds.add(s.id);
return buildSession(s.id, s, cascadeMap[s.id]);
});
// 2) Cascades에만 있는 대화 추가 (Sessions API 누락분)
for (const [cascadeId, cascade] of Object.entries(cascadeMap)) {
if (!sessionIds.has(cascadeId)) {
bridgeSessions.push(buildSession(cascadeId, null, cascade));
}
}
// 최근 수정 순 정렬
bridgeSessions.sort((a, b) => {
const ta = a.lastModified ? new Date(a.lastModified).getTime() : 0;
const tb = b.lastModified ? new Date(b.lastModified).getTime() : 0;
return tb - ta;
});
sessionPanel.update(bridgeSessions);
// 활성 세션 없으면 가장 최근 대화 자동 선택
if (!sessionPanel.activeSessionId && bridgeSessions.length > 0) {
@@ -148,6 +174,10 @@
}
}
// 채팅 메시지 저장소 (lazy load용)
let allMessages = [];
const PAGE_SIZE = 30;
async function selectBridgeSession(sessionId) {
activeBridgeSession = sessionId;
sessionPanel.setActive(sessionId);
@@ -155,121 +185,96 @@
if (session) {
chatPanel.showSession({ name: session.name, status: session.isRunning ? 'running' : 'connected' });
}
await refreshTrajectory(sessionId);
// lazy load 콜백 등록
chatPanel.onLoadMore = () => {
const shown = chatPanel.getShownCount();
const remaining = allMessages.length - shown;
if (remaining <= 0) return;
const batch = allMessages.slice(Math.max(0, remaining - PAGE_SIZE), remaining);
chatPanel.prependMessages(batch);
};
try {
await loadSessionMessages(sessionId);
} catch (e) {
console.warn('[Bridge] 초기 로드 실패:', e);
chatPanel.updateChat([{ type: 'status', text: '⚠️ 데이터 로드 실패. 잠시 후 자동 재시도...' }], false, true);
}
}
async function refreshTrajectory(sessionId, scrollToBottom = true) {
async function loadSessionMessages(sessionId, scrollToBottom = true) {
try {
const [trajRes, cascRes] = await Promise.all([
// 1. trajectory (히스토리, 최대 ~341 스텝)
// 2. 서버 누적 메시지 (trajectory 이후 cascade diff로 캡쳐된 것들)
const [trajRes, accRes] = await Promise.all([
fetch(`/api/bridge/trajectory/${sessionId}`),
fetch('/api/bridge/cascades'),
fetch(`/api/bridge/messages/${sessionId}`),
]);
let messages = [];
let isComplete = true; // trajectory가 전체를 포함하는지
allMessages = [];
let trajLastStepIndex = -1;
// trajectory 파싱
if (trajRes.ok) {
const trajData = await trajRes.json();
if (trajData.trajectory?.steps) {
const total = trajData.numTotalSteps || 0;
const got = trajData.trajectory.steps.length;
isComplete = (total <= got);
if (isComplete) {
// ≤336 스텝: 전체 표시 가능
messages = parseTrajectoryToMessages(trajData.trajectory.steps);
}
// > 336 스텝: trajectory 건너뛰고 cascades 중심으로 표시
}
}
// cascades에서 최신 상태
if (cascRes.ok) {
const cascData = await cascRes.json();
const cascade = (cascData.cascades || cascData)[sessionId];
if (cascade) {
// 긴 대화일 때: cascades 기반 표시
if (!isComplete) {
// 대화 요약
if (cascade.summary) {
messages.push({
type: 'status',
text: `💬 대화 요약: ${cascade.summary}`,
});
}
messages.push({
type: 'status',
text: `📊 총 ${cascade.stepCount || '?'}개 스텝 · 최종 입력: ${formatRelativeTime(cascade.lastModifiedTime)}`,
});
}
// 현재 Task 상태 (항상 최신)
if (cascade.latestTaskBoundaryStep?.step?.taskBoundary) {
const tb = cascade.latestTaskBoundaryStep.step.taskBoundary;
// 이미 trajectory에서 같은 task가 없는 경우만 추가
const lastTask = messages.filter(m => m.type === 'task').pop();
if (!lastTask || lastTask.title !== tb.taskName) {
messages.push({
type: 'task',
title: tb.taskName || '',
summary: tb.taskSummary || '',
status: tb.taskStatus || '',
mode: tb.mode || '',
tools: [],
});
}
}
// 최신 AI 응답 (항상 표시)
if (cascade.latestNotifyUserStep?.step?.notifyUser) {
const nu = cascade.latestNotifyUserStep.step.notifyUser;
const content = nu.notificationContent || '';
if (content) {
// 이미 trajectory에서 같은 내용이 없는 경우만 추가
const lastText = messages.filter(m => m.type === 'text').pop();
const contentSnip = content.substring(0, 50);
if (!lastText || !lastText.html?.includes(contentSnip.replace(/[<>&]/g, ''))) {
messages.push({
type: 'text',
html: simpleMarkdown(content),
});
}
}
// 🔴 사용자 행동 필요 (isBlocking)
if (nu.isBlocking) {
messages.push({
type: 'actions',
label: '⚠️ 사용자 승인 대기 중',
buttons: [
{ label: '✅ 진행', action: 'api_call', endpoint: '/api/bridge/action', body: { action: 'acceptStep' } },
{ label: '❌ 거절', action: 'api_call', endpoint: '/api/bridge/action', body: { action: 'rejectStep' }, variant: 'danger' },
],
});
}
}
// 실행 중 상태 표시
if (cascade.status === 'CASCADE_RUN_STATUS_RUNNING') {
const lastMsg = messages[messages.length - 1];
if (!lastMsg || lastMsg.type !== 'actions') {
messages.push({
type: 'status',
text: '🔄 AI가 작업 중...',
});
const steps = trajData.trajectory.steps;
allMessages = parseTrajectoryToMessages(steps);
// 마지막 stepIndex 기억
for (let i = steps.length - 1; i >= 0; i--) {
if (steps[i].metadata?.createdAt) {
trajLastStepIndex = i;
break;
}
}
}
}
chatPanel.updateChat(messages);
if (scrollToBottom) {
const el = document.getElementById('chatMessages');
if (el) setTimeout(() => el.scrollTop = el.scrollHeight, 100);
// 3. 서버 누적 메시지 병합 (trajectory 이후 것만)
let currentStatus = { isRunning: false, isBlocking: false };
if (accRes.ok) {
const accData = await accRes.json();
currentStatus = { isRunning: accData.isRunning, isBlocking: accData.isBlocking };
if (accData.messages?.length > 0) {
for (const msg of accData.messages) {
// trajectory에 이미 있는 메시지는 스킵
if (msg.stepIndex !== undefined && msg.stepIndex <= trajLastStepIndex) continue;
if (msg.type === 'task') {
allMessages.push({
type: 'task', title: msg.title || '',
summary: msg.summary || '', status: msg.status || '',
mode: msg.mode || '', tools: [],
});
} else if (msg.type === 'text') {
allMessages.push({
type: 'text', html: simpleMarkdown(msg.content || ''),
});
} else if (msg.type === 'user_input') {
// 사용자 입력 (텍스트 없음, 시간만)
}
}
}
}
// 4. 상태 표시 (blocking / waiting / running)
if (currentStatus.isBlocking || currentStatus.isWaiting) {
allMessages.push({
type: 'actions',
label: '⚠️ 사용자 승인 대기 중',
buttons: [
{ label: '✅ 진행', action: 'api_call', endpoint: '/api/bridge/approve', body: { type: 'accept' } },
{ label: '❌ 거절', action: 'api_call', endpoint: '/api/bridge/approve', body: { type: 'reject' }, variant: 'danger' },
],
});
} else if (currentStatus.isRunning) {
allMessages.push({ type: 'status', text: '🔄 AI가 작업 중...' });
}
// 5. 화면 표시
const initial = allMessages.slice(Math.max(0, allMessages.length - PAGE_SIZE));
chatPanel.updateChat(initial, allMessages.length > PAGE_SIZE, scrollToBottom);
} catch (e) {
console.warn('[Bridge] trajectory 로드 실패:', e);
console.warn('[Bridge] 메시지 로드 실패:', e);
}
}
@@ -284,14 +289,74 @@
return `${Math.floor(hrs / 24)}일 전`;
}
// 실시간 갱신 디바운스
let refreshTimer = null;
function scheduleRefresh() {
if (refreshTimer) return;
refreshTimer = setTimeout(() => {
refreshTimer = null;
if (activeBridgeSession) refreshTrajectory(activeBridgeSession, false);
}, 2000); // 2초 디바운스
/**
* 서버 WS push된 새 메시지를 화면에 즉시 반영
let _prevStatusKey = ''; // 이전 상태 추적 (상태 변경 시만 DOM 갱신)
function applyNewMessages(data) {
if (!activeBridgeSession) return;
// 버튼 클릭 처리 중이면 DOM 교체 방지
if (chatPanel.actionInProgress) return;
const hasNewMessages = data.messages && data.messages.length > 0;
// 현재 상태 키 계산
const statusKey = data.isBlocking ? 'blocking' : data.isWaiting ? 'waiting' : data.isRunning ? 'running' : 'idle';
const statusChanged = statusKey !== _prevStatusKey;
_prevStatusKey = statusKey;
// 기존 상태 메시지(status/actions) 제거
while (allMessages.length > 0) {
const last = allMessages[allMessages.length - 1];
if (last.type === 'status' || last.type === 'actions') {
allMessages.pop();
} else {
break;
}
}
// 새 메시지 추가
if (hasNewMessages) {
for (const msg of data.messages) {
if (msg.type === 'task') {
const lastTask = allMessages.filter(m => m.type === 'task').pop();
if (lastTask && lastTask.title === msg.title) {
lastTask.summary = msg.summary || lastTask.summary;
lastTask.status = msg.status || lastTask.status;
} else {
allMessages.push({
type: 'task', title: msg.title || '',
summary: msg.summary || '', status: msg.status || '',
mode: msg.mode || '', tools: [],
});
}
} else if (msg.type === 'text') {
allMessages.push({
type: 'text', html: simpleMarkdown(msg.content || ''),
});
}
}
}
// 상태 표시
if (data.isBlocking || data.isWaiting) {
allMessages.push({
type: 'actions',
label: '⚠️ 사용자 승인 대기 중',
buttons: [
{ label: '✅ 진행', action: 'api_call', endpoint: '/api/bridge/approve', body: { type: 'accept' } },
{ label: '❌ 거절', action: 'api_call', endpoint: '/api/bridge/approve', body: { type: 'reject' }, variant: 'danger' },
],
});
} else if (data.isRunning) {
allMessages.push({ type: 'status', text: '🔄 AI가 작업 중...' });
}
// 화면 갱신: 새 메시지 또는 상태 변경 시만 (동일 상태 반복은 건너뛰어 스크롤 점프 방지)
if (hasNewMessages || statusChanged) {
const initial = allMessages.slice(Math.max(0, allMessages.length - PAGE_SIZE));
chatPanel.updateChat(initial, allMessages.length > PAGE_SIZE, false);
}
}
/**
@@ -323,11 +388,22 @@
case 'CORTEX_STEP_TYPE_USER_INPUT': {
// 이전 task 플러시
flushTask();
// 사용자 턴 구분
const text = step.userInput?.items?.[0]?.chunk?.value || '';
let text = '';
const ur = step.userInput?.userResponse || '';
const itemText = step.userInput?.items?.[0]?.item?.text
|| step.userInput?.items?.[0]?.item?.recipe?.title
|| '';
// 시스템 자동승인 메시지 필터
if (ur && !ur.includes('system-generated message')) {
text = ur;
} else if (itemText) {
text = itemText;
}
// 시스템 자동승인만 있는 경우 표시 스킵
if (!text || text.includes('system-generated message')) break;
msgs.push({
type: 'user',
text: text || '(사용자 입력)',
text: text,
time: step.metadata?.createdAt || '',
});
break;
@@ -400,9 +476,17 @@
}
function simpleMarkdown(text) {
return text
// 코드 블록을 임시 플레이스홀더로 보존
const codeBlocks = [];
let processed = text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre class="md-code"><code>$2</code></pre>')
.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
const idx = codeBlocks.length;
codeBlocks.push(`<pre class="md-code"><code>${code}</code></pre>`);
return `___CODE_BLOCK_${idx}___`;
});
// 코드 블록 외부만 줄바꿈 변환
processed = processed
.replace(/\n/g, '<br>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
@@ -410,6 +494,11 @@
.replace(/^### (.*)/gm, '<h4>$1</h4>')
.replace(/^\- (.*)/gm, '<li>$1</li>')
.replace(/^\d+\. (.*)/gm, '<li>$1</li>');
// 코드 블록 복원
for (let i = 0; i < codeBlocks.length; i++) {
processed = processed.replace(`___CODE_BLOCK_${i}___`, codeBlocks[i]);
}
return processed;
}
// ─── 서버 메시지 핸들러 ───────────────────────────────
@@ -437,14 +526,21 @@
break;
case 'bridge_event':
// Bridge WS 이벤트 처리
if (msg.step_changed || msg.type === 'step_changed') {
scheduleRefresh();
loadBridgeSessions(); // 세션 목록도 갱신 (stepCount 등)
// Bridge WS 이벤트 → 세션 목록만 갱신 (메시지는 new_messages로 처리)
if (msg.type === 'step_changed') {
if (!this._lastSessionLoad || Date.now() - this._lastSessionLoad > 10000) {
this._lastSessionLoad = Date.now();
loadBridgeSessions();
}
} else if (msg.type === 'session_changed' || msg.type === 'new_conversation') {
loadBridgeSessions();
} else if (msg.type === 'state_changed') {
scheduleRefresh();
}
break;
case 'new_messages':
// 서버가 push한 누적 메시지 → 즉시 화면 반영
if (msg.sessionId === activeBridgeSession) {
applyNewMessages(msg);
}
break;
@@ -466,19 +562,7 @@
}
}
function handleBridgeEvent(msg) {
switch (msg.type) {
case 'bridge_event':
// step_changed → 활성 대화 갱신
if (msg.step_changed || msg.sessions) {
loadBridgeSessions();
}
if (msg.new_conversation) {
loadBridgeSessions();
}
break;
}
}
// ─── 세션 패널 이벤트 ─────────────────────────────────
sessionPanel.onSessionSelect = (sessionId) => {
@@ -529,7 +613,27 @@
});
};
chatPanel.onActionClick = (button) => {
chatPanel.onActionClick = async (button) => {
// Bridge 세션이면 REST API로 호출
const isBridge = bridgeSessions.some(s => s.id === sessionPanel.activeSessionId);
if (isBridge && button.action === 'api_call' && button.endpoint) {
try {
const res = await fetch(button.endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(button.body || {}),
});
if (res.ok) {
showToast(`"${button.label}" 완료`, 'success');
} else {
showToast(`"${button.label}" 실패`, 'error');
}
} catch {
showToast(`"${button.label}" 오류`, 'error');
}
return;
}
// CDP 세션: WS로 좌표 클릭
sendWs({
type: 'click_action',
sessionId: sessionPanel.activeSessionId,
@@ -637,6 +741,41 @@
});
// ─── 스크린샷 ─────────────────────────────────────────
// ─── 헤더 승인/거절 버튼 ──────────────────────────────────
const btnApprove = document.getElementById('btnApprove');
const btnReject = document.getElementById('btnReject');
async function handleApproval(type, btn) {
btn.disabled = true;
const origText = btn.textContent;
btn.textContent = '처리 중...';
try {
const res = await fetch('/api/bridge/approve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type }),
});
const data = await res.json();
if (res.ok) {
btn.textContent = '✓ 완료';
showToast(`${type === 'accept' ? '승인' : '거절'} 성공`, 'success');
} else {
btn.textContent = `실패`;
showToast(data.error || '요청 실패', 'error');
}
} catch (e) {
btn.textContent = '오류';
showToast(e.message, 'error');
}
setTimeout(() => {
btn.textContent = origText;
btn.disabled = false;
}, 2000);
}
btnApprove.addEventListener('click', () => handleApproval('accept', btnApprove));
btnReject.addEventListener('click', () => handleApproval('reject', btnReject));
screenshotBtn.addEventListener('click', () => {
sendWs({ type: 'get_screenshot', sessionId: sessionPanel.activeSessionId });
});

View File

@@ -60,10 +60,7 @@ class ChatPanel {
this.containerEl.style.display = 'none';
}
/**
* 구조화된 메시지 배열로 채팅 렌더링
*/
updateChat(messages) {
updateChat(messages, hasMore = false, scrollToBottom = true) {
if (!messages || !Array.isArray(messages) || messages.length === 0) {
this.messagesEl.innerHTML = `
<div class="chat-welcome">
@@ -73,14 +70,27 @@ class ChatPanel {
return;
}
// 변경 감지 — 같은 내용이면 리렌더 안 함
const hash = JSON.stringify(messages).length + ':' + messages.length;
// 변경 감지 — 실제 내용 기반 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);
@@ -89,7 +99,51 @@ class ChatPanel {
this.messagesEl.innerHTML = '';
this.messagesEl.appendChild(frag);
if (wasAtBottom) this._scrollToBottom();
// 스크롤 이벤트: 최상단 → 이전 메시지 로드
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;
}
/**
@@ -320,9 +374,12 @@ class ChatPanel {
// api_call: 직접 API 호출 (승인/거절 등)
if (btn.action === 'api_call' && btn.endpoint) {
el.addEventListener('click', async () => {
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',
@@ -339,6 +396,8 @@ class ChatPanel {
}
} catch (e) {
el.textContent = `오류: ${e.message}`;
} finally {
this.actionInProgress = false;
}
});
} else if (btn.action === 'switch_mirror') {
@@ -406,7 +465,7 @@ class ChatPanel {
_renderUser(msg) {
const wrapper = document.createElement('div');
wrapper.className = 'msg-user';
wrapper.textContent = msg.content;
wrapper.textContent = msg.text || msg.content || '';
return wrapper;
}
@@ -416,7 +475,7 @@ class ChatPanel {
_renderStatus(msg) {
const wrapper = document.createElement('div');
wrapper.className = 'msg-status';
wrapper.textContent = msg.content;
wrapper.textContent = msg.text || msg.content || '';
return wrapper;
}
}

View File

@@ -29,7 +29,7 @@ class SessionPanel {
}
/**
* 세션 목록 렌더링
* 세션 목록 렌더링 (프로젝트별 그룹핑)
*/
render() {
if (!this.listEl) return;
@@ -43,28 +43,57 @@ class SessionPanel {
return;
}
this.listEl.innerHTML = this.sessions.map(s => {
const timeStr = s.lastModified ? this._relativeTime(s.lastModified) : '';
const projectBadge = s.project
? `<span class="session-project" style="background:${this._projectColor(s.project)}">${this._escapeHtml(s.project)}</span>`
: '';
const runningDot = s.isRunning ? '<span class="running-dot">●</span>' : '';
const detail = s.isBridge
? `${s.stepCount || 0} steps${timeStr ? ' · ' + timeStr : ''}`
: `${s.host}:${s.cdpPort} · ${this._statusText(s.status)}`;
// 프로젝트별 그룹핑
const groups = {};
for (const s of this.sessions) {
const proj = s.project || '기타';
if (!groups[proj]) groups[proj] = [];
groups[proj].push(s);
}
return `
// 활성(running) 있는 프로젝트 → 상단, 나머지 → 시간순
const sortedProjects = Object.keys(groups).sort((a, b) => {
const aRunning = groups[a].some(s => s.isRunning);
const bRunning = groups[b].some(s => s.isRunning);
if (aRunning && !bRunning) return -1;
if (!aRunning && bRunning) return 1;
// 최근 수정 순
const aTime = Math.max(...groups[a].map(s => s.lastModified ? new Date(s.lastModified).getTime() : 0));
const bTime = Math.max(...groups[b].map(s => s.lastModified ? new Date(s.lastModified).getTime() : 0));
return bTime - aTime;
});
let html = '';
for (const proj of sortedProjects) {
const sessions = groups[proj];
const color = this._projectColor(proj);
html += `<div class="project-group-header">
<span class="project-group-dot" style="background:${color}"></span>
${this._escapeHtml(proj)} <span style="opacity:0.5;font-weight:400">(${sessions.length})</span>
</div>`;
for (const s of sessions) {
const timeStr = s.lastModified ? this._relativeTime(s.lastModified) : '';
const runningDot = s.isRunning ? '<span class="running-dot">●</span>' : '';
const detail = s.isBridge
? `${s.stepCount || 0} steps${timeStr ? ' · ' + timeStr : ''}`
: `${s.host}:${s.cdpPort} · ${this._statusText(s.status)}`;
html += `
<div class="session-card ${s.id === this.activeSessionId ? 'active' : ''} ${s.isRunning ? 'is-running' : ''}"
data-session-id="${s.id}">
<div class="session-indicator ${s.status}"></div>
<div class="session-info">
<div class="session-name">${runningDot}${this._escapeHtml(s.name)}</div>
<div class="session-detail">${projectBadge}${detail}</div>
<div class="session-detail">${detail}</div>
</div>
<button class="session-remove" data-remove-id="${s.id}" title="세션 제거">✕</button>
</div>
`;
}).join('');
}
}
this.listEl.innerHTML = html;
// 이벤트 바인딩
this.listEl.querySelectorAll('.session-card').forEach(card => {