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:
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user