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 {}