Changeset 0.19.1 (#83)

This commit is contained in:
2026-03-01 00:28:58 +00:00
parent 748f49bedd
commit 37a9b1a68e
12 changed files with 580 additions and 22 deletions

View File

@@ -2418,10 +2418,16 @@ select option { background: var(--bg-surface); color: var(--text); }
font-style: italic;
}
/* Active project indicator (v0.19.1) */
.project-group.active { border-left: 2px solid var(--accent); }
.project-group.active .project-header { background: color-mix(in srgb, var(--accent) 6%, transparent); }
.project-pin { font-size: 10px; margin-left: 2px; opacity: 0.7; }
.sidebar.collapsed .project-group { display: none; }
.sidebar.collapsed .recent-section .chat-group-label { display: none; }
/* Recent section drop target */
.recent-section { min-height: 40px; }
.recent-section.drag-over {
background: color-mix(in srgb, var(--accent) 8%, transparent);
border-radius: var(--radius);
@@ -2454,3 +2460,33 @@ select option { background: var(--bg-surface); color: var(--text); }
/* Time labels inside project-grouped sidebar */
.chat-time-label { padding-left: 12px; font-size: 10px; }
/* ── Project Detail Panel (v0.19.1) ───────── */
.project-panel { padding: 16px; display: flex; flex-direction: column; gap: 16px; }
.project-panel-section { display: flex; flex-direction: column; gap: 6px; }
.project-panel-section-header { display: flex; justify-content: space-between; align-items: center; }
.project-panel-label { font-size: 12px; font-weight: 600; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.5px; }
.project-panel-textarea {
width: 100%; resize: vertical; min-height: 80px; padding: 8px;
font-family: var(--font-mono, monospace); font-size: 13px;
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
color: var(--text);
}
.project-panel-textarea:focus { outline: none; border-color: var(--accent); }
.project-panel-status { font-size: 11px; margin-left: 8px; }
.project-panel-status.success { color: var(--success, #22c55e); }
.project-panel-status.error { color: var(--danger, #ef4444); }
.project-panel-list { display: flex; flex-direction: column; gap: 2px; }
.project-panel-item {
display: flex; align-items: center; justify-content: space-between;
padding: 6px 8px; border-radius: var(--radius);
background: var(--bg); border: 1px solid var(--border);
font-size: 13px;
}
.project-panel-item-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.project-panel-item-remove {
background: none; border: none; cursor: pointer; color: var(--text-3);
font-size: 14px; padding: 2px 4px; border-radius: 4px; flex-shrink: 0;
}
.project-panel-item-remove:hover { color: var(--danger, #ef4444); background: var(--danger-bg, #fef2f2); }
.project-panel-empty { font-size: 12px; color: var(--text-3); font-style: italic; padding: 4px 0; }

View File

@@ -19,6 +19,7 @@ const App = {
storageConfigured: false,
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
// Find model by composite ID, with fallback to bare model_id match
findModel(id) {
@@ -427,6 +428,7 @@ function initListeners() {
_initAttachmentListeners(); // from attachments.js
_registerPreviewPanel(); // from ui-format.js — register preview with PanelRegistry
_registerNotesPanel(); // from notes.js — register notes with PanelRegistry
_registerProjectPanel(); // from projects-ui.js — register project detail panel
_initGlobalKeyboard(); // local: Escape, Ctrl+K, Ctrl+\, resize
_initSidePanelResize(); // from panels.js
_initDualDivider(); // from panels.js — dual-view split drag

View File

@@ -469,6 +469,13 @@ async function sendMessage() {
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, projectId: resp.project_id || null, updatedAt: resp.updated_at, settings: resp.settings || {} };
// Auto-assign to active project (v0.19.1)
if (App.activeProjectId && !chat.projectId) {
try {
await API.addChannelToProject(App.activeProjectId, chat.id, 0);
chat.projectId = App.activeProjectId;
} catch (_) { /* best effort — chat still works without project */ }
}
App.chats.unshift(chat);
App.currentChatId = chat.id;
UI.renderChatList();

View File

@@ -25,6 +25,14 @@ async function loadProjects() {
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 = [];
@@ -87,6 +95,11 @@ async function 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) {
@@ -118,6 +131,19 @@ async function setProjectColor(projectId) {
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) {
@@ -265,9 +291,19 @@ function showProjectMenu(e, projectId) {
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>
@@ -286,3 +322,218 @@ function showProjectMenu(e, projectId) {
_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">
<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>`;
body.appendChild(el);
// Wire save button
el.querySelector('#projectPromptSave').addEventListener('click', _saveProjectPrompt);
// Wire KB add button
el.querySelector('#projectAddKB').addEventListener('click', _showKBPicker);
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)
try {
const full = await API.getProject(projectId);
const sp = (full.settings && full.settings.system_prompt) || '';
document.getElementById('projectSystemPrompt').value = sp;
document.getElementById('projectPromptStatus').textContent = '';
} catch (_) {
document.getElementById('projectSystemPrompt').value = '';
}
// 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 = '<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');
}
}

View File

@@ -312,7 +312,9 @@ const UI = {
: '<span class="project-dot"></span>';
const arrow = isCollapsed ? '▸' : '▾';
html += `<div class="project-group"
const isActive = App.activeProjectId === proj.id;
html += `<div class="project-group${isActive ? ' active' : ''}"
ondragover="onProjectDragOver(event)"
ondragleave="onProjectDragLeave(event)"
ondrop="onProjectDrop(event,'${proj.id}')">
@@ -320,6 +322,7 @@ const UI = {
<span class="project-arrow">${arrow}</span>
${colorDot}
<span class="project-name">${esc(proj.name)}</span>
${isActive ? '<span class="project-pin" title="Active project">📌</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>`;
@@ -346,6 +349,9 @@ const UI = {
ondragleave="onProjectDragLeave(event)"
ondrop="onRecentDrop(event)">
<div class="chat-group-label" style="padding-top:8px">Recent</div>`;
if (recentChats.length === 0) {
html += '<div class="project-empty">Drop chats here to unassign</div>';
}
}
}