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

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