This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/src/js/projects-ui.js
2026-03-02 22:16:08 +00:00

1030 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==========================================
// 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 += `<button class="ctx-item" onclick="_moveChatInProject('${chat.projectId}','${chatId}',-1);dismissChatContextMenu()">
<span class="ctx-icon">↑</span> Move up
</button>`;
}
if (idx >= 0 && idx < order.length - 1) {
items += `<button class="ctx-item" onclick="_moveChatInProject('${chat.projectId}','${chatId}',1);dismissChatContextMenu()">
<span class="ctx-icon">↓</span> Move down
</button>`;
}
if (idx >= 0) items += '<div class="ctx-divider"></div>';
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>`;
// Workspace override (v0.21.5)
items += '<div class="ctx-divider"></div>';
items += `<button class="ctx-item" onclick="showWorkspacePicker('${chatId}');dismissChatContextMenu()">
<span class="ctx-icon">📁</span> Set workspace…
</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 isActive = App.activeProjectId === projectId;
const activeLabel = isActive ? 'Unpin project' : 'Pin as active';
const menu = document.createElement('div');
menu.className = 'project-ctx-menu';
menu.innerHTML = `
<button class="ctx-item" onclick="toggleActiveProject('${projectId}');dismissChatContextMenu()">
<span class="ctx-icon">${isActive ? '📌' : '📌'}</span> ${activeLabel}
</button>
<button class="ctx-item" onclick="openProjectPanel('${projectId}');dismissChatContextMenu()">
<span class="ctx-icon">⚙</span> Project settings
</button>
<div class="ctx-divider"></div>
<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);
}
// ═══════════════════════════════════════════
// 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 = `
<div class="project-panel" id="projectPanelInner">
<div class="project-panel-section">
<label class="project-panel-label">System Prompt</label>
<textarea id="projectSystemPrompt" class="project-panel-textarea"
placeholder="Instructions for all chats in this project..." rows="6"></textarea>
<button class="btn-small btn-primary" id="projectPromptSave" style="margin-top:6px">Save</button>
<span id="projectPromptStatus" class="project-panel-status"></span>
</div>
<div class="project-panel-section">
<label class="project-panel-label">Default Persona</label>
<select id="projectPersonaSelect" class="project-panel-select">
<option value="">None (use model selector)</option>
</select>
<div class="project-panel-hint">New chats in this project inherit the persona's model, parameters, and system prompt.</div>
</div>
<div class="project-panel-section">
<div class="project-panel-section-header">
<label class="project-panel-label">Knowledge Bases</label>
<button class="btn-small" id="projectAddKB" title="Add KB">+ Add</button>
</div>
<div id="projectKBList" class="project-panel-list"></div>
</div>
<div class="project-panel-section">
<label class="project-panel-label">Notes</label>
<div id="projectNoteList" class="project-panel-list"></div>
</div>
<div class="project-panel-section">
<label class="project-panel-label">Workspace</label>
<div class="project-panel-ws-row" id="projectWsRow">
<select id="projectWsSelect" class="project-panel-select">
<option value="">None</option>
</select>
<button class="btn-small" id="projectWsCreate" title="Create workspace">+ New</button>
</div>
<div class="project-panel-hint">Workspace files are available to AI in all project chats. Enables the Editor surface.</div>
</div>
<div class="project-panel-section" id="projectGitSection" style="display:none">
<label class="project-panel-label">Git Settings</label>
<div style="display:flex;flex-direction:column;gap:6px">
<input type="text" id="projectGitRemote" class="project-panel-input" placeholder="Git remote URL (https://...)" spellcheck="false">
<input type="text" id="projectGitBranch" class="project-panel-input" placeholder="Branch (default: main)" spellcheck="false">
<select id="projectGitCredential" class="project-panel-select">
<option value="">No credential</option>
</select>
<button class="btn-small" id="projectGitSave">Save Git Config</button>
</div>
<div class="project-panel-hint">Configure git remote to sync workspace files with a repository.</div>
</div>
<div class="project-panel-section" id="projectFilesSection">
<label class="project-panel-label">Project Files</label>
<div id="projectFileList" class="project-panel-list"></div>
<div style="margin-top:6px">
<input type="file" id="projectFileInput" style="display:none" multiple>
<button class="btn-small" id="projectFileUploadBtn">Upload File</button>
</div>
</div>
<div class="project-panel-section project-panel-footer">
<label class="project-panel-checkbox">
<input type="checkbox" id="projectArchiveToggle">
<span>Archive this project</span>
</label>
</div>
</div>`;
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 = '<div class="project-panel-empty">No KBs bound to this project</div>';
return;
}
list.innerHTML = kbs.map(kb => `
<div class="project-panel-item">
<span class="project-panel-item-name">${esc(kb.name || kb.kb_id)}</span>
<button class="project-panel-item-remove" onclick="_removeProjectKB('${projectId}','${kb.kb_id}')" title="Remove">✕</button>
</div>`).join('');
} catch (_) {
list.innerHTML = '<div class="project-panel-empty">Failed to load KBs</div>';
}
}
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 => `
<button class="ctx-item" onclick="_addProjectKB('${_projectPanelId}','${kb.id}');dismissChatContextMenu()">
${esc(kb.name)}
</button>`).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 = '<div class="project-panel-empty">No notes in this project</div>';
return;
}
list.innerHTML = notes.map(n => `
<div class="project-panel-item">
<span class="project-panel-item-name">${esc(n.title || n.note_id)}</span>
<button class="project-panel-item-remove" onclick="_removeProjectNote('${projectId}','${n.note_id}')" title="Remove">✕</button>
</div>`).join('');
} catch (_) {
list.innerHTML = '<div class="project-panel-empty">Failed to load notes</div>';
}
}
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 = '<option value="">None (use model selector)</option>';
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 = '<option value="">None</option>';
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 = '<option value="">No credential</option>';
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 = '<div class="project-panel-empty">No files uploaded</div>';
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 `<div class="project-panel-item" style="display:flex;justify-content:space-between;align-items:center">
<span title="${esc(f.filename)}">${esc(f.filename)}</span>
<span class="text-muted" style="font-size:11px">${size}</span>
</div>`;
}).join('');
} catch (e) {
list.innerHTML = '<div class="project-panel-empty">Failed to load files</div>';
}
}
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 = `
<div class="editor-qo-dialog" style="width:340px">
<div style="padding:10px 14px; font-weight:600; border-bottom:1px solid var(--border)">Set Workspace</div>
<div class="editor-qo-results" id="wsPickerList"></div>
</div>`;
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');
}
}