feat(ui): session identification — project badges, relative time, running indicator

This commit is contained in:
2026-03-08 00:11:29 +09:00
parent 808eb66849
commit db70840b7a
3 changed files with 112 additions and 18 deletions

View File

@@ -259,6 +259,44 @@ body {
font-size: 11px; font-size: 11px;
color: var(--text-muted); color: var(--text-muted);
margin-top: 2px; margin-top: 2px;
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.session-project {
display: inline-block;
font-size: 9px;
padding: 1px 6px;
border-radius: 8px;
color: #fff;
font-weight: 600;
letter-spacing: 0.3px;
line-height: 1.4;
}
.running-dot {
color: #22c55e;
font-size: 10px;
margin-right: 3px;
animation: pulse 1.5s ease-in-out infinite;
}
.session-card.is-running {
border-left: 2px solid #22c55e;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
} }
.session-remove { .session-remove {

View File

@@ -92,19 +92,41 @@
async function loadBridgeSessions() { async function loadBridgeSessions() {
try { try {
const res = await fetch('/api/bridge/sessions'); // 세션 목록 + cascades를 병렬로 가져오기
if (!res.ok) return; const [sessRes, cascRes] = await Promise.all([
const data = await res.json(); fetch('/api/bridge/sessions'),
bridgeSessions = (data.sessions || []).map(s => ({ fetch('/api/bridge/cascades'),
id: s.id, ]);
name: s.title || s.id.substring(0, 8), if (!sessRes.ok) return;
host: 'bridge', const sessData = await sessRes.json();
cdpPort: 0, let cascadeMap = {};
status: 'connected', if (cascRes.ok) {
title: s.title, const cascData = await cascRes.json();
stepCount: s.stepCount, cascadeMap = cascData.cascades || cascData;
isBridge: true, }
}));
bridgeSessions = (sessData.sessions || []).map(s => {
const cascade = cascadeMap[s.id] || {};
// 워크스페이스에서 프로젝트명 추출
const wsUri = cascade.workspaces?.[0]?.workspaceFolderAbsoluteUri || '';
const project = wsUri ? decodeURIComponent(wsUri.split('/').pop()) : '';
// 상태
const runStatus = cascade.status || '';
const isRunning = runStatus.includes('RUNNING');
return {
id: s.id,
name: s.title || s.id.substring(0, 8),
host: 'bridge',
cdpPort: 0,
status: isRunning ? 'running' : 'connected',
title: s.title,
stepCount: s.stepCount,
lastModified: s.lastModifiedTime,
project: project,
isRunning: isRunning,
isBridge: true,
};
});
sessionPanel.update(bridgeSessions); sessionPanel.update(bridgeSessions);
// 활성 세션 없으면 가장 최근 대화 자동 선택 // 활성 세션 없으면 가장 최근 대화 자동 선택
if (!sessionPanel.activeSessionId && bridgeSessions.length > 0) { if (!sessionPanel.activeSessionId && bridgeSessions.length > 0) {

View File

@@ -43,17 +43,28 @@ class SessionPanel {
return; return;
} }
this.listEl.innerHTML = this.sessions.map(s => ` this.listEl.innerHTML = this.sessions.map(s => {
<div class="session-card ${s.id === this.activeSessionId ? 'active' : ''}" 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)}`;
return `
<div class="session-card ${s.id === this.activeSessionId ? 'active' : ''} ${s.isRunning ? 'is-running' : ''}"
data-session-id="${s.id}"> data-session-id="${s.id}">
<div class="session-indicator ${s.status}"></div> <div class="session-indicator ${s.status}"></div>
<div class="session-info"> <div class="session-info">
<div class="session-name">${this._escapeHtml(s.name)}</div> <div class="session-name">${runningDot}${this._escapeHtml(s.name)}</div>
<div class="session-detail">${s.isBridge ? `${s.stepCount || 0} steps · 대화` : `${s.host}:${s.cdpPort} · ${this._statusText(s.status)}`}</div> <div class="session-detail">${projectBadge}${detail}</div>
</div> </div>
<button class="session-remove" data-remove-id="${s.id}" title="세션 제거">✕</button> <button class="session-remove" data-remove-id="${s.id}" title="세션 제거">✕</button>
</div> </div>
`).join(''); `;
}).join('');
// 이벤트 바인딩 // 이벤트 바인딩
this.listEl.querySelectorAll('.session-card').forEach(card => { this.listEl.querySelectorAll('.session-card').forEach(card => {
@@ -78,11 +89,34 @@ class SessionPanel {
connected: '연결됨', connected: '연결됨',
connecting: '연결 중...', connecting: '연결 중...',
disconnected: '연결 끊김', disconnected: '연결 끊김',
running: '실행 중',
error: '오류', error: '오류',
}; };
return map[status] || status; 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) { _escapeHtml(str) {
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = str; div.textContent = str;