Changeset 0.19.0.1 (#82)
This commit is contained in:
@@ -2364,3 +2364,93 @@ select option { background: var(--bg-surface); color: var(--text); }
|
||||
.admin-main { padding: 1rem; }
|
||||
.admin-cat { padding: 10px 12px; font-size: 13px; }
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
PROJECTS (v0.19.0)
|
||||
========================================= */
|
||||
|
||||
.project-group {
|
||||
margin-bottom: 2px;
|
||||
border-radius: var(--radius);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.project-group.drag-over {
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
outline: 1px dashed var(--accent);
|
||||
outline-offset: -1px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.project-header {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 6px 10px; cursor: pointer; user-select: none;
|
||||
border-radius: var(--radius);
|
||||
transition: background var(--transition);
|
||||
}
|
||||
.project-header:hover { background: var(--bg-hover); }
|
||||
|
||||
.project-arrow {
|
||||
font-size: 10px; color: var(--text-3); width: 12px; text-align: center; flex-shrink: 0;
|
||||
}
|
||||
.project-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
||||
background: var(--text-3); display: inline-block;
|
||||
}
|
||||
.project-name {
|
||||
font-size: 12px; font-weight: 600; color: var(--text-2);
|
||||
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.project-count {
|
||||
font-size: 10px; color: var(--text-3); flex-shrink: 0; margin-left: auto;
|
||||
}
|
||||
.project-menu-btn {
|
||||
opacity: 0; background: none; border: none; color: var(--text-3);
|
||||
cursor: pointer; font-size: 14px; padding: 0 4px; border-radius: 4px;
|
||||
transition: opacity var(--transition), background var(--transition);
|
||||
line-height: 1; flex-shrink: 0;
|
||||
}
|
||||
.project-header:hover .project-menu-btn { opacity: 1; }
|
||||
.project-header:hover .project-count { display: none; }
|
||||
.project-menu-btn:hover { background: var(--bg-raised); color: var(--text); }
|
||||
|
||||
.project-empty {
|
||||
font-size: 11px; color: var(--text-3); padding: 4px 10px 8px 28px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .project-group { display: none; }
|
||||
.sidebar.collapsed .recent-section .chat-group-label { display: none; }
|
||||
|
||||
/* Recent section drop target */
|
||||
.recent-section.drag-over {
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Dragging state */
|
||||
.chat-item.dragging { opacity: 0.4; }
|
||||
.chat-item[draggable="true"] { cursor: grab; }
|
||||
.chat-item[draggable="true"]:active { cursor: grabbing; }
|
||||
|
||||
/* Context menu */
|
||||
.project-ctx-menu {
|
||||
position: fixed; z-index: 9999;
|
||||
background: var(--bg-surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); box-shadow: var(--shadow);
|
||||
padding: 4px; min-width: 160px; max-width: 220px;
|
||||
}
|
||||
.ctx-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
width: 100%; padding: 7px 10px; border: none; background: none;
|
||||
color: var(--text-2); font-size: 13px; cursor: pointer;
|
||||
border-radius: calc(var(--radius) - 2px); text-align: left;
|
||||
white-space: nowrap; overflow: hidden;
|
||||
}
|
||||
.ctx-item:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.ctx-item.ctx-danger:hover { background: var(--danger-bg, #fef2f2); color: var(--danger, #ef4444); }
|
||||
.ctx-icon { width: 16px; text-align: center; flex-shrink: 0; font-size: 12px; }
|
||||
.ctx-divider { height: 1px; background: var(--border); margin: 3px 0; }
|
||||
.ctx-hint { font-size: 11px; color: var(--text-3); padding: 6px 10px; }
|
||||
|
||||
/* Time labels inside project-grouped sidebar */
|
||||
.chat-time-label { padding-left: 12px; font-size: 10px; }
|
||||
|
||||
@@ -53,6 +53,10 @@
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
|
||||
New Chat
|
||||
</button>
|
||||
<button class="split-dropdown-item" onclick="createProject()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
New Project
|
||||
</button>
|
||||
<button class="split-dropdown-item disabled" title="Coming soon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
Group Chat <span class="dd-hint">soon</span>
|
||||
@@ -1126,6 +1130,7 @@
|
||||
<script src="js/knowledge-ui.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/memory-ui.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/persona-kb.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/projects-ui.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/chat.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/settings-handlers.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/admin-handlers.js?v=%%APP_VERSION%%"></script>
|
||||
|
||||
@@ -161,6 +161,40 @@ const API = {
|
||||
generateTitle(id) { return this._post(`/api/v1/channels/${id}/generate-title`, {}); },
|
||||
deleteChannel(id) { return this._del(`/api/v1/channels/${id}`); },
|
||||
|
||||
// Projects (v0.19.0)
|
||||
listProjects(includeArchived) {
|
||||
const q = includeArchived ? '?include_archived=true' : '';
|
||||
return this._get(`/api/v1/projects${q}`);
|
||||
},
|
||||
createProject(data) { return this._post('/api/v1/projects', data); },
|
||||
getProject(id) { return this._get(`/api/v1/projects/${id}`); },
|
||||
updateProject(id, data) { return this._put(`/api/v1/projects/${id}`, data); },
|
||||
deleteProject(id) { return this._del(`/api/v1/projects/${id}`); },
|
||||
addChannelToProject(projectId, channelId, position) {
|
||||
return this._post(`/api/v1/projects/${projectId}/channels`, { channel_id: channelId, position: position || 0 });
|
||||
},
|
||||
removeChannelFromProject(projectId, channelId) {
|
||||
return this._del(`/api/v1/projects/${projectId}/channels/${channelId}`);
|
||||
},
|
||||
reorderProjectChannels(projectId, channelIds) {
|
||||
return this._put(`/api/v1/projects/${projectId}/channels/reorder`, { channel_ids: channelIds });
|
||||
},
|
||||
listProjectChannels(projectId) { return this._get(`/api/v1/projects/${projectId}/channels`); },
|
||||
addKBToProject(projectId, kbId, autoSearch) {
|
||||
return this._post(`/api/v1/projects/${projectId}/knowledge-bases`, { kb_id: kbId, auto_search: autoSearch || false });
|
||||
},
|
||||
removeKBFromProject(projectId, kbId) {
|
||||
return this._del(`/api/v1/projects/${projectId}/knowledge-bases/${kbId}`);
|
||||
},
|
||||
listProjectKBs(projectId) { return this._get(`/api/v1/projects/${projectId}/knowledge-bases`); },
|
||||
addNoteToProject(projectId, noteId) {
|
||||
return this._post(`/api/v1/projects/${projectId}/notes`, { note_id: noteId });
|
||||
},
|
||||
removeNoteFromProject(projectId, noteId) {
|
||||
return this._del(`/api/v1/projects/${projectId}/notes/${noteId}`);
|
||||
},
|
||||
listProjectNotes(projectId) { return this._get(`/api/v1/projects/${projectId}/notes`); },
|
||||
|
||||
// ── Messages ─────────────────────────────
|
||||
|
||||
listMessages(channelId, page = 1, perPage = 200) {
|
||||
@@ -429,6 +463,13 @@ const API = {
|
||||
adminUpdateGroup(id, updates) { return this._put(`/api/v1/admin/groups/${id}`, updates); },
|
||||
adminDeleteGroup(id) { return this._del(`/api/v1/admin/groups/${id}`); },
|
||||
adminListGroupMembers(groupId) { return this._get(`/api/v1/admin/groups/${groupId}/members`); },
|
||||
|
||||
// Admin — Projects (v0.19.0)
|
||||
adminListProjects(includeArchived) {
|
||||
const q = includeArchived ? '?include_archived=true' : '';
|
||||
return this._get(`/api/v1/admin/projects${q}`);
|
||||
},
|
||||
adminDeleteProject(id) { return this._del(`/api/v1/admin/projects/${id}`); },
|
||||
adminAddGroupMember(groupId, userId) { return this._post(`/api/v1/admin/groups/${groupId}/members`, { user_id: userId }); },
|
||||
adminRemoveGroupMember(groupId, userId) { return this._del(`/api/v1/admin/groups/${groupId}/members/${userId}`); },
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ const App = {
|
||||
stagedAttachments: [],
|
||||
channelAttachments: {}, // messageId → [attachment, ...] — populated on chat load
|
||||
storageConfigured: false,
|
||||
projects: [], // v0.19.0: loaded from /api/v1/projects
|
||||
collapsedProjects: {}, // projectId → bool — sidebar collapse state
|
||||
|
||||
// Find model by composite ID, with fallback to bare model_id match
|
||||
findModel(id) {
|
||||
@@ -179,6 +181,7 @@ async function startApp() {
|
||||
}
|
||||
|
||||
await loadChats();
|
||||
await loadProjects();
|
||||
await fetchModels();
|
||||
|
||||
// Guard: if token was invalidated during startup (401 → refresh fail → clearTokens),
|
||||
|
||||
@@ -152,6 +152,7 @@ async function loadChats() {
|
||||
type: c.type || 'direct',
|
||||
model: c.model || '',
|
||||
messageCount: c.message_count || 0,
|
||||
projectId: c.project_id || null,
|
||||
messages: [],
|
||||
updatedAt: c.updated_at,
|
||||
settings: c.settings || {},
|
||||
@@ -467,7 +468,7 @@ async function sendMessage() {
|
||||
try {
|
||||
const title = text.slice(0, 50) + (text.length > 50 ? '...' : '');
|
||||
const resp = await API.createChannel(title, model, App.settings.systemPrompt, 'direct');
|
||||
const chat = { id: resp.id, title: resp.title, type: 'direct', model, messages: [], messageCount: 0, updatedAt: resp.updated_at };
|
||||
const chat = { id: resp.id, title: resp.title, type: 'direct', model, messages: [], messageCount: 0, projectId: resp.project_id || null, updatedAt: resp.updated_at, settings: resp.settings || {} };
|
||||
App.chats.unshift(chat);
|
||||
App.currentChatId = chat.id;
|
||||
UI.renderChatList();
|
||||
|
||||
288
src/js/projects-ui.js
Normal file
288
src/js/projects-ui.js
Normal file
@@ -0,0 +1,288 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Projects UI (v0.19.0)
|
||||
// ==========================================
|
||||
// Manages project CRUD, sidebar grouping, and
|
||||
// chat-to-project association via context menu
|
||||
// and drag-and-drop.
|
||||
// ==========================================
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const resp = await API.listProjects();
|
||||
App.projects = (resp.data || []).map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description || '',
|
||||
color: p.color || null,
|
||||
icon: p.icon || null,
|
||||
channelCount: p.channel_count || 0,
|
||||
kbCount: p.kb_count || 0,
|
||||
noteCount: p.note_count || 0,
|
||||
isArchived: p.is_archived || false,
|
||||
}));
|
||||
// Restore collapse state from localStorage
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('cs-collapsed-projects') || '{}');
|
||||
App.collapsedProjects = saved;
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load projects:', e.message);
|
||||
App.projects = [];
|
||||
}
|
||||
}
|
||||
|
||||
function _saveCollapseState() {
|
||||
try { localStorage.setItem('cs-collapsed-projects', JSON.stringify(App.collapsedProjects)); } catch (_) {}
|
||||
}
|
||||
|
||||
function toggleProjectCollapse(projectId) {
|
||||
App.collapsedProjects[projectId] = !App.collapsedProjects[projectId];
|
||||
_saveCollapseState();
|
||||
UI.renderChatList();
|
||||
}
|
||||
|
||||
// ── Project CRUD ────────────────────────────
|
||||
|
||||
async function createProject() {
|
||||
const name = prompt('Project name:');
|
||||
if (!name || !name.trim()) return;
|
||||
try {
|
||||
const p = await API.createProject({ name: name.trim() });
|
||||
App.projects.push({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description || '',
|
||||
color: p.color || null,
|
||||
icon: p.icon || null,
|
||||
channelCount: 0, kbCount: 0, noteCount: 0,
|
||||
isArchived: false,
|
||||
});
|
||||
UI.renderChatList();
|
||||
UI.toast('Project created', 'success');
|
||||
} catch (e) {
|
||||
UI.toast('Failed to create project', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function renameProject(projectId) {
|
||||
const proj = App.projects.find(p => p.id === projectId);
|
||||
if (!proj) return;
|
||||
const name = prompt('Rename project:', proj.name);
|
||||
if (!name || !name.trim() || name.trim() === proj.name) return;
|
||||
try {
|
||||
await API.updateProject(projectId, { name: name.trim() });
|
||||
proj.name = name.trim();
|
||||
UI.renderChatList();
|
||||
} catch (e) {
|
||||
UI.toast('Failed to rename project', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProject(projectId) {
|
||||
const proj = App.projects.find(p => p.id === projectId);
|
||||
if (!proj) return;
|
||||
if (!confirm(`Delete project "${proj.name}"?\nChats inside will be kept but unassigned.`)) return;
|
||||
try {
|
||||
await API.deleteProject(projectId);
|
||||
App.projects = App.projects.filter(p => p.id !== projectId);
|
||||
// Unassign chats client-side
|
||||
App.chats.forEach(c => { if (c.projectId === projectId) c.projectId = null; });
|
||||
UI.renderChatList();
|
||||
UI.toast('Project deleted', 'success');
|
||||
} catch (e) {
|
||||
UI.toast('Failed to delete project', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function setProjectColor(projectId) {
|
||||
const proj = App.projects.find(p => p.id === projectId);
|
||||
if (!proj) return;
|
||||
// Simple color picker via an invisible input
|
||||
const input = document.createElement('input');
|
||||
input.type = 'color';
|
||||
input.value = proj.color || '#3B82F6';
|
||||
input.style.position = 'fixed';
|
||||
input.style.opacity = '0';
|
||||
document.body.appendChild(input);
|
||||
input.addEventListener('change', async () => {
|
||||
try {
|
||||
await API.updateProject(projectId, { color: input.value });
|
||||
proj.color = input.value;
|
||||
UI.renderChatList();
|
||||
} catch (e) {
|
||||
UI.toast('Failed to set color', 'error');
|
||||
}
|
||||
input.remove();
|
||||
});
|
||||
input.addEventListener('blur', () => setTimeout(() => input.remove(), 200));
|
||||
input.click();
|
||||
}
|
||||
|
||||
// ── Move Chat to Project ────────────────────
|
||||
|
||||
async function moveChatToProject(chatId, projectId) {
|
||||
const chat = App.chats.find(c => c.id === chatId);
|
||||
if (!chat) return;
|
||||
const oldProjectId = chat.projectId;
|
||||
|
||||
if (projectId === null) {
|
||||
// Remove from project
|
||||
if (!oldProjectId) return;
|
||||
try {
|
||||
await API.removeChannelFromProject(oldProjectId, chatId);
|
||||
chat.projectId = null;
|
||||
UI.renderChatList();
|
||||
} catch (e) {
|
||||
UI.toast('Failed to remove from project', 'error');
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await API.addChannelToProject(projectId, chatId, 0);
|
||||
chat.projectId = projectId;
|
||||
UI.renderChatList();
|
||||
} catch (e) {
|
||||
UI.toast('Failed to move to project', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Context Menu ────────────────────────────
|
||||
|
||||
let _ctxMenu = null;
|
||||
|
||||
function showChatContextMenu(e, chatId) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dismissChatContextMenu();
|
||||
|
||||
const chat = App.chats.find(c => c.id === chatId);
|
||||
if (!chat) return;
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'project-ctx-menu';
|
||||
|
||||
// Move to project submenu
|
||||
let items = '';
|
||||
if (chat.projectId) {
|
||||
items += `<button class="ctx-item" onclick="moveChatToProject('${chatId}', null);dismissChatContextMenu()">
|
||||
<span class="ctx-icon">↩</span> Remove from project
|
||||
</button>`;
|
||||
items += '<div class="ctx-divider"></div>';
|
||||
}
|
||||
|
||||
App.projects.forEach(p => {
|
||||
if (p.id === chat.projectId) return; // skip current
|
||||
const dot = p.color ? `<span class="project-dot" style="background:${esc(p.color)}"></span>` : '';
|
||||
items += `<button class="ctx-item" onclick="moveChatToProject('${chatId}','${p.id}');dismissChatContextMenu()">
|
||||
${dot}<span>${esc(p.name)}</span>
|
||||
</button>`;
|
||||
});
|
||||
|
||||
if (App.projects.length === 0 && !chat.projectId) {
|
||||
items += '<div class="ctx-hint">No projects yet</div>';
|
||||
}
|
||||
|
||||
items += '<div class="ctx-divider"></div>';
|
||||
items += `<button class="ctx-item" onclick="createProjectAndMove('${chatId}');dismissChatContextMenu()">
|
||||
<span class="ctx-icon">+</span> New project…
|
||||
</button>`;
|
||||
|
||||
menu.innerHTML = items;
|
||||
|
||||
// Position near cursor
|
||||
menu.style.left = Math.min(e.clientX, window.innerWidth - 200) + 'px';
|
||||
menu.style.top = Math.min(e.clientY, window.innerHeight - 200) + 'px';
|
||||
|
||||
document.body.appendChild(menu);
|
||||
_ctxMenu = menu;
|
||||
|
||||
// Dismiss on click outside
|
||||
setTimeout(() => document.addEventListener('click', dismissChatContextMenu, { once: true }), 0);
|
||||
}
|
||||
|
||||
function dismissChatContextMenu() {
|
||||
if (_ctxMenu) { _ctxMenu.remove(); _ctxMenu = null; }
|
||||
}
|
||||
|
||||
async function createProjectAndMove(chatId) {
|
||||
const name = prompt('New project name:');
|
||||
if (!name || !name.trim()) return;
|
||||
try {
|
||||
const p = await API.createProject({ name: name.trim() });
|
||||
App.projects.push({
|
||||
id: p.id, name: p.name, description: '', color: null, icon: null,
|
||||
channelCount: 0, kbCount: 0, noteCount: 0, isArchived: false,
|
||||
});
|
||||
await moveChatToProject(chatId, p.id);
|
||||
} catch (e) {
|
||||
UI.toast('Failed to create project', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drag and Drop ───────────────────────────
|
||||
|
||||
function onChatDragStart(e, chatId) {
|
||||
e.dataTransfer.setData('text/plain', chatId);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.target.classList.add('dragging');
|
||||
}
|
||||
|
||||
function onChatDragEnd(e) {
|
||||
e.target.classList.remove('dragging');
|
||||
document.querySelectorAll('.project-group.drag-over').forEach(el => el.classList.remove('drag-over'));
|
||||
}
|
||||
|
||||
function onProjectDragOver(e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
e.currentTarget.classList.add('drag-over');
|
||||
}
|
||||
|
||||
function onProjectDragLeave(e) {
|
||||
e.currentTarget.classList.remove('drag-over');
|
||||
}
|
||||
|
||||
async function onProjectDrop(e, projectId) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.remove('drag-over');
|
||||
const chatId = e.dataTransfer.getData('text/plain');
|
||||
if (!chatId) return;
|
||||
await moveChatToProject(chatId, projectId);
|
||||
}
|
||||
|
||||
async function onRecentDrop(e) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.remove('drag-over');
|
||||
const chatId = e.dataTransfer.getData('text/plain');
|
||||
if (!chatId) return;
|
||||
await moveChatToProject(chatId, null);
|
||||
}
|
||||
|
||||
// ── Project Header Menu ─────────────────────
|
||||
|
||||
function showProjectMenu(e, projectId) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dismissChatContextMenu();
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'project-ctx-menu';
|
||||
menu.innerHTML = `
|
||||
<button class="ctx-item" onclick="renameProject('${projectId}');dismissChatContextMenu()">
|
||||
<span class="ctx-icon">✏</span> Rename
|
||||
</button>
|
||||
<button class="ctx-item" onclick="setProjectColor('${projectId}');dismissChatContextMenu()">
|
||||
<span class="ctx-icon">🎨</span> Color
|
||||
</button>
|
||||
<div class="ctx-divider"></div>
|
||||
<button class="ctx-item ctx-danger" onclick="deleteProject('${projectId}');dismissChatContextMenu()">
|
||||
<span class="ctx-icon">🗑</span> Delete project
|
||||
</button>`;
|
||||
|
||||
menu.style.left = Math.min(e.clientX, window.innerWidth - 180) + 'px';
|
||||
menu.style.top = Math.min(e.clientY, window.innerHeight - 150) + 'px';
|
||||
|
||||
document.body.appendChild(menu);
|
||||
_ctxMenu = menu;
|
||||
setTimeout(() => document.addEventListener('click', dismissChatContextMenu, { once: true }), 0);
|
||||
}
|
||||
@@ -272,16 +272,84 @@ const UI = {
|
||||
);
|
||||
}
|
||||
|
||||
if (chats.length === 0 && query) {
|
||||
const projects = App.projects || [];
|
||||
|
||||
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) {
|
||||
if (chats.length === 0 && projects.length === 0) {
|
||||
el.innerHTML = '<div class="sidebar-empty">No conversations yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by time
|
||||
let html = '';
|
||||
|
||||
// ── Chat item renderer ──────────────────
|
||||
const renderChatItem = (c) => {
|
||||
const time = _relativeTime(c.updatedAt);
|
||||
return `
|
||||
<div class="chat-item ${c.id === App.currentChatId ? 'active' : ''}"
|
||||
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">${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 ──────────────────────
|
||||
if (projects.length > 0) {
|
||||
projects.forEach(proj => {
|
||||
const projChats = 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 ? '▸' : '▾';
|
||||
|
||||
html += `<div class="project-group"
|
||||
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>
|
||||
<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>';
|
||||
});
|
||||
}
|
||||
|
||||
// ── Recent (unassigned) chats ───────────
|
||||
const recentChats = chats.filter(c => !c.projectId);
|
||||
|
||||
if (recentChats.length > 0 || projects.length > 0) {
|
||||
// Only show "Recent" label if there are also projects
|
||||
if (projects.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>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Group recent by time
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today - 86400000);
|
||||
@@ -289,7 +357,7 @@ const UI = {
|
||||
|
||||
const groups = { today: [], yesterday: [], week: [], older: [] };
|
||||
|
||||
chats.forEach(c => {
|
||||
recentChats.forEach(c => {
|
||||
const d = new Date(c.updatedAt);
|
||||
if (d >= today) groups.today.push(c);
|
||||
else if (d >= yesterday) groups.yesterday.push(c);
|
||||
@@ -297,20 +365,16 @@ const UI = {
|
||||
else groups.older.push(c);
|
||||
});
|
||||
|
||||
let html = '';
|
||||
const renderGroup = (label, chats) => {
|
||||
if (chats.length === 0) return;
|
||||
html += `<div class="chat-group-label">${label}</div>`;
|
||||
chats.forEach(c => {
|
||||
const time = _relativeTime(c.updatedAt);
|
||||
html += `
|
||||
<div class="chat-item ${c.id === App.currentChatId ? 'active' : ''}"
|
||||
onclick="selectChat('${c.id}')">
|
||||
<span class="chat-item-title" ondblclick="startRenameChat('${c.id}');event.stopPropagation();" title="Double-click to rename">${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>`;
|
||||
});
|
||||
const renderGroup = (label, items) => {
|
||||
if (items.length === 0) return;
|
||||
// Only show time labels when there are no projects (preserve original look)
|
||||
// or when inside the Recent section
|
||||
if (projects.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);
|
||||
@@ -318,6 +382,13 @@ const UI = {
|
||||
renderGroup('Previous 7 days', groups.week);
|
||||
renderGroup('Older', groups.older);
|
||||
|
||||
if (projects.length > 0 && recentChats.length > 0) {
|
||||
html += '</div>'; // close recent-section
|
||||
} else if (projects.length > 0 && recentChats.length === 0) {
|
||||
// Close the recent-section div we opened
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user