diff --git a/public/css/style.css b/public/css/style.css index 734a703..28ccd22 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -489,6 +489,52 @@ body { line-height: 1.4; } +/* --- 액션 버튼 영역 --- */ +.msg-actions { + background: rgba(239, 68, 68, 0.08); + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: 8px; + padding: 12px; + margin: 8px 0; +} + +.msg-actions-label { + font-size: 13px; + font-weight: 600; + color: #f87171; + margin-bottom: 8px; +} + +.msg-action-btn { + background: var(--accent-primary); + color: white; + border: none; + border-radius: 6px; + padding: 6px 14px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + margin-right: 6px; + transition: opacity 0.2s; +} + +.msg-action-btn:hover { + opacity: 0.85; +} + +.msg-action-primary { + background: var(--accent-primary); +} + +/* --- 상태 메시지 --- */ +.msg-status { + text-align: center; + color: var(--text-muted); + font-size: 12px; + padding: 8px 0; + opacity: 0.7; +} + .msg-step-icon { width: 16px; text-align: center; diff --git a/public/js/app.js b/public/js/app.js index 93d2816..6ee095e 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -149,57 +149,104 @@ async function refreshTrajectory(sessionId, scrollToBottom = true) { try { - // 1) trajectory (앞부분 ~336개) - // 2) cascades (최신 상태: latestNotifyUserStep + latestTaskBoundaryStep) const [trajRes, cascRes] = await Promise.all([ fetch(`/api/bridge/trajectory/${sessionId}`), fetch('/api/bridge/cascades'), ]); let messages = []; + let isComplete = true; // trajectory가 전체를 포함하는지 // trajectory 파싱 if (trajRes.ok) { const trajData = await trajRes.json(); if (trajData.trajectory?.steps) { - messages = parseTrajectoryToMessages(trajData.trajectory.steps); - - // trajectory가 전체가 아닌 경우 (numTotalSteps > steps.length) const total = trajData.numTotalSteps || 0; const got = trajData.trajectory.steps.length; - if (total > got) { - messages.push({ - type: 'status', - text: `⋯ ${total - got}개 스텝 생략 ⋯`, - }); + isComplete = (total <= got); + + if (isComplete) { + // ≤336 스텝: 전체 표시 가능 + messages = parseTrajectoryToMessages(trajData.trajectory.steps); } + // > 336 스텝: trajectory 건너뛰고 cascades 중심으로 표시 } } - // cascades에서 최신 상태 보강 + // cascades에서 최신 상태 if (cascRes.ok) { const cascData = await cascRes.json(); const cascade = (cascData.cascades || cascData)[sessionId]; if (cascade) { - // latestTaskBoundaryStep → 현재 작업 상태 - if (cascade.latestTaskBoundaryStep?.step?.taskBoundary) { - const tb = cascade.latestTaskBoundaryStep.step.taskBoundary; + // 긴 대화일 때: cascades 기반 표시 + if (!isComplete) { + // 대화 요약 + if (cascade.summary) { + messages.push({ + type: 'status', + text: `💬 대화 요약: ${cascade.summary}`, + }); + } messages.push({ - type: 'task', - title: tb.taskName || '', - summary: tb.taskSummary || '', - status: tb.taskStatus || '', - mode: tb.mode || '', - tools: [], + type: 'status', + text: `📊 총 ${cascade.stepCount || '?'}개 스텝 · 최종 입력: ${formatRelativeTime(cascade.lastModifiedTime)}`, }); } - // latestNotifyUserStep → 마지막 AI 응답 - if (cascade.latestNotifyUserStep?.step?.notifyUser?.notificationContent) { - const content = cascade.latestNotifyUserStep.step.notifyUser.notificationContent; - messages.push({ - type: 'text', - html: simpleMarkdown(content), - }); + + // 현재 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: '⚠️ Antigravity에서 리뷰가 필요합니다', + buttons: [ + { label: '미러 탭에서 확인', action: 'switch_mirror' }, + ], + }); + } + } + + // 실행 중 상태 표시 + if (cascade.status === 'CASCADE_RUN_STATUS_RUNNING') { + const lastMsg = messages[messages.length - 1]; + if (!lastMsg || lastMsg.type !== 'actions') { + messages.push({ + type: 'status', + text: '🔄 AI가 작업 중...', + }); + } } } } @@ -214,6 +261,17 @@ } } + function formatRelativeTime(isoStr) { + if (!isoStr) return ''; + const diff = Date.now() - new Date(isoStr).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return '방금 전'; + if (mins < 60) return `${mins}분 전`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}시간 전`; + return `${Math.floor(hrs / 24)}일 전`; + } + // 실시간 갱신 디바운스 let refreshTimer = null; function scheduleRefresh() { diff --git a/public/js/chat-panel.js b/public/js/chat-panel.js index 162c1ee..bf2003d 100644 --- a/public/js/chat-panel.js +++ b/public/js/chat-panel.js @@ -304,20 +304,26 @@ class ChatPanel { 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 = 'msg-action-btn'; + el.className = 'msg-action-btn msg-action-primary'; el.textContent = btn.label || btn; + el.style.cursor = 'pointer'; - // 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'; + // action 기반 처리 + 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 });