// ========================================== // 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, workspace_id: p.workspace_id || 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 += ``; // Workspace override (v0.21.5) 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.
Workspace files are available to AI in all project chats. Enables the Editor surface.
`; 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); // Wire workspace select + create (v0.21.5) el.querySelector('#projectWsSelect').addEventListener('change', _saveProjectWorkspace); el.querySelector('#projectWsCreate').addEventListener('click', _createProjectWorkspace); // Wire git settings (v0.22.4) el.querySelector('#projectGitSave').addEventListener('click', _saveProjectGitConfig); // Wire project files (v0.22.4) el.querySelector('#projectFileUploadBtn').addEventListener('click', () => { document.getElementById('projectFileInput').click(); }); el.querySelector('#projectFileInput').addEventListener('change', _handleProjectFileUpload); 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); // Load workspace picker (v0.21.5) await _renderProjectWorkspace(projectId); // Load git settings (v0.22.4) — depends on workspace being loaded await _renderProjectGitSettings(projectId); // Load project files (v0.22.4) await _renderProjectFiles(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; } } // ── Workspace picker (v0.21.5) ─────────────── async function _renderProjectWorkspace(projectId) { const sel = document.getElementById('projectWsSelect'); if (!sel) return; // Fetch current project state + available workspaces in parallel const [proj, workspaces] = await Promise.all([ API.getProject(projectId).catch(() => null), API.listWorkspaces().catch(() => []), ]); const currentWsId = proj?.workspace_id || ''; const wsList = Array.isArray(workspaces) ? workspaces : (workspaces?.data || []); sel.innerHTML = ''; for (const ws of wsList) { const opt = document.createElement('option'); opt.value = ws.id; opt.textContent = ws.name || ws.id.slice(0, 8); if (ws.id === currentWsId) opt.selected = true; sel.appendChild(opt); } } async function _saveProjectWorkspace() { if (!_projectPanelId) return; const wsId = document.getElementById('projectWsSelect').value; try { await API.updateProject(_projectPanelId, { workspace_id: wsId || null }); UI.toast(wsId ? 'Workspace linked' : 'Workspace unlinked', 'success'); // Trigger editor surface check if (typeof EditorMode !== 'undefined') EditorMode.check(); // Refresh git settings (v0.22.4) await _renderProjectGitSettings(_projectPanelId); } catch (e) { UI.toast('Failed to update workspace', 'error'); } } async function _createProjectWorkspace() { if (!_projectPanelId) return; const proj = App.projects.find(p => p.id === _projectPanelId); const name = prompt('Workspace name:', (proj?.name || 'Project') + ' Workspace'); if (!name || !name.trim()) return; try { const ws = await API.createWorkspace({ name: name.trim(), owner_type: 'user', owner_id: API.user?.id || '', }); // Auto-bind to project await API.updateProject(_projectPanelId, { workspace_id: ws.id }); UI.toast('Workspace created and linked', 'success'); // Refresh the picker await _renderProjectWorkspace(_projectPanelId); // Trigger editor surface check if (typeof EditorMode !== 'undefined') EditorMode.check(); } catch (e) { UI.toast('Failed to create workspace: ' + (e.message || e), 'error'); } } // ── Channel workspace picker (v0.21.5) ─────── // ── Workspace Git Config (v0.22.4) ─────────── async function _renderProjectGitSettings(projectId) { const gitSection = document.getElementById('projectGitSection'); const wsId = document.getElementById('projectWsSelect')?.value; if (!wsId) { gitSection.style.display = 'none'; return; } gitSection.style.display = ''; try { const ws = await API.getWorkspace(wsId); document.getElementById('projectGitRemote').value = ws.git_remote_url || ''; document.getElementById('projectGitBranch').value = ws.git_branch || ''; // Load git credentials const credSel = document.getElementById('projectGitCredential'); credSel.innerHTML = ''; try { const creds = await API.listGitCredentials(); const list = Array.isArray(creds) ? creds : (creds?.data || []); for (const c of list) { const opt = document.createElement('option'); opt.value = c.id; opt.textContent = c.name || c.id.slice(0, 8); if (c.id === ws.git_credential_id) opt.selected = true; credSel.appendChild(opt); } } catch (_) {} } catch (_) { gitSection.style.display = 'none'; } } async function _saveProjectGitConfig() { const wsId = document.getElementById('projectWsSelect')?.value; if (!wsId) return; const remote = document.getElementById('projectGitRemote').value.trim(); const branch = document.getElementById('projectGitBranch').value.trim() || null; const credId = document.getElementById('projectGitCredential').value || null; try { await API.updateWorkspace(wsId, { git_remote_url: remote || null, git_branch: branch, git_credential_id: credId, }); UI.toast('Git config saved', 'success'); } catch (e) { UI.toast('Failed to save git config: ' + (e.message || e), 'error'); } } // ── Project Files (v0.22.4) ────────────────── async function _renderProjectFiles(projectId) { const list = document.getElementById('projectFileList'); if (!list) return; try { const resp = await API.projectListFiles(projectId); const files = resp?.files || []; if (!files.length) { list.innerHTML = '
No files uploaded
'; return; } list.innerHTML = files.map(f => { const size = f.size_bytes > 1024 * 1024 ? (f.size_bytes / 1024 / 1024).toFixed(1) + ' MB' : (f.size_bytes / 1024).toFixed(1) + ' KB'; return `
${esc(f.filename)} ${size}
`; }).join(''); } catch (e) { list.innerHTML = '
Failed to load files
'; } } async function _handleProjectFileUpload() { const input = document.getElementById('projectFileInput'); if (!input.files?.length || !_projectPanelId) return; for (const file of input.files) { try { await API.projectUploadFile(_projectPanelId, file); UI.toast(`Uploaded ${file.name}`, 'success'); } catch (e) { UI.toast(`Upload failed: ${e.message || e}`, 'error'); } } input.value = ''; await _renderProjectFiles(_projectPanelId); } async function showWorkspacePicker(chatId) { let workspaces = []; try { const resp = await API.listWorkspaces(); workspaces = Array.isArray(resp) ? resp : (resp?.data || []); } catch (_) {} // Get current channel workspace let currentWsId = ''; try { const ch = await API.getChannel(chatId); currentWsId = ch.workspace_id || ''; } catch (_) {} // Build options const options = [{ value: '', label: 'None (inherit from project)' }]; for (const ws of workspaces) { options.push({ value: ws.id, label: ws.name || ws.id.slice(0, 8) }); } options.push({ value: '__new__', label: '+ Create new workspace…' }); // Use simple select via popup const overlay = document.createElement('div'); overlay.className = 'editor-quick-open'; // reuse overlay style overlay.innerHTML = `
Set Workspace
`; const list = overlay.querySelector('#wsPickerList'); for (const opt of options) { const row = document.createElement('div'); row.className = 'editor-qo-row' + (opt.value === currentWsId ? ' active' : ''); row.style.fontWeight = opt.value === currentWsId ? '600' : ''; row.textContent = opt.label; row.addEventListener('click', async () => { overlay.remove(); if (opt.value === '__new__') { const name = prompt('Workspace name:'); if (!name || !name.trim()) return; try { const ws = await API.createWorkspace({ name: name.trim(), owner_type: 'user', owner_id: API.user?.id || '', }); await API.updateChannel(chatId, { workspace_id: ws.id }); UI.toast('Workspace created and linked', 'success'); if (typeof EditorMode !== 'undefined') EditorMode.check(); } catch (e) { UI.toast('Failed: ' + (e.message || e), 'error'); } } else { try { await API.updateChannel(chatId, { workspace_id: opt.value || '' }); UI.toast(opt.value ? 'Workspace linked' : 'Workspace unlinked', 'success'); if (typeof EditorMode !== 'undefined') EditorMode.check(); } catch (e) { UI.toast('Failed: ' + (e.message || e), 'error'); } } }); list.appendChild(row); } overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); document.body.appendChild(overlay); } // ═══════════════════════════════════════════ // 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'); } }