// ==========================================
// 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 (_) {}
// Restore active project (v0.19.1)
const savedActive = localStorage.getItem('cs-active-project');
if (savedActive && App.projects.some(p => p.id === savedActive)) {
App.activeProjectId = savedActive;
} else {
App.activeProjectId = null;
localStorage.removeItem('cs-active-project');
}
} 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; });
// Clear active if this was the active project (v0.19.1)
if (App.activeProjectId === projectId) {
App.activeProjectId = null;
localStorage.removeItem('cs-active-project');
}
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();
}
// ── Active Project (v0.19.1) ────────────────
function toggleActiveProject(projectId) {
if (App.activeProjectId === projectId) {
App.activeProjectId = null;
localStorage.removeItem('cs-active-project');
} else {
App.activeProjectId = projectId;
localStorage.setItem('cs-active-project', projectId);
}
UI.renderChatList();
}
// ── 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;
// Remove from order tracking (v0.19.2)
const order = getProjectChannelOrder(oldProjectId);
setProjectChannelOrder(oldProjectId, order.filter(id => id !== chatId));
UI.renderChatList();
} catch (e) {
UI.toast('Failed to remove from project', 'error');
}
} else {
try {
await API.addChannelToProject(projectId, chatId, 0);
chat.projectId = projectId;
// Add to order tracking (v0.19.2)
const order = getProjectChannelOrder(projectId);
if (!order.includes(chatId)) order.push(chatId);
setProjectChannelOrder(projectId, order);
// Remove from old project order
if (oldProjectId) {
const oldOrder = getProjectChannelOrder(oldProjectId);
setProjectChannelOrder(oldProjectId, oldOrder.filter(id => id !== chatId));
}
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) {
// Reorder within project (v0.19.2)
const order = getProjectChannelOrder(chat.projectId);
const idx = order.indexOf(chatId);
if (idx > 0) {
items += ``;
}
if (idx >= 0 && idx < order.length - 1) {
items += ``;
}
if (idx >= 0) items += '
';
items += ``;
items += '';
}
App.projects.forEach(p => {
if (p.id === chat.projectId) return; // skip current
const dot = p.color ? `` : '';
items += ``;
});
if (App.projects.length === 0 && !chat.projectId) {
items += 'No projects yet
';
}
items += '';
items += ``;
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 isActive = App.activeProjectId === projectId;
const activeLabel = isActive ? 'Unpin project' : 'Pin as active';
const menu = document.createElement('div');
menu.className = 'project-ctx-menu';
menu.innerHTML = `
`;
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);
}
// ═══════════════════════════════════════════
// PROJECT DETAIL PANEL (v0.19.1)
// ═══════════════════════════════════════════
let _projectPanelId = null; // currently-displayed project ID
function _registerProjectPanel() {
const body = document.getElementById('sidePanelBody');
if (!body || typeof PanelRegistry === 'undefined') return;
// Create the panel element dynamically
const el = document.createElement('div');
el.className = 'side-panel-page';
el.id = 'sidePanelProject';
el.style.display = 'none';
el.innerHTML = `
New chats in this project inherit the persona's model, parameters, and system prompt.
`;
body.appendChild(el);
// Wire save button
el.querySelector('#projectPromptSave').addEventListener('click', _saveProjectPrompt);
// Wire KB add button
el.querySelector('#projectAddKB').addEventListener('click', _showKBPicker);
// Wire persona select (v0.19.2)
el.querySelector('#projectPersonaSelect').addEventListener('change', _saveProjectPersona);
// Wire archive toggle (v0.19.2)
el.querySelector('#projectArchiveToggle').addEventListener('change', _toggleProjectArchive);
PanelRegistry.register('project', {
element: el,
label: 'Project',
onOpen() { if (_projectPanelId) _loadProjectPanel(_projectPanelId); },
});
}
async function openProjectPanel(projectId) {
_projectPanelId = projectId;
const proj = App.projects.find(p => p.id === projectId);
if (typeof PanelRegistry !== 'undefined') {
PanelRegistry.open('project');
// Update label to show project name
const label = document.getElementById('sidePanelLabel');
if (label && proj) label.textContent = proj.name;
}
await _loadProjectPanel(projectId);
}
async function _loadProjectPanel(projectId) {
const proj = App.projects.find(p => p.id === projectId);
if (!proj) return;
// Load full project data from server (includes settings)
let fullSettings = {};
try {
const full = await API.getProject(projectId);
fullSettings = full.settings || {};
document.getElementById('projectSystemPrompt').value = fullSettings.system_prompt || '';
document.getElementById('projectPromptStatus').textContent = '';
// Archive toggle
document.getElementById('projectArchiveToggle').checked = !!full.is_archived;
} catch (_) {
document.getElementById('projectSystemPrompt').value = '';
}
// Populate persona picker (v0.19.2)
await _renderPersonaPicker(fullSettings.persona_id || '');
// Load KBs
await _renderProjectKBs(projectId);
// Load notes
await _renderProjectNotes(projectId);
}
async function _saveProjectPrompt() {
if (!_projectPanelId) return;
const sp = document.getElementById('projectSystemPrompt').value;
const status = document.getElementById('projectPromptStatus');
try {
await API.updateProject(_projectPanelId, {
settings: { system_prompt: sp }
});
status.textContent = '✓ Saved';
status.className = 'project-panel-status success';
setTimeout(() => { status.textContent = ''; }, 2000);
} catch (e) {
status.textContent = '✗ Failed';
status.className = 'project-panel-status error';
}
}
async function _renderProjectKBs(projectId) {
const list = document.getElementById('projectKBList');
if (!list) return;
try {
const resp = await API.listProjectKBs(projectId);
const kbs = resp.data || resp || [];
if (kbs.length === 0) {
list.innerHTML = 'No KBs bound to this project
';
return;
}
list.innerHTML = kbs.map(kb => `
${esc(kb.name || kb.kb_id)}
`).join('');
} catch (_) {
list.innerHTML = 'Failed to load KBs
';
}
}
async function _removeProjectKB(projectId, kbId) {
try {
await API.removeKBFromProject(projectId, kbId);
await _renderProjectKBs(projectId);
UI.toast('KB removed from project', 'success');
} catch (e) {
UI.toast('Failed to remove KB', 'error');
}
}
async function _showKBPicker() {
if (!_projectPanelId) return;
try {
// Get all KBs user can see
const allKBs = await API.listKnowledgeBases();
const kbList = allKBs.data || allKBs || [];
if (kbList.length === 0) {
UI.toast('No knowledge bases available', 'info');
return;
}
// Get already-bound KBs
const boundResp = await API.listProjectKBs(_projectPanelId);
const boundIds = new Set((boundResp.data || boundResp || []).map(k => k.kb_id));
// Filter to unbound only
const available = kbList.filter(kb => !boundIds.has(kb.id));
if (available.length === 0) {
UI.toast('All KBs already bound to this project', 'info');
return;
}
// Simple picker: use a dropdown menu
const btn = document.getElementById('projectAddKB');
const rect = btn.getBoundingClientRect();
dismissChatContextMenu();
const menu = document.createElement('div');
menu.className = 'project-ctx-menu';
menu.style.left = Math.min(rect.left, window.innerWidth - 200) + 'px';
menu.style.top = (rect.bottom + 4) + 'px';
menu.innerHTML = available.map(kb => `
`).join('');
document.body.appendChild(menu);
_ctxMenu = menu;
setTimeout(() => document.addEventListener('click', dismissChatContextMenu, { once: true }), 0);
} catch (e) {
UI.toast('Failed to load KBs', 'error');
}
}
async function _addProjectKB(projectId, kbId) {
try {
await API.addKBToProject(projectId, kbId, true);
await _renderProjectKBs(projectId);
UI.toast('KB added to project', 'success');
} catch (e) {
UI.toast('Failed to add KB', 'error');
}
}
async function _renderProjectNotes(projectId) {
const list = document.getElementById('projectNoteList');
if (!list) return;
try {
const resp = await API.listProjectNotes(projectId);
const notes = resp.data || resp || [];
if (notes.length === 0) {
list.innerHTML = 'No notes in this project
';
return;
}
list.innerHTML = notes.map(n => `
${esc(n.title || n.note_id)}
`).join('');
} catch (_) {
list.innerHTML = 'Failed to load notes
';
}
}
async function _removeProjectNote(projectId, noteId) {
try {
await API.removeNoteFromProject(projectId, noteId);
await _renderProjectNotes(projectId);
UI.toast('Note removed from project', 'success');
} catch (e) {
UI.toast('Failed to remove note', 'error');
}
}
// ═══════════════════════════════════════════
// PERSONA PICKER (v0.19.2)
// ═══════════════════════════════════════════
async function _renderPersonaPicker(currentPersonaId) {
const select = document.getElementById('projectPersonaSelect');
if (!select) return;
select.innerHTML = '';
try {
const resp = await API.listPresets();
const personas = resp.data || resp || [];
personas.forEach(p => {
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.name + (p.base_model_id ? ` (${p.base_model_id})` : '');
if (p.id === currentPersonaId) opt.selected = true;
select.appendChild(opt);
});
} catch (_) { /* best effort */ }
}
async function _saveProjectPersona() {
if (!_projectPanelId) return;
const personaId = document.getElementById('projectPersonaSelect').value;
try {
await API.updateProject(_projectPanelId, {
settings: { persona_id: personaId || null }
});
UI.toast(personaId ? 'Persona set' : 'Persona cleared', 'success');
} catch (e) {
UI.toast('Failed to update persona', 'error');
}
}
// ═══════════════════════════════════════════
// ARCHIVE TOGGLE (v0.19.2)
// ═══════════════════════════════════════════
async function _toggleProjectArchive() {
if (!_projectPanelId) return;
const checked = document.getElementById('projectArchiveToggle').checked;
try {
await API.updateProject(_projectPanelId, { is_archived: checked });
const proj = App.projects.find(p => p.id === _projectPanelId);
if (proj) proj.isArchived = checked;
UI.renderChatList();
UI.toast(checked ? 'Project archived' : 'Project unarchived', 'success');
} catch (e) {
UI.toast('Failed to update archive status', 'error');
document.getElementById('projectArchiveToggle').checked = !checked;
}
}
// ═══════════════════════════════════════════
// CHANNEL REORDER WITHIN PROJECT (v0.19.2)
// ═══════════════════════════════════════════
// Stores project channel order: projectId → [channelId, ...]
const _projectChannelOrder = {};
function getProjectChannelOrder(projectId) {
return _projectChannelOrder[projectId] || [];
}
function setProjectChannelOrder(projectId, channelIds) {
_projectChannelOrder[projectId] = channelIds;
}
// Called after loadProjects + loadChats to initialize order from server positions
async function loadProjectChannelPositions() {
for (const proj of App.projects) {
try {
const resp = await API.listProjectChannels(proj.id);
const channels = resp.data || resp || [];
// Sorted by position from server
setProjectChannelOrder(proj.id, channels.map(c => c.channel_id));
} catch (_) { /* best effort — fall back to no order */ }
}
}
function _getReorderedProjectChats(projectId, chats) {
const order = getProjectChannelOrder(projectId);
const projChats = chats.filter(c => c.projectId === projectId);
if (order.length === 0) return projChats;
// Sort by position order; unordered chats go to end
const posMap = new Map(order.map((id, i) => [id, i]));
return projChats.sort((a, b) => {
const pa = posMap.has(a.id) ? posMap.get(a.id) : 9999;
const pb = posMap.has(b.id) ? posMap.get(b.id) : 9999;
return pa - pb;
});
}
async function _reorderOnDrop(e, projectId) {
// Get all chat items in this project group from the DOM
const group = e.currentTarget;
const chatItems = group.querySelectorAll('.chat-item');
const newOrder = [];
chatItems.forEach(el => {
const onclick = el.getAttribute('onclick') || '';
const m = onclick.match(/selectChat\('([^']+)'\)/);
if (m) newOrder.push(m[1]);
});
if (newOrder.length === 0) return;
// Optimistic update
setProjectChannelOrder(projectId, newOrder);
try {
await API.reorderProjectChannels(projectId, newOrder);
} catch (e) {
UI.toast('Failed to save order', 'error');
}
}
async function _moveChatInProject(projectId, chatId, direction) {
const order = getProjectChannelOrder(projectId);
const idx = order.indexOf(chatId);
if (idx < 0) return;
const newIdx = idx + direction;
if (newIdx < 0 || newIdx >= order.length) return;
// Swap
[order[idx], order[newIdx]] = [order[newIdx], order[idx]];
setProjectChannelOrder(projectId, order);
UI.renderChatList();
try {
await API.reorderProjectChannels(projectId, order);
} catch (e) {
// Revert on failure
[order[idx], order[newIdx]] = [order[newIdx], order[idx]];
setProjectChannelOrder(projectId, order);
UI.renderChatList();
UI.toast('Failed to reorder', 'error');
}
}