// ==========================================
// Chat Switchboard – Chat Operations
// ==========================================
// Chat management, send, stream, regenerate, edit, branch,
// summarize, per-chat model persistence.
//
// Exports: ChatInput, loadChats, selectChat, newChat, newGroupChat,
// deleteChat, startRenameChat, sendMessage, stopGeneration,
// reloadActivePath, regenerateMessage, regenerate, editMessage,
// submitEdit, cancelEdit, switchSibling, advanceWorkflowStage,
// rejectWorkflowStage, _initChatListeners,
// summarizeAndContinue, toggleChannelAutoCompact
// ── Chat Input Abstraction ──────────────────
// Wraps CM6 editor (when available) or fallback textarea.
// All code that reads/writes the message input goes through this.
const ChatInput = {
_editor: null, // CM6 instance, set during init
_textarea: null, // fallback textarea element
getValue() {
if (this._editor) return this._editor.getValue();
return this._textarea?.value || '';
},
setValue(text) {
if (this._editor) {
this._editor.setValue(text || '');
} else if (this._textarea) {
this._textarea.value = text || '';
this._textarea.style.height = 'auto';
}
},
focus() {
if (this._editor) this._editor.focus();
else this._textarea?.focus();
},
/** Get the DOM element (for paste listeners, etc.) */
getDom() {
if (this._editor) return this._editor.getView().contentDOM;
return this._textarea;
},
/** Get the visible input element (for positioning, getBoundingClientRect) */
get el() {
if (this._editor) return this._editor.getView().dom;
return this._textarea;
},
/** Get the wrapper DOM element (for resize, etc.) */
getWrapDom() {
if (this._editor) return this._editor.getView().dom;
return this._textarea;
},
/** Get cursor byte offset (for @mention autocomplete) */
getCursorPos() {
if (this._editor) {
const view = this._editor.getView();
return view.state.selection.main.head;
}
return this._textarea?.selectionStart || 0;
},
/** Set cursor byte offset (for @mention autocomplete) */
setCursorPos(pos) {
if (this._editor) {
const view = this._editor.getView();
view.dispatch({ selection: { anchor: pos } });
} else if (this._textarea) {
this._textarea.selectionStart = this._textarea.selectionEnd = pos;
}
},
/** Initialize — try CM6, fall back to textarea */
init() {
this._textarea = document.getElementById('messageInput');
const wrap = document.getElementById('messageInputWrap');
if (window.CM?.chatInput && wrap) {
// Hide the textarea, create CM6 editor in its place
this._textarea.style.display = 'none';
this._editor = CM.chatInput(wrap, {
placeholder: 'Type a message\u2026',
onSubmit: () => sendMessage(),
onChange: () => {
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,
});
// @mention autocomplete: intercept keys on CM6 contentDOM (v0.20.0)
this._editor.getView().contentDOM.addEventListener('keydown', (e) => {
if (typeof ChannelModels !== 'undefined' && ChannelModels.handleKey(e)) {
e.preventDefault();
e.stopPropagation();
}
});
DebugLog?.push?.('chat', '[CM6] Chat input initialized');
} else {
// Fallback: textarea with manual event handling
DebugLog?.push?.('chat', '[CM6] Not available — using textarea fallback');
this._textarea.addEventListener('keydown', (e) => {
// @mention autocomplete intercepts arrow/enter/escape (v0.20.0)
if (typeof ChannelModels !== 'undefined' && ChannelModels.handleKey(e)) return;
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
this._textarea.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
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.activeId;
if (!channelId) return;
const btn = document.getElementById('summarizeBtn');
if (btn) {
btn.disabled = true;
btn.textContent = '⏳ Summarizing…';
}
try {
const result = await API.summarizeChannel(channelId);
UI.toast(`Conversation summarized (${result.summarized_count} messages)`, 'success');
// Reload the active path to get the updated message tree
const resp = await API.getActivePath(channelId);
const chat = App.chats.find(c => c.id === channelId);
if (chat && resp) {
chat.messages = (resp.messages || []).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,
}));
UI.renderMessages(chat.messages);
updateContextWarning(); updateChatTokenCount();
updateInputTokens();
}
} catch (err) {
console.error('Summarize failed:', err);
UI.toast(err.message || 'Summarization failed', 'error');
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = '📝 Summarize & Continue';
}
}
}
// ── Per-Channel Auto-Compaction Toggle ──────
async function toggleChannelAutoCompact(enabled) {
const chatId = App.activeId;
if (!chatId) return;
const chat = App.chats.find(c => c.id === chatId);
if (!chat) return;
// Update local state
if (!chat.settings) chat.settings = {};
chat.settings.auto_compaction = enabled;
// Persist to server (JSONB merge)
try {
await API.updateChannel(chatId, { settings: { auto_compaction: enabled } });
} catch (e) {
console.warn('Failed to save auto-compaction setting:', e.message);
}
}
// ── Chat Management ──────────────────────────
async function loadChats() {
try {
const resp = await API.listChannels(1, 100);
App.chats = (resp.data || []).map(c => ({
id: c.id,
title: c.title,
type: c.type || 'direct',
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,
settings: c.settings || {},
}));
} catch (e) {
console.error('Failed to load chats:', e.message);
UI.toast('Failed to load chats', 'error');
}
}
async function selectChat(chatId) {
clearStaged(); // Discard any staged files from previous chat
// v0.28.6: Reset virtual scroll when switching conversations
if (typeof VirtualScroll !== 'undefined') VirtualScroll.destroy();
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';
}
if (!chat) return;
if (chat.messages.length === 0 && chat.messageCount > 0) {
try {
const resp = await API.getActivePath(chatId);
chat.messages = (resp.messages || []).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,
}));
} catch (e) {
console.error('Failed to load messages:', e.message);
UI.toast('Failed to load messages', 'error');
}
}
// Restore the last-used model/persona for this chat
_restoreChatModel(chatId, chat);
// Load multi-model roster for @mention routing (v0.20.0)
if (typeof ChannelModels !== 'undefined') ChannelModels.load(chatId);
// Load files for message rendering (non-blocking, best-effort)
await loadChannelFiles(chatId);
UI.renderMessages(chat.messages);
UI.updateContextBanner();
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
Tokens._warningDismissed = false;
updateContextWarning(); updateChatTokenCount();
updateInputTokens();
// Refresh KB toggle state for the new channel
if (typeof KnowledgeUI !== 'undefined') KnowledgeUI.onChatChanged();
updateChatTokenCount();
// Notify surfaces and extensions about channel switch (v0.21.6)
sw.emit('chat.switched', { chatId, projectId: chat.projectId || null }, { localOnly: true });
// 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 ──────────────────
function updateChatTokenCount() {
const el = document.getElementById('chatTokenCount');
if (!el) return;
const chat = App.getActiveChat();
if (!chat || !chat.messages.length) { el.textContent = ''; return; }
const tokens = Tokens.estimateConversation(chat.messages, App.settings?.systemPrompt);
const budget = Tokens.getContextBudget();
if (budget.maxContext > 0) {
const pct = tokens / budget.maxContext;
el.textContent = `${Tokens.format(tokens)} / ${Tokens.format(budget.maxContext)}`;
el.className = 'chat-token-count' + (pct > 0.9 ? ' danger' : pct > 0.75 ? ' warning' : '');
} else {
el.textContent = `~${Tokens.format(tokens)}`;
el.className = 'chat-token-count';
}
}
// ── Per-Chat Model/Persona Persistence ────────
// Server-side: saved in channel.settings.last_selector_id (JSONB merge).
// localStorage used as write-through cache for instant restore without
// waiting for the channel list API call.
function _saveChatModel(chatId) {
if (!chatId) return;
const selectorId = UI.getModelValue();
if (!selectorId) return;
// Write-through: localStorage for instant restore on next visit
try {
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
map[chatId] = selectorId;
localStorage.setItem('cs-chat-models', JSON.stringify(map));
} catch (e) { /* quota exceeded, etc */ }
// Update in-memory chat object
const chat = App.chats.find(c => c.id === chatId);
if (chat) {
if (!chat.settings) chat.settings = {};
chat.settings.last_selector_id = selectorId;
}
// Persist to server (fire-and-forget — non-critical)
API.updateChannel(chatId, { settings: { last_selector_id: selectorId } })
.catch(e => console.debug('Failed to persist model selection:', e.message));
}
function _restoreChatModel(chatId, chat) {
// Priority: server settings → localStorage fallback → channel base model → keep current
let targetId = null;
// 1. Server-side settings (most authoritative, roams across devices)
if (chat?.settings?.last_selector_id) {
targetId = chat.settings.last_selector_id;
}
// 2. localStorage fallback (covers race where settings haven't loaded yet)
if (!targetId) {
try {
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
if (map[chatId]) targetId = map[chatId];
} catch (e) { /* */ }
}
// 3. Verify the stored ID still exists in available models
if (targetId) {
const found = App.models.find(m => m.id === targetId);
if (found) {
UI.setModelValue(found.id, found.name + (found.provider ? ` (${found.provider})` : ''));
return;
}
// Stored model removed — clean up stale entries
_cleanStaleChatModel(chatId);
}
// 4. Fall back to channel's base model ID (match by baseModelId)
if (chat?.model) {
const match = App.models.find(m => m.baseModelId === chat.model && !m.isPersona && !m.hidden);
if (match) {
UI.setModelValue(match.id, match.name + (match.provider ? ` (${match.provider})` : ''));
return;
}
}
// 5. Stored model gone — fall back to admin default → first visible
const visible = App.models.filter(m => !m.hidden);
const def = App.defaultModel
? visible.find(m => m.id === App.defaultModel || m.baseModelId === App.defaultModel)
: null;
const fallback = def || visible[0];
if (fallback) {
UI.setModelValue(fallback.id, fallback.name + (fallback.provider ? ` (${fallback.provider})` : ''));
}
}
function _cleanStaleChatModel(chatId) {
// Remove from localStorage
try {
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
delete map[chatId];
localStorage.setItem('cs-chat-models', JSON.stringify(map));
} catch (e) { /* */ }
// Clear from in-memory settings (server cleanup is fire-and-forget)
const chat = App.chats.find(c => c.id === chatId);
if (chat?.settings?.last_selector_id) {
delete chat.settings.last_selector_id;
}
}
async function newChat() {
clearStaged(); // Discard any staged files
if (typeof VirtualScroll !== 'undefined') VirtualScroll.destroy();
App.setActive(null);
UI.renderChatList();
UI.renderChannelsSection();
UI.showEmptyState();
UI.showRegenerate(false);
Tokens._warningDismissed = false;
updateContextWarning(); updateChatTokenCount();
updateInputTokens();
ChatInput.focus();
if (window.innerWidth <= 768) {
document.getElementById('sidebar').classList.add('collapsed');
const ov = document.getElementById('sidebarOverlay');
if (ov) ov.style.display = 'none';
}
if (typeof KnowledgeUI !== 'undefined') KnowledgeUI.onChatChanged();
// Notify surfaces — no channel selected (v0.21.6)
sw.emit('chat.switched', { chatId: null, projectId: null }, { localOnly: true });
window.history.replaceState(null, '', `${window.__BASE__ || ''}/`);
}
// ── New Group Chat (v0.23.0) ─────────────────
// Creates a group channel and adds persona participants.
// Personas can @mention each other → AI-to-AI chaining.
async function newGroupChat() {
const personas = (App.models || []).filter(m => m.isPersona);
if (personas.length === 0) {
UI.toast('No personas available. Ask your admin to create one.', 'warning');
return;
}
return new Promise(resolve => {
const overlay = document.createElement('div');
overlay.className = 'confirm-overlay';
const personaCheckboxes = personas.map(p => {
const scope = p.personaScope || 'global';
const badge = scope === 'personal' ? ' personal'
: scope === 'team' ? ` ${esc(p.personaTeamName || 'team')}` : '';
const handle = p.personaHandle ? ` @${esc(p.personaHandle)}` : '';
return ``;
}).join('');
overlay.innerHTML = `
`;
function close(result) {
overlay.remove();
document.removeEventListener('keydown', onKey);
resolve(result);
}
function onKey(e) {
if (e.key === 'Escape') close(false);
}
overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => close(false));
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(false); });
document.addEventListener('keydown', onKey);
overlay.querySelector('[data-action="ok"]').addEventListener('click', async () => {
const title = overlay.querySelector('#groupChatTitle').value.trim() || 'Group Chat';
const checked = [...overlay.querySelectorAll('.group-persona-list input:checked')];
if (checked.length === 0) {
UI.toast('Select at least one persona', 'warning');
return;
}
try {
// Create group channel
const resp = await API.createChannel(title, null, null, 'group');
const chat = {
id: resp.id, title: resp.title, type: 'group', model: null,
messages: [], messageCount: 0, projectId: resp.project_id || null,
workspace_id: resp.workspace_id || null, updatedAt: resp.updated_at,
settings: resp.settings || {}
};
// Add persona participants
for (const cb of checked) {
try {
await API.addParticipant(chat.id, {
participant_type: 'persona',
participant_id: cb.value,
role: 'member'
});
} catch (e) {
console.warn('Failed to add persona:', cb.dataset.name, e.message);
}
}
App.chats.unshift(chat);
App.setActive(chat.id, 'group');
UI.renderChatList();
sw.emit('chat.created', { chatId: chat.id, projectId: null }, { localOnly: true });
window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chat.id}`);
// Load the channel model roster (populated by participant auto-add)
if (typeof ChannelModels !== 'undefined') ChannelModels.load(chat.id);
UI.showEmptyState();
const names = checked.map(cb => cb.dataset.name).join(', ');
UI.toast(`Group created with ${checked.length} persona${checked.length > 1 ? 's' : ''}: ${names}`, 'success');
ChatInput.focus();
} catch (e) {
UI.toast('Failed to create group: ' + e.message, 'error');
}
close(true);
});
document.body.appendChild(overlay);
overlay.querySelector('#groupChatTitle').focus();
});
}
async function deleteChat(chatId) {
if (!await showConfirm('Delete this chat?')) return;
try {
await API.deleteChannel(chatId);
App.chats = App.chats.filter(c => c.id !== chatId);
// Clean up stored model preference
try {
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
delete map[chatId];
localStorage.setItem('cs-chat-models', JSON.stringify(map));
} catch (e) { /* */ }
if (App.activeId === chatId) { clearPreview(); newChat(); }
UI.renderChatList();
} catch (e) { UI.toast('Failed to delete: ' + e.message, 'error'); }
}
// ── Auto-Name Chat (utility model) ───────────
let _autoNamePending = new Set(); // debounce: one inflight per chat
async function autoNameChat(chatId, chat) {
if (!chatId || !chat) return;
if (_autoNamePending.has(chatId)) return;
// Only auto-name if title looks auto-generated (first 50 chars of user msg or "New Chat")
const firstUserMsg = chat.messages.find(m => m.role === 'user');
if (!firstUserMsg) return;
const truncated = firstUserMsg.content.slice(0, 50).trim();
const currentTitle = (chat.title || '').trim();
const isAutoGenerated = !currentTitle ||
currentTitle === 'New Chat' ||
currentTitle === truncated ||
truncated.startsWith(currentTitle);
if (!isAutoGenerated) return;
_autoNamePending.add(chatId);
try {
const resp = await API.generateTitle(chatId);
if (resp?.title && resp.title !== chat.title) {
chat.title = resp.title;
UI.renderChatList();
}
} catch (e) {
// Silently fail — keep truncated title
console.debug('Auto-name failed:', e.message);
} finally {
_autoNamePending.delete(chatId);
}
}
// ── Chat Rename (inline edit) ────────────────
function startRenameChat(chatId) {
const chat = App.chats.find(c => c.id === chatId);
if (!chat) return;
// Find the title span for this chat
const items = document.querySelectorAll('.chat-item');
let titleSpan = null;
for (const item of items) {
if (item.getAttribute('onclick')?.includes(chatId)) {
titleSpan = item.querySelector('.chat-item-title');
break;
}
}
if (!titleSpan) return;
// Replace span with input
const input = document.createElement('input');
input.type = 'text';
input.className = 'chat-rename-input';
input.value = chat.title || '';
input.onclick = (e) => e.stopPropagation();
input.ondblclick = (e) => e.stopPropagation();
const commit = async () => {
const newTitle = input.value.trim();
if (newTitle && newTitle !== chat.title) {
try {
await API.updateChannel(chatId, { title: newTitle });
chat.title = newTitle;
} catch (e) {
UI.toast('Rename failed: ' + e.message, 'error');
}
}
UI.renderChatList();
};
input.addEventListener('blur', commit);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
if (e.key === 'Escape') { input.value = chat.title || ''; input.blur(); }
});
titleSpan.replaceWith(input);
input.focus();
input.select();
}
// ── Send Message ─────────────────────────────
// v0.28.5: Build context object for pre-send pipe filters.
function _buildPreSendContext(opts) {
const chat = opts.channel || {};
return {
message: opts.message || '',
channel: { id: chat.id || null, type: chat.type || 'direct', title: chat.title || '' },
attachments: opts.attachments || [],
model: opts.model || null,
persona: opts.personaId || null,
metadata: {},
regenerate: opts.regenerate || false,
regenerateMessageId: opts.regenerateMessageId || null,
};
}
async function sendMessage() {
const text = ChatInput.getValue().trim();
const hasFiles = hasStagedFiles();
// Need text or files, not generating, not blocked by uploads
if ((!text && !hasFiles) || App.isGenerating || isSendBlocked()) return;
ChatInput.setValue('');
// Snapshot file IDs before clearing staged state
const fileIds = getStagedFileIds();
const selectedId = UI.getModelValue();
const modelInfo = App.findModel(selectedId);
// For personas, send the base model ID; for regular models, send the model ID
const model = modelInfo?.baseModelId || selectedId;
const personaId = modelInfo?.isPersona ? modelInfo.personaId : null;
if (!App.activeId) {
try {
const title = text.slice(0, 50) + (text.length > 50 ? '...' : '');
const resp = await API.createChannel(title, model, App.settings.systemPrompt, 'direct');
const chat = { id: resp.id, title: resp.title, type: 'direct', model, messages: [], messageCount: 0, projectId: resp.project_id || null, workspace_id: resp.workspace_id || null, updatedAt: resp.updated_at, settings: resp.settings || {} };
// Auto-assign to active project (v0.19.1)
if (App.activeProjectId && !chat.projectId) {
try {
await API.addChannelToProject(App.activeProjectId, chat.id, 0);
chat.projectId = App.activeProjectId;
} catch (_) { /* best effort — chat still works without project */ }
}
App.chats.unshift(chat);
App.setActive(chat.id, 'direct');
UI.renderChatList();
// Notify surfaces about new chat creation (v0.21.6)
sw.emit('chat.created', { chatId: chat.id, projectId: chat.projectId || null }, { localOnly: true });
window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chat.id}`);
} catch (e) { UI.toast('Failed to create chat: ' + e.message, 'error'); return; }
}
const chat = App.getActiveChat();
if (!chat) return;
// ── v0.28.5: Pre-send pipe ──────────────────────────────
// Filters can transform message, inject metadata, or halt (return null).
if (typeof sw !== 'undefined' && sw.pipe) {
const preSendCtx = _buildPreSendContext({
message: text, channel: chat, model, personaId,
attachments: fileIds, regenerate: false,
});
const result = sw.pipe._runPre(preSendCtx);
if (result === null) {
// Filter halted — suppress message
UI.toast('Message blocked by extension', 'warning');
return;
}
// Apply any mutations from filters — model/persona may have changed
// but message text is the most common mutation (KB inject, rewrite).
// Note: we don't overwrite `text` (const) — the API call reads from ctx.
var _preSendText = result.message;
var _preSendModel = result.model || model;
var _preSendPersonaId = result.persona || personaId;
var _preSendMeta = result.metadata || {};
} else {
var _preSendText = text;
var _preSendModel = model;
var _preSendPersonaId = personaId;
var _preSendMeta = {};
}
// Optimistic: show user message immediately
const userContent = text || (hasFiles ? `[${fileIds.length} file${fileIds.length > 1 ? 's' : ''} attached]` : '');
chat.messages.push({ role: 'user', content: userContent, timestamp: new Date().toISOString(), siblingCount: 1, siblingIndex: 0 });
UI.renderMessages(chat.messages);
// Clear staged files (they're now part of this message)
if (hasFiles) consumeStaged();
App.isGenerating = true;
App.abortController = new AbortController();
UI.setGenerating(true);
try {
const resp = await API.streamCompletion(App.activeId, _preSendText, _preSendModel, App.abortController.signal, modelInfo?.configId, _preSendPersonaId, 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
await reloadActivePath();
UI.showRegenerate(true);
// Persist the model/persona selection for this chat
_saveChatModel(App.activeId);
// Auto-name: title the chat after first assistant response (v0.17.0)
if (chat.messages.length <= 3) {
autoNameChat(App.activeId, chat);
}
} catch (e) {
if (e.name === 'AbortError') {
UI.toast('Generation stopped', 'warning');
await reloadActivePath();
} else {
console.error('Completion error:', e);
const msg = e.message || '';
if (e.proxyBlocked) {
UI.toast('Network proxy blocked this request — contact your network admin', 'error');
} else if (msg.includes('provider error') && msg.includes('401')) {
UI.toast('Provider API key rejected — check Settings → Providers', 'error');
} else if (msg.includes('provider error') && msg.includes('429')) {
UI.toast('Provider rate limit — wait and retry', 'warning');
} else if (msg.includes('no API config') || msg.includes('no model')) {
UI.toast('No provider configured — add one in Settings', 'error');
} else {
UI.toast(msg, 'error');
}
}
} finally {
App.isGenerating = false;
App.abortController = null;
UI.setGenerating(false);
}
}
function stopGeneration() { if (App.abortController) App.abortController.abort(); }
// ── Reload Active Path from Server ──────────
async function reloadActivePath() {
if (!App.activeId) return;
const chat = App.getActiveChat();
if (!chat) return;
try {
const resp = await API.getActivePath(App.activeId);
chat.messages = (resp.messages || []).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,
}));
chat.messageCount = chat.messages.length;
await loadChannelFiles(App.activeId);
UI.renderMessages(chat.messages);
updateContextWarning(); updateChatTokenCount();
} catch (e) {
console.error('Failed to reload path:', e.message);
}
}
// ── Regenerate (tree-aware: creates sibling) ─
async function regenerateMessage(messageId) {
if (App.isGenerating || !App.activeId) return;
const selectedId = UI.getModelValue();
const modelInfo = App.findModel(selectedId);
const model = modelInfo?.baseModelId || selectedId;
const personaId = modelInfo?.isPersona ? modelInfo.personaId : null;
// 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.getActiveChat();
if (chat) {
const msgIdx = chat.messages.findIndex(m => m.id === messageId);
if (msgIdx > 0) {
const truncated = chat.messages.slice(0, msgIdx);
UI.renderMessages(truncated);
}
}
// ── v0.28.5: Pre-send pipe (regeneration path) ──────────
if (typeof sw !== 'undefined' && sw.pipe) {
const preSendCtx = _buildPreSendContext({
message: '', channel: chat, model, personaId,
regenerate: true, regenerateMessageId: messageId,
});
const result = sw.pipe._runPre(preSendCtx);
if (result === null) {
UI.toast('Regeneration blocked by extension', 'warning');
// Restore display since we truncated above
if (chat) UI.renderMessages(chat.messages);
return;
}
var _regenModel = result.model || model;
var _regenPersonaId = result.persona || personaId;
} else {
var _regenModel = model;
var _regenPersonaId = personaId;
}
App.isGenerating = true;
App.abortController = new AbortController();
UI.setGenerating(true);
try {
const resp = await API.streamRegenerate(
App.activeId, messageId, App.abortController.signal,
_regenModel, _regenPersonaId, modelInfo?.configId,
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []
);
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
// Reload from server to get proper tree state
await reloadActivePath();
UI.showRegenerate(true);
} catch (e) {
if (e.name === 'AbortError') {
UI.toast('Generation stopped', 'warning');
await reloadActivePath();
} else {
const msg = e.message || '';
if (e.proxyBlocked) {
UI.toast('Network proxy blocked this request', 'error');
} else if (msg.includes('provider error') && msg.includes('401')) {
UI.toast('Provider API key rejected — check Settings', 'error');
} else { UI.toast(msg, 'error'); }
}
} finally {
App.isGenerating = false;
App.abortController = null;
UI.setGenerating(false);
}
}
// Legacy: bottom-bar regenerate button targets the last assistant message
async function regenerate() {
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
for (let i = chat.messages.length - 1; i >= 0; i--) {
if (chat.messages[i].role === 'assistant' && chat.messages[i].id) {
return regenerateMessage(chat.messages[i].id);
}
}
UI.toast('No assistant message to regenerate', 'warning');
}
// ── Edit Message (creates sibling, triggers completion) ──
async function editMessage(messageId) {
if (App.isGenerating || !App.activeId) return;
const chat = App.getActiveChat();
if (!chat) return;
const msg = chat.messages.find(m => m.id === messageId);
if (!msg || msg.role !== 'user') return;
// Show inline edit UI
UI.showEditInline(messageId, msg.content);
}
async function submitEdit(messageId, newContent) {
if (App.isGenerating || !App.activeId) return;
if (!newContent.trim()) return;
const selectedId = UI.getModelValue();
const modelInfo = App.findModel(selectedId);
const model = modelInfo?.baseModelId || selectedId;
const personaId = modelInfo?.isPersona ? modelInfo.personaId : null;
App.isGenerating = true;
App.abortController = new AbortController();
UI.setGenerating(true);
try {
const chat = App.getActiveChat();
// Step 1: Create sibling user message via edit endpoint
const editResp = await API.editMessage(App.activeId, messageId, newContent.trim());
// Step 2: Reload path to show the new edited message
await reloadActivePath();
// Step 3: Generate assistant response as child of the new edit.
// Uses regenerate endpoint (which handles user messages by creating
// a child response, not a sibling). This avoids the duplicate user
// message that streamCompletion would create.
const resp = await API.streamRegenerate(
App.activeId, editResp.id, App.abortController.signal,
model, personaId, modelInfo?.configId,
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []
);
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
// Step 4: Final reload to get the complete tree state
await reloadActivePath();
UI.showRegenerate(true);
} catch (e) {
if (e.name === 'AbortError') {
UI.toast('Generation stopped', 'warning');
await reloadActivePath();
} else {
console.error('Edit error:', e);
UI.toast(e.message || 'Edit failed', 'error');
await reloadActivePath();
}
} finally {
App.isGenerating = false;
App.abortController = null;
UI.setGenerating(false);
}
}
function cancelEdit() {
// Re-render to remove the edit form
const chat = App.getActiveChat();
if (chat) UI.renderMessages(chat.messages);
}
// ── Switch Branch (sibling navigation) ──────
async function switchSibling(messageId, direction) {
if (App.isGenerating || !App.activeId) return;
try {
// Get siblings for this message
const data = await API.listSiblings(App.activeId, messageId);
const siblings = data.siblings || [];
const currentIdx = data.current_index ?? 0;
const targetIdx = currentIdx + direction;
if (targetIdx < 0 || targetIdx >= siblings.length) return;
const targetSibling = siblings[targetIdx];
// Update cursor to the target sibling (backend walks to leaf)
const resp = await API.updateCursor(App.activeId, targetSibling.id);
// Update local state with the new path
const chat = App.getActiveChat();
if (chat && resp.messages) {
chat.messages = resp.messages.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,
}));
chat.messageCount = chat.messages.length;
UI.renderMessages(chat.messages);
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
}
} catch (e) {
console.error('Branch switch failed:', e.message);
UI.toast('Failed to switch branch', 'error');
}
}
// ── Chat Listeners (extracted from initListeners) ──
function _initChatListeners() {
// Sidebar
document.getElementById('sidebarToggle').addEventListener('click', UI.toggleSidebar);
document.getElementById('newChatBtn').addEventListener('click', () => newChat());
// User flyout
document.getElementById('userMenuBtn').addEventListener('click', (e) => {
e.stopPropagation();
UI.toggleUserMenu();
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.sidebar-bottom')) UI.closeUserMenu();
});
// Flyout items
document.getElementById('menuSettings').addEventListener('click', () => { UI.closeUserMenu(); UI.openSettings(); });
document.getElementById('menuAdmin')?.addEventListener('click', () => { UI.closeUserMenu(); UI.openAdmin(); });
document.getElementById('menuTeamAdmin')?.addEventListener('click', () => { UI.closeUserMenu(); UI.openTeamAdmin(); });
document.getElementById('teamAdminCloseBtn')?.addEventListener('click', () => UI.closeTeamAdmin());
document.getElementById('menuDebug')?.addEventListener('click', () => { UI.closeUserMenu(); openDebugModal(); });
document.getElementById('menuSignout').addEventListener('click', () => { UI.closeUserMenu(); handleLogout(); });
// Sidebar chat search
const _chatSearchInput = document.getElementById('chatSearchInput');
_chatSearchInput?.addEventListener('input', () => UI.renderChatList());
document.getElementById('chatSearchClear')?.addEventListener('click', () => {
if (_chatSearchInput) { _chatSearchInput.value = ''; UI.renderChatList(); _chatSearchInput.focus(); }
});
// Chat actions
document.getElementById('sendBtn').addEventListener('click', sendMessage);
document.getElementById('stopBtn').addEventListener('click', stopGeneration);
// Model selector (custom dropdown)
UI.initModelDropdown();
UI.initAppearance();
// Mobile hamburger
document.getElementById('mobileMenuBtn')?.addEventListener('click', UI.toggleSidebar);
document.getElementById('sidebarOverlay')?.addEventListener('click', () => {
document.getElementById('sidebar').classList.add('collapsed');
document.getElementById('sidebarOverlay').style.display = 'none';
localStorage.setItem('sb_sidebar', '1');
});
document.getElementById('fetchModelsBtn')?.addEventListener('click', async () => {
await fetchModelsAndUpdateUI();
const visible = App.models.filter(m => !m.hidden).length;
UI.toast(`Loaded ${visible} model${visible !== 1 ? 's' : ''}`, 'success');
});
// Input — CM6 or textarea fallback
ChatInput.init();
// Multi-model channel roster (v0.20.0)
if (typeof ChannelModels !== 'undefined') ChannelModels.init();
// AI-to-AI chain: handle messages arriving via WebSocket (v0.23.0)
// When a persona @mentions another persona, the follow-up completion
// runs server-side and delivers the result via WS, not SSE.
sw.on('message.created', (payload) => {
if (!payload || !payload.channel_id) 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;
// Append the chained message
chat.messages.push({
id: payload.id,
parent_id: null,
role: payload.role || 'assistant',
content: payload.content || '',
model: payload.model || '',
modelName: payload.display_name || payload.model || '',
timestamp: new Date().toISOString(),
tool_calls: null,
metadata: payload.chain_depth ? { chain_depth: payload.chain_depth } : null,
siblingCount: 1,
siblingIndex: 0,
participant_type: payload.participant_type,
participant_id: payload.participant_id,
});
// Remove typing indicator and re-render
document.getElementById('typingIndicator')?.remove();
document.getElementById('userTypingIndicator')?.remove();
UI.renderMessages(chat.messages);
});
// Typing indicators from persona chain activity
sw.on('typing.start', (payload) => {
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');
typing.id = 'typingIndicator';
typing.className = 'message assistant';
const name = payload.display_name || 'AI';
typing.innerHTML = `
`;
container.appendChild(typing);
container.scrollTop = container.scrollHeight;
});
sw.on('typing.stop', (payload) => {
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
sw.on('typing.user', (payload) => {
if (!payload || payload.channel_id !== App.activeId) return;
if (payload.user_id === sw.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 = `
`;
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
sw.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
sw.on('user.presence', (payload) => {
if (!payload || !payload.user_id) return;
App.presence[payload.user_id] = payload.status || 'offline';
if (typeof UI !== 'undefined') UI.renderChannelsSection();
});
// v0.27.0: Workflow stage advanced — refresh stage bar + auto-switch persona
sw.on('workflow.advanced', (payload) => {
if (!payload) return;
const ac = App.activeConversation;
if (ac && ac.id === payload.channel_id) {
UI.updateWorkflowStageBar();
}
// Refresh queue sidebar if present
if (typeof WorkflowQueue !== 'undefined') WorkflowQueue.refresh();
});
// v0.27.0: Workflow completed — refresh stage bar
sw.on('workflow.completed', (payload) => {
if (!payload) return;
const ac = App.activeConversation;
if (ac && ac.id === payload.channel_id) {
UI.updateWorkflowStageBar();
}
if (typeof WorkflowQueue !== 'undefined') WorkflowQueue.refresh();
});
// v0.27.0: Workflow assigned — refresh queue + toast
sw.on('workflow.assigned', (payload) => {
if (!payload) return;
if (payload.assigned_to === sw.user?.id) {
UI.toast('Workflow stage "' + (payload.stage_name || '?') + '" assigned to you', 'info');
}
if (typeof WorkflowQueue !== 'undefined') WorkflowQueue.refresh();
});
// Close modals on overlay click
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeModal(overlay.id);
});
});
}
// v0.27.0: Advance workflow stage (called from stage bar button)
async function advanceWorkflowStage() {
var ac = App.activeConversation;
if (!ac || ac.type !== 'workflow') return;
var confirmed = await showConfirm('Advance to the next stage?', { title: 'Advance Workflow', danger: false, ok: 'Advance' });
if (!confirmed) return;
try {
var resp = await API._post('/api/v1/channels/' + ac.id + '/workflow/advance', {});
UI.toast('Advanced to stage: ' + (resp.stage?.name || resp.current_stage), 'success');
UI.updateWorkflowStageBar();
} catch (e) {
UI.toast('Advance failed: ' + (e.message || e), 'error');
}
}
// v0.27.0: Reject workflow stage (called from stage bar button)
async function rejectWorkflowStage() {
var ac = App.activeConversation;
if (!ac || ac.type !== 'workflow') return;
var reason = await showPrompt({ title: 'Reject Stage', label: 'Reason for rejection:', placeholder: 'Enter reason...', ok: 'Reject' });
if (!reason) return;
try {
var resp = await API._post('/api/v1/channels/' + ac.id + '/workflow/reject', { reason: reason });
UI.toast('Rejected to stage ' + resp.current_stage + ': ' + reason, 'warning');
UI.updateWorkflowStageBar();
} catch (e) {
UI.toast('Reject failed: ' + (e.message || e), 'error');
}
}
// ── Exports ─────────────────────────────────
sb.ns('ChatInput', ChatInput);
sb.register('loadChats', loadChats);
sb.register('selectChat', selectChat);
sb.register('newChat', newChat);
sb.register('newGroupChat', newGroupChat);
sb.register('deleteChat', deleteChat);
sb.register('startRenameChat', startRenameChat);
sb.register('sendMessage', sendMessage);
sb.register('stopGeneration', stopGeneration);
sb.register('reloadActivePath', reloadActivePath);
sb.register('regenerateMessage', regenerateMessage);
sb.register('regenerate', regenerate);
sb.register('editMessage', editMessage);
sb.register('submitEdit', submitEdit);
sb.register('cancelEdit', cancelEdit);
sb.register('switchSibling', switchSibling);
sb.register('advanceWorkflowStage', advanceWorkflowStage);
sb.register('rejectWorkflowStage', rejectWorkflowStage);
sb.register('_initChatListeners', _initChatListeners);
sb.register('summarizeAndContinue', summarizeAndContinue);
sb.register('toggleChannelAutoCompact', toggleChannelAutoCompact);