fix(ui): smart display for long convos, action buttons for isBlocking, no middle gap

This commit is contained in:
2026-03-08 01:24:14 +09:00
parent df46b91fc8
commit 8568985e7a
3 changed files with 147 additions and 37 deletions

View File

@@ -489,6 +489,52 @@ body {
line-height: 1.4; 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 { .msg-step-icon {
width: 16px; width: 16px;
text-align: center; text-align: center;

View File

@@ -149,57 +149,104 @@
async function refreshTrajectory(sessionId, scrollToBottom = true) { async function refreshTrajectory(sessionId, scrollToBottom = true) {
try { try {
// 1) trajectory (앞부분 ~336개)
// 2) cascades (최신 상태: latestNotifyUserStep + latestTaskBoundaryStep)
const [trajRes, cascRes] = await Promise.all([ const [trajRes, cascRes] = await Promise.all([
fetch(`/api/bridge/trajectory/${sessionId}`), fetch(`/api/bridge/trajectory/${sessionId}`),
fetch('/api/bridge/cascades'), fetch('/api/bridge/cascades'),
]); ]);
let messages = []; let messages = [];
let isComplete = true; // trajectory가 전체를 포함하는지
// trajectory 파싱 // trajectory 파싱
if (trajRes.ok) { if (trajRes.ok) {
const trajData = await trajRes.json(); const trajData = await trajRes.json();
if (trajData.trajectory?.steps) { if (trajData.trajectory?.steps) {
messages = parseTrajectoryToMessages(trajData.trajectory.steps);
// trajectory가 전체가 아닌 경우 (numTotalSteps > steps.length)
const total = trajData.numTotalSteps || 0; const total = trajData.numTotalSteps || 0;
const got = trajData.trajectory.steps.length; const got = trajData.trajectory.steps.length;
if (total > got) { isComplete = (total <= got);
messages.push({
type: 'status', if (isComplete) {
text: `${total - got}개 스텝 생략 ⋯`, // ≤336 스텝: 전체 표시 가능
}); messages = parseTrajectoryToMessages(trajData.trajectory.steps);
} }
// > 336 스텝: trajectory 건너뛰고 cascades 중심으로 표시
} }
} }
// cascades에서 최신 상태 보강 // cascades에서 최신 상태
if (cascRes.ok) { if (cascRes.ok) {
const cascData = await cascRes.json(); const cascData = await cascRes.json();
const cascade = (cascData.cascades || cascData)[sessionId]; const cascade = (cascData.cascades || cascData)[sessionId];
if (cascade) { if (cascade) {
// latestTaskBoundaryStep → 현재 작업 상태 // 긴 대화일 때: cascades 기반 표시
if (cascade.latestTaskBoundaryStep?.step?.taskBoundary) { if (!isComplete) {
const tb = cascade.latestTaskBoundaryStep.step.taskBoundary; // 대화 요약
if (cascade.summary) {
messages.push({
type: 'status',
text: `💬 대화 요약: ${cascade.summary}`,
});
}
messages.push({ messages.push({
type: 'task', type: 'status',
title: tb.taskName || '', text: `📊 총 ${cascade.stepCount || '?'}개 스텝 · 최종 입력: ${formatRelativeTime(cascade.lastModifiedTime)}`,
summary: tb.taskSummary || '',
status: tb.taskStatus || '',
mode: tb.mode || '',
tools: [],
}); });
} }
// latestNotifyUserStep → 마지막 AI 응답
if (cascade.latestNotifyUserStep?.step?.notifyUser?.notificationContent) { // 현재 Task 상태 (항상 최신)
const content = cascade.latestNotifyUserStep.step.notifyUser.notificationContent; if (cascade.latestTaskBoundaryStep?.step?.taskBoundary) {
messages.push({ const tb = cascade.latestTaskBoundaryStep.step.taskBoundary;
type: 'text', // 이미 trajectory에서 같은 task가 없는 경우만 추가
html: simpleMarkdown(content), 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; let refreshTimer = null;
function scheduleRefresh() { function scheduleRefresh() {

View File

@@ -304,20 +304,26 @@ class ChatPanel {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'msg-actions'; 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 || [])) { for (const btn of (msg.buttons || [])) {
const el = document.createElement('button'); const el = document.createElement('button');
el.className = 'msg-action-btn'; el.className = 'msg-action-btn msg-action-primary';
el.textContent = btn.label || btn; el.textContent = btn.label || btn;
el.style.cursor = 'pointer';
// Proceed/Review 등 주요 액션은 강조 // action 기반 처리
const label = btn.label || btn; if (btn.action === 'switch_mirror') {
if (['Proceed', 'Approve', 'Accept', 'Yes', 'Allow'].some(k => label.includes(k))) { el.addEventListener('click', () => {
el.classList.add('msg-action-primary'); document.getElementById('tabMirror')?.click();
} });
} else if (btn.x && btn.y) {
// 좌표가 있으면 클릭 가능
if (btn.x && btn.y) {
el.style.cursor = 'pointer';
el.addEventListener('click', () => { el.addEventListener('click', () => {
if (this.onActionClick) { if (this.onActionClick) {
this.onActionClick({ label: btn.label, x: btn.x, y: btn.y }); this.onActionClick({ label: btn.label, x: btn.x, y: btn.y });