Changeset 0.23.1 (#154)
This commit is contained in:
@@ -458,3 +458,39 @@
|
||||
.group-persona-model { font-size: 11px; color: var(--text-3); font-family: var(--font-mono); }
|
||||
.chain-typing-name { font-size: 11px; font-weight: 600; color: var(--accent); margin-right: 6px; }
|
||||
|
||||
/* ── Creation Dialog (project / channel / folder — v0.23.1) ── */
|
||||
.sb-dialog-overlay {
|
||||
position: fixed; inset: 0; z-index: 9999;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--overlay);
|
||||
animation: fade-in 0.12s ease;
|
||||
}
|
||||
.sb-dialog {
|
||||
background: var(--bg-raised); border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px 22px 16px;
|
||||
min-width: 300px; max-width: 420px; width: 90vw;
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
animation: confirm-in 0.15s ease;
|
||||
}
|
||||
.sb-dialog-title { font-size: 14px; font-weight: 600; color: var(--text); }
|
||||
.sb-dialog-input {
|
||||
background: var(--bg-surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 8px 10px;
|
||||
font-size: 13px; color: var(--text);
|
||||
font-family: inherit; outline: none; width: 100%; box-sizing: border-box;
|
||||
}
|
||||
.sb-dialog-input:focus { border-color: var(--accent); }
|
||||
.sb-dialog-color-row { display: flex; align-items: center; gap: 10px; }
|
||||
.sb-dialog-color-label { font-size: 12px; color: var(--text-2); }
|
||||
.sb-dialog-color-input { width: 32px; height: 26px; border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; padding: 1px; background: none; }
|
||||
.sb-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.sb-dialog-btn {
|
||||
padding: 6px 14px; border-radius: var(--radius); font-size: 12px; font-family: inherit;
|
||||
cursor: pointer; border: 1px solid var(--border);
|
||||
background: var(--bg-hover); color: var(--text-2);
|
||||
}
|
||||
.sb-dialog-btn:hover { background: var(--bg-raised); }
|
||||
.sb-dialog-btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.sb-dialog-btn-primary:hover { opacity: 0.88; }
|
||||
|
||||
@@ -522,3 +522,241 @@
|
||||
.admin-content-body { padding: 16px; }
|
||||
.editor-tree { width: 180px; }
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
v0.23.1 — Three-section sidebar (Projects/Channels/Chats)
|
||||
===================================================== */
|
||||
|
||||
/* Scrollable nav container */
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.sidebar-nav::-webkit-scrollbar { width: 4px; }
|
||||
.sidebar-nav::-webkit-scrollbar-track { background: transparent; }
|
||||
.sidebar-nav::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
|
||||
/* Section wrapper */
|
||||
.sb-section { margin-bottom: 2px; }
|
||||
|
||||
/* Section header row */
|
||||
.sb-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 10px 4px 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.sb-section-header:hover .sb-section-add { opacity: 1; }
|
||||
.sidebar.collapsed .sb-section-header { justify-content: center; padding: 6px 0; }
|
||||
|
||||
.sb-section-arrow {
|
||||
font-size: 9px;
|
||||
color: var(--text-3);
|
||||
width: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar.collapsed .sb-section-arrow { display: none; }
|
||||
|
||||
.sb-section-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--text-3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.7px;
|
||||
flex: 1;
|
||||
}
|
||||
.sidebar.collapsed .sb-section-label { display: none; }
|
||||
|
||||
.sb-section-add {
|
||||
opacity: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: opacity 0.1s, color 0.1s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sb-section-add:hover { color: var(--text); }
|
||||
.sidebar.collapsed .sb-section-add { display: none; }
|
||||
|
||||
.sb-section-body { /* no extra padding — children handle it */ }
|
||||
|
||||
.sb-section-empty {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
padding: 4px 14px 8px;
|
||||
font-style: italic;
|
||||
}
|
||||
.sidebar.collapsed .sb-section-empty { display: none; }
|
||||
|
||||
/* ── Projects section items ── */
|
||||
|
||||
.sb-proj-group { margin-bottom: 1px; }
|
||||
.sb-proj-group.drag-over .sb-proj-header { background: var(--accent-dim); }
|
||||
|
||||
.sb-proj-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
margin: 0 4px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.sb-proj-header:hover { background: var(--bg-hover); }
|
||||
.sb-proj-group.active .sb-proj-header {
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.sb-proj-group.active .sb-proj-name { color: var(--accent); font-weight: 600; }
|
||||
.sb-proj-group.active .sb-proj-dot { background: var(--accent); }
|
||||
.sb-proj-pin { font-size: 11px; margin-left: auto; opacity: 0.75; flex-shrink: 0; }
|
||||
|
||||
.sidebar.collapsed .sb-proj-header { justify-content: center; padding: 5px 0; margin: 0 6px; }
|
||||
|
||||
.sb-proj-arrow { font-size: 9px; color: var(--text-3); flex-shrink: 0; }
|
||||
.sidebar.collapsed .sb-proj-arrow { display: none; }
|
||||
|
||||
.sb-proj-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--text-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sb-proj-name {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sidebar.collapsed .sb-proj-name { display: none; }
|
||||
|
||||
.sb-proj-count {
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
min-width: 14px;
|
||||
text-align: right;
|
||||
}
|
||||
.sidebar.collapsed .sb-proj-count { display: none; }
|
||||
|
||||
.sb-proj-menu {
|
||||
opacity: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
border-radius: 4px;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
.sb-proj-header:hover .sb-proj-menu { opacity: 1; }
|
||||
.sidebar.collapsed .sb-proj-menu { display: none; }
|
||||
|
||||
.sb-proj-empty {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
padding: 4px 10px 6px 28px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.sb-show-archived {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
padding: 4px 14px 6px;
|
||||
font-family: inherit;
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
.sb-show-archived:hover { color: var(--text-2); }
|
||||
|
||||
/* ── Channels section items ── */
|
||||
|
||||
.sb-channel-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 6px;
|
||||
margin: 0 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
position: relative;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.sb-channel-item:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.sb-channel-item.active { background: var(--bg-raised); color: var(--text); }
|
||||
.sidebar.collapsed .sb-channel-item { justify-content: center; padding: 5px 0; margin: 0 6px; }
|
||||
|
||||
.sb-ch-icon { color: var(--text-3); flex-shrink: 0; display: flex; }
|
||||
.sb-ch-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sidebar.collapsed .sb-ch-name { display: none; }
|
||||
|
||||
.sb-presence {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--text-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sb-presence.online { background: var(--success-light, #22c55e); }
|
||||
|
||||
.sb-unread {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 1px 5px;
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Chats section folder items ── */
|
||||
|
||||
.sb-folder-group { margin-bottom: 1px; }
|
||||
.sb-folder-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sb-folder-menu {
|
||||
display: none; background: none; border: none; cursor: pointer;
|
||||
color: var(--text-3); font-size: 14px; padding: 0 2px; line-height: 1;
|
||||
flex-shrink: 0; border-radius: 3px;
|
||||
}
|
||||
.sb-folder-menu:hover { color: var(--text); background: var(--bg-hover); }
|
||||
.sb-folder-header:hover .sb-folder-menu { display: block; }
|
||||
.sb-folder-empty { padding: 4px 14px 6px 28px; font-size: 11px; color: var(--text-3); font-style: italic; }
|
||||
|
||||
.sb-folder-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 6px;
|
||||
margin: 0 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-3);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.sb-folder-header:hover { background: var(--bg-hover); color: var(--text-2); }
|
||||
|
||||
.sb-folder-arrow { margin-left: auto; font-size: 9px; }
|
||||
|
||||
/* Indented chat items inside folders */
|
||||
.chat-item.indented { padding-left: 28px; }
|
||||
|
||||
@@ -240,6 +240,20 @@ const API = {
|
||||
},
|
||||
listProjectNotes(projectId) { return this._get(`/api/v1/projects/${projectId}/notes`); },
|
||||
|
||||
// ── Folders (v0.23.1) ────────────────────
|
||||
listFolders() { return this._get('/api/v1/folders'); },
|
||||
createFolder(name, sortOrder = 0) { return this._post('/api/v1/folders', { name, sort_order: sortOrder }); },
|
||||
updateFolder(id, data) { return this._put(`/api/v1/folders/${id}`, data); },
|
||||
deleteFolder(id) { return this._del(`/api/v1/folders/${id}`); },
|
||||
|
||||
// ── Sidebar helpers ───────────────────────
|
||||
listSidebarChannels(page = 1, perPage = 500) {
|
||||
return this._get(`/api/v1/channels?page=${page}&per_page=${perPage}&types=dm,channel`);
|
||||
},
|
||||
|
||||
// ── Presence ──────────────────────────────
|
||||
presenceHeartbeat() { return this._post('/api/v1/presence/heartbeat', {}); },
|
||||
|
||||
// ── Workspaces (v0.21.0) ────────────────
|
||||
getWorkspace(id) { return this._get(`/api/v1/workspaces/${id}`); },
|
||||
listWorkspaceFiles(wsId, path = '', recursive = false) {
|
||||
|
||||
@@ -21,6 +21,12 @@ const App = {
|
||||
collapsedProjects: {},
|
||||
activeProjectId: null,
|
||||
showArchivedProjects: false,
|
||||
// v0.23.1: Multi-user
|
||||
channels: [], // DMs + named channels
|
||||
currentChannelId: null,
|
||||
folders: [], // chat folders (user-scoped)
|
||||
collapsedFolders: {},
|
||||
presence: {}, // { userId: 'online' | 'offline' }
|
||||
|
||||
findModel(id) {
|
||||
if (!id) return null;
|
||||
|
||||
@@ -70,6 +70,10 @@ async function startApp() {
|
||||
UI.restoreSidebar();
|
||||
await loadSettings();
|
||||
|
||||
// v0.23.1: Load folders and channels before rendering sidebar
|
||||
await loadFolders();
|
||||
await loadChannels();
|
||||
|
||||
// Load extensions BEFORE chats so block renderers (mermaid, katex, csv, diff)
|
||||
// are registered when messages are first rendered.
|
||||
try {
|
||||
@@ -107,6 +111,8 @@ async function startApp() {
|
||||
console.error('[App] KnowledgeUI module not defined — knowledge-ui.js failed to load or parse');
|
||||
}
|
||||
UI.renderChatList();
|
||||
UI.renderChannelsSection();
|
||||
UI.restoreSidebarSections();
|
||||
UI.updateModelSelector();
|
||||
|
||||
// Restore last-active chat from sessionStorage
|
||||
|
||||
@@ -377,12 +377,6 @@ function _cleanStaleChatModel(chatId) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the new-chat dropdown and reset any fixed positioning from collapsed mode. */
|
||||
function closeNewChatMenu() {
|
||||
const dd = document.getElementById('newChatDropdown');
|
||||
if (dd) { dd.classList.remove('open'); dd.style.cssText = ''; }
|
||||
}
|
||||
|
||||
async function newChat() {
|
||||
clearStaged(); // Discard any staged files
|
||||
App.currentChatId = null;
|
||||
@@ -945,34 +939,7 @@ async function switchSibling(messageId, direction) {
|
||||
function _initChatListeners() {
|
||||
// Sidebar
|
||||
document.getElementById('sidebarToggle').addEventListener('click', UI.toggleSidebar);
|
||||
document.getElementById('newChatBtn').addEventListener('click', (e) => {
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
if (sidebar && sidebar.classList.contains('collapsed')) {
|
||||
// Collapsed: show the dropdown menu, positioned to pop out right
|
||||
const dd = document.getElementById('newChatDropdown');
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
dd.style.position = 'fixed';
|
||||
dd.style.left = rect.right + 4 + 'px';
|
||||
dd.style.top = rect.top + 'px';
|
||||
dd.style.width = '200px';
|
||||
dd.classList.toggle('open');
|
||||
} else {
|
||||
newChat();
|
||||
}
|
||||
});
|
||||
|
||||
// Split button dropdown
|
||||
document.getElementById('newChatDropBtn').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
document.getElementById('newChatDropdown').classList.toggle('open');
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.split-btn')) {
|
||||
const dd = document.getElementById('newChatDropdown');
|
||||
dd.classList.remove('open');
|
||||
dd.style.cssText = ''; // Reset any fixed positioning from collapsed mode
|
||||
}
|
||||
});
|
||||
document.getElementById('newChatBtn').addEventListener('click', () => newChat());
|
||||
|
||||
// User flyout
|
||||
document.getElementById('userMenuBtn').addEventListener('click', (e) => {
|
||||
|
||||
@@ -681,7 +681,9 @@ const EditorMode = {
|
||||
});
|
||||
return r; // true = save, false = discard
|
||||
}
|
||||
return window.confirm(`Save changes to ${path.split('/').pop()}?`);
|
||||
return window.showConfirm
|
||||
? showConfirm(`Save changes to ${path.split('/').pop()}?`, { ok: 'Save', cancel: 'Discard', danger: false })
|
||||
: Promise.resolve(true);
|
||||
},
|
||||
|
||||
_markModified(path) {
|
||||
@@ -768,7 +770,7 @@ const EditorMode = {
|
||||
// ── New File ─────────────────────────────
|
||||
|
||||
async _createNewFile() {
|
||||
const name = prompt('File name (e.g. notes.md, src/main.go):');
|
||||
const name = await showPrompt({ title: 'New File', placeholder: 'e.g. notes.md, src/main.go', ok: 'Create' });
|
||||
if (!name?.trim()) return;
|
||||
// Normalize: strip leading ./ and / — backend expects relative paths
|
||||
const path = name.trim().replace(/^\.?\/+/, '');
|
||||
|
||||
@@ -26,7 +26,7 @@ const Notifications = {
|
||||
|
||||
async _fetchUnreadCount() {
|
||||
try {
|
||||
const data = await API._get('/notifications/unread-count');
|
||||
const data = await API._get('/api/v1/notifications/unread-count');
|
||||
this._unreadCount = data.count || 0;
|
||||
this._renderBadge();
|
||||
} catch (e) {
|
||||
@@ -40,7 +40,7 @@ const Notifications = {
|
||||
const unreadOnly = opts.unreadOnly || false;
|
||||
try {
|
||||
const data = await API._get(
|
||||
`/notifications?limit=${limit}&offset=${offset}&unread_only=${unreadOnly}`);
|
||||
`/api/v1/notifications?limit=${limit}&offset=${offset}&unread_only=${unreadOnly}`);
|
||||
return data;
|
||||
} catch (e) {
|
||||
return { data: [], total: 0 };
|
||||
@@ -186,7 +186,7 @@ const Notifications = {
|
||||
|
||||
async _markRead(id) {
|
||||
try {
|
||||
await API._authed(`/notifications/${id}/read`, 'PATCH');
|
||||
await API._authed(`/api/v1/notifications/${id}/read`, 'PATCH');
|
||||
this._unreadCount = Math.max(0, this._unreadCount - 1);
|
||||
const item = this._items.find(n => n.id === id);
|
||||
if (item) item.is_read = true;
|
||||
@@ -196,7 +196,7 @@ const Notifications = {
|
||||
|
||||
async markAllRead() {
|
||||
try {
|
||||
await API._post('/notifications/mark-all-read');
|
||||
await API._post('/api/v1/notifications/mark-all-read');
|
||||
this._unreadCount = 0;
|
||||
this._items.forEach(n => n.is_read = true);
|
||||
this._renderBadge();
|
||||
@@ -312,7 +312,7 @@ const Notifications = {
|
||||
|
||||
async _deleteItem(id) {
|
||||
try {
|
||||
await API._del(`/notifications/${id}`);
|
||||
await API._del(`/api/v1/notifications/${id}`);
|
||||
// Check if it was unread before removal
|
||||
const item = this._panelItems.find(n => n.id === id);
|
||||
if (item && !item.is_read) {
|
||||
|
||||
@@ -166,7 +166,7 @@ const Pages = {
|
||||
},
|
||||
|
||||
async editUserRole(id, username, currentRole) {
|
||||
const role = prompt(`Role for ${username}:`, currentRole);
|
||||
const role = await showPrompt({ title: `Set role for ${username}`, value: currentRole, ok: 'Save' });
|
||||
if (!role || role === currentRole) return;
|
||||
const ok = await _api('PUT', '/api/v1/admin/users/' + id, { role });
|
||||
if (ok) window.location.reload();
|
||||
|
||||
@@ -50,18 +50,70 @@ function toggleProjectCollapse(projectId) {
|
||||
UI.renderChatList();
|
||||
}
|
||||
|
||||
// ── Creation Dialog (shared) ─────────────────
|
||||
// Lightweight modal for name + optional color. Returns {name, color} or null.
|
||||
function _showCreationDialog({ title, placeholder, withColor = false, defaultColor = '#3B82F6', defaultValue = '', confirmLabel = null }) {
|
||||
const btnLabel = confirmLabel || (defaultValue ? 'Rename' : 'Create');
|
||||
return new Promise(resolve => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'sb-dialog-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="sb-dialog" role="dialog" aria-modal="true">
|
||||
<div class="sb-dialog-title">${title}</div>
|
||||
<input id="sbDialogName" class="sb-dialog-input" type="text"
|
||||
placeholder="${placeholder}" autocomplete="off" maxlength="200" value="${defaultValue.replace(/"/g, '"')}">
|
||||
${withColor ? `
|
||||
<div class="sb-dialog-color-row">
|
||||
<label class="sb-dialog-color-label">Color</label>
|
||||
<input id="sbDialogColor" type="color" value="${defaultColor}" class="sb-dialog-color-input">
|
||||
</div>` : ''}
|
||||
<div class="sb-dialog-actions">
|
||||
<button id="sbDialogCancel" class="sb-dialog-btn">Cancel</button>
|
||||
<button id="sbDialogOK" class="sb-dialog-btn sb-dialog-btn-primary">${btnLabel}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
const nameEl = overlay.querySelector('#sbDialogName');
|
||||
const okBtn = overlay.querySelector('#sbDialogOK');
|
||||
const cancelEl = overlay.querySelector('#sbDialogCancel');
|
||||
setTimeout(() => { nameEl.focus(); nameEl.select(); }, 30);
|
||||
|
||||
function done(result) {
|
||||
overlay.remove();
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
okBtn.addEventListener('click', () => {
|
||||
const name = nameEl.value.trim();
|
||||
if (!name) { nameEl.focus(); return; }
|
||||
const color = withColor ? overlay.querySelector('#sbDialogColor').value : null;
|
||||
done({ name, color });
|
||||
});
|
||||
cancelEl.addEventListener('click', () => done(null));
|
||||
overlay.addEventListener('click', e => { if (e.target === overlay) done(null); });
|
||||
nameEl.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') okBtn.click();
|
||||
if (e.key === 'Escape') done(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
// ── Project CRUD ────────────────────────────
|
||||
|
||||
async function createProject() {
|
||||
const name = prompt('Project name:');
|
||||
if (!name || !name.trim()) return;
|
||||
const result = await _showCreationDialog({
|
||||
title: 'New Project',
|
||||
placeholder: 'Project name',
|
||||
withColor: true,
|
||||
});
|
||||
if (!result) return;
|
||||
try {
|
||||
const p = await API.createProject({ name: name.trim() });
|
||||
const p = await API.createProject({ name: result.name, color: result.color });
|
||||
App.projects.push({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description || '',
|
||||
color: p.color || null,
|
||||
color: p.color || result.color || null,
|
||||
icon: p.icon || null,
|
||||
channelCount: 0, kbCount: 0, noteCount: 0,
|
||||
isArchived: false,
|
||||
@@ -332,7 +384,7 @@ function showProjectMenu(e, projectId) {
|
||||
menu.className = 'project-ctx-menu';
|
||||
menu.innerHTML = `
|
||||
<button class="ctx-item" onclick="toggleActiveProject('${projectId}');dismissChatContextMenu()">
|
||||
<span class="ctx-icon">${isActive ? '📌' : '📌'}</span> ${activeLabel}
|
||||
<span class="ctx-icon">${isActive ? '📍' : '📌'}</span> ${activeLabel}
|
||||
</button>
|
||||
<button class="ctx-item" onclick="openProjectPanel('${projectId}');dismissChatContextMenu()">
|
||||
<span class="ctx-icon">⚙</span> Project settings
|
||||
@@ -1027,3 +1079,187 @@ async function _moveChatInProject(projectId, chatId, direction) {
|
||||
UI.toast('Failed to reorder', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// v0.23.1 — Channels + Folders
|
||||
// ==========================================
|
||||
|
||||
// ── Folders ──────────────────────────────────────────────────────────
|
||||
|
||||
async function loadFolders() {
|
||||
try {
|
||||
const resp = await API.listFolders();
|
||||
App.folders = (resp.folders || []).map(f => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
sortOrder: f.sort_order || 0,
|
||||
}));
|
||||
App.folders.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
} catch (e) {
|
||||
console.warn('[folders] load failed:', e.message);
|
||||
App.folders = [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Channels (DMs + named channels) ──────────────────────────────────
|
||||
|
||||
async function loadChannels() {
|
||||
try {
|
||||
const resp = await API.listSidebarChannels();
|
||||
App.channels = (resp.channels || []).map(normalizeChannel);
|
||||
if (typeof UI !== 'undefined') UI.renderChannelsSection();
|
||||
} catch (e) {
|
||||
console.warn('[channels] load failed:', e.message);
|
||||
App.channels = [];
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeChannel(ch) {
|
||||
return {
|
||||
id: ch.id,
|
||||
name: ch.title || ch.name || 'Untitled',
|
||||
type: ch.type, // 'dm' | 'channel'
|
||||
aiMode: ch.ai_mode || 'auto',
|
||||
topic: ch.topic || '',
|
||||
unread: ch.unread_count || 0,
|
||||
dmPartnerId: ch.dm_partner_id || null,
|
||||
updatedAt: ch.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
async function selectChannel(channelId) {
|
||||
App.currentChannelId = channelId;
|
||||
App.currentChatId = null; // clear chat selection
|
||||
if (typeof UI !== 'undefined') {
|
||||
UI.renderChannelsSection();
|
||||
UI.renderChatList();
|
||||
}
|
||||
// Load messages for this channel
|
||||
try {
|
||||
const resp = await API._get(`/api/v1/channels/${channelId}/messages`);
|
||||
const messages = resp.messages || [];
|
||||
if (typeof UI !== 'undefined') UI.renderMessages(messages);
|
||||
} catch (e) {
|
||||
console.warn('[channel] message load failed:', e.message);
|
||||
}
|
||||
try {
|
||||
sessionStorage.setItem('cs-active-channel', channelId);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function newChannelOrDM() {
|
||||
const result = await _showCreationDialog({
|
||||
title: 'New Channel',
|
||||
placeholder: 'Channel name',
|
||||
withColor: false,
|
||||
});
|
||||
if (!result) return;
|
||||
try {
|
||||
const raw = await API.createChannel(result.name, '', '', 'channel');
|
||||
const ch = normalizeChannel(raw);
|
||||
App.channels = App.channels || [];
|
||||
App.channels.push(ch);
|
||||
if (typeof UI !== 'undefined') UI.renderChannelsSection();
|
||||
UI.toast(`#${result.name} created`, 'success');
|
||||
} catch (e) {
|
||||
UI.toast('Failed to create channel', 'error');
|
||||
console.error('[newChannelOrDM]', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function newFolder() {
|
||||
const result = await _showCreationDialog({
|
||||
title: 'New Folder',
|
||||
placeholder: 'Folder name',
|
||||
withColor: false,
|
||||
});
|
||||
if (!result) return;
|
||||
try {
|
||||
const resp = await API.createFolder(result.name, App.folders.length);
|
||||
const f = resp.folder || resp;
|
||||
App.folders.push({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
sortOrder: f.sort_order ?? App.folders.length,
|
||||
});
|
||||
App.folders.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
if (typeof UI !== 'undefined') UI.renderChatList();
|
||||
UI.toast('Folder created', 'success');
|
||||
} catch (e) {
|
||||
UI.toast('Failed to create folder', 'error');
|
||||
console.error('[newFolder]', e);
|
||||
}
|
||||
}
|
||||
|
||||
function showFolderMenu(e, folderId) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dismissChatContextMenu();
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'project-ctx-menu';
|
||||
menu.innerHTML = `
|
||||
<button class="ctx-item" onclick="renameFolder('${folderId}');dismissChatContextMenu()">
|
||||
<span class="ctx-icon">✏</span> Rename
|
||||
</button>
|
||||
<div class="ctx-divider"></div>
|
||||
<button class="ctx-item ctx-danger" onclick="deleteFolder('${folderId}');dismissChatContextMenu()">
|
||||
<span class="ctx-icon">🗑</span> Delete folder
|
||||
</button>`;
|
||||
|
||||
menu.style.left = Math.min(e.clientX, window.innerWidth - 160) + 'px';
|
||||
menu.style.top = Math.min(e.clientY, window.innerHeight - 100) + 'px';
|
||||
document.body.appendChild(menu);
|
||||
_ctxMenu = menu;
|
||||
setTimeout(() => document.addEventListener('click', dismissChatContextMenu, { once: true }), 0);
|
||||
}
|
||||
|
||||
async function renameFolder(folderId) {
|
||||
const folder = (App.folders || []).find(f => f.id === folderId);
|
||||
if (!folder) return;
|
||||
const result = await _showCreationDialog({
|
||||
title: 'Rename Folder',
|
||||
placeholder: 'Folder name',
|
||||
withColor: false,
|
||||
defaultValue: folder.name,
|
||||
});
|
||||
if (!result || !result.name.trim() || result.name.trim() === folder.name) return;
|
||||
try {
|
||||
await API.updateFolder(folderId, { name: result.name.trim() });
|
||||
folder.name = result.name.trim();
|
||||
if (typeof UI !== 'undefined') UI.renderChatList();
|
||||
UI.toast('Folder renamed', 'success');
|
||||
} catch (e) {
|
||||
UI.toast('Failed to rename folder', 'error');
|
||||
console.error('[renameFolder]', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFolder(folderId) {
|
||||
const folder = (App.folders || []).find(f => f.id === folderId);
|
||||
if (!folder) return;
|
||||
// Move any chats in this folder back to unfiled before deleting
|
||||
(App.chats || []).forEach(c => { if (c.folderId === folderId) c.folderId = null; });
|
||||
try {
|
||||
await API.deleteFolder(folderId);
|
||||
App.folders = App.folders.filter(f => f.id !== folderId);
|
||||
if (typeof UI !== 'undefined') UI.renderChatList();
|
||||
UI.toast('Folder deleted', 'success');
|
||||
} catch (e) {
|
||||
UI.toast('Failed to delete folder', 'error');
|
||||
console.error('[deleteFolder]', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Presence heartbeat ────────────────────────────────────────────────
|
||||
|
||||
(function startPresenceHeartbeat() {
|
||||
const INTERVAL = 30000; // 30s
|
||||
async function beat() {
|
||||
if (!API.isAuthed) return;
|
||||
try {
|
||||
await API.presenceHeartbeat();
|
||||
} catch (_) { /* non-critical */ }
|
||||
}
|
||||
setInterval(beat, INTERVAL);
|
||||
})();
|
||||
|
||||
@@ -282,167 +282,268 @@ const UI = {
|
||||
document.getElementById('userFlyout').classList.remove('open');
|
||||
},
|
||||
|
||||
// ── Chat List ────────────────────────────
|
||||
// ── Sidebar Section Collapse ──────────────
|
||||
// State persisted in localStorage per section key.
|
||||
|
||||
renderChatList() {
|
||||
const el = document.getElementById('chatHistory');
|
||||
const searchInput = document.getElementById('chatSearchInput');
|
||||
const searchWrap = document.getElementById('sidebarSearch');
|
||||
const query = (searchInput?.value || '').trim().toLowerCase();
|
||||
_sidebarSections: null,
|
||||
|
||||
// Toggle clear button visibility
|
||||
if (searchWrap) searchWrap.classList.toggle('has-value', query.length > 0);
|
||||
|
||||
// Filter chats by search query
|
||||
let chats = App.chats;
|
||||
if (query) {
|
||||
chats = chats.filter(c =>
|
||||
(c.title || '').toLowerCase().includes(query)
|
||||
);
|
||||
_getSidebarSections() {
|
||||
if (!this._sidebarSections) {
|
||||
try {
|
||||
this._sidebarSections = JSON.parse(localStorage.getItem('cs-sidebar-sections') || '{}');
|
||||
} catch (_) { this._sidebarSections = {}; }
|
||||
}
|
||||
return this._sidebarSections;
|
||||
},
|
||||
|
||||
toggleSidebarSection(key) {
|
||||
const sections = this._getSidebarSections();
|
||||
sections[key] = !sections[key]; // true = collapsed
|
||||
try { localStorage.setItem('cs-sidebar-sections', JSON.stringify(sections)); } catch (_) {}
|
||||
this._applySidebarSection(key, sections[key]);
|
||||
},
|
||||
|
||||
_applySidebarSection(key, collapsed) {
|
||||
const body = document.getElementById('sbBody' + key[0].toUpperCase() + key.slice(1));
|
||||
const arrow = document.getElementById('sbArrow' + key[0].toUpperCase() + key.slice(1));
|
||||
if (body) body.style.display = collapsed ? 'none' : '';
|
||||
if (arrow) arrow.textContent = collapsed ? '▸' : '▾';
|
||||
},
|
||||
|
||||
restoreSidebarSections() {
|
||||
const sections = this._getSidebarSections();
|
||||
['projects', 'channels', 'chats'].forEach(k => {
|
||||
this._applySidebarSection(k, !!sections[k]);
|
||||
});
|
||||
},
|
||||
|
||||
// ── Projects Section ──────────────────────
|
||||
|
||||
renderProjectsSection() {
|
||||
const el = document.getElementById('sbBodyProjects');
|
||||
if (!el) return;
|
||||
|
||||
const projects = App.projects || [];
|
||||
const showArchived = App.showArchivedProjects || false;
|
||||
const visible = showArchived ? projects : projects.filter(p => !p.isArchived);
|
||||
const hasArchived = projects.some(p => p.isArchived);
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const collapsed = sidebar?.classList.contains('collapsed');
|
||||
|
||||
if (chats.length === 0 && projects.length === 0 && query) {
|
||||
el.innerHTML = `<div class="sidebar-search-empty">No chats matching "${esc(query)}"</div>`;
|
||||
return;
|
||||
}
|
||||
if (chats.length === 0 && projects.length === 0) {
|
||||
el.innerHTML = '<div class="sidebar-empty">No conversations yet</div>';
|
||||
if (visible.length === 0 && !hasArchived) {
|
||||
el.innerHTML = collapsed ? '' :
|
||||
'<div class="sb-section-empty">No projects yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
visible.forEach(proj => {
|
||||
const isCollapsed = App.collapsedProjects?.[proj.id];
|
||||
const isActive = App.activeProjectId === proj.id;
|
||||
const dot = proj.color
|
||||
? `<span class="sb-proj-dot" style="background:${esc(proj.color)}"></span>`
|
||||
: '<span class="sb-proj-dot"></span>';
|
||||
const arrow = isCollapsed ? '▸' : '▾';
|
||||
const pinIcon = isActive ? '<span class="sb-proj-pin" title="Pinned for new chats">📍</span>' : '';
|
||||
const chats = (App.chats || []).filter(c => c.projectId === proj.id);
|
||||
|
||||
// ── Chat item renderer ──────────────────
|
||||
const renderChatItem = (c) => {
|
||||
const time = _relativeTime(c.updatedAt);
|
||||
const typeIcon = c.type === 'group' ? '<span class="chat-type-icon" title="Group Chat">👥</span> '
|
||||
: c.type === 'workflow' ? '<span class="chat-type-icon" title="Workflow">⚙</span> ' : '';
|
||||
return `
|
||||
<div class="chat-item ${c.id === App.currentChatId ? 'active' : ''}"
|
||||
html += `<div class="sb-proj-group${isActive ? ' active' : ''}${proj.isArchived ? ' archived' : ''}"
|
||||
ondragover="onProjectDragOver(event)"
|
||||
ondragleave="onProjectDragLeave(event)"
|
||||
ondrop="onProjectDrop(event,'${proj.id}')">
|
||||
<div class="sb-proj-header" onclick="toggleProjectCollapse('${proj.id}')">
|
||||
${collapsed ? dot : `<span class="sb-proj-arrow">${arrow}</span>${dot}<span class="sb-proj-name">${esc(proj.name)}</span>${pinIcon}`}
|
||||
${!collapsed ? `<span class="sb-proj-count">${chats.length}</span>
|
||||
<button class="sb-proj-menu" onclick="event.stopPropagation();showProjectMenu(event,'${proj.id}')" title="Options">⋯</button>` : ''}
|
||||
</div>`;
|
||||
|
||||
if (!isCollapsed && !collapsed) {
|
||||
if (chats.length === 0) {
|
||||
html += '<div class="sb-proj-empty">Drop chats here</div>';
|
||||
} else {
|
||||
chats.forEach(c => { html += this._chatItemHTML(c); });
|
||||
}
|
||||
}
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
if (hasArchived && !collapsed) {
|
||||
html += `<button class="sb-show-archived" onclick="App.showArchivedProjects=!App.showArchivedProjects;UI.renderProjectsSection()">
|
||||
${showArchived ? '▾ Hide archived' : `▸ Show archived (${projects.filter(p => p.isArchived).length})`}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
},
|
||||
|
||||
// ── Channels Section ──────────────────────
|
||||
|
||||
renderChannelsSection() {
|
||||
const el = document.getElementById('sbBodyChannels');
|
||||
if (!el) return;
|
||||
|
||||
const channels = App.channels || [];
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const collapsed = sidebar?.classList.contains('collapsed');
|
||||
|
||||
if (channels.length === 0) {
|
||||
el.innerHTML = collapsed ? '' :
|
||||
'<div class="sb-section-empty">No channels yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
channels.forEach(ch => {
|
||||
const isActive = App.currentChannelId === ch.id;
|
||||
const isDM = ch.type === 'dm';
|
||||
const isChannel = ch.type === 'channel';
|
||||
const online = App.presence?.[ch.dmPartnerId] === 'online';
|
||||
|
||||
// Icon: @ for DM, # for channel
|
||||
const icon = isDM
|
||||
? `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`
|
||||
: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/></svg>`;
|
||||
|
||||
const onlineDot = isDM && online
|
||||
? '<span class="sb-presence online"></span>'
|
||||
: '';
|
||||
const unreadBadge = ch.unread > 0
|
||||
? `<span class="sb-unread">${ch.unread > 99 ? '99+' : ch.unread}</span>`
|
||||
: '';
|
||||
|
||||
html += `<div class="sb-channel-item${isActive ? ' active' : ''}"
|
||||
onclick="selectChannel('${ch.id}')"
|
||||
title="${esc(ch.name)}">
|
||||
<span class="sb-ch-icon">${icon}</span>
|
||||
${!collapsed ? `<span class="sb-ch-name">${esc(ch.name)}</span>${onlineDot}${unreadBadge}` : unreadBadge}
|
||||
</div>`;
|
||||
});
|
||||
|
||||
el.innerHTML = html;
|
||||
},
|
||||
|
||||
// ── Chats Section (renderChatList) ────────
|
||||
|
||||
renderChatList() {
|
||||
// Target the new section body; fall back to legacy id during transition.
|
||||
const el = document.getElementById('sbBodyChats') || document.getElementById('chatHistory');
|
||||
if (!el) return;
|
||||
|
||||
const searchInput = document.getElementById('chatSearchInput');
|
||||
const searchWrap = document.getElementById('sidebarSearch');
|
||||
const query = (searchInput?.value || '').trim().toLowerCase();
|
||||
if (searchWrap) searchWrap.classList.toggle('has-value', query.length > 0);
|
||||
|
||||
// Chats section shows only direct/group chats not in a project.
|
||||
// type='channel' and type='dm' belong to the Channels section (App.channels).
|
||||
// Project chats rendered by renderProjectsSection.
|
||||
let chats = (App.chats || []).filter(c =>
|
||||
!c.projectId &&
|
||||
c.type !== 'channel' &&
|
||||
c.type !== 'dm'
|
||||
);
|
||||
if (query) chats = chats.filter(c => (c.title || '').toLowerCase().includes(query));
|
||||
|
||||
const folders = App.folders || [];
|
||||
|
||||
if (chats.length === 0 && folders.length === 0) {
|
||||
el.innerHTML = query
|
||||
? `<div class="sb-section-empty">No chats matching "${esc(query)}"</div>`
|
||||
: '<div class="sb-section-empty">No chats yet</div>';
|
||||
// Still re-render projects in case search is cross-cutting
|
||||
this.renderProjectsSection();
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by folder, then by time for unfiled
|
||||
const folderMap = {};
|
||||
folders.forEach(f => { folderMap[f.id] = []; });
|
||||
|
||||
const unfiled = [];
|
||||
chats.forEach(c => {
|
||||
if (c.folderId && folderMap[c.folderId]) {
|
||||
folderMap[c.folderId].push(c);
|
||||
} else {
|
||||
unfiled.push(c);
|
||||
}
|
||||
});
|
||||
|
||||
let html = '';
|
||||
|
||||
// Folder groups — always render, even when empty
|
||||
folders.forEach(f => {
|
||||
const items = folderMap[f.id] || [];
|
||||
const isOpen = !App.collapsedFolders?.[f.id];
|
||||
html += `<div class="sb-folder-group">
|
||||
<div class="sb-folder-header" onclick="UI.toggleFolder('${f.id}')">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
|
||||
</svg>
|
||||
<span class="sb-folder-name">${esc(f.name)}</span>
|
||||
<span class="sb-folder-arrow">${isOpen ? '▾' : '▸'}</span>
|
||||
<button class="sb-folder-menu" onclick="event.stopPropagation();showFolderMenu(event,'${f.id}')" title="Folder options">⋯</button>
|
||||
</div>
|
||||
${isOpen
|
||||
? (items.length > 0
|
||||
? items.map(c => this._chatItemHTML(c, true)).join('')
|
||||
: '<div class="sb-folder-empty">Drop chats here</div>')
|
||||
: ''}
|
||||
</div>`;
|
||||
});
|
||||
|
||||
// Unfiled chats grouped by time
|
||||
const now = Date.now();
|
||||
const DAY = 86400000;
|
||||
const groups = { today: [], yesterday: [], week: [], older: [] };
|
||||
unfiled.forEach(c => {
|
||||
const age = now - new Date(c.updatedAt).getTime();
|
||||
if (age < DAY) groups.today.push(c);
|
||||
else if (age < 2*DAY) groups.yesterday.push(c);
|
||||
else if (age < 7*DAY) groups.week.push(c);
|
||||
else groups.older.push(c);
|
||||
});
|
||||
|
||||
const renderTimeGroup = (label, items) => {
|
||||
if (!items.length) return;
|
||||
html += `<div class="chat-group-label">${label}</div>`;
|
||||
items.forEach(c => { html += this._chatItemHTML(c); });
|
||||
};
|
||||
|
||||
renderTimeGroup('Today', groups.today);
|
||||
renderTimeGroup('Yesterday', groups.yesterday);
|
||||
renderTimeGroup('Previous 7 days', groups.week);
|
||||
renderTimeGroup('Older', groups.older);
|
||||
|
||||
el.innerHTML = html;
|
||||
|
||||
// Always keep projects section in sync
|
||||
this.renderProjectsSection();
|
||||
},
|
||||
|
||||
toggleFolder(folderId) {
|
||||
if (!App.collapsedFolders) App.collapsedFolders = {};
|
||||
App.collapsedFolders[folderId] = !App.collapsedFolders[folderId];
|
||||
this.renderChatList();
|
||||
},
|
||||
|
||||
_chatItemHTML(c, indented = false) {
|
||||
const time = _relativeTime(c.updatedAt);
|
||||
const active = c.id === App.currentChatId ? ' active' : '';
|
||||
const indent = indented ? ' indented' : '';
|
||||
const typeIcon = c.type === 'group'
|
||||
? '<span class="chat-type-icon" title="Group Chat">👥</span> '
|
||||
: c.type === 'workflow' ? '<span class="chat-type-icon" title="Workflow">⚙</span> ' : '';
|
||||
return `<div class="chat-item${active}${indent}"
|
||||
onclick="selectChat('${c.id}')"
|
||||
oncontextmenu="showChatContextMenu(event,'${c.id}')"
|
||||
draggable="true"
|
||||
ondragstart="onChatDragStart(event,'${c.id}')"
|
||||
ondragend="onChatDragEnd(event)">
|
||||
<span class="chat-item-title" ondblclick="startRenameChat('${c.id}');event.stopPropagation();" title="Double-click to rename">${typeIcon}${esc(c.title)}</span>
|
||||
<span class="chat-item-time">${time}</span>
|
||||
<button class="chat-item-delete" onclick="deleteChat('${c.id}');event.stopPropagation();" title="Delete">✕</button>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
// ── Project groups ──────────────────────
|
||||
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
|
||||
? `<span class="project-dot" style="background:${esc(proj.color)}"></span>`
|
||||
: '<span class="project-dot"></span>';
|
||||
const arrow = isCollapsed ? '▸' : '▾';
|
||||
|
||||
const isActive = App.activeProjectId === proj.id;
|
||||
|
||||
const groupClasses = 'project-group' + (isActive ? ' active' : '') + (proj.isArchived ? ' archived' : '');
|
||||
|
||||
html += `<div class="${groupClasses}"
|
||||
ondragover="onProjectDragOver(event)"
|
||||
ondragleave="onProjectDragLeave(event)"
|
||||
ondrop="onProjectDrop(event,'${proj.id}')">
|
||||
<div class="project-header" onclick="toggleProjectCollapse('${proj.id}')">
|
||||
<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>`;
|
||||
|
||||
if (!isCollapsed) {
|
||||
if (projChats.length === 0) {
|
||||
html += '<div class="project-empty">Drop chats here</div>';
|
||||
} else {
|
||||
projChats.forEach(c => { html += renderChatItem(c); });
|
||||
}
|
||||
}
|
||||
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 ───────────
|
||||
// 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 || visibleProjects.length > 0) {
|
||||
// Only show "Recent" label if there are also projects
|
||||
if (visibleProjects.length > 0) {
|
||||
html += `<div class="recent-section"
|
||||
ondragover="onProjectDragOver(event)"
|
||||
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>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group recent by time
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today - 86400000);
|
||||
const weekAgo = new Date(today - 7 * 86400000);
|
||||
|
||||
const groups = { today: [], yesterday: [], week: [], older: [] };
|
||||
|
||||
recentChats.forEach(c => {
|
||||
const d = new Date(c.updatedAt);
|
||||
if (d >= today) groups.today.push(c);
|
||||
else if (d >= yesterday) groups.yesterday.push(c);
|
||||
else if (d >= weekAgo) groups.week.push(c);
|
||||
else groups.older.push(c);
|
||||
});
|
||||
|
||||
const renderGroup = (label, items) => {
|
||||
if (items.length === 0) return;
|
||||
if (visibleProjects.length > 0) {
|
||||
html += `<div class="chat-group-label chat-time-label">${label}</div>`;
|
||||
} else {
|
||||
html += `<div class="chat-group-label">${label}</div>`;
|
||||
}
|
||||
items.forEach(c => { html += renderChatItem(c); });
|
||||
};
|
||||
|
||||
renderGroup('Today', groups.today);
|
||||
renderGroup('Yesterday', groups.yesterday);
|
||||
renderGroup('Previous 7 days', groups.week);
|
||||
renderGroup('Older', groups.older);
|
||||
|
||||
if (visibleProjects.length > 0 && recentChats.length > 0) {
|
||||
html += '</div>'; // close recent-section
|
||||
} else if (visibleProjects.length > 0 && recentChats.length === 0) {
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
<span class="chat-item-title" ondblclick="startRenameChat('${c.id}');event.stopPropagation();"
|
||||
title="Double-click to rename">${typeIcon}${esc(c.title)}</span>
|
||||
<span class="chat-item-time">${time}</span>
|
||||
<button class="chat-item-delete" onclick="deleteChat('${c.id}');event.stopPropagation();" title="Delete">✕</button>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
// ── Messages ─────────────────────────────
|
||||
|
||||
@@ -792,6 +792,74 @@ function showConfirm(message, opts = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
// §8b PROMPT DIALOG
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Custom prompt dialog replacing native prompt().
|
||||
* Returns a Promise<string|null> — null means cancelled or empty.
|
||||
*
|
||||
* @param {Object} opts
|
||||
* @param {string} [opts.title='Enter value']
|
||||
* @param {string} [opts.label] - Optional helper text below title
|
||||
* @param {string} [opts.value=''] - Pre-filled value
|
||||
* @param {string} [opts.placeholder='']
|
||||
* @param {string} [opts.ok='OK']
|
||||
* @param {string} [opts.cancel='Cancel']
|
||||
* @param {string} [opts.inputType='text']
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
function showPrompt(opts = {}) {
|
||||
return new Promise(resolve => {
|
||||
const title = opts.title || 'Enter value';
|
||||
const label = opts.label || '';
|
||||
const defVal = opts.value || '';
|
||||
const ph = opts.placeholder || '';
|
||||
const okText = opts.ok || 'OK';
|
||||
const caText = opts.cancel || 'Cancel';
|
||||
const iType = opts.inputType || 'text';
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'confirm-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="confirm-dialog">
|
||||
<div class="confirm-header">${esc(title)}</div>
|
||||
${label ? `<div class="confirm-body">${esc(label)}</div>` : ''}
|
||||
<div class="prompt-input-wrap">
|
||||
<input class="prompt-input" type="${esc(iType)}"
|
||||
value="${esc(defVal)}" placeholder="${esc(ph)}" autocomplete="off">
|
||||
</div>
|
||||
<div class="confirm-footer">
|
||||
<button class="btn-small" data-action="cancel">${esc(caText)}</button>
|
||||
<button class="btn-small btn-primary" data-action="ok">${esc(okText)}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const input = overlay.querySelector('.prompt-input');
|
||||
|
||||
function close(result) {
|
||||
overlay.remove();
|
||||
document.removeEventListener('keydown', onKey);
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
function onKey(e) {
|
||||
if (e.key === 'Escape') { close(null); }
|
||||
if (e.key === 'Enter') { const v = input.value.trim(); close(v || null); }
|
||||
}
|
||||
|
||||
overlay.querySelector('[data-action="ok"]').addEventListener('click', () => {
|
||||
close(input.value.trim() || null);
|
||||
});
|
||||
overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => close(null));
|
||||
overlay.addEventListener('click', e => { if (e.target === overlay) close(null); });
|
||||
document.addEventListener('keydown', onKey);
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
requestAnimationFrame(() => { input.focus(); if (defVal) input.select(); });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Modal open/close ────────────────────────
|
||||
// Generic modal show/hide used by settings, admin, debug, team-admin modals.
|
||||
// Moved here from app.js so all surfaces (not just chat) can use modals.
|
||||
|
||||
Reference in New Issue
Block a user