Changeset 0.23.2 (#155)

This commit is contained in:
2026-03-06 23:17:03 +00:00
parent 4c6555cb06
commit 2dc4514a57
36 changed files with 2784 additions and 192 deletions

View File

@@ -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