Changeset 0.23.2 (#155)
This commit is contained in:
@@ -191,6 +191,11 @@
|
||||
border-radius: 4px;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
/* v0.23.2: User mention pill — teal to distinguish from AI mentions */
|
||||
.mention-pill.mention-user {
|
||||
color: #2dd4bf;
|
||||
background: rgba(45,212,191,0.12);
|
||||
}
|
||||
|
||||
/* ── Model Attribution (multi-model messages) ─ */
|
||||
|
||||
|
||||
@@ -32,6 +32,48 @@
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
}
|
||||
|
||||
/* v0.23.2: Channel context banner */
|
||||
.channel-context-banner {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
background: var(--bg-raised);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.channel-context-banner .ctx-mode {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.channel-context-banner .ctx-mode-btn {
|
||||
cursor: pointer; border: 1px solid var(--border);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.channel-context-banner .ctx-mode-btn:hover {
|
||||
background: var(--bg-hover); color: var(--text);
|
||||
}
|
||||
.channel-context-banner .ctx-topic {
|
||||
color: var(--text-2); font-size: 12px;
|
||||
}
|
||||
.channel-context-banner .ctx-hint {
|
||||
color: var(--text-3);
|
||||
font-style: italic;
|
||||
}
|
||||
.ctx-participants-btn {
|
||||
margin-left: auto;
|
||||
background: none; border: 1px solid var(--border); border-radius: 4px;
|
||||
color: var(--text-2); font-size: 11px; padding: 2px 8px; cursor: pointer;
|
||||
}
|
||||
.ctx-participants-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.hover-bg:hover { background: var(--bg-hover); }
|
||||
|
||||
/* Message container (scrollable) */
|
||||
.msg-container {
|
||||
flex: 1; overflow-y: auto; scroll-behavior: smooth;
|
||||
@@ -171,6 +213,16 @@
|
||||
.message.user .msg-avatar { background: var(--accent-dim); }
|
||||
.message.assistant .msg-avatar { background: var(--bg-raised); }
|
||||
|
||||
/* Other human's messages — left-aligned like assistant, teal accent */
|
||||
.message.other-user .msg-body {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px 16px 16px 4px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.message.other-user .msg-role { color: #2dd4bf; }
|
||||
.message.other-user .msg-avatar { background: rgba(45,212,191,0.12); color: #2dd4bf; }
|
||||
|
||||
.msg-body { flex: 1; min-width: 0; }
|
||||
.msg-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||||
.msg-role { font-size: 13px; font-weight: 600; }
|
||||
|
||||
@@ -705,6 +705,14 @@
|
||||
.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-menu {
|
||||
visibility: hidden; background: none; border: none; color: var(--text-3); cursor: pointer;
|
||||
font-size: 14px; padding: 0 4px; line-height: 1; border-radius: 3px; margin-left: auto;
|
||||
}
|
||||
.sb-ch-menu:hover { color: var(--text); background: var(--bg-hover); }
|
||||
.sb-channel-item:hover .sb-ch-menu { visibility: visible; }
|
||||
.sidebar.collapsed .sb-ch-menu { display: none; }
|
||||
|
||||
.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; }
|
||||
@@ -731,14 +739,15 @@
|
||||
/* ── Chats section folder items ── */
|
||||
|
||||
.sb-folder-group { margin-bottom: 1px; }
|
||||
.sb-folder-group.drag-over .sb-folder-header { background: var(--accent-dim); }
|
||||
.sb-folder-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sb-folder-menu {
|
||||
display: none; background: none; border: none; cursor: pointer;
|
||||
visibility: hidden; 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-header:hover .sb-folder-menu { visibility: visible; }
|
||||
.sb-folder-empty { padding: 4px 14px 6px 28px; font-size: 11px; color: var(--text-3); font-style: italic; }
|
||||
|
||||
.sb-folder-header {
|
||||
@@ -756,7 +765,13 @@
|
||||
}
|
||||
.sb-folder-header:hover { background: var(--bg-hover); color: var(--text-2); }
|
||||
|
||||
.sb-folder-arrow { margin-left: auto; font-size: 9px; }
|
||||
.sb-folder-arrow { font-size: 9px; flex-shrink: 0; width: 12px; text-align: center; }
|
||||
|
||||
/* v0.23.2: Drop zone for chats section */
|
||||
#sbBodyChats.drag-over { outline: 2px dashed var(--accent); outline-offset: -2px; border-radius: 4px; }
|
||||
.sb-unfiled-zone { min-height: 30px; border-radius: 4px; margin-top: 2px; }
|
||||
.sb-unfiled-zone.drag-over { outline: 2px dashed var(--accent); outline-offset: -2px; }
|
||||
.sb-unfiled-hint { padding: 6px 14px; font-size: 11px; color: var(--text-3); font-style: italic; }
|
||||
|
||||
/* Indented chat items inside folders */
|
||||
.chat-item.indented { padding-left: 28px; }
|
||||
|
||||
@@ -220,6 +220,13 @@
|
||||
SCAFFOLDING.stats =
|
||||
'<div id="adminStats" class="stat-cards-grid"><div class="loading">Loading...</div></div>';
|
||||
|
||||
SCAFFOLDING.channels =
|
||||
'<div class="stat-cards-grid" style="margin-bottom:16px">' +
|
||||
'<div class="stat-card"><div class="stat-value" id="adminArchivedCount">—</div><div class="stat-label">Archived</div></div>' +
|
||||
'<div class="stat-card"><div class="stat-value" id="adminRetentionMode">—</div><div class="stat-label">Retention Mode</div></div>' +
|
||||
'</div>' +
|
||||
'<div id="adminArchivedList" class="admin-list"><div class="loading">Loading...</div></div>';
|
||||
|
||||
|
||||
// === Add button actions ==============================================
|
||||
|
||||
@@ -272,7 +279,7 @@
|
||||
users:'people', teams:'people', groups:'people',
|
||||
providers:'ai', models:'ai', personas:'ai', roles:'ai', knowledgeBases:'ai', memory:'ai',
|
||||
health:'routing', routing:'routing', capabilities:'routing',
|
||||
settings:'system', storage:'system', extensions:'system',
|
||||
settings:'system', storage:'system', extensions:'system', channels:'system',
|
||||
usage:'monitoring', audit:'monitoring', stats:'monitoring',
|
||||
};
|
||||
var cat = catMap[section] || 'people';
|
||||
@@ -280,7 +287,7 @@
|
||||
people: [{id:'users',l:'Users'},{id:'teams',l:'Teams'},{id:'groups',l:'Groups'}],
|
||||
ai: [{id:'providers',l:'Providers'},{id:'models',l:'Models'},{id:'personas',l:'Personas'},{id:'roles',l:'Roles'},{id:'knowledgeBases',l:'Knowledge'},{id:'memory',l:'Memory'}],
|
||||
routing: [{id:'health',l:'Health'},{id:'routing',l:'Routing'},{id:'capabilities',l:'Capabilities'}],
|
||||
system: [{id:'settings',l:'Settings'},{id:'storage',l:'Storage'},{id:'extensions',l:'Extensions'}],
|
||||
system: [{id:'settings',l:'Settings'},{id:'storage',l:'Storage'},{id:'extensions',l:'Extensions'},{id:'channels',l:'Channels'}],
|
||||
monitoring: [{id:'usage',l:'Usage'},{id:'audit',l:'Audit'},{id:'stats',l:'Stats'}],
|
||||
};
|
||||
|
||||
|
||||
@@ -187,6 +187,7 @@ const API = {
|
||||
updateChannel(id, updates) { return this._put(`/api/v1/channels/${id}`, updates); },
|
||||
generateTitle(id) { return this._post(`/api/v1/channels/${id}/generate-title`, {}); },
|
||||
deleteChannel(id) { return this._del(`/api/v1/channels/${id}`); },
|
||||
markRead(id) { return this._post(`/api/v1/channels/${id}/mark-read`, {}); },
|
||||
|
||||
// Channel models (v0.20.0 — multi-model @mention routing)
|
||||
listChannelModels(channelId) { return this._get(`/api/v1/channels/${channelId}/models`); },
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
const App = {
|
||||
chats: [],
|
||||
currentChatId: null,
|
||||
models: [],
|
||||
hiddenModels: new Set(),
|
||||
isGenerating: false,
|
||||
@@ -21,12 +20,32 @@ const App = {
|
||||
collapsedProjects: {},
|
||||
activeProjectId: null,
|
||||
showArchivedProjects: false,
|
||||
// v0.23.2: Unified active conversation
|
||||
activeConversation: null, // { id, type } where type = direct|dm|group|channel
|
||||
// v0.23.1: Multi-user
|
||||
channels: [], // DMs + named channels
|
||||
currentChannelId: null,
|
||||
channels: [], // DMs + named channels (sidebar display data)
|
||||
folders: [], // chat folders (user-scoped)
|
||||
collapsedFolders: {},
|
||||
presence: {}, // { userId: 'online' | 'offline' }
|
||||
users: [], // v0.23.2: [{id, username, displayName}] for @mention autocomplete
|
||||
|
||||
/** Get active conversation ID (shorthand). */
|
||||
get activeId() { return this.activeConversation?.id || null; },
|
||||
|
||||
/** Get active conversation type. */
|
||||
get activeType() { return this.activeConversation?.type || null; },
|
||||
|
||||
/** Set the active conversation. Clears if id is null. */
|
||||
setActive(id, type) {
|
||||
this.activeConversation = id ? { id, type: type || 'direct' } : null;
|
||||
},
|
||||
|
||||
/** Find the active conversation's chat object (with cached messages). */
|
||||
getActiveChat() {
|
||||
const id = this.activeConversation?.id;
|
||||
if (!id) return null;
|
||||
return this.chats.find(c => c.id === id) || null;
|
||||
},
|
||||
|
||||
findModel(id) {
|
||||
if (!id) return null;
|
||||
|
||||
@@ -93,6 +93,9 @@ async function startApp() {
|
||||
UI.updateCapabilityBadges();
|
||||
}
|
||||
|
||||
// v0.23.2: Load user list for @mention autocomplete
|
||||
await loadUsers();
|
||||
|
||||
// Guard: if token was invalidated during startup (401 → refresh fail → clearTokens),
|
||||
// kick back to login instead of rendering a broken UI.
|
||||
if (!API.isAuthed) {
|
||||
@@ -103,6 +106,16 @@ async function startApp() {
|
||||
|
||||
await initBanners();
|
||||
initFiles();
|
||||
|
||||
// v0.23.2: Create primary ChatPane instance from server-rendered mount points
|
||||
if (typeof ChatPane !== 'undefined') {
|
||||
ChatPane.primary = ChatPane.create({
|
||||
id: 'main',
|
||||
messagesEl: document.getElementById('chatMessages'),
|
||||
inputEl: document.getElementById('chatInputBar'),
|
||||
standalone: false,
|
||||
});
|
||||
}
|
||||
if (typeof ToolsToggle !== 'undefined') ToolsToggle.init();
|
||||
if (typeof REPL !== 'undefined') REPL.init(); // v0.21.3: admin-gated REPL tab
|
||||
if (typeof KnowledgeUI !== 'undefined') {
|
||||
@@ -115,16 +128,25 @@ async function startApp() {
|
||||
UI.restoreSidebarSections();
|
||||
UI.updateModelSelector();
|
||||
|
||||
// Restore last-active chat from sessionStorage
|
||||
// Restore last-active conversation from sessionStorage
|
||||
try {
|
||||
const savedChat = sessionStorage.getItem('cs-active-chat');
|
||||
if (savedChat && App.chats.some(c => c.id === savedChat)) {
|
||||
selectChat(savedChat);
|
||||
const saved = sessionStorage.getItem('cs-active-conversation');
|
||||
if (saved) {
|
||||
const ac = JSON.parse(saved);
|
||||
if (ac?.id) {
|
||||
const inChats = App.chats.some(c => c.id === ac.id);
|
||||
const inChannels = (App.channels || []).some(c => c.id === ac.id);
|
||||
if (inChats) {
|
||||
selectChat(ac.id);
|
||||
} else if (inChannels) {
|
||||
selectChannel(ac.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// If no chat was restored, show the welcome/empty state
|
||||
if (!App.currentChatId) {
|
||||
// If no conversation was restored, show the welcome/empty state
|
||||
if (!App.activeId) {
|
||||
UI.showEmptyState();
|
||||
}
|
||||
|
||||
@@ -234,7 +256,7 @@ async function handleLogout() {
|
||||
Events.clear();
|
||||
await API.logout();
|
||||
App.chats = [];
|
||||
App.currentChatId = null;
|
||||
App.setActive(null);
|
||||
location.reload();
|
||||
}
|
||||
|
||||
|
||||
@@ -206,22 +206,41 @@ const ChannelModels = {
|
||||
|
||||
// Build match list from ALL enabled models + personas (not just roster)
|
||||
const allModels = (App.models || []).filter(m => !m.hidden);
|
||||
const matches = allModels.filter(m => {
|
||||
// Match against persona handle
|
||||
const modelMatches = allModels.filter(m => {
|
||||
const handle = (m.personaHandle || '').toLowerCase().replace(/-/g, ' ');
|
||||
// Match against model ID
|
||||
const modelId = (m.baseModelId || m.id || '').toLowerCase().replace(/-/g, ' ');
|
||||
// Match against display name
|
||||
const name = (m.name || '').toLowerCase().replace(/-/g, ' ');
|
||||
const firstName = name.split(' ')[0] || '';
|
||||
|
||||
if (partial.length === 0) return true; // show all on bare @
|
||||
if (partial.length === 0) return true;
|
||||
return handle.startsWith(partial)
|
||||
|| modelId.startsWith(partial)
|
||||
|| name.startsWith(partial)
|
||||
|| firstName.startsWith(partial);
|
||||
});
|
||||
|
||||
// v0.23.2: Include users in autocomplete (after personas, before models)
|
||||
const userMatches = (App.users || []).filter(u => {
|
||||
const uname = u.username.toLowerCase();
|
||||
const dname = (u.displayName || '').toLowerCase();
|
||||
if (partial.length === 0) return true;
|
||||
return uname.startsWith(partial) || dname.startsWith(partial);
|
||||
}).map(u => ({
|
||||
id: u.username,
|
||||
name: u.displayName || u.username,
|
||||
baseModelId: u.username,
|
||||
isPersona: false,
|
||||
isUser: true,
|
||||
personaHandle: null,
|
||||
personaAvatar: null,
|
||||
provider: '',
|
||||
}));
|
||||
|
||||
// Resolution order: personas first, then users, then models
|
||||
const personas = modelMatches.filter(m => m.isPersona);
|
||||
const models = modelMatches.filter(m => !m.isPersona);
|
||||
const matches = [...personas, ...userMatches, ...models];
|
||||
|
||||
if (matches.length === 0) { this.hideAutocomplete(); return; }
|
||||
// Cap at 10 to keep dropdown manageable
|
||||
this.showAutocomplete(inputEl, atIdx, cursorPos, matches.slice(0, 10));
|
||||
@@ -253,13 +272,17 @@ const ChannelModels = {
|
||||
? `<img src="${esc(m.personaAvatar)}" class="mention-ac-avatar" alt="">`
|
||||
: m.isPersona
|
||||
? `<span class="mention-ac-avatar mention-ac-avatar-fallback">⚡</span>`
|
||||
: `<span class="mention-ac-avatar mention-ac-avatar-fallback">🤖</span>`;
|
||||
: m.isUser
|
||||
? `<span class="mention-ac-avatar mention-ac-avatar-fallback mention-ac-user">👤</span>`
|
||||
: `<span class="mention-ac-avatar mention-ac-avatar-fallback">🤖</span>`;
|
||||
|
||||
const handleText = m.isPersona && m.personaHandle
|
||||
? `@${esc(m.personaHandle)}`
|
||||
: `@${esc(m.baseModelId || m.id)}`;
|
||||
: m.isUser
|
||||
? `@${esc(m.baseModelId)}`
|
||||
: `@${esc(m.baseModelId || m.id)}`;
|
||||
|
||||
const providerHint = m.provider ? esc(m.provider) : '';
|
||||
const providerHint = m.isUser ? 'user' : (m.provider ? esc(m.provider) : '');
|
||||
|
||||
item.innerHTML = `${avatar}<div class="mention-ac-info"><span class="mention-ac-name">${esc(m.name || m.id)}</span><span class="mention-ac-handle">${handleText}</span></div><span class="mention-ac-model">${providerHint}</span>`;
|
||||
item.addEventListener('click', () => {
|
||||
|
||||
204
src/js/chat.js
204
src/js/chat.js
@@ -83,6 +83,8 @@ const ChatInput = {
|
||||
updateInputTokens();
|
||||
// @mention autocomplete (v0.20.0)
|
||||
if (typeof ChannelModels !== 'undefined') ChannelModels.onInput(ChatInput);
|
||||
// v0.23.2: Typing indicator for multi-user channels
|
||||
_emitTyping();
|
||||
},
|
||||
maxHeight: 200,
|
||||
});
|
||||
@@ -108,15 +110,30 @@ const ChatInput = {
|
||||
updateInputTokens();
|
||||
// @mention autocomplete (v0.20.0)
|
||||
if (typeof ChannelModels !== 'undefined') ChannelModels.onInput(this);
|
||||
// v0.23.2: Typing indicator
|
||||
_emitTyping();
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// v0.23.2: Debounced typing indicator for multi-user channels
|
||||
let _typingTimer = null;
|
||||
function _emitTyping() {
|
||||
if (!App.activeId) return;
|
||||
// Only emit for multi-user conversation types
|
||||
const type = App.activeConversation?.type;
|
||||
if (type !== 'dm' && type !== 'channel' && type !== 'group') return;
|
||||
// Debounce: emit at most once every 3s
|
||||
if (_typingTimer) return;
|
||||
_typingTimer = setTimeout(() => { _typingTimer = null; }, 3000);
|
||||
API._post(`/api/v1/channels/${App.activeId}/typing`, {}).catch(() => {});
|
||||
}
|
||||
|
||||
// ── Summarize & Continue ──────────────────
|
||||
|
||||
async function summarizeAndContinue() {
|
||||
const channelId = App.currentChatId;
|
||||
const channelId = App.activeId;
|
||||
if (!channelId) return;
|
||||
|
||||
const btn = document.getElementById('summarizeBtn');
|
||||
@@ -145,6 +162,10 @@ async function summarizeAndContinue() {
|
||||
metadata: m.metadata || null,
|
||||
siblingCount: m.sibling_count || 1,
|
||||
siblingIndex: m.sibling_index || 0,
|
||||
participant_type: m.participant_type || null,
|
||||
participant_id: m.participant_id || null,
|
||||
sender_name: m.sender_name || null,
|
||||
sender_avatar: m.sender_avatar || null,
|
||||
}));
|
||||
UI.renderMessages(chat.messages);
|
||||
updateContextWarning(); updateChatTokenCount();
|
||||
@@ -164,7 +185,7 @@ async function summarizeAndContinue() {
|
||||
// ── Per-Channel Auto-Compaction Toggle ──────
|
||||
|
||||
async function toggleChannelAutoCompact(enabled) {
|
||||
const chatId = App.currentChatId;
|
||||
const chatId = App.activeId;
|
||||
if (!chatId) return;
|
||||
const chat = App.chats.find(c => c.id === chatId);
|
||||
if (!chat) return;
|
||||
@@ -193,6 +214,7 @@ async function loadChats() {
|
||||
model: c.model || '',
|
||||
messageCount: c.message_count || 0,
|
||||
projectId: c.project_id || null,
|
||||
folderId: c.folder || null,
|
||||
workspace_id: c.workspace_id || null,
|
||||
messages: [],
|
||||
updatedAt: c.updated_at,
|
||||
@@ -206,16 +228,17 @@ async function loadChats() {
|
||||
|
||||
async function selectChat(chatId) {
|
||||
clearStaged(); // Discard any staged files from previous chat
|
||||
App.currentChatId = chatId;
|
||||
try { sessionStorage.setItem('cs-active-chat', chatId); } catch (_) {}
|
||||
const chat = App.chats.find(c => c.id === chatId);
|
||||
App.setActive(chatId, chat?.type || 'direct');
|
||||
try { sessionStorage.setItem('cs-active-conversation', JSON.stringify(App.activeConversation)); } catch (_) {}
|
||||
UI.renderChatList();
|
||||
UI.renderChannelsSection();
|
||||
if (window.innerWidth <= 768) {
|
||||
document.getElementById('sidebar').classList.add('collapsed');
|
||||
const ov = document.getElementById('sidebarOverlay');
|
||||
if (ov) ov.style.display = 'none';
|
||||
}
|
||||
|
||||
const chat = App.chats.find(c => c.id === chatId);
|
||||
if (!chat) return;
|
||||
|
||||
if (chat.messages.length === 0 && chat.messageCount > 0) {
|
||||
@@ -233,6 +256,10 @@ async function selectChat(chatId) {
|
||||
metadata: m.metadata || null,
|
||||
siblingCount: m.sibling_count || 1,
|
||||
siblingIndex: m.sibling_index || 0,
|
||||
participant_type: m.participant_type || null,
|
||||
participant_id: m.participant_id || null,
|
||||
sender_name: m.sender_name || null,
|
||||
sender_avatar: m.sender_avatar || null,
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Failed to load messages:', e.message);
|
||||
@@ -250,6 +277,7 @@ async function selectChat(chatId) {
|
||||
await loadChannelFiles(chatId);
|
||||
|
||||
UI.renderMessages(chat.messages);
|
||||
UI.updateContextBanner();
|
||||
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
|
||||
Tokens._warningDismissed = false;
|
||||
updateContextWarning(); updateChatTokenCount();
|
||||
@@ -264,6 +292,9 @@ async function selectChat(chatId) {
|
||||
|
||||
// Update browser URL to reflect selected chat
|
||||
window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chatId}`);
|
||||
|
||||
// v0.23.2: Mark as read (non-blocking)
|
||||
API.markRead(chatId).catch(() => {});
|
||||
}
|
||||
|
||||
// ── Chat Header Token Count ──────────────────
|
||||
@@ -271,7 +302,7 @@ async function selectChat(chatId) {
|
||||
function updateChatTokenCount() {
|
||||
const el = document.getElementById('chatTokenCount');
|
||||
if (!el) return;
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
const chat = App.getActiveChat();
|
||||
if (!chat || !chat.messages.length) { el.textContent = ''; return; }
|
||||
|
||||
const tokens = Tokens.estimateConversation(chat.messages, App.settings?.systemPrompt);
|
||||
@@ -379,8 +410,9 @@ function _cleanStaleChatModel(chatId) {
|
||||
|
||||
async function newChat() {
|
||||
clearStaged(); // Discard any staged files
|
||||
App.currentChatId = null;
|
||||
App.setActive(null);
|
||||
UI.renderChatList();
|
||||
UI.renderChannelsSection();
|
||||
UI.showEmptyState();
|
||||
UI.showRegenerate(false);
|
||||
Tokens._warningDismissed = false;
|
||||
@@ -492,7 +524,7 @@ async function newGroupChat() {
|
||||
}
|
||||
|
||||
App.chats.unshift(chat);
|
||||
App.currentChatId = chat.id;
|
||||
App.setActive(chat.id, 'group');
|
||||
UI.renderChatList();
|
||||
Events.emit('chat.created', { chatId: chat.id, projectId: null }, { localOnly: true });
|
||||
window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chat.id}`);
|
||||
@@ -526,7 +558,7 @@ async function deleteChat(chatId) {
|
||||
delete map[chatId];
|
||||
localStorage.setItem('cs-chat-models', JSON.stringify(map));
|
||||
} catch (e) { /* */ }
|
||||
if (App.currentChatId === chatId) { clearPreview(); newChat(); }
|
||||
if (App.activeId === chatId) { clearPreview(); newChat(); }
|
||||
UI.renderChatList();
|
||||
} catch (e) { UI.toast('Failed to delete: ' + e.message, 'error'); }
|
||||
}
|
||||
@@ -634,7 +666,7 @@ async function sendMessage() {
|
||||
const model = modelInfo?.baseModelId || selectedId;
|
||||
const personaId = modelInfo?.isPersona ? modelInfo.personaId : null;
|
||||
|
||||
if (!App.currentChatId) {
|
||||
if (!App.activeId) {
|
||||
try {
|
||||
const title = text.slice(0, 50) + (text.length > 50 ? '...' : '');
|
||||
const resp = await API.createChannel(title, model, App.settings.systemPrompt, 'direct');
|
||||
@@ -647,7 +679,7 @@ async function sendMessage() {
|
||||
} catch (_) { /* best effort — chat still works without project */ }
|
||||
}
|
||||
App.chats.unshift(chat);
|
||||
App.currentChatId = chat.id;
|
||||
App.setActive(chat.id, 'direct');
|
||||
UI.renderChatList();
|
||||
// Notify surfaces about new chat creation (v0.21.6)
|
||||
Events.emit('chat.created', { chatId: chat.id, projectId: chat.projectId || null }, { localOnly: true });
|
||||
@@ -655,7 +687,7 @@ async function sendMessage() {
|
||||
} catch (e) { UI.toast('Failed to create chat: ' + e.message, 'error'); return; }
|
||||
}
|
||||
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
const chat = App.getActiveChat();
|
||||
if (!chat) return;
|
||||
|
||||
// Optimistic: show user message immediately
|
||||
@@ -671,8 +703,20 @@ async function sendMessage() {
|
||||
UI.setGenerating(true);
|
||||
|
||||
try {
|
||||
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, personaId, fileIds,
|
||||
const resp = await API.streamCompletion(App.activeId, text, model, App.abortController.signal, modelInfo?.configId, personaId, fileIds,
|
||||
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []);
|
||||
|
||||
// v0.23.2: Non-streaming response (DM with mention_only, no @mention)
|
||||
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||||
if (ct.includes('application/json')) {
|
||||
const data = await resp.json();
|
||||
if (data.ai_skipped) {
|
||||
// Message was persisted server-side; reload to get proper IDs
|
||||
await reloadActivePath();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await UI.streamResponse(resp, chat.messages, modelInfo?.name);
|
||||
|
||||
// Reload active path from server to get proper IDs and sibling metadata
|
||||
@@ -680,11 +724,11 @@ async function sendMessage() {
|
||||
UI.showRegenerate(true);
|
||||
|
||||
// Persist the model/persona selection for this chat
|
||||
_saveChatModel(App.currentChatId);
|
||||
_saveChatModel(App.activeId);
|
||||
|
||||
// Auto-name: title the chat after first assistant response (v0.17.0)
|
||||
if (chat.messages.length <= 3) {
|
||||
autoNameChat(App.currentChatId, chat);
|
||||
autoNameChat(App.activeId, chat);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') {
|
||||
@@ -717,12 +761,12 @@ function stopGeneration() { if (App.abortController) App.abortController.abort()
|
||||
// ── Reload Active Path from Server ──────────
|
||||
|
||||
async function reloadActivePath() {
|
||||
if (!App.currentChatId) return;
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
if (!App.activeId) return;
|
||||
const chat = App.getActiveChat();
|
||||
if (!chat) return;
|
||||
|
||||
try {
|
||||
const resp = await API.getActivePath(App.currentChatId);
|
||||
const resp = await API.getActivePath(App.activeId);
|
||||
chat.messages = (resp.path || []).map(m => ({
|
||||
id: m.id,
|
||||
parent_id: m.parent_id || null,
|
||||
@@ -735,9 +779,13 @@ async function reloadActivePath() {
|
||||
metadata: m.metadata || null,
|
||||
siblingCount: m.sibling_count || 1,
|
||||
siblingIndex: m.sibling_index || 0,
|
||||
participant_type: m.participant_type || null,
|
||||
participant_id: m.participant_id || null,
|
||||
sender_name: m.sender_name || null,
|
||||
sender_avatar: m.sender_avatar || null,
|
||||
}));
|
||||
chat.messageCount = chat.messages.length;
|
||||
await loadChannelFiles(App.currentChatId);
|
||||
await loadChannelFiles(App.activeId);
|
||||
UI.renderMessages(chat.messages);
|
||||
updateContextWarning(); updateChatTokenCount();
|
||||
} catch (e) {
|
||||
@@ -748,7 +796,7 @@ async function reloadActivePath() {
|
||||
// ── Regenerate (tree-aware: creates sibling) ─
|
||||
|
||||
async function regenerateMessage(messageId) {
|
||||
if (App.isGenerating || !App.currentChatId) return;
|
||||
if (App.isGenerating || !App.activeId) return;
|
||||
|
||||
const selectedId = UI.getModelValue();
|
||||
const modelInfo = App.findModel(selectedId);
|
||||
@@ -758,7 +806,7 @@ async function regenerateMessage(messageId) {
|
||||
// Truncate display to the parent of the message being regenerated.
|
||||
// This makes the stream appear in the correct position — as a fresh
|
||||
// response to the parent user message, not appended at the bottom.
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
const chat = App.getActiveChat();
|
||||
if (chat) {
|
||||
const msgIdx = chat.messages.findIndex(m => m.id === messageId);
|
||||
if (msgIdx > 0) {
|
||||
@@ -773,7 +821,7 @@ async function regenerateMessage(messageId) {
|
||||
|
||||
try {
|
||||
const resp = await API.streamRegenerate(
|
||||
App.currentChatId, messageId, App.abortController.signal,
|
||||
App.activeId, messageId, App.abortController.signal,
|
||||
model, personaId, modelInfo?.configId,
|
||||
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []
|
||||
);
|
||||
@@ -803,8 +851,8 @@ async function regenerateMessage(messageId) {
|
||||
|
||||
// Legacy: bottom-bar regenerate button targets the last assistant message
|
||||
async function regenerate() {
|
||||
if (App.isGenerating || !App.currentChatId) return;
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
if (App.isGenerating || !App.activeId) return;
|
||||
const chat = App.getActiveChat();
|
||||
if (!chat || chat.messages.length === 0) return;
|
||||
|
||||
// Find the last assistant message with an ID
|
||||
@@ -819,9 +867,9 @@ async function regenerate() {
|
||||
// ── Edit Message (creates sibling, triggers completion) ──
|
||||
|
||||
async function editMessage(messageId) {
|
||||
if (App.isGenerating || !App.currentChatId) return;
|
||||
if (App.isGenerating || !App.activeId) return;
|
||||
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
const chat = App.getActiveChat();
|
||||
if (!chat) return;
|
||||
|
||||
const msg = chat.messages.find(m => m.id === messageId);
|
||||
@@ -832,7 +880,7 @@ async function editMessage(messageId) {
|
||||
}
|
||||
|
||||
async function submitEdit(messageId, newContent) {
|
||||
if (App.isGenerating || !App.currentChatId) return;
|
||||
if (App.isGenerating || !App.activeId) return;
|
||||
if (!newContent.trim()) return;
|
||||
|
||||
const selectedId = UI.getModelValue();
|
||||
@@ -845,10 +893,10 @@ async function submitEdit(messageId, newContent) {
|
||||
UI.setGenerating(true);
|
||||
|
||||
try {
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
const chat = App.getActiveChat();
|
||||
|
||||
// Step 1: Create sibling user message via edit endpoint
|
||||
const editResp = await API.editMessage(App.currentChatId, messageId, newContent.trim());
|
||||
const editResp = await API.editMessage(App.activeId, messageId, newContent.trim());
|
||||
|
||||
// Step 2: Reload path to show the new edited message
|
||||
await reloadActivePath();
|
||||
@@ -858,7 +906,7 @@ async function submitEdit(messageId, newContent) {
|
||||
// a child response, not a sibling). This avoids the duplicate user
|
||||
// message that streamCompletion would create.
|
||||
const resp = await API.streamRegenerate(
|
||||
App.currentChatId, editResp.id, App.abortController.signal,
|
||||
App.activeId, editResp.id, App.abortController.signal,
|
||||
model, personaId, modelInfo?.configId,
|
||||
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []
|
||||
);
|
||||
@@ -885,18 +933,18 @@ async function submitEdit(messageId, newContent) {
|
||||
|
||||
function cancelEdit() {
|
||||
// Re-render to remove the edit form
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
const chat = App.getActiveChat();
|
||||
if (chat) UI.renderMessages(chat.messages);
|
||||
}
|
||||
|
||||
// ── Switch Branch (sibling navigation) ──────
|
||||
|
||||
async function switchSibling(messageId, direction) {
|
||||
if (App.isGenerating || !App.currentChatId) return;
|
||||
if (App.isGenerating || !App.activeId) return;
|
||||
|
||||
try {
|
||||
// Get siblings for this message
|
||||
const data = await API.listSiblings(App.currentChatId, messageId);
|
||||
const data = await API.listSiblings(App.activeId, messageId);
|
||||
const siblings = data.siblings || [];
|
||||
const currentIdx = data.current_index ?? 0;
|
||||
|
||||
@@ -906,10 +954,10 @@ async function switchSibling(messageId, direction) {
|
||||
const targetSibling = siblings[targetIdx];
|
||||
|
||||
// Update cursor to the target sibling (backend walks to leaf)
|
||||
const resp = await API.updateCursor(App.currentChatId, targetSibling.id);
|
||||
const resp = await API.updateCursor(App.activeId, targetSibling.id);
|
||||
|
||||
// Update local state with the new path
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
const chat = App.getActiveChat();
|
||||
if (chat && resp.path) {
|
||||
chat.messages = resp.path.map(m => ({
|
||||
id: m.id,
|
||||
@@ -923,6 +971,10 @@ async function switchSibling(messageId, direction) {
|
||||
metadata: m.metadata || null,
|
||||
siblingCount: m.sibling_count || 1,
|
||||
siblingIndex: m.sibling_index || 0,
|
||||
participant_type: m.participant_type || null,
|
||||
participant_id: m.participant_id || null,
|
||||
sender_name: m.sender_name || null,
|
||||
sender_avatar: m.sender_avatar || null,
|
||||
}));
|
||||
chat.messageCount = chat.messages.length;
|
||||
UI.renderMessages(chat.messages);
|
||||
@@ -997,7 +1049,26 @@ function _initChatListeners() {
|
||||
// runs server-side and delivers the result via WS, not SSE.
|
||||
Events.on('message.created', (payload) => {
|
||||
if (!payload || !payload.channel_id) return;
|
||||
if (payload.channel_id !== App.currentChatId) return;
|
||||
|
||||
// v0.23.2: If this channel isn't in our sidebar yet, fetch and add it
|
||||
const knownChannel = (App.channels || []).some(c => c.id === payload.channel_id);
|
||||
const knownChat = App.chats.some(c => c.id === payload.channel_id);
|
||||
if (!knownChannel && !knownChat) {
|
||||
// New channel we're a participant in — reload channels sidebar
|
||||
if (typeof loadChannels === 'function') loadChannels();
|
||||
UI.toast(`New message from ${payload.display_name || payload.user_id?.slice(0, 8) || 'someone'}`, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's a channel/DM we know but it's not the active one, bump unread
|
||||
if (payload.channel_id !== App.activeId) {
|
||||
const ch = (App.channels || []).find(c => c.id === payload.channel_id);
|
||||
if (ch) {
|
||||
ch.unread = (ch.unread || 0) + 1;
|
||||
if (typeof UI !== 'undefined') UI.renderChannelsSection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const chat = App.chats.find(c => c.id === payload.channel_id);
|
||||
if (!chat) return;
|
||||
@@ -1021,16 +1092,18 @@ function _initChatListeners() {
|
||||
|
||||
// Remove typing indicator and re-render
|
||||
document.getElementById('typingIndicator')?.remove();
|
||||
document.getElementById('userTypingIndicator')?.remove();
|
||||
UI.renderMessages(chat.messages);
|
||||
});
|
||||
|
||||
// Typing indicators from persona chain activity
|
||||
Events.on('typing.start', (payload) => {
|
||||
if (!payload || payload.channel_id !== App.currentChatId) return;
|
||||
if (!payload || payload.channel_id !== App.activeId) return;
|
||||
if (payload.participant_type !== 'persona') return;
|
||||
|
||||
// Show typing indicator with persona name
|
||||
document.getElementById('typingIndicator')?.remove();
|
||||
document.getElementById('userTypingIndicator')?.remove();
|
||||
const container = document.getElementById('chatMessages');
|
||||
if (!container) return;
|
||||
const typing = document.createElement('div');
|
||||
@@ -1047,8 +1120,63 @@ function _initChatListeners() {
|
||||
});
|
||||
|
||||
Events.on('typing.stop', (payload) => {
|
||||
if (!payload || payload.channel_id !== App.currentChatId) return;
|
||||
if (!payload || payload.channel_id !== App.activeId) return;
|
||||
document.getElementById('typingIndicator')?.remove();
|
||||
document.getElementById('userTypingIndicator')?.remove();
|
||||
});
|
||||
|
||||
// v0.23.2: Human typing indicator from other participants
|
||||
Events.on('typing.user', (payload) => {
|
||||
if (!payload || payload.channel_id !== App.activeId) return;
|
||||
if (payload.user_id === API.user?.id) return; // ignore self
|
||||
|
||||
// Show or refresh typing indicator
|
||||
let typing = document.getElementById('userTypingIndicator');
|
||||
if (!typing) {
|
||||
const container = document.getElementById('chatMessages');
|
||||
if (!container) return;
|
||||
typing = document.createElement('div');
|
||||
typing.id = 'userTypingIndicator';
|
||||
typing.className = 'message other-user';
|
||||
container.appendChild(typing);
|
||||
}
|
||||
const name = payload.display_name || 'Someone';
|
||||
typing.innerHTML = `
|
||||
<div class="msg-inner">
|
||||
<div class="msg-avatar" style="background:rgba(45,212,191,0.12);color:#2dd4bf;">👤</div>
|
||||
<div class="msg-body" style="background:var(--bg-surface);border:1px solid var(--border);border-radius:16px 16px 16px 4px;padding:8px 12px;">
|
||||
<span style="color:#2dd4bf;font-size:12px;font-weight:600;">${esc(name)}</span>
|
||||
<div class="typing-dots"><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
</div>`;
|
||||
const container = document.getElementById('chatMessages');
|
||||
if (container) container.scrollTop = container.scrollHeight;
|
||||
|
||||
// Auto-remove after 4s if no new event
|
||||
clearTimeout(typing._timeout);
|
||||
typing._timeout = setTimeout(() => typing.remove(), 4000);
|
||||
});
|
||||
|
||||
// v0.23.2: User @mention notification
|
||||
Events.on('user.mentioned', (payload) => {
|
||||
if (!payload) return;
|
||||
const channelId = payload.channel_id;
|
||||
// Increment unread badge on the channel sidebar item
|
||||
const ch = (App.channels || []).find(c => c.id === channelId);
|
||||
if (ch) {
|
||||
ch.unread = (ch.unread || 0) + 1;
|
||||
if (typeof UI !== 'undefined') UI.renderChannelsSection();
|
||||
}
|
||||
// Toast with deep-link
|
||||
const preview = payload.content || 'You were mentioned';
|
||||
UI.toast(`@mention: ${preview}`, 'info');
|
||||
});
|
||||
|
||||
// v0.23.2: Live presence updates for sidebar online dots
|
||||
Events.on('user.presence', (payload) => {
|
||||
if (!payload || !payload.user_id) return;
|
||||
App.presence[payload.user_id] = payload.status || 'offline';
|
||||
if (typeof UI !== 'undefined') UI.renderChannelsSection();
|
||||
});
|
||||
|
||||
// Close modals on overlay click
|
||||
|
||||
@@ -238,7 +238,7 @@ const DebugLog = {
|
||||
if (typeof App !== 'undefined') {
|
||||
snap.app = {
|
||||
chatCount: App.chats?.length || 0,
|
||||
currentChatId: App.currentChatId,
|
||||
activeConversation: App.activeConversation,
|
||||
modelCount: App.models?.length || 0,
|
||||
isGenerating: App.isGenerating,
|
||||
settings: {
|
||||
|
||||
@@ -81,7 +81,7 @@ async function stageFile(file) {
|
||||
_updateSendBlock();
|
||||
|
||||
// Ensure a channel exists (files are channel-scoped)
|
||||
if (!App.currentChatId) {
|
||||
if (!App.activeId) {
|
||||
try {
|
||||
const title = file.name.slice(0, 50);
|
||||
const selectedId = UI.getModelValue();
|
||||
@@ -93,7 +93,7 @@ async function stageFile(file) {
|
||||
model, messages: [], messageCount: 0, updatedAt: resp.updated_at,
|
||||
};
|
||||
App.chats.unshift(chat);
|
||||
App.currentChatId = chat.id;
|
||||
App.setActive(chat.id, 'direct');
|
||||
UI.renderChatList();
|
||||
} catch (e) {
|
||||
staged.status = 'error';
|
||||
@@ -106,7 +106,7 @@ async function stageFile(file) {
|
||||
|
||||
// Upload
|
||||
try {
|
||||
const result = await API.uploadFile(App.currentChatId, file);
|
||||
const result = await API.uploadFile(App.activeId, file);
|
||||
staged.serverId = result.id;
|
||||
staged.status = _mapExtractionStatus(result.metadata?.extraction_status);
|
||||
staged.extractionStatus = result.metadata?.extraction_status || null;
|
||||
|
||||
@@ -28,7 +28,7 @@ const KnowledgeUI = (() => {
|
||||
const popup = document.getElementById('kbPopup');
|
||||
if (!popup) return;
|
||||
|
||||
if (!App.currentChatId) {
|
||||
if (!App.activeId) {
|
||||
popup.innerHTML = '<div class="kb-popup-empty">Start a conversation first</div>';
|
||||
popup.classList.add('open');
|
||||
return;
|
||||
@@ -40,7 +40,7 @@ const KnowledgeUI = (() => {
|
||||
try {
|
||||
const [allResp, chResp] = await Promise.all([
|
||||
API.listKnowledgeBases(),
|
||||
API.getChannelKBs(App.currentChatId),
|
||||
API.getChannelKBs(App.activeId),
|
||||
]);
|
||||
_allKBs = allResp.data || [];
|
||||
_channelKBs = chResp.data || [];
|
||||
@@ -82,7 +82,7 @@ const KnowledgeUI = (() => {
|
||||
}
|
||||
|
||||
async function _toggleChannelKB(kbId) {
|
||||
if (!App.currentChatId) return;
|
||||
if (!App.activeId) return;
|
||||
|
||||
const linkedSet = new Set(_channelKBs.map(kb => kb.kb_id));
|
||||
if (linkedSet.has(kbId)) {
|
||||
@@ -92,7 +92,7 @@ const KnowledgeUI = (() => {
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await API.setChannelKBs(App.currentChatId, [...linkedSet]);
|
||||
const resp = await API.setChannelKBs(App.activeId, [...linkedSet]);
|
||||
_channelKBs = resp.data || [];
|
||||
const popup = document.getElementById('kbPopup');
|
||||
if (popup) _renderChannelPopup(popup);
|
||||
@@ -506,9 +506,9 @@ const KnowledgeUI = (() => {
|
||||
/** Refresh channel KB state when switching chats. */
|
||||
async function onChatChanged() {
|
||||
_channelKBs = [];
|
||||
if (App.currentChatId) {
|
||||
if (App.activeId) {
|
||||
try {
|
||||
const resp = await API.getChannelKBs(App.currentChatId);
|
||||
const resp = await API.getChannelKBs(App.activeId);
|
||||
_channelKBs = resp.data || [];
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
@@ -596,7 +596,7 @@ async function _navigateDaily(offset) {
|
||||
// ── Save-to-Note (from chat messages) ──
|
||||
|
||||
async function saveMessageToNote(msgIndex) {
|
||||
const chat = App.chats?.find(c => c.id === App.currentChatId);
|
||||
const chat = App.chats?.find(c => c.id === App.activeId);
|
||||
const msg = chat?.messages?.[msgIndex];
|
||||
if (!msg) return;
|
||||
|
||||
@@ -617,7 +617,7 @@ async function saveMessageToNote(msgIndex) {
|
||||
// Show save-to-note modal
|
||||
_showSaveToNoteModal({
|
||||
content,
|
||||
sourceChannelId: App.currentChatId || '',
|
||||
sourceChannelId: App.activeId || '',
|
||||
sourceMessageId: msg.id || '',
|
||||
defaultTitle,
|
||||
});
|
||||
|
||||
@@ -299,6 +299,23 @@ function showChatContextMenu(e, chatId) {
|
||||
<span class="ctx-icon">📁</span> Set workspace…
|
||||
</button>`;
|
||||
|
||||
// v0.23.2: Folder options
|
||||
const folders = App.folders || [];
|
||||
if (folders.length > 0 || chat.folderId) {
|
||||
items += '<div class="ctx-divider"></div>';
|
||||
if (chat.folderId) {
|
||||
items += `<button class="ctx-item" onclick="moveChatToFolder('${chatId}',null);dismissChatContextMenu()">
|
||||
<span class="ctx-icon">↩</span> Remove from folder
|
||||
</button>`;
|
||||
}
|
||||
folders.forEach(f => {
|
||||
if (f.id === chat.folderId) return; // skip current
|
||||
items += `<button class="ctx-item" onclick="moveChatToFolder('${chatId}','${f.id}');dismissChatContextMenu()">
|
||||
<span class="ctx-icon">📂</span> ${esc(f.name)}
|
||||
</button>`;
|
||||
});
|
||||
}
|
||||
|
||||
menu.innerHTML = items;
|
||||
|
||||
// Position near cursor
|
||||
@@ -341,7 +358,8 @@ function onChatDragStart(e, chatId) {
|
||||
|
||||
function onChatDragEnd(e) {
|
||||
e.target.classList.remove('dragging');
|
||||
document.querySelectorAll('.project-group.drag-over').forEach(el => el.classList.remove('drag-over'));
|
||||
// Clear all drag-over highlights (projects, folders, section bodies, unfiled zone)
|
||||
document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
|
||||
}
|
||||
|
||||
function onProjectDragOver(e) {
|
||||
@@ -370,6 +388,64 @@ async function onRecentDrop(e) {
|
||||
await moveChatToProject(chatId, null);
|
||||
}
|
||||
|
||||
// v0.23.2: Folder drag-and-drop
|
||||
|
||||
async function onFolderDrop(e, folderId) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.currentTarget.classList.remove('drag-over');
|
||||
const chatId = e.dataTransfer.getData('text/plain');
|
||||
if (!chatId) return;
|
||||
await moveChatToFolder(chatId, folderId);
|
||||
}
|
||||
|
||||
async function onChatSectionDrop(e) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.remove('drag-over');
|
||||
const chatId = e.dataTransfer.getData('text/plain');
|
||||
if (!chatId) return;
|
||||
const chat = App.chats.find(c => c.id === chatId);
|
||||
if (!chat) return;
|
||||
|
||||
// Unfile from folder if filed
|
||||
if (chat.folderId) {
|
||||
await moveChatToFolder(chatId, null);
|
||||
}
|
||||
// Remove from project if in one
|
||||
if (chat.projectId) {
|
||||
await moveChatToProject(chatId, null);
|
||||
}
|
||||
}
|
||||
|
||||
async function moveChatToFolder(chatId, folderId) {
|
||||
try {
|
||||
await API.updateChannel(chatId, { folder: folderId || '' });
|
||||
const chat = App.chats.find(c => c.id === chatId);
|
||||
if (chat) chat.folderId = folderId || null;
|
||||
UI.renderChatList();
|
||||
} catch (e) {
|
||||
UI.toast('Failed to move chat: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function onUnfiledDrop(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.currentTarget.classList.remove('drag-over');
|
||||
const chatId = e.dataTransfer.getData('text/plain');
|
||||
if (!chatId) return;
|
||||
const chat = App.chats.find(c => c.id === chatId);
|
||||
if (!chat) return;
|
||||
// Unfile from folder
|
||||
if (chat.folderId) {
|
||||
await moveChatToFolder(chatId, null);
|
||||
}
|
||||
// Remove from project
|
||||
if (chat.projectId) {
|
||||
await moveChatToProject(chatId, null);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Project Header Menu ─────────────────────
|
||||
|
||||
function showProjectMenu(e, projectId) {
|
||||
@@ -1106,7 +1182,19 @@ async function loadFolders() {
|
||||
async function loadChannels() {
|
||||
try {
|
||||
const resp = await API.listSidebarChannels();
|
||||
App.channels = (resp.channels || []).map(normalizeChannel);
|
||||
App.channels = (resp.data || []).map(normalizeChannel);
|
||||
|
||||
// Query presence for DM partners
|
||||
const dmPartnerIds = App.channels
|
||||
.filter(c => c.type === 'dm' && c.dmPartnerId)
|
||||
.map(c => c.dmPartnerId);
|
||||
if (dmPartnerIds.length > 0) {
|
||||
try {
|
||||
const presResp = await API._get(`/api/v1/presence?users=${dmPartnerIds.join(',')}`);
|
||||
App.presence = presResp.presence || {};
|
||||
} catch (_) { /* non-critical */ }
|
||||
}
|
||||
|
||||
if (typeof UI !== 'undefined') UI.renderChannelsSection();
|
||||
} catch (e) {
|
||||
console.warn('[channels] load failed:', e.message);
|
||||
@@ -1127,27 +1215,117 @@ function normalizeChannel(ch) {
|
||||
};
|
||||
}
|
||||
|
||||
// v0.23.2: Load user list for @mention autocomplete and DM creation
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const resp = await API._get('/api/v1/users/search?q=');
|
||||
App.users = (resp.users || []).map(u => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
displayName: u.display_name || u.username,
|
||||
}));
|
||||
} catch (e) {
|
||||
console.warn('[users] load failed:', e.message);
|
||||
App.users = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function selectChannel(channelId) {
|
||||
App.currentChannelId = channelId;
|
||||
App.currentChatId = null; // clear chat selection
|
||||
const ch = (App.channels || []).find(c => c.id === channelId);
|
||||
App.setActive(channelId, ch?.type || 'channel');
|
||||
if (typeof UI !== 'undefined') {
|
||||
UI.renderChannelsSection();
|
||||
UI.renderChatList();
|
||||
}
|
||||
// Ensure conversation object exists in App.chats for message caching
|
||||
if (!App.chats.find(c => c.id === channelId)) {
|
||||
App.chats.unshift({
|
||||
id: channelId, title: ch?.name || 'Channel', type: ch?.type || 'channel',
|
||||
model: '', messages: [], messageCount: 0, projectId: null,
|
||||
workspace_id: null, updatedAt: ch?.updatedAt || new Date().toISOString(),
|
||||
settings: {},
|
||||
});
|
||||
}
|
||||
// Load messages for this channel
|
||||
try {
|
||||
const chat = App.chats.find(c => c.id === channelId);
|
||||
const resp = await API._get(`/api/v1/channels/${channelId}/messages`);
|
||||
const messages = resp.messages || [];
|
||||
if (typeof UI !== 'undefined') UI.renderMessages(messages);
|
||||
const messages = (resp.data || []).map(m => ({
|
||||
id: m.id,
|
||||
parent_id: m.parent_id || null,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
model: m.model || '',
|
||||
modelName: '',
|
||||
timestamp: m.created_at,
|
||||
tool_calls: m.tool_calls || null,
|
||||
metadata: m.metadata || null,
|
||||
siblingCount: m.sibling_count || 1,
|
||||
siblingIndex: m.sibling_index || 0,
|
||||
participant_type: m.participant_type || null,
|
||||
participant_id: m.participant_id || null,
|
||||
sender_name: m.sender_name || null,
|
||||
sender_avatar: m.sender_avatar || null,
|
||||
}));
|
||||
if (chat) chat.messages = messages;
|
||||
if (typeof UI !== 'undefined') {
|
||||
UI.renderMessages(messages);
|
||||
UI.updateContextBanner();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[channel] message load failed:', e.message);
|
||||
}
|
||||
try {
|
||||
sessionStorage.setItem('cs-active-channel', channelId);
|
||||
sessionStorage.setItem('cs-active-conversation', JSON.stringify(App.activeConversation));
|
||||
} catch (_) {}
|
||||
|
||||
// v0.23.2: Mark as read and clear unread badge
|
||||
const selCh = (App.channels || []).find(c => c.id === channelId);
|
||||
if (selCh) selCh.unread = 0;
|
||||
API.markRead(channelId).catch(() => {});
|
||||
}
|
||||
|
||||
async function newChannelOrDM() {
|
||||
// Show choice: Channel or DM
|
||||
const choice = await new Promise(resolve => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay active';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal" style="width:340px;">
|
||||
<div class="modal-header"><h3>New Conversation</h3></div>
|
||||
<div class="modal-body" style="display:flex;flex-direction:column;gap:8px;padding:16px;">
|
||||
<button class="btn btn-primary" id="_ncChannel">
|
||||
<span style="margin-right:6px;">#</span> New Channel
|
||||
</button>
|
||||
<button class="btn" id="_ncDM">
|
||||
<span style="margin-right:6px;">@</span> New Direct Message
|
||||
</button>
|
||||
<button class="btn" id="_ncGroup">
|
||||
<span style="margin-right:6px;">👥</span> New Group Chat
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
overlay.addEventListener('click', e => { if (e.target === overlay) { overlay.remove(); resolve(null); } });
|
||||
overlay.querySelector('#_ncChannel').onclick = () => { overlay.remove(); resolve('channel'); };
|
||||
overlay.querySelector('#_ncDM').onclick = () => { overlay.remove(); resolve('dm'); };
|
||||
overlay.querySelector('#_ncGroup').onclick = () => { overlay.remove(); resolve('group'); };
|
||||
document.body.appendChild(overlay);
|
||||
});
|
||||
|
||||
if (choice === 'channel') {
|
||||
await _createNewChannel();
|
||||
} else if (choice === 'dm') {
|
||||
await _createNewDM();
|
||||
} else if (choice === 'group') {
|
||||
if (typeof newGroupChat === 'function') {
|
||||
await newGroupChat();
|
||||
} else {
|
||||
UI.toast('Group chat not available', 'warning');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function _createNewChannel() {
|
||||
const result = await _showCreationDialog({
|
||||
title: 'New Channel',
|
||||
placeholder: 'Channel name',
|
||||
@@ -1159,11 +1337,139 @@ async function newChannelOrDM() {
|
||||
const ch = normalizeChannel(raw);
|
||||
App.channels = App.channels || [];
|
||||
App.channels.push(ch);
|
||||
if (typeof UI !== 'undefined') UI.renderChannelsSection();
|
||||
// Also add to App.chats for message caching / send path
|
||||
App.chats.unshift({
|
||||
id: raw.id, title: raw.title || result.name, type: 'channel',
|
||||
model: '', messages: [], messageCount: 0, projectId: raw.project_id || null,
|
||||
workspace_id: raw.workspace_id || null, updatedAt: raw.updated_at,
|
||||
settings: raw.settings || {},
|
||||
});
|
||||
App.setActive(raw.id, 'channel');
|
||||
if (typeof UI !== 'undefined') {
|
||||
UI.renderChannelsSection();
|
||||
UI.renderChatList();
|
||||
UI.updateContextBanner();
|
||||
}
|
||||
UI.toast(`#${result.name} created`, 'success');
|
||||
} catch (e) {
|
||||
UI.toast('Failed to create channel', 'error');
|
||||
console.error('[newChannelOrDM]', e);
|
||||
console.error('[newChannel]', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function _createNewDM() {
|
||||
return new Promise(resolve => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay active';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal" style="width:360px;">
|
||||
<div class="modal-header"><h3>New Direct Message</h3></div>
|
||||
<div class="modal-body" style="padding:16px;">
|
||||
<input type="text" id="_dmSearchInput" class="input"
|
||||
placeholder="Search users…" autocomplete="off"
|
||||
style="width:100%;margin-bottom:8px;">
|
||||
<div id="_dmResults" style="max-height:200px;overflow-y:auto;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn" id="_dmCancel">Cancel</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const input = overlay.querySelector('#_dmSearchInput');
|
||||
const results = overlay.querySelector('#_dmResults');
|
||||
let debounce = null;
|
||||
let selectedUser = null;
|
||||
|
||||
const close = () => { overlay.remove(); resolve(); };
|
||||
overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
|
||||
overlay.querySelector('#_dmCancel').onclick = close;
|
||||
|
||||
async function search(q) {
|
||||
try {
|
||||
const resp = await API._get(`/api/v1/users/search?q=${encodeURIComponent(q)}`);
|
||||
const users = resp.users || [];
|
||||
if (users.length === 0) {
|
||||
results.innerHTML = '<div style="padding:8px;color:var(--text-3);font-size:12px;">No users found</div>';
|
||||
return;
|
||||
}
|
||||
results.innerHTML = users.map(u => `
|
||||
<div class="dm-user-item" data-id="${u.id}" data-name="${esc(u.display_name || u.username)}"
|
||||
style="padding:8px 10px;cursor:pointer;border-radius:4px;display:flex;align-items:center;gap:8px;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;">
|
||||
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
|
||||
<b>${esc(u.display_name || u.username)}</b>
|
||||
${u.display_name ? `<span style="color:var(--text-3);font-size:11px;margin-left:4px;">@${esc(u.username)}</span>` : ''}
|
||||
</span>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
results.querySelectorAll('.dm-user-item').forEach(el => {
|
||||
el.onmouseenter = () => el.style.background = 'var(--bg-hover)';
|
||||
el.onmouseleave = () => el.style.background = '';
|
||||
el.onclick = async () => {
|
||||
const userId = el.dataset.id;
|
||||
const userName = el.dataset.name;
|
||||
overlay.remove();
|
||||
await _finishDMCreation(userId, userName);
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
results.innerHTML = `<div style="padding:8px;color:var(--text-3);font-size:12px;">Search failed: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(debounce);
|
||||
const q = input.value.trim();
|
||||
if (q.length === 0) {
|
||||
// Show all users when empty
|
||||
debounce = setTimeout(() => search(''), 100);
|
||||
return;
|
||||
}
|
||||
debounce = setTimeout(() => search(q), 200);
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
input.focus();
|
||||
// Show all users immediately
|
||||
search('');
|
||||
});
|
||||
}
|
||||
|
||||
async function _finishDMCreation(targetId, targetName) {
|
||||
try {
|
||||
const raw = await API._post('/api/v1/channels', {
|
||||
title: targetName,
|
||||
type: 'dm',
|
||||
participants: [targetId],
|
||||
});
|
||||
const ch = normalizeChannel(raw);
|
||||
// Avoid duplicate in sidebar list (dedup guard may return existing)
|
||||
if (!(App.channels || []).find(c => c.id === ch.id)) {
|
||||
App.channels = App.channels || [];
|
||||
App.channels.push(ch);
|
||||
}
|
||||
if (!App.chats.find(c => c.id === raw.id)) {
|
||||
App.chats.unshift({
|
||||
id: raw.id, title: raw.title || targetName,
|
||||
type: 'dm', model: '', messages: [], messageCount: 0,
|
||||
projectId: null, workspace_id: null, updatedAt: raw.updated_at,
|
||||
settings: raw.settings || {},
|
||||
});
|
||||
}
|
||||
App.setActive(raw.id, 'dm');
|
||||
if (typeof UI !== 'undefined') {
|
||||
UI.renderChannelsSection();
|
||||
UI.renderChatList();
|
||||
UI.updateContextBanner();
|
||||
}
|
||||
UI.toast(`DM with ${targetName}`, 'success');
|
||||
} catch (e) {
|
||||
UI.toast('Failed to create DM: ' + e.message, 'error');
|
||||
console.error('[newDM]', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1191,6 +1497,162 @@ async function newFolder() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Channel CRUD (v0.23.2) ─────────────────────
|
||||
|
||||
function showChannelContextMenu(e, channelId) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dismissChatContextMenu();
|
||||
|
||||
const ch = (App.channels || []).find(c => c.id === channelId);
|
||||
if (!ch) return;
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'project-ctx-menu';
|
||||
|
||||
let items = '';
|
||||
items += `<button class="ctx-item" onclick="openParticipantsPanel('${channelId}');dismissChatContextMenu()">
|
||||
<span class="ctx-icon">👥</span> Members
|
||||
</button>`;
|
||||
items += `<button class="ctx-item" onclick="renameChannel('${channelId}');dismissChatContextMenu()">
|
||||
<span class="ctx-icon">✏</span> Rename
|
||||
</button>`;
|
||||
if (ch.type === 'channel' || ch.type === 'group') {
|
||||
items += `<button class="ctx-item" onclick="editChannelTopic('${channelId}');dismissChatContextMenu()">
|
||||
<span class="ctx-icon">💬</span> Set topic
|
||||
</button>`;
|
||||
items += `<button class="ctx-item" onclick="cycleChannelAiMode('${channelId}');dismissChatContextMenu()">
|
||||
<span class="ctx-icon">🤖</span> AI mode: ${esc(ch.aiMode || 'auto')}
|
||||
</button>`;
|
||||
}
|
||||
items += '<div class="ctx-divider"></div>';
|
||||
items += `<button class="ctx-item" onclick="archiveChannel('${channelId}');dismissChatContextMenu()">
|
||||
<span class="ctx-icon">📦</span> Archive
|
||||
</button>`;
|
||||
// Delete gated by retention mode (default=flexible allows delete)
|
||||
const retentionMode = App.policies?.channel_retention_mode || 'flexible';
|
||||
if (retentionMode === 'flexible') {
|
||||
items += `<button class="ctx-item ctx-danger" onclick="deleteChannel('${channelId}');dismissChatContextMenu()">
|
||||
<span class="ctx-icon">🗑</span> Delete
|
||||
</button>`;
|
||||
}
|
||||
|
||||
menu.innerHTML = items;
|
||||
menu.style.left = Math.min(e.clientX, window.innerWidth - 180) + 'px';
|
||||
menu.style.top = Math.min(e.clientY, window.innerHeight - 150) + 'px';
|
||||
|
||||
document.body.appendChild(menu);
|
||||
_ctxMenu = menu;
|
||||
setTimeout(() => document.addEventListener('click', dismissChatContextMenu, { once: true }), 0);
|
||||
}
|
||||
|
||||
async function renameChannel(channelId) {
|
||||
const ch = (App.channels || []).find(c => c.id === channelId);
|
||||
if (!ch) return;
|
||||
const result = await _showCreationDialog({
|
||||
title: 'Rename Channel',
|
||||
placeholder: 'Channel name',
|
||||
withColor: false,
|
||||
defaultValue: ch.name,
|
||||
confirmLabel: 'Rename',
|
||||
});
|
||||
if (!result) return;
|
||||
try {
|
||||
await API.updateChannel(channelId, { title: result.name.trim() });
|
||||
ch.name = result.name.trim();
|
||||
// Also update in App.chats if present
|
||||
const chat = App.chats.find(c => c.id === channelId);
|
||||
if (chat) chat.title = result.name.trim();
|
||||
if (typeof UI !== 'undefined') UI.renderChannelsSection();
|
||||
UI.toast('Channel renamed', 'success');
|
||||
} catch (e) {
|
||||
UI.toast('Failed to rename channel', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function editChannelTopic(channelId) {
|
||||
const ch = (App.channels || []).find(c => c.id === channelId);
|
||||
if (!ch) return;
|
||||
const result = await _showCreationDialog({
|
||||
title: 'Set Topic',
|
||||
placeholder: 'Channel topic (optional)',
|
||||
withColor: false,
|
||||
defaultValue: ch.topic || '',
|
||||
confirmLabel: 'Save',
|
||||
});
|
||||
if (!result) return;
|
||||
try {
|
||||
// Topic is now a proper field on updateChannelRequest (v0.23.2)
|
||||
await API._put(`/api/v1/channels/${channelId}`, { topic: result.name.trim() });
|
||||
ch.topic = result.name.trim();
|
||||
if (typeof UI !== 'undefined') UI.updateContextBanner();
|
||||
UI.toast('Topic updated', 'success');
|
||||
} catch (e) {
|
||||
UI.toast('Failed to set topic', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteChannel(channelId) {
|
||||
if (!await showConfirm('Delete this channel and all its messages?')) return;
|
||||
try {
|
||||
await API.deleteChannel(channelId);
|
||||
App.channels = (App.channels || []).filter(c => c.id !== channelId);
|
||||
App.chats = App.chats.filter(c => c.id !== channelId);
|
||||
if (App.activeId === channelId) {
|
||||
App.setActive(null);
|
||||
UI.showEmptyState();
|
||||
}
|
||||
if (typeof UI !== 'undefined') {
|
||||
UI.renderChannelsSection();
|
||||
UI.renderChatList();
|
||||
UI.updateContextBanner();
|
||||
}
|
||||
UI.toast('Channel deleted', 'success');
|
||||
} catch (e) {
|
||||
UI.toast('Failed to delete: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveChannel(channelId) {
|
||||
try {
|
||||
await API.updateChannel(channelId, { is_archived: true });
|
||||
// Remove from sidebar (archived channels are hidden)
|
||||
App.channels = (App.channels || []).filter(c => c.id !== channelId);
|
||||
App.chats = App.chats.filter(c => c.id !== channelId);
|
||||
if (App.activeId === channelId) {
|
||||
App.setActive(null);
|
||||
UI.showEmptyState();
|
||||
}
|
||||
if (typeof UI !== 'undefined') {
|
||||
UI.renderChannelsSection();
|
||||
UI.renderChatList();
|
||||
UI.updateContextBanner();
|
||||
}
|
||||
UI.toast('Channel archived', 'success');
|
||||
} catch (e) {
|
||||
UI.toast('Failed to archive: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// v0.23.2: Cycle ai_mode on click: auto → mention_only → off → auto
|
||||
async function cycleChannelAiMode(channelId) {
|
||||
const ch = (App.channels || []).find(c => c.id === channelId);
|
||||
if (!ch) return;
|
||||
const order = ['auto', 'mention_only', 'off'];
|
||||
const idx = order.indexOf(ch.aiMode || 'auto');
|
||||
const next = order[(idx + 1) % order.length];
|
||||
try {
|
||||
await API.updateChannel(channelId, { ai_mode: next });
|
||||
ch.aiMode = next;
|
||||
if (typeof UI !== 'undefined') UI.updateContextBanner();
|
||||
UI.toast(`AI mode: ${next === 'mention_only' ? '@mention only' : next}`, 'success');
|
||||
} catch (e) {
|
||||
UI.toast('Failed to change AI mode: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Folder Menu + CRUD ─────────────────────────
|
||||
|
||||
function showFolderMenu(e, folderId) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -1238,13 +1700,69 @@ async function renameFolder(folderId) {
|
||||
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; });
|
||||
|
||||
const chatsInFolder = (App.chats || []).filter(c => c.folderId === folderId);
|
||||
|
||||
// If folder has chats, ask whether to preserve or delete them
|
||||
let preserveChats = true;
|
||||
if (chatsInFolder.length > 0) {
|
||||
const choice = await new Promise(resolve => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay active';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal" style="width:380px;">
|
||||
<div class="modal-header"><h3>Delete folder "${esc(folder.name)}"</h3></div>
|
||||
<div class="modal-body" style="padding:16px;font-size:13px;color:var(--text-2);">
|
||||
This folder contains <b>${chatsInFolder.length} chat${chatsInFolder.length > 1 ? 's' : ''}</b>.
|
||||
What should happen to them?
|
||||
</div>
|
||||
<div class="modal-footer" style="display:flex;gap:8px;justify-content:flex-end;">
|
||||
<button class="btn" id="_dfCancel">Cancel</button>
|
||||
<button class="btn btn-danger" id="_dfDeleteAll">Delete chats too</button>
|
||||
<button class="btn btn-primary" id="_dfPreserve">Keep chats</button>
|
||||
</div>
|
||||
</div>`;
|
||||
overlay.addEventListener('click', e => { if (e.target === overlay) { overlay.remove(); resolve(null); } });
|
||||
overlay.querySelector('#_dfCancel').onclick = () => { overlay.remove(); resolve(null); };
|
||||
overlay.querySelector('#_dfDeleteAll').onclick = () => { overlay.remove(); resolve('delete'); };
|
||||
overlay.querySelector('#_dfPreserve').onclick = () => { overlay.remove(); resolve('preserve'); };
|
||||
document.body.appendChild(overlay);
|
||||
});
|
||||
|
||||
if (!choice) return; // cancelled
|
||||
preserveChats = choice === 'preserve';
|
||||
} else {
|
||||
// Empty folder — just confirm
|
||||
if (!await showConfirm(`Delete folder "${folder.name}"?`)) return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (preserveChats) {
|
||||
// Unfile chats before deleting the folder
|
||||
for (const c of chatsInFolder) {
|
||||
c.folderId = null;
|
||||
}
|
||||
} else {
|
||||
// Delete the chats in the folder
|
||||
for (const c of chatsInFolder) {
|
||||
try {
|
||||
await API.deleteChannel(c.id);
|
||||
// Clean up active state if deleting the active chat
|
||||
if (App.activeId === c.id) {
|
||||
App.setActive(null);
|
||||
}
|
||||
} catch (_) { /* best effort */ }
|
||||
}
|
||||
App.chats = App.chats.filter(c => c.folderId !== folderId);
|
||||
}
|
||||
|
||||
await API.deleteFolder(folderId);
|
||||
App.folders = App.folders.filter(f => f.id !== folderId);
|
||||
if (typeof UI !== 'undefined') UI.renderChatList();
|
||||
UI.toast('Folder deleted', 'success');
|
||||
if (typeof UI !== 'undefined') {
|
||||
UI.renderChatList();
|
||||
if (!preserveChats && !App.activeId) UI.showEmptyState();
|
||||
}
|
||||
UI.toast(preserveChats ? 'Folder deleted, chats preserved' : 'Folder and chats deleted', 'success');
|
||||
} catch (e) {
|
||||
UI.toast('Failed to delete folder', 'error');
|
||||
console.error('[deleteFolder]', e);
|
||||
@@ -1255,11 +1773,172 @@ async function deleteFolder(folderId) {
|
||||
|
||||
(function startPresenceHeartbeat() {
|
||||
const INTERVAL = 30000; // 30s
|
||||
let timer = null;
|
||||
async function beat() {
|
||||
if (!API.isAuthed) return;
|
||||
try {
|
||||
await API.presenceHeartbeat();
|
||||
} catch (_) { /* non-critical */ }
|
||||
}
|
||||
setInterval(beat, INTERVAL);
|
||||
function start() {
|
||||
if (timer) return;
|
||||
beat(); // immediate on resume
|
||||
timer = setInterval(beat, INTERVAL);
|
||||
}
|
||||
function stop() {
|
||||
if (timer) { clearInterval(timer); timer = null; }
|
||||
}
|
||||
start();
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) stop(); else start();
|
||||
});
|
||||
})();
|
||||
|
||||
// ── Participants Panel (v0.23.2) ──────────────────────────────────────────
|
||||
|
||||
async function openParticipantsPanel(channelId) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay active';
|
||||
overlay.id = '_participantsOverlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal" style="width:440px;max-height:80vh;display:flex;flex-direction:column;">
|
||||
<div class="modal-header"><h3>Channel Participants</h3></div>
|
||||
<div class="modal-body" id="_participantsList" style="padding:12px;overflow-y:auto;flex:1;">
|
||||
<div class="loading">Loading…</div>
|
||||
</div>
|
||||
<div style="padding:12px;border-top:1px solid var(--border);display:flex;gap:8px;">
|
||||
<button class="btn btn-primary" id="_addUserBtn" style="flex:1;">+ Add User</button>
|
||||
<button class="btn" id="_addPersonaBtn" style="flex:1;">+ Add Persona</button>
|
||||
</div>
|
||||
<div style="padding:0 12px 12px;">
|
||||
<button class="btn" style="width:100%;" id="_closeParticipants">Close</button>
|
||||
</div>
|
||||
</div>`;
|
||||
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
|
||||
overlay.querySelector('#_closeParticipants').onclick = () => overlay.remove();
|
||||
overlay.querySelector('#_addUserBtn').onclick = () => _showAddParticipantSearch(channelId, 'user');
|
||||
overlay.querySelector('#_addPersonaBtn').onclick = () => _showAddParticipantSearch(channelId, 'persona');
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
await _refreshParticipantsList(channelId);
|
||||
}
|
||||
|
||||
async function _refreshParticipantsList(channelId) {
|
||||
const el = document.getElementById('_participantsList');
|
||||
if (!el) return;
|
||||
try {
|
||||
const resp = await API._get(`/api/v1/channels/${channelId}/participants`);
|
||||
const parts = resp.participants || [];
|
||||
if (parts.length === 0) {
|
||||
el.innerHTML = '<div class="empty-hint">No participants yet</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = parts.map(p => {
|
||||
const icon = p.participant_type === 'persona' ? '⚡' : '👤';
|
||||
// Resolve name: display_name from record, or look up in App state
|
||||
let name = p.display_name;
|
||||
if (!name && p.participant_type === 'user') {
|
||||
const u = (App.users || []).find(u => u.id === p.participant_id);
|
||||
name = u?.displayName || u?.username || null;
|
||||
// Also check current user
|
||||
if (!name && API.user && API.user.id === p.participant_id) {
|
||||
name = API.user.display_name || API.user.username;
|
||||
}
|
||||
}
|
||||
if (!name && p.participant_type === 'persona') {
|
||||
const m = (App.models || []).find(m => m.personaId === p.participant_id);
|
||||
name = m?.name || null;
|
||||
}
|
||||
if (!name) name = p.participant_id.slice(0, 8) + '…';
|
||||
const role = p.role || 'member';
|
||||
return `<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border-faint);">
|
||||
<span style="font-size:16px;">${icon}</span>
|
||||
<span style="flex:1;font-size:13px;">${esc(name)}</span>
|
||||
<span style="font-size:11px;color:var(--text-3);">${esc(p.participant_type)} · ${esc(role)}</span>
|
||||
${role !== 'owner' ? `<button class="btn-small" style="color:var(--danger);"
|
||||
onclick="_removeParticipant('${channelId}','${p.id}')">✕</button>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div class="empty-hint">Failed to load: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function _showAddParticipantSearch(channelId, pType) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay active';
|
||||
overlay.style.zIndex = '10001';
|
||||
const label = pType === 'persona' ? 'Persona' : 'User';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal" style="width:360px;">
|
||||
<div class="modal-header"><h3>Add ${label}</h3></div>
|
||||
<div class="modal-body" style="padding:12px;">
|
||||
<input class="input-full" id="_addPartSearch" placeholder="Search ${label.toLowerCase()}s…" autocomplete="off">
|
||||
<div id="_addPartResults" style="margin-top:8px;max-height:200px;overflow-y:auto;"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const input = overlay.querySelector('#_addPartSearch');
|
||||
const results = overlay.querySelector('#_addPartResults');
|
||||
input.focus();
|
||||
|
||||
let debounce = null;
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(debounce);
|
||||
debounce = setTimeout(async () => {
|
||||
const q = input.value.trim();
|
||||
if (q.length < 1) { results.innerHTML = ''; return; }
|
||||
try {
|
||||
let items = [];
|
||||
if (pType === 'user') {
|
||||
const resp = await API._get(`/api/v1/users/search?q=${encodeURIComponent(q)}`);
|
||||
items = (resp.users || []).map(u => ({ id: u.id, name: u.display_name || u.username }));
|
||||
} else {
|
||||
// Search personas from App.models
|
||||
items = (App.models || [])
|
||||
.filter(m => m.isPersona && (m.name || '').toLowerCase().includes(q.toLowerCase()))
|
||||
.map(m => ({ id: m.personaId, name: m.name }));
|
||||
}
|
||||
if (items.length === 0) {
|
||||
results.innerHTML = '<div class="empty-hint" style="font-size:12px;">No matches</div>';
|
||||
return;
|
||||
}
|
||||
results.innerHTML = items.slice(0, 10).map(item =>
|
||||
`<div style="padding:6px 8px;cursor:pointer;border-radius:4px;font-size:13px;"
|
||||
class="hover-bg"
|
||||
onclick="_addParticipant('${channelId}','${pType}','${item.id}');this.closest('.modal-overlay').remove();">
|
||||
${pType === 'persona' ? '⚡' : '👤'} ${esc(item.name)}
|
||||
</div>`
|
||||
).join('');
|
||||
} catch (_) {
|
||||
results.innerHTML = '<div class="empty-hint" style="font-size:12px;">Search failed</div>';
|
||||
}
|
||||
}, 250);
|
||||
});
|
||||
}
|
||||
|
||||
async function _addParticipant(channelId, pType, participantId) {
|
||||
try {
|
||||
await API._post(`/api/v1/channels/${channelId}/participants`, {
|
||||
participant_type: pType,
|
||||
participant_id: participantId,
|
||||
role: 'member',
|
||||
});
|
||||
UI.toast(`${pType === 'persona' ? 'Persona' : 'User'} added`, 'success');
|
||||
await _refreshParticipantsList(channelId);
|
||||
} catch (e) {
|
||||
UI.toast('Failed to add: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function _removeParticipant(channelId, recordId) {
|
||||
try {
|
||||
await API._del(`/api/v1/channels/${channelId}/participants/${recordId}`);
|
||||
UI.toast('Participant removed', 'success');
|
||||
await _refreshParticipantsList(channelId);
|
||||
} catch (e) {
|
||||
UI.toast('Failed to remove: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ function updateInputTokens() {
|
||||
const budget = Tokens.getContextBudget();
|
||||
if (budget.maxContext > 0) {
|
||||
// Show relative to available context
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
const chat = App.chats.find(c => c.id === App.activeId);
|
||||
const convTokens = Tokens.estimateConversation(chat?.messages || [], App.settings.systemPrompt);
|
||||
// Include staged file estimates
|
||||
const fileTokens = Tokens.estimateFiles(
|
||||
@@ -114,7 +114,7 @@ function updateContextWarning() {
|
||||
return;
|
||||
}
|
||||
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
const chat = App.chats.find(c => c.id === App.activeId);
|
||||
if (!chat || !chat.messages?.length) {
|
||||
warning.style.display = 'none';
|
||||
return;
|
||||
|
||||
@@ -47,6 +47,7 @@ const ADMIN_LOADERS = {
|
||||
usage: () => UI.loadAdminUsage(),
|
||||
audit: () => UI.loadAuditLog(),
|
||||
stats: () => UI.loadAdminStats(),
|
||||
channels: () => UI.loadAdminChannels(),
|
||||
};
|
||||
|
||||
// Find which category a section belongs to
|
||||
@@ -1447,4 +1448,55 @@ Object.assign(UI, {
|
||||
prev.style.color = fg;
|
||||
},
|
||||
|
||||
// ── Admin Channels (v0.23.2) ──────────────
|
||||
|
||||
async loadAdminChannels() {
|
||||
const list = document.getElementById('adminArchivedList');
|
||||
const countEl = document.getElementById('adminArchivedCount');
|
||||
const modeEl = document.getElementById('adminRetentionMode');
|
||||
if (!list) return;
|
||||
|
||||
try {
|
||||
const resp = await API._get('/api/v1/admin/channels/archived');
|
||||
const channels = resp.channels || [];
|
||||
if (countEl) countEl.textContent = channels.length;
|
||||
if (modeEl) modeEl.textContent = resp.retention_mode || 'flexible';
|
||||
|
||||
if (channels.length === 0) {
|
||||
list.innerHTML = '<div class="empty-hint">No archived channels</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<table class="admin-table"><thead><tr>' +
|
||||
'<th>Title</th><th>Type</th><th>Owner</th><th>Messages</th><th>Archived</th><th></th>' +
|
||||
'</tr></thead><tbody>';
|
||||
for (const ch of channels) {
|
||||
const age = _relativeTime(ch.updated_at);
|
||||
html += `<tr>
|
||||
<td>${esc(ch.title || 'Untitled')}</td>
|
||||
<td>${esc(ch.type)}</td>
|
||||
<td>${esc(ch.owner_name)}</td>
|
||||
<td>${ch.message_count}</td>
|
||||
<td>${age}</td>
|
||||
<td><button class="btn-small btn-danger" onclick="UI._purgeChannel('${ch.id}')">Purge</button></td>
|
||||
</tr>`;
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
list.innerHTML = html;
|
||||
} catch (e) {
|
||||
list.innerHTML = `<div class="empty-hint">Failed to load: ${esc(e.message)}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
async _purgeChannel(channelId) {
|
||||
if (!await showConfirm('Permanently delete this archived channel and all its messages? This cannot be undone.')) return;
|
||||
try {
|
||||
await API._del(`/api/v1/admin/channels/${channelId}/purge`);
|
||||
UI.toast('Channel purged', 'success');
|
||||
UI.loadAdminChannels(); // refresh list
|
||||
} catch (e) {
|
||||
UI.toast('Purge failed: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ function avatarHTML(role, avatarDataURI) {
|
||||
if (avatarDataURI) {
|
||||
return `<img src="${avatarDataURI}" alt="" class="msg-avatar-img">`;
|
||||
}
|
||||
return role === 'user' ? '👤' : '🤖';
|
||||
return role === 'user' ? '👤' : role === 'other-user' ? '👤' : '🤖';
|
||||
}
|
||||
|
||||
// Look up the current model/persona avatar for assistant messages.
|
||||
@@ -394,7 +394,7 @@ const UI = {
|
||||
|
||||
let html = '';
|
||||
channels.forEach(ch => {
|
||||
const isActive = App.currentChannelId === ch.id;
|
||||
const isActive = App.activeId === ch.id;
|
||||
const isDM = ch.type === 'dm';
|
||||
const isChannel = ch.type === 'channel';
|
||||
const online = App.presence?.[ch.dmPartnerId] === 'online';
|
||||
@@ -413,9 +413,11 @@ const UI = {
|
||||
|
||||
html += `<div class="sb-channel-item${isActive ? ' active' : ''}"
|
||||
onclick="selectChannel('${ch.id}')"
|
||||
oncontextmenu="showChannelContextMenu(event,'${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}
|
||||
${!collapsed ? `<span class="sb-ch-name">${esc(ch.name)}</span>${onlineDot}${unreadBadge}
|
||||
<button class="sb-ch-menu" onclick="event.stopPropagation();showChannelContextMenu(event,'${ch.id}')" title="Options">⋯</button>` : unreadBadge}
|
||||
</div>`;
|
||||
});
|
||||
|
||||
@@ -470,18 +472,21 @@ const UI = {
|
||||
|
||||
let html = '';
|
||||
|
||||
// Folder groups — always render, even when empty
|
||||
// Folder groups — always render, even when empty (with drag handlers)
|
||||
folders.forEach(f => {
|
||||
const items = folderMap[f.id] || [];
|
||||
const isOpen = !App.collapsedFolders?.[f.id];
|
||||
html += `<div class="sb-folder-group">
|
||||
html += `<div class="sb-folder-group"
|
||||
ondragover="event.preventDefault();event.dataTransfer.dropEffect='move';this.classList.add('drag-over')"
|
||||
ondragleave="this.classList.remove('drag-over')"
|
||||
ondrop="onFolderDrop(event,'${f.id}')">
|
||||
<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>
|
||||
<span class="sb-folder-arrow">${isOpen ? '▾' : '▸'}</span>
|
||||
</div>
|
||||
${isOpen
|
||||
? (items.length > 0
|
||||
@@ -503,6 +508,17 @@ const UI = {
|
||||
else groups.older.push(c);
|
||||
});
|
||||
|
||||
// When folders exist, wrap unfiled chats in a drop zone
|
||||
if (folders.length > 0) {
|
||||
html += `<div class="sb-unfiled-zone"
|
||||
ondragover="event.preventDefault();event.dataTransfer.dropEffect='move';this.classList.add('drag-over')"
|
||||
ondragleave="this.classList.remove('drag-over')"
|
||||
ondrop="onUnfiledDrop(event)">`;
|
||||
if (unfiled.length === 0) {
|
||||
html += '<div class="sb-unfiled-hint">Drop here to unfile</div>';
|
||||
}
|
||||
}
|
||||
|
||||
const renderTimeGroup = (label, items) => {
|
||||
if (!items.length) return;
|
||||
html += `<div class="chat-group-label">${label}</div>`;
|
||||
@@ -514,6 +530,10 @@ const UI = {
|
||||
renderTimeGroup('Previous 7 days', groups.week);
|
||||
renderTimeGroup('Older', groups.older);
|
||||
|
||||
if (folders.length > 0) {
|
||||
html += '</div>'; // close .sb-unfiled-zone
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
|
||||
// Always keep projects section in sync
|
||||
@@ -528,7 +548,7 @@ const UI = {
|
||||
|
||||
_chatItemHTML(c, indented = false) {
|
||||
const time = _relativeTime(c.updatedAt);
|
||||
const active = c.id === App.currentChatId ? ' active' : '';
|
||||
const active = c.id === App.activeId ? ' active' : '';
|
||||
const indent = indented ? ' indented' : '';
|
||||
const typeIcon = c.type === 'group'
|
||||
? '<span class="chat-type-icon" title="Group Chat">👥</span> '
|
||||
@@ -617,19 +637,40 @@ const UI = {
|
||||
// Skip summary messages in normal rendering — they get _summaryHTML
|
||||
if (_isSummaryMessage(msg)) return '';
|
||||
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
||||
let assistantLabel = 'Assistant';
|
||||
if (!isUser) {
|
||||
// Use stored modelName, or resolve from channel roster, or model list, or fall back
|
||||
if (msg.modelName) {
|
||||
assistantLabel = msg.modelName;
|
||||
|
||||
// v0.23.2: Sender attribution — distinguish self from other humans
|
||||
const isSelf = !isUser ? false
|
||||
: (!msg.participant_id || msg.participant_id === API.user?.id);
|
||||
const isOtherUser = isUser && !isSelf;
|
||||
|
||||
let senderLabel = 'You';
|
||||
let senderAvatarURI = API.user?.avatar || null;
|
||||
|
||||
if (isOtherUser) {
|
||||
senderLabel = msg.sender_name || 'User';
|
||||
senderAvatarURI = msg.sender_avatar || null;
|
||||
} else if (!isUser) {
|
||||
// Assistant message — use model/persona label
|
||||
senderLabel = 'Assistant';
|
||||
senderAvatarURI = assistantAvatarURI(msg);
|
||||
if (msg.sender_name) {
|
||||
senderLabel = msg.sender_name;
|
||||
} else if (msg.modelName) {
|
||||
senderLabel = msg.modelName;
|
||||
} else if (typeof ChannelModels !== 'undefined' && msg.model) {
|
||||
assistantLabel = ChannelModels.resolveDisplayName(msg.model);
|
||||
senderLabel = ChannelModels.resolveDisplayName(msg.model);
|
||||
} else if (msg.model) {
|
||||
const m = App.models.find(x => x.id === msg.model || x.baseModelId === msg.model);
|
||||
assistantLabel = m?.name || msg.model;
|
||||
senderLabel = m?.name || msg.model;
|
||||
}
|
||||
if (msg.sender_avatar) {
|
||||
senderAvatarURI = msg.sender_avatar;
|
||||
}
|
||||
}
|
||||
|
||||
// CSS class: self = right-aligned "user", other humans = left-aligned "other-user", AI = "assistant"
|
||||
const roleClass = isSelf ? 'user' : isOtherUser ? 'other-user' : msg.role;
|
||||
|
||||
// Branch indicator: show ‹ 2/3 › when siblings exist
|
||||
const hasSiblings = (msg.siblingCount || 1) > 1;
|
||||
const sibPos = (msg.siblingIndex || 0) + 1; // display as 1-indexed
|
||||
@@ -645,16 +686,16 @@ const UI = {
|
||||
|
||||
// Action buttons
|
||||
const msgId = msg.id ? esc(msg.id) : '';
|
||||
const editBtn = isUser && msgId ? `<button class="msg-action-btn" onclick="editMessage('${msgId}')" title="Edit">Edit</button>` : '';
|
||||
const editBtn = isSelf && msgId ? `<button class="msg-action-btn" onclick="editMessage('${msgId}')" title="Edit">Edit</button>` : '';
|
||||
const regenBtn = !isUser && msgId ? `<button class="msg-action-btn" onclick="regenerateMessage('${msgId}')" title="Regenerate">Regen</button>` : '';
|
||||
|
||||
return `
|
||||
<div class="message ${msg.role}${dimmed ? ' msg-dimmed' : ''}" data-msg-id="${msgId}">
|
||||
<div class="message ${roleClass}${dimmed ? ' msg-dimmed' : ''}" data-msg-id="${msgId}">
|
||||
<div class="msg-inner">
|
||||
<div class="msg-avatar">${avatarHTML(msg.role, isUser ? API.user?.avatar : assistantAvatarURI(msg))}</div>
|
||||
<div class="msg-avatar">${avatarHTML(isOtherUser ? 'other-user' : msg.role, senderAvatarURI)}</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-head">
|
||||
<span class="msg-role">${isUser ? 'You' : esc(assistantLabel)}</span>
|
||||
<span class="msg-role">${esc(senderLabel)}</span>
|
||||
${branchNav}
|
||||
${time ? `<span class="msg-time">${time}</span>` : ''}
|
||||
<div class="msg-actions">
|
||||
@@ -680,6 +721,45 @@ const UI = {
|
||||
<h2>Chat Switchboard</h2>
|
||||
<p>Select a model and start chatting</p>
|
||||
</div>`;
|
||||
this.updateContextBanner();
|
||||
},
|
||||
|
||||
// v0.23.2: Show/hide context banner based on active conversation type
|
||||
updateContextBanner() {
|
||||
const el = document.getElementById('channelContextBanner');
|
||||
if (!el) return;
|
||||
const ac = App.activeConversation;
|
||||
if (!ac || ac.type === 'direct') {
|
||||
el.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
if (ac.type === 'dm') {
|
||||
const ch = (App.channels || []).find(c => c.id === ac.id);
|
||||
const name = ch?.name || 'someone';
|
||||
html = `<span class="ctx-mode">DM</span>` +
|
||||
`<span>with <b>${esc(name)}</b></span>` +
|
||||
`<span class="ctx-hint">@mention a persona to bring in AI</span>` +
|
||||
`<span style="flex:1;"></span>` +
|
||||
`<button class="ctx-participants-btn" onclick="openParticipantsPanel('${ac.id}')" title="Manage participants">👥 Members</button>`;
|
||||
} else if (ac.type === 'group') {
|
||||
html = `<span class="ctx-mode">Group</span>` +
|
||||
`<span class="ctx-hint">No @mention = leader responds · @mention to route · @all for everyone</span>` +
|
||||
`<span style="flex:1;"></span>` +
|
||||
`<button class="ctx-participants-btn" onclick="openParticipantsPanel('${ac.id}')" title="Manage participants">👥 Members</button>`;
|
||||
} else if (ac.type === 'channel') {
|
||||
const ch = (App.channels || []).find(c => c.id === ac.id);
|
||||
const mode = ch?.aiMode || 'auto';
|
||||
const topic = ch?.topic || '';
|
||||
const modeLabel = mode === 'auto' ? 'AI: auto' : mode === 'mention_only' ? 'AI: @mention only' : 'AI: off';
|
||||
html = `<button class="ctx-mode ctx-mode-btn" onclick="cycleChannelAiMode('${ac.id}')" title="Click to change AI mode">${esc(modeLabel)}</button>`;
|
||||
if (topic) html += `<span class="ctx-topic">${esc(topic)}</span>`;
|
||||
html += `<span style="flex:1;"></span>`;
|
||||
html += `<button class="ctx-participants-btn" onclick="openParticipantsPanel('${ac.id}')" title="Manage participants">👥 Members</button>`;
|
||||
}
|
||||
el.innerHTML = html;
|
||||
el.style.display = '';
|
||||
},
|
||||
|
||||
showEditInline(messageId, currentContent) {
|
||||
@@ -1278,7 +1358,7 @@ const UI = {
|
||||
// ── Export ────────────────────────────────
|
||||
|
||||
exportChat(format) {
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
const chat = App.getActiveChat();
|
||||
if (!chat) return UI.toast('No chat to export', 'warning');
|
||||
|
||||
let content, ext, mime;
|
||||
@@ -1302,7 +1382,7 @@ const UI = {
|
||||
// ── Message Actions ──────────────────────
|
||||
|
||||
copyMessage(index) {
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
const chat = App.getActiveChat();
|
||||
if (chat?.messages[index]) {
|
||||
navigator.clipboard.writeText(chat.messages[index].content)
|
||||
.then(() => UI.toast('Copied', 'success'))
|
||||
|
||||
@@ -58,35 +58,44 @@ function formatMessage(content) {
|
||||
function _highlightMentions(html) {
|
||||
if (typeof ChannelModels === 'undefined') return html;
|
||||
const roster = ChannelModels.getRoster();
|
||||
if (!roster || roster.length < 2) return html;
|
||||
|
||||
// Build patterns from handles (primary) and display names (fallback)
|
||||
const patterns = [];
|
||||
// Build patterns from roster handles and display names (AI mentions)
|
||||
const aiPatterns = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const m of roster) {
|
||||
// Handle pattern (preferred)
|
||||
if (m.handle) {
|
||||
const escaped = m.handle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
if (!seen.has(escaped)) {
|
||||
patterns.push(new RegExp('(@' + escaped + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
|
||||
seen.add(escaped);
|
||||
if (roster && roster.length >= 2) {
|
||||
for (const m of roster) {
|
||||
if (m.handle) {
|
||||
const escaped = m.handle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
if (!seen.has(escaped)) {
|
||||
aiPatterns.push(new RegExp('(@' + escaped + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
|
||||
seen.add(escaped);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Display name pattern (hyphenated form)
|
||||
if (m.display_name) {
|
||||
const hyphenated = m.display_name.replace(/\s+/g, '-');
|
||||
const escaped = hyphenated.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
if (!seen.has(escaped.toLowerCase())) {
|
||||
const flexible = escaped.replace(/[-]+/g, '[\\s-]+');
|
||||
patterns.push(new RegExp('(@' + flexible + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
|
||||
seen.add(escaped.toLowerCase());
|
||||
if (m.display_name) {
|
||||
const hyphenated = m.display_name.replace(/\s+/g, '-');
|
||||
const escaped = hyphenated.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
if (!seen.has(escaped.toLowerCase())) {
|
||||
const flexible = escaped.replace(/[-]+/g, '[\\s-]+');
|
||||
aiPatterns.push(new RegExp('(@' + flexible + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
|
||||
seen.add(escaped.toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @all
|
||||
patterns.push(new RegExp('(@all)(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
|
||||
// @all is special
|
||||
aiPatterns.push(new RegExp('(@all)(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
|
||||
|
||||
// v0.23.2: Build user mention patterns
|
||||
const userPatterns = [];
|
||||
for (const u of (App.users || [])) {
|
||||
const escaped = u.username.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
if (!seen.has(escaped.toLowerCase())) {
|
||||
userPatterns.push(new RegExp('(@' + escaped + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
|
||||
seen.add(escaped.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
// Don't highlight inside <code>, <pre>, <a> tags
|
||||
const parts = html.split(/(<[^>]+>)/);
|
||||
@@ -101,9 +110,14 @@ function _highlightMentions(html) {
|
||||
}
|
||||
if (inCode) continue;
|
||||
let text = part;
|
||||
for (const re of patterns) {
|
||||
// AI mentions first (persona/model)
|
||||
for (const re of aiPatterns) {
|
||||
text = text.replace(re, '<span class="mention-pill">$1</span>');
|
||||
}
|
||||
// User mentions (different color)
|
||||
for (const re of userPatterns) {
|
||||
text = text.replace(re, '<span class="mention-pill mention-user">$1</span>');
|
||||
}
|
||||
parts[i] = text;
|
||||
}
|
||||
return parts.join('');
|
||||
|
||||
@@ -106,6 +106,11 @@ Object.assign(UI, {
|
||||
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
|
||||
if (scaleEl) prefs.scale = parseInt(scaleEl.value);
|
||||
if (msgFontEl) prefs.msgFont = parseInt(msgFontEl.value);
|
||||
// v0.23.2: Theme saved via Theme API (localStorage key: switchboard_theme)
|
||||
const activeThemeBtn = document.querySelector('#themeToggle .theme-btn.active');
|
||||
if (activeThemeBtn && typeof Theme !== 'undefined') {
|
||||
Theme.set(activeThemeBtn.dataset.theme);
|
||||
}
|
||||
localStorage.setItem('cs-appearance', JSON.stringify(prefs));
|
||||
UI.toast('Appearance saved', 'success');
|
||||
},
|
||||
@@ -141,7 +146,8 @@ Object.assign(UI, {
|
||||
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
|
||||
const scale = prefs.scale || 100;
|
||||
const msgFont = prefs.msgFont || 14;
|
||||
const theme = prefs.theme || 'system';
|
||||
// v0.23.2: Read theme from Theme API
|
||||
const theme = typeof Theme !== 'undefined' ? Theme.get() : 'system';
|
||||
const keymap = prefs.editorKeymap || 'standard';
|
||||
|
||||
const scaleEl = document.getElementById('settingsScale');
|
||||
@@ -149,9 +155,14 @@ Object.assign(UI, {
|
||||
if (scaleEl) { scaleEl.value = scale; document.getElementById('scaleValue').textContent = scale + '%'; }
|
||||
if (msgFontEl) { msgFontEl.value = msgFont; document.getElementById('msgFontValue').textContent = msgFont + 'px'; }
|
||||
|
||||
// Highlight active theme button
|
||||
// Highlight active theme button and wire click
|
||||
document.querySelectorAll('#themeToggle .theme-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.theme === theme);
|
||||
btn.onclick = () => {
|
||||
document.querySelectorAll('#themeToggle .theme-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
if (typeof Theme !== 'undefined') Theme.set(btn.dataset.theme);
|
||||
};
|
||||
});
|
||||
|
||||
// Highlight active keymap button
|
||||
|
||||
Reference in New Issue
Block a user