- 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
155 lines
5.1 KiB
JavaScript
155 lines
5.1 KiB
JavaScript
/**
|
|
* Session Panel — 세션 목록 UI 관리
|
|
*/
|
|
|
|
class SessionPanel {
|
|
constructor() {
|
|
this.sessions = [];
|
|
this.activeSessionId = null;
|
|
this.onSessionSelect = null; // callback(sessionId)
|
|
this.onSessionRemove = null; // callback(sessionId)
|
|
|
|
this.listEl = document.getElementById('sessionList');
|
|
}
|
|
|
|
/**
|
|
* 세션 목록 업데이트
|
|
*/
|
|
update(sessions) {
|
|
this.sessions = sessions;
|
|
this.render();
|
|
}
|
|
|
|
/**
|
|
* 활성 세션 설정
|
|
*/
|
|
setActive(sessionId) {
|
|
this.activeSessionId = sessionId;
|
|
this.render();
|
|
}
|
|
|
|
/**
|
|
* 세션 목록 렌더링 (프로젝트별 그룹핑)
|
|
*/
|
|
render() {
|
|
if (!this.listEl) return;
|
|
|
|
if (this.sessions.length === 0) {
|
|
this.listEl.innerHTML = `
|
|
<div style="padding: 20px; text-align: center; color: var(--text-muted); font-size: 12px;">
|
|
세션이 없습니다<br>+ 버튼으로 추가하세요
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
// 프로젝트별 그룹핑
|
|
const groups = {};
|
|
for (const s of this.sessions) {
|
|
const proj = s.project || '기타';
|
|
if (!groups[proj]) groups[proj] = [];
|
|
groups[proj].push(s);
|
|
}
|
|
|
|
// 활성(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">${detail}</div>
|
|
</div>
|
|
<button class="session-remove" data-remove-id="${s.id}" title="세션 제거">✕</button>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
this.listEl.innerHTML = html;
|
|
|
|
// 이벤트 바인딩
|
|
this.listEl.querySelectorAll('.session-card').forEach(card => {
|
|
card.addEventListener('click', (e) => {
|
|
if (e.target.closest('.session-remove')) return;
|
|
const id = card.dataset.sessionId;
|
|
if (this.onSessionSelect) this.onSessionSelect(id);
|
|
});
|
|
});
|
|
|
|
this.listEl.querySelectorAll('.session-remove').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const id = btn.dataset.removeId;
|
|
if (this.onSessionRemove) this.onSessionRemove(id);
|
|
});
|
|
});
|
|
}
|
|
|
|
_statusText(status) {
|
|
const map = {
|
|
connected: '연결됨',
|
|
connecting: '연결 중...',
|
|
disconnected: '연결 끊김',
|
|
running: '실행 중',
|
|
error: '오류',
|
|
};
|
|
return map[status] || status;
|
|
}
|
|
|
|
_relativeTime(iso) {
|
|
const diff = Date.now() - new Date(iso).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}시간 전`;
|
|
const days = Math.floor(hrs / 24);
|
|
return `${days}일 전`;
|
|
}
|
|
|
|
_projectColor(name) {
|
|
const colors = {
|
|
'gravity_web': '#6366f1',
|
|
'Deriva': '#f59e0b',
|
|
'MMF': '#10b981',
|
|
'test1': '#8b5cf6',
|
|
'test2': '#ec4899',
|
|
};
|
|
return colors[name] || '#64748b';
|
|
}
|
|
|
|
_escapeHtml(str) {
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
}
|