Changeset 0.23.1 (#154)

This commit is contained in:
2026-03-06 13:26:25 +00:00
parent 2fc620e1ac
commit 4c6555cb06
38 changed files with 1536 additions and 4031 deletions

View File

@@ -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 ─────────────────────────────