Changeset 0.19.1 (#83)
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user