diff --git a/public/app.js b/public/app.js index ffb5ec1..4695690 100644 --- a/public/app.js +++ b/public/app.js @@ -1288,6 +1288,109 @@ return sessions.filter((s) => normalizeAgent(s.agent) === currentAgent); } + function getSessionCwdFromCache(sessionId) { + if (!sessionId) return ''; + const cachedCwd = sessionCache.get(sessionId)?.snapshot?.cwd; + if (cachedCwd) return cachedCwd; + if (sessionId === currentSessionId && currentCwd) return currentCwd; + return ''; + } + + function getSessionEffectiveCwd(session) { + return session?.cwd || getSessionCwdFromCache(session?.id) || ''; + } + + function getSessionProjectName(session) { + if (session?.projectName) return session.projectName; + const cwd = String(getSessionEffectiveCwd(session)).replace(/\\/g, '/').replace(/\/+$/, ''); + return cwd ? (getPathLeaf(cwd) || cwd) : ''; + } + + function groupSessionsByProject(sessionItems) { + const groups = []; + const groupMap = new Map(); + const ungroupedSessions = []; + for (const session of sessionItems) { + const projectName = getSessionProjectName(session); + if (!projectName) { + ungroupedSessions.push(session); + continue; + } + if (!groupMap.has(projectName)) { + const cwd = getSessionEffectiveCwd(session); + const group = { + name: projectName, + cwd, + sessions: [], + latestUpdated: session.updated || '', + }; + groupMap.set(projectName, group); + groups.push(group); + } + const group = groupMap.get(projectName); + group.sessions.push(session); + if (new Date(session.updated || 0) > new Date(group.latestUpdated || 0)) { + group.latestUpdated = session.updated || group.latestUpdated; + group.cwd = getSessionEffectiveCwd(session) || group.cwd; + } + } + return { + groups: groups.sort((a, b) => new Date(b.latestUpdated || 0) - new Date(a.latestUpdated || 0)), + ungroupedSessions, + }; + } + + function createSessionListItem(session) { + const item = document.createElement('div'); + item.className = `session-item${session.id === currentSessionId ? ' active' : ''}`; + item.dataset.id = session.id; + const sessionCwd = getSessionEffectiveCwd(session); + if (sessionCwd) item.title = sessionCwd; + item.innerHTML = ` +
+ ${escapeHtml(session.title || 'Untitled')} + ${session.isRunning ? '运行中' : ''} +
+ ${session.hasUnread ? '' : ''} + ${timeAgo(session.updated)} +
+ + +
+ `; + + item.addEventListener('click', (e) => { + const target = e.target; + if (target.classList.contains('delete')) { + e.stopPropagation(); + const doDelete = () => { + if (getLastSessionForAgent(currentAgent) === session.id) { + localStorage.removeItem(getAgentSessionStorageKey(currentAgent)); + } + invalidateSessionCache(session.id); + send({ type: 'delete_session', sessionId: session.id }); + if (session.id === currentSessionId) { + resetChatView(currentAgent); + } + }; + if (skipDeleteConfirm) { + doDelete(); + } else { + showDeleteConfirm(session.agent, doDelete); + } + return; + } + if (target.classList.contains('edit')) { + e.stopPropagation(); + startEditSessionTitle(item, session); + return; + } + openSession(session.id); + }); + + return item; + } + function updateCwdBadge() { if (!chatCwd) return; if (currentCwd) { @@ -1754,6 +1857,16 @@ case 'session_info': if (pendingNewSessionRequest) pendingNewSessionRequest = null; const snapshot = normalizeSessionSnapshot(msg); + sessions = sessions.map((session) => ( + session.id === snapshot.sessionId + ? { + ...session, + cwd: snapshot.cwd || session.cwd || '', + projectName: snapshot.cwd ? getPathLeaf(snapshot.cwd) : session.projectName || '', + title: snapshot.title || session.title, + } + : session + )); if (activeSessionLoad?.sessionId === msg.sessionId) { activeSessionLoad.snapshot = snapshot; } @@ -3135,53 +3248,29 @@ return; } - for (const s of visibleSessions) { - const item = document.createElement('div'); - item.className = `session-item${s.id === currentSessionId ? ' active' : ''}`; - item.dataset.id = s.id; - item.innerHTML = ` -
- ${escapeHtml(s.title || 'Untitled')} - ${s.isRunning ? '运行中' : ''} -
- ${s.hasUnread ? '' : ''} - ${timeAgo(s.updated)} -
- - -
+ const { groups: projectGroups, ungroupedSessions } = groupSessionsByProject(visibleSessions); + for (const group of projectGroups) { + const groupEl = document.createElement('section'); + groupEl.className = 'session-project-group'; + + const header = document.createElement('div'); + header.className = 'session-project-header'; + header.title = group.cwd || group.name; + header.innerHTML = ` + ${escapeHtml(group.name)} + ${group.sessions.length} `; + groupEl.appendChild(header); - item.addEventListener('click', (e) => { - const target = e.target; - if (target.classList.contains('delete')) { - e.stopPropagation(); - const doDelete = () => { - if (getLastSessionForAgent(currentAgent) === s.id) { - localStorage.removeItem(getAgentSessionStorageKey(currentAgent)); - } - invalidateSessionCache(s.id); - send({ type: 'delete_session', sessionId: s.id }); - if (s.id === currentSessionId) { - resetChatView(currentAgent); - } - }; - if (skipDeleteConfirm) { - doDelete(); - } else { - showDeleteConfirm(s.agent, doDelete); - } - return; - } - if (target.classList.contains('edit')) { - e.stopPropagation(); - startEditSessionTitle(item, s); - return; - } - openSession(s.id); - }); + for (const s of group.sessions) { + groupEl.appendChild(createSessionListItem(s)); + } - sessionList.appendChild(item); + sessionList.appendChild(groupEl); + } + + for (const s of ungroupedSessions) { + sessionList.appendChild(createSessionListItem(s)); } } diff --git a/public/style.css b/public/style.css index a2f7e99..54caba2 100644 --- a/public/style.css +++ b/public/style.css @@ -164,6 +164,17 @@ html[data-theme='coolvibe'] .attachment-tray-note { border-color: rgba(191, 220, 228, 0.96); } +html[data-theme='coolvibe'] .session-project-header { + background: linear-gradient(180deg, rgba(247, 251, 252, 0.94), rgba(239, 248, 250, 0.88)); + color: #5f7f87; + border-bottom: 1px solid rgba(191, 220, 228, 0.56); +} + +html[data-theme='coolvibe'] .session-project-count { + background: rgba(8, 145, 178, 0.1); + color: #0a6d83; +} + html[data-theme='coolvibe'] .session-item { border: 1px solid transparent; border-radius: 14px; @@ -290,6 +301,17 @@ html[data-theme='coolvibe'] .theme-card.active { box-shadow: 0 0 0 2px rgba(8, 145, 178, 0.14); } +html[data-theme='editorial'] .session-project-header { + background: linear-gradient(180deg, rgba(239, 232, 220, 0.94), rgba(246, 241, 232, 0.84)); + color: #7f6f61; + border-bottom: 1px solid rgba(139, 94, 60, 0.12); +} + +html[data-theme='editorial'] .session-project-count { + background: rgba(139, 94, 60, 0.1); + color: #71482d; +} + html[data-theme='editorial'] { --bg-primary: #f6f1e8; --bg-secondary: #efe8dc; @@ -621,6 +643,46 @@ body.session-loading-active { line-height: 1.6; background: rgba(255, 249, 242, 0.7); } +.session-project-group { + margin: 0 0 12px; +} +.session-project-header { + position: sticky; + top: 0; + z-index: 2; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin: 4px 2px 5px; + padding: 6px 8px 5px; + background: rgba(242, 235, 226, 0.92); + backdrop-filter: blur(8px); + color: var(--text-muted); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.session-project-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.session-project-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 18px; + padding: 0 7px; + border-radius: 999px; + background: var(--bg-tertiary); + color: var(--text-secondary); + font-size: 10px; + letter-spacing: 0; +} .session-item { display: flex; align-items: center; diff --git a/server.js b/server.js index ab5c2d8..aa5fa8d 100644 --- a/server.js +++ b/server.js @@ -1460,12 +1460,15 @@ function sendSessionList(ws) { for (const f of files) { try { const s = normalizeSession(JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8'))); + const cwd = s.cwd || ''; sessions.push({ id: s.id, title: s.title || 'Untitled', updated: s.updated, hasUnread: !!s.hasUnread, agent: getSessionAgent(s), + cwd, + projectName: cwd ? path.basename(cwd.replace(/[\\/]+$/, '')) : '', isRunning: activeProcesses.has(s.id), }); } catch {}