Changeset 0.19.2 (#84)

This commit is contained in:
2026-03-01 01:05:54 +00:00
parent 37a9b1a68e
commit eb74180611
9 changed files with 473 additions and 18 deletions

View File

@@ -20,6 +20,7 @@ const App = {
projects: [], // v0.19.0: loaded from /api/v1/projects
collapsedProjects: {}, // projectId → bool — sidebar collapse state
activeProjectId: null, // v0.19.1: new chats auto-assign to this project
showArchivedProjects: false, // v0.19.2: toggle archived projects in sidebar
// Find model by composite ID, with fallback to bare model_id match
findModel(id) {
@@ -183,6 +184,7 @@ async function startApp() {
await loadChats();
await loadProjects();
await loadProjectChannelPositions(); // v0.19.2: load channel order per project
await fetchModels();
// Guard: if token was invalidated during startup (401 → refresh fail → clearTokens),

View File

@@ -157,6 +157,9 @@ async function moveChatToProject(chatId, projectId) {
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');
@@ -165,6 +168,15 @@ async function moveChatToProject(chatId, projectId) {
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');
@@ -190,6 +202,21 @@ function showChatContextMenu(e, chatId) {
// 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>`;
@@ -347,6 +374,13 @@ function _registerProjectPanel() {
<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>
@@ -358,6 +392,12 @@ function _registerProjectPanel() {
<label class="project-panel-label">Notes</label>
<div id="projectNoteList" class="project-panel-list"></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);
@@ -367,6 +407,12 @@ function _registerProjectPanel() {
// 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',
@@ -391,15 +437,21 @@ async function _loadProjectPanel(projectId) {
if (!proj) return;
// Load full project data from server (includes settings)
let fullSettings = {};
try {
const full = await API.getProject(projectId);
const sp = (full.settings && full.settings.system_prompt) || '';
document.getElementById('projectSystemPrompt').value = sp;
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);
@@ -537,3 +589,143 @@ async function _removeProjectNote(projectId, noteId) {
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;
}
}
// ═══════════════════════════════════════════
// 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');
}
}

View File

@@ -302,9 +302,15 @@ const UI = {
};
// ── Project groups ──────────────────────
if (projects.length > 0) {
projects.forEach(proj => {
const projChats = chats.filter(c => c.projectId === proj.id);
const showArchived = App.showArchivedProjects || false;
const visibleProjects = showArchived ? projects : projects.filter(p => !p.isArchived);
const hasArchived = projects.some(p => p.isArchived);
if (visibleProjects.length > 0) {
visibleProjects.forEach(proj => {
const projChats = typeof _getReorderedProjectChats === 'function'
? _getReorderedProjectChats(proj.id, chats)
: chats.filter(c => c.projectId === proj.id);
if (projChats.length === 0 && query) return; // hide empty projects during search
const isCollapsed = App.collapsedProjects[proj.id];
const colorDot = proj.color
@@ -314,7 +320,9 @@ const UI = {
const isActive = App.activeProjectId === proj.id;
html += `<div class="project-group${isActive ? ' active' : ''}"
const groupClasses = 'project-group' + (isActive ? ' active' : '') + (proj.isArchived ? ' archived' : '');
html += `<div class="${groupClasses}"
ondragover="onProjectDragOver(event)"
ondragleave="onProjectDragLeave(event)"
ondrop="onProjectDrop(event,'${proj.id}')">
@@ -336,14 +344,25 @@ const UI = {
}
html += '</div>';
});
// "Show archived" toggle
if (hasArchived) {
html += `<button class="project-show-archived" onclick="App.showArchivedProjects=!App.showArchivedProjects;UI.renderChatList()">
${showArchived ? '▾ Hide archived' : '▸ Show archived (' + projects.filter(p => p.isArchived).length + ')'}
</button>`;
}
}
// ── Recent (unassigned) chats ───────────
const recentChats = chats.filter(c => !c.projectId);
// Include chats from archived-and-hidden projects so they don't vanish
const hiddenProjectIds = new Set(
projects.filter(p => p.isArchived && !showArchived).map(p => p.id)
);
const recentChats = chats.filter(c => !c.projectId || hiddenProjectIds.has(c.projectId));
if (recentChats.length > 0 || projects.length > 0) {
if (recentChats.length > 0 || visibleProjects.length > 0) {
// Only show "Recent" label if there are also projects
if (projects.length > 0) {
if (visibleProjects.length > 0) {
html += `<div class="recent-section"
ondragover="onProjectDragOver(event)"
ondragleave="onProjectDragLeave(event)"
@@ -373,9 +392,7 @@ const UI = {
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) {
if (visibleProjects.length > 0) {
html += `<div class="chat-group-label chat-time-label">${label}</div>`;
} else {
html += `<div class="chat-group-label">${label}</div>`;
@@ -388,10 +405,9 @@ const UI = {
renderGroup('Previous 7 days', groups.week);
renderGroup('Older', groups.older);
if (projects.length > 0 && recentChats.length > 0) {
if (visibleProjects.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
} else if (visibleProjects.length > 0 && recentChats.length === 0) {
html += '</div>';
}