Changeset 0.23.1 (#154)
This commit is contained in:
@@ -282,167 +282,268 @@ const UI = {
|
||||
document.getElementById('userFlyout').classList.remove('open');
|
||||
},
|
||||
|
||||
// ── Chat List ────────────────────────────
|
||||
// ── Sidebar Section Collapse ──────────────
|
||||
// State persisted in localStorage per section key.
|
||||
|
||||
renderChatList() {
|
||||
const el = document.getElementById('chatHistory');
|
||||
const searchInput = document.getElementById('chatSearchInput');
|
||||
const searchWrap = document.getElementById('sidebarSearch');
|
||||
const query = (searchInput?.value || '').trim().toLowerCase();
|
||||
_sidebarSections: null,
|
||||
|
||||
// Toggle clear button visibility
|
||||
if (searchWrap) searchWrap.classList.toggle('has-value', query.length > 0);
|
||||
|
||||
// Filter chats by search query
|
||||
let chats = App.chats;
|
||||
if (query) {
|
||||
chats = chats.filter(c =>
|
||||
(c.title || '').toLowerCase().includes(query)
|
||||
);
|
||||
_getSidebarSections() {
|
||||
if (!this._sidebarSections) {
|
||||
try {
|
||||
this._sidebarSections = JSON.parse(localStorage.getItem('cs-sidebar-sections') || '{}');
|
||||
} catch (_) { this._sidebarSections = {}; }
|
||||
}
|
||||
return this._sidebarSections;
|
||||
},
|
||||
|
||||
toggleSidebarSection(key) {
|
||||
const sections = this._getSidebarSections();
|
||||
sections[key] = !sections[key]; // true = collapsed
|
||||
try { localStorage.setItem('cs-sidebar-sections', JSON.stringify(sections)); } catch (_) {}
|
||||
this._applySidebarSection(key, sections[key]);
|
||||
},
|
||||
|
||||
_applySidebarSection(key, collapsed) {
|
||||
const body = document.getElementById('sbBody' + key[0].toUpperCase() + key.slice(1));
|
||||
const arrow = document.getElementById('sbArrow' + key[0].toUpperCase() + key.slice(1));
|
||||
if (body) body.style.display = collapsed ? 'none' : '';
|
||||
if (arrow) arrow.textContent = collapsed ? '▸' : '▾';
|
||||
},
|
||||
|
||||
restoreSidebarSections() {
|
||||
const sections = this._getSidebarSections();
|
||||
['projects', 'channels', 'chats'].forEach(k => {
|
||||
this._applySidebarSection(k, !!sections[k]);
|
||||
});
|
||||
},
|
||||
|
||||
// ── Projects Section ──────────────────────
|
||||
|
||||
renderProjectsSection() {
|
||||
const el = document.getElementById('sbBodyProjects');
|
||||
if (!el) return;
|
||||
|
||||
const projects = App.projects || [];
|
||||
const showArchived = App.showArchivedProjects || false;
|
||||
const visible = showArchived ? projects : projects.filter(p => !p.isArchived);
|
||||
const hasArchived = projects.some(p => p.isArchived);
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const collapsed = sidebar?.classList.contains('collapsed');
|
||||
|
||||
if (chats.length === 0 && projects.length === 0 && query) {
|
||||
el.innerHTML = `<div class="sidebar-search-empty">No chats matching "${esc(query)}"</div>`;
|
||||
return;
|
||||
}
|
||||
if (chats.length === 0 && projects.length === 0) {
|
||||
el.innerHTML = '<div class="sidebar-empty">No conversations yet</div>';
|
||||
if (visible.length === 0 && !hasArchived) {
|
||||
el.innerHTML = collapsed ? '' :
|
||||
'<div class="sb-section-empty">No projects yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
visible.forEach(proj => {
|
||||
const isCollapsed = App.collapsedProjects?.[proj.id];
|
||||
const isActive = App.activeProjectId === proj.id;
|
||||
const dot = proj.color
|
||||
? `<span class="sb-proj-dot" style="background:${esc(proj.color)}"></span>`
|
||||
: '<span class="sb-proj-dot"></span>';
|
||||
const arrow = isCollapsed ? '▸' : '▾';
|
||||
const pinIcon = isActive ? '<span class="sb-proj-pin" title="Pinned for new chats">📍</span>' : '';
|
||||
const chats = (App.chats || []).filter(c => c.projectId === proj.id);
|
||||
|
||||
// ── Chat item renderer ──────────────────
|
||||
const renderChatItem = (c) => {
|
||||
const time = _relativeTime(c.updatedAt);
|
||||
const typeIcon = c.type === 'group' ? '<span class="chat-type-icon" title="Group Chat">👥</span> '
|
||||
: c.type === 'workflow' ? '<span class="chat-type-icon" title="Workflow">⚙</span> ' : '';
|
||||
return `
|
||||
<div class="chat-item ${c.id === App.currentChatId ? 'active' : ''}"
|
||||
html += `<div class="sb-proj-group${isActive ? ' active' : ''}${proj.isArchived ? ' archived' : ''}"
|
||||
ondragover="onProjectDragOver(event)"
|
||||
ondragleave="onProjectDragLeave(event)"
|
||||
ondrop="onProjectDrop(event,'${proj.id}')">
|
||||
<div class="sb-proj-header" onclick="toggleProjectCollapse('${proj.id}')">
|
||||
${collapsed ? dot : `<span class="sb-proj-arrow">${arrow}</span>${dot}<span class="sb-proj-name">${esc(proj.name)}</span>${pinIcon}`}
|
||||
${!collapsed ? `<span class="sb-proj-count">${chats.length}</span>
|
||||
<button class="sb-proj-menu" onclick="event.stopPropagation();showProjectMenu(event,'${proj.id}')" title="Options">⋯</button>` : ''}
|
||||
</div>`;
|
||||
|
||||
if (!isCollapsed && !collapsed) {
|
||||
if (chats.length === 0) {
|
||||
html += '<div class="sb-proj-empty">Drop chats here</div>';
|
||||
} else {
|
||||
chats.forEach(c => { html += this._chatItemHTML(c); });
|
||||
}
|
||||
}
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
if (hasArchived && !collapsed) {
|
||||
html += `<button class="sb-show-archived" onclick="App.showArchivedProjects=!App.showArchivedProjects;UI.renderProjectsSection()">
|
||||
${showArchived ? '▾ Hide archived' : `▸ Show archived (${projects.filter(p => p.isArchived).length})`}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
},
|
||||
|
||||
// ── Channels Section ──────────────────────
|
||||
|
||||
renderChannelsSection() {
|
||||
const el = document.getElementById('sbBodyChannels');
|
||||
if (!el) return;
|
||||
|
||||
const channels = App.channels || [];
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const collapsed = sidebar?.classList.contains('collapsed');
|
||||
|
||||
if (channels.length === 0) {
|
||||
el.innerHTML = collapsed ? '' :
|
||||
'<div class="sb-section-empty">No channels yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
channels.forEach(ch => {
|
||||
const isActive = App.currentChannelId === ch.id;
|
||||
const isDM = ch.type === 'dm';
|
||||
const isChannel = ch.type === 'channel';
|
||||
const online = App.presence?.[ch.dmPartnerId] === 'online';
|
||||
|
||||
// Icon: @ for DM, # for channel
|
||||
const icon = isDM
|
||||
? `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`
|
||||
: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/></svg>`;
|
||||
|
||||
const onlineDot = isDM && online
|
||||
? '<span class="sb-presence online"></span>'
|
||||
: '';
|
||||
const unreadBadge = ch.unread > 0
|
||||
? `<span class="sb-unread">${ch.unread > 99 ? '99+' : ch.unread}</span>`
|
||||
: '';
|
||||
|
||||
html += `<div class="sb-channel-item${isActive ? ' active' : ''}"
|
||||
onclick="selectChannel('${ch.id}')"
|
||||
title="${esc(ch.name)}">
|
||||
<span class="sb-ch-icon">${icon}</span>
|
||||
${!collapsed ? `<span class="sb-ch-name">${esc(ch.name)}</span>${onlineDot}${unreadBadge}` : unreadBadge}
|
||||
</div>`;
|
||||
});
|
||||
|
||||
el.innerHTML = html;
|
||||
},
|
||||
|
||||
// ── Chats Section (renderChatList) ────────
|
||||
|
||||
renderChatList() {
|
||||
// Target the new section body; fall back to legacy id during transition.
|
||||
const el = document.getElementById('sbBodyChats') || document.getElementById('chatHistory');
|
||||
if (!el) return;
|
||||
|
||||
const searchInput = document.getElementById('chatSearchInput');
|
||||
const searchWrap = document.getElementById('sidebarSearch');
|
||||
const query = (searchInput?.value || '').trim().toLowerCase();
|
||||
if (searchWrap) searchWrap.classList.toggle('has-value', query.length > 0);
|
||||
|
||||
// Chats section shows only direct/group chats not in a project.
|
||||
// type='channel' and type='dm' belong to the Channels section (App.channels).
|
||||
// Project chats rendered by renderProjectsSection.
|
||||
let chats = (App.chats || []).filter(c =>
|
||||
!c.projectId &&
|
||||
c.type !== 'channel' &&
|
||||
c.type !== 'dm'
|
||||
);
|
||||
if (query) chats = chats.filter(c => (c.title || '').toLowerCase().includes(query));
|
||||
|
||||
const folders = App.folders || [];
|
||||
|
||||
if (chats.length === 0 && folders.length === 0) {
|
||||
el.innerHTML = query
|
||||
? `<div class="sb-section-empty">No chats matching "${esc(query)}"</div>`
|
||||
: '<div class="sb-section-empty">No chats yet</div>';
|
||||
// Still re-render projects in case search is cross-cutting
|
||||
this.renderProjectsSection();
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by folder, then by time for unfiled
|
||||
const folderMap = {};
|
||||
folders.forEach(f => { folderMap[f.id] = []; });
|
||||
|
||||
const unfiled = [];
|
||||
chats.forEach(c => {
|
||||
if (c.folderId && folderMap[c.folderId]) {
|
||||
folderMap[c.folderId].push(c);
|
||||
} else {
|
||||
unfiled.push(c);
|
||||
}
|
||||
});
|
||||
|
||||
let html = '';
|
||||
|
||||
// Folder groups — always render, even when empty
|
||||
folders.forEach(f => {
|
||||
const items = folderMap[f.id] || [];
|
||||
const isOpen = !App.collapsedFolders?.[f.id];
|
||||
html += `<div class="sb-folder-group">
|
||||
<div class="sb-folder-header" onclick="UI.toggleFolder('${f.id}')">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
|
||||
</svg>
|
||||
<span class="sb-folder-name">${esc(f.name)}</span>
|
||||
<span class="sb-folder-arrow">${isOpen ? '▾' : '▸'}</span>
|
||||
<button class="sb-folder-menu" onclick="event.stopPropagation();showFolderMenu(event,'${f.id}')" title="Folder options">⋯</button>
|
||||
</div>
|
||||
${isOpen
|
||||
? (items.length > 0
|
||||
? items.map(c => this._chatItemHTML(c, true)).join('')
|
||||
: '<div class="sb-folder-empty">Drop chats here</div>')
|
||||
: ''}
|
||||
</div>`;
|
||||
});
|
||||
|
||||
// Unfiled chats grouped by time
|
||||
const now = Date.now();
|
||||
const DAY = 86400000;
|
||||
const groups = { today: [], yesterday: [], week: [], older: [] };
|
||||
unfiled.forEach(c => {
|
||||
const age = now - new Date(c.updatedAt).getTime();
|
||||
if (age < DAY) groups.today.push(c);
|
||||
else if (age < 2*DAY) groups.yesterday.push(c);
|
||||
else if (age < 7*DAY) groups.week.push(c);
|
||||
else groups.older.push(c);
|
||||
});
|
||||
|
||||
const renderTimeGroup = (label, items) => {
|
||||
if (!items.length) return;
|
||||
html += `<div class="chat-group-label">${label}</div>`;
|
||||
items.forEach(c => { html += this._chatItemHTML(c); });
|
||||
};
|
||||
|
||||
renderTimeGroup('Today', groups.today);
|
||||
renderTimeGroup('Yesterday', groups.yesterday);
|
||||
renderTimeGroup('Previous 7 days', groups.week);
|
||||
renderTimeGroup('Older', groups.older);
|
||||
|
||||
el.innerHTML = html;
|
||||
|
||||
// Always keep projects section in sync
|
||||
this.renderProjectsSection();
|
||||
},
|
||||
|
||||
toggleFolder(folderId) {
|
||||
if (!App.collapsedFolders) App.collapsedFolders = {};
|
||||
App.collapsedFolders[folderId] = !App.collapsedFolders[folderId];
|
||||
this.renderChatList();
|
||||
},
|
||||
|
||||
_chatItemHTML(c, indented = false) {
|
||||
const time = _relativeTime(c.updatedAt);
|
||||
const active = c.id === App.currentChatId ? ' active' : '';
|
||||
const indent = indented ? ' indented' : '';
|
||||
const typeIcon = c.type === 'group'
|
||||
? '<span class="chat-type-icon" title="Group Chat">👥</span> '
|
||||
: c.type === 'workflow' ? '<span class="chat-type-icon" title="Workflow">⚙</span> ' : '';
|
||||
return `<div class="chat-item${active}${indent}"
|
||||
onclick="selectChat('${c.id}')"
|
||||
oncontextmenu="showChatContextMenu(event,'${c.id}')"
|
||||
draggable="true"
|
||||
ondragstart="onChatDragStart(event,'${c.id}')"
|
||||
ondragend="onChatDragEnd(event)">
|
||||
<span class="chat-item-title" ondblclick="startRenameChat('${c.id}');event.stopPropagation();" title="Double-click to rename">${typeIcon}${esc(c.title)}</span>
|
||||
<span class="chat-item-time">${time}</span>
|
||||
<button class="chat-item-delete" onclick="deleteChat('${c.id}');event.stopPropagation();" title="Delete">✕</button>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
// ── Project groups ──────────────────────
|
||||
const showArchived = App.showArchivedProjects || false;
|
||||
const visibleProjects = showArchived ? projects : projects.filter(p => !p.isArchived);
|
||||
const hasArchived = projects.some(p => p.isArchived);
|
||||
|
||||
if (visibleProjects.length > 0) {
|
||||
visibleProjects.forEach(proj => {
|
||||
const projChats = typeof _getReorderedProjectChats === 'function'
|
||||
? _getReorderedProjectChats(proj.id, chats)
|
||||
: chats.filter(c => c.projectId === proj.id);
|
||||
if (projChats.length === 0 && query) return; // hide empty projects during search
|
||||
const isCollapsed = App.collapsedProjects[proj.id];
|
||||
const colorDot = proj.color
|
||||
? `<span class="project-dot" style="background:${esc(proj.color)}"></span>`
|
||||
: '<span class="project-dot"></span>';
|
||||
const arrow = isCollapsed ? '▸' : '▾';
|
||||
|
||||
const isActive = App.activeProjectId === proj.id;
|
||||
|
||||
const groupClasses = 'project-group' + (isActive ? ' active' : '') + (proj.isArchived ? ' archived' : '');
|
||||
|
||||
html += `<div class="${groupClasses}"
|
||||
ondragover="onProjectDragOver(event)"
|
||||
ondragleave="onProjectDragLeave(event)"
|
||||
ondrop="onProjectDrop(event,'${proj.id}')">
|
||||
<div class="project-header" onclick="toggleProjectCollapse('${proj.id}')">
|
||||
<span class="project-arrow">${arrow}</span>
|
||||
${colorDot}
|
||||
<span class="project-name">${esc(proj.name)}</span>
|
||||
${isActive ? '<span class="project-pin" title="Active project">📌</span>' : ''}
|
||||
<span class="project-count">${projChats.length}</span>
|
||||
<button class="project-menu-btn" onclick="event.stopPropagation();showProjectMenu(event,'${proj.id}')" title="Project options">⋯</button>
|
||||
</div>`;
|
||||
|
||||
if (!isCollapsed) {
|
||||
if (projChats.length === 0) {
|
||||
html += '<div class="project-empty">Drop chats here</div>';
|
||||
} else {
|
||||
projChats.forEach(c => { html += renderChatItem(c); });
|
||||
}
|
||||
}
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
// "Show archived" toggle
|
||||
if (hasArchived) {
|
||||
html += `<button class="project-show-archived" onclick="App.showArchivedProjects=!App.showArchivedProjects;UI.renderChatList()">
|
||||
${showArchived ? '▾ Hide archived' : '▸ Show archived (' + projects.filter(p => p.isArchived).length + ')'}
|
||||
</button>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Recent (unassigned) chats ───────────
|
||||
// Include chats from archived-and-hidden projects so they don't vanish
|
||||
const hiddenProjectIds = new Set(
|
||||
projects.filter(p => p.isArchived && !showArchived).map(p => p.id)
|
||||
);
|
||||
const recentChats = chats.filter(c => !c.projectId || hiddenProjectIds.has(c.projectId));
|
||||
|
||||
if (recentChats.length > 0 || visibleProjects.length > 0) {
|
||||
// Only show "Recent" label if there are also projects
|
||||
if (visibleProjects.length > 0) {
|
||||
html += `<div class="recent-section"
|
||||
ondragover="onProjectDragOver(event)"
|
||||
ondragleave="onProjectDragLeave(event)"
|
||||
ondrop="onRecentDrop(event)">
|
||||
<div class="chat-group-label" style="padding-top:8px">Recent</div>`;
|
||||
if (recentChats.length === 0) {
|
||||
html += '<div class="project-empty">Drop chats here to unassign</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group recent by time
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today - 86400000);
|
||||
const weekAgo = new Date(today - 7 * 86400000);
|
||||
|
||||
const groups = { today: [], yesterday: [], week: [], older: [] };
|
||||
|
||||
recentChats.forEach(c => {
|
||||
const d = new Date(c.updatedAt);
|
||||
if (d >= today) groups.today.push(c);
|
||||
else if (d >= yesterday) groups.yesterday.push(c);
|
||||
else if (d >= weekAgo) groups.week.push(c);
|
||||
else groups.older.push(c);
|
||||
});
|
||||
|
||||
const renderGroup = (label, items) => {
|
||||
if (items.length === 0) return;
|
||||
if (visibleProjects.length > 0) {
|
||||
html += `<div class="chat-group-label chat-time-label">${label}</div>`;
|
||||
} else {
|
||||
html += `<div class="chat-group-label">${label}</div>`;
|
||||
}
|
||||
items.forEach(c => { html += renderChatItem(c); });
|
||||
};
|
||||
|
||||
renderGroup('Today', groups.today);
|
||||
renderGroup('Yesterday', groups.yesterday);
|
||||
renderGroup('Previous 7 days', groups.week);
|
||||
renderGroup('Older', groups.older);
|
||||
|
||||
if (visibleProjects.length > 0 && recentChats.length > 0) {
|
||||
html += '</div>'; // close recent-section
|
||||
} else if (visibleProjects.length > 0 && recentChats.length === 0) {
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
<span class="chat-item-title" ondblclick="startRenameChat('${c.id}');event.stopPropagation();"
|
||||
title="Double-click to rename">${typeIcon}${esc(c.title)}</span>
|
||||
<span class="chat-item-time">${time}</span>
|
||||
<button class="chat-item-delete" onclick="deleteChat('${c.id}');event.stopPropagation();" title="Delete">✕</button>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
// ── Messages ─────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user