Changeset 0.37.10 (#222)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
122
src/js/sw/components/chat-pane/code-block.js
Normal file
122
src/js/sw/components/chat-pane/code-block.js
Normal file
@@ -0,0 +1,122 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — CodeBlock Component
|
||||
// ==========================================
|
||||
// Enhanced code block: language badge, copy button.
|
||||
// Independently importable.
|
||||
//
|
||||
// Usage (from markdown post-processing):
|
||||
// Enhances <pre><code> blocks after markdown rendering.
|
||||
|
||||
const html = window.html;
|
||||
const { useState, useCallback } = window.hooks;
|
||||
|
||||
/**
|
||||
* SVG icons for copy/check.
|
||||
*/
|
||||
const COPY_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>`;
|
||||
|
||||
const CHECK_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>`;
|
||||
|
||||
/**
|
||||
* CodeBlock — wraps a <pre><code> block with language badge + copy button.
|
||||
*
|
||||
* @param {{ code: string, language?: string }} props
|
||||
*/
|
||||
export function CodeBlock({ code, language }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const _copy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (_) {
|
||||
// Fallback
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = code;
|
||||
ta.style.cssText = 'position:fixed;left:-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}, [code]);
|
||||
|
||||
const langLabel = language && language !== 'text' ? language : null;
|
||||
|
||||
return html`
|
||||
<div class="sw-code-block">
|
||||
<div class="sw-code-block__header">
|
||||
${langLabel && html`<span class="sw-code-block__lang">${langLabel}</span>`}
|
||||
<button class="sw-code-block__copy"
|
||||
title=${copied ? 'Copied!' : 'Copy code'}
|
||||
onClick=${_copy}
|
||||
aria-label="Copy code to clipboard">
|
||||
${copied ? CHECK_SVG : COPY_SVG}
|
||||
<span class="sw-code-block__copy-text">${copied ? 'Copied' : 'Copy'}</span>
|
||||
</button>
|
||||
</div>
|
||||
<pre class="sw-code-block__pre"><code class=${langLabel ? 'language-' + langLabel : ''}
|
||||
dangerouslySetInnerHTML=${{ __html: _escapeHtml(code) }} /></pre>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const _escMap = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
function _escapeHtml(text) {
|
||||
return text.replace(/[&<>"']/g, c => _escMap[c]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-process rendered markdown HTML to enhance code blocks.
|
||||
* Extracts language from class="language-xxx" and returns
|
||||
* an array of { type, code, language } blocks for Preact rendering.
|
||||
*
|
||||
* @param {string} html — rendered markdown HTML
|
||||
* @returns {{ segments: Array<{type: 'html'|'code', html?: string, code?: string, language?: string}> }}
|
||||
*/
|
||||
export function extractCodeBlocks(htmlStr) {
|
||||
// Match <pre><code class="language-xxx">...</code></pre> blocks
|
||||
const pattern = /<pre><code(?:\s+class="language-(\w+)")?>([\s\S]*?)<\/code><\/pre>/g;
|
||||
const segments = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = pattern.exec(htmlStr)) !== null) {
|
||||
// Push preceding HTML
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({ type: 'html', html: htmlStr.slice(lastIndex, match.index) });
|
||||
}
|
||||
// Push code block
|
||||
const language = match[1] || '';
|
||||
const code = _unescapeHtml(match[2]);
|
||||
segments.push({ type: 'code', code, language });
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Push trailing HTML
|
||||
if (lastIndex < htmlStr.length) {
|
||||
segments.push({ type: 'html', html: htmlStr.slice(lastIndex) });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function _unescapeHtml(text) {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
205
src/js/sw/components/chat-pane/emoji-picker.js
Normal file
205
src/js/sw/components/chat-pane/emoji-picker.js
Normal file
@@ -0,0 +1,205 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — EmojiPicker Component
|
||||
// ==========================================
|
||||
// Categorized emoji selector popup with search and recents.
|
||||
// Independently importable.
|
||||
//
|
||||
// Usage:
|
||||
// html`<${EmojiPicker} open=${true} onSelect=${fn} onClose=${fn} />`
|
||||
|
||||
const html = window.html;
|
||||
const { useState, useRef, useEffect, useCallback, useMemo } = window.hooks;
|
||||
|
||||
// ── Emoji data (compact) ─────────────────
|
||||
// Categories with popular emoji — not a full Unicode dump.
|
||||
// Keeps bundle small. Each entry is just the emoji character.
|
||||
const CATEGORIES = [
|
||||
{ key: 'recent', icon: '🕐', label: 'Recent', emoji: [] },
|
||||
{ key: 'smileys', icon: '😀', label: 'Smileys & People', emoji: [
|
||||
'😀','😃','😄','😁','😆','😅','🤣','😂','🙂','😊',
|
||||
'😇','🥰','😍','🤩','😘','😋','😛','😜','🤪','😝',
|
||||
'🤗','🤭','🤫','🤔','😐','😑','😶','😏','😒','🙄',
|
||||
'😬','😮💨','🤥','😌','😔','😪','🤤','😴','😷','🤒',
|
||||
'🤕','🤢','🤮','🥵','🥶','🥴','😵','🤯','🤠','🥳',
|
||||
'🥸','😎','🤓','🧐','😕','😟','🙁','😮','😯','😲',
|
||||
'😳','🥺','😦','😧','😨','😰','😥','😢','😭','😱',
|
||||
'😖','😣','😞','😓','😩','😫','🥱','😤','😡','😠',
|
||||
'🤬','😈','👿','💀','☠️','💩','🤡','👹','👺','👻',
|
||||
'👽','👾','🤖','👋','🤚','🖐️','✋','🖖','👌','🤌',
|
||||
'🤏','✌️','🤞','🤟','🤘','🤙','👈','👉','👆','🖕',
|
||||
'👇','☝️','👍','👎','✊','👊','🤛','🤜','👏','🙌',
|
||||
'👐','🤲','🤝','🙏',
|
||||
]},
|
||||
{ key: 'nature', icon: '🌿', label: 'Nature', emoji: [
|
||||
'🐶','🐱','🐭','🐹','🐰','🦊','🐻','🐼','🐨','🐯',
|
||||
'🦁','🐮','🐷','🐸','🐵','🐔','🐧','🐦','🐤','🦆',
|
||||
'🦅','🦉','🦇','🐺','🐗','🐴','🦄','🐝','🐛','🦋',
|
||||
'🐌','🐞','🐜','🪲','🪳','🦟','🦗','🕷️','🌸','💐',
|
||||
'🌷','🌹','🥀','🌺','🌻','🌼','🌱','🌲','🌳','🌴',
|
||||
'🌵','🍀','🍁','🍂','🍃','🍄',
|
||||
]},
|
||||
{ key: 'food', icon: '🍔', label: 'Food & Drink', emoji: [
|
||||
'🍇','🍈','🍉','🍊','🍋','🍌','🍍','🥭','🍎','🍐',
|
||||
'🍑','🍒','🍓','🫐','🥝','🍅','🫒','🥥','🥑','🍆',
|
||||
'🥔','🥕','🌽','🌶️','🫑','🥒','🥬','🧄','🧅','🍄',
|
||||
'🥜','🫘','🌰','🍞','🥐','🥖','🫓','🥨','🥯','🥞',
|
||||
'🧇','🧀','🍖','🍗','🥩','🥓','🍔','🍟','🍕','🌭',
|
||||
'🥪','🌮','🌯','🫔','🥙','🧆','🥚','🍳','🥘','🍲',
|
||||
'🫕','🥣','🥗','🍿','🧈','🍱','🍘','🍙','🍚','🍛',
|
||||
'🍜','🍝','🍠','🍢','🍣','🍤','🍥','🥮','🍡','🥟',
|
||||
'🥠','🥡','☕','🍵','🧃','🥤','🧋','🍺','🍻','🥂',
|
||||
'🍷','🥃','🍸','🍹','🧉','🍾','🫗',
|
||||
]},
|
||||
{ key: 'activity', icon: '⚽', label: 'Activities', emoji: [
|
||||
'⚽','🏀','🏈','⚾','🥎','🎾','🏐','🏉','🥏','🎱',
|
||||
'🏓','🏸','🏒','🥅','⛳','🏹','🎣','🤿','🥊','🥋',
|
||||
'🎿','⛷️','🏂','🪂','🏋️','🤸','🤺','⛹️','🤾','🏌️',
|
||||
'🏇','🧘','🏄','🏊','🤽','🚣','🧗','🚴','🚵','🎪',
|
||||
'🎭','🎨','🎬','🎤','🎧','🎼','🎹','🥁','🎷','🎺',
|
||||
'🎸','🪕','🎲','🎯','🎳','🎮','🕹️','🧩',
|
||||
]},
|
||||
{ key: 'objects', icon: '💡', label: 'Objects', emoji: [
|
||||
'⌚','📱','💻','⌨️','🖥️','🖨️','🖱️','🖲️','💾','💿',
|
||||
'📷','📹','🎥','📽️','🎞️','📞','📟','📠','📺','📻',
|
||||
'🎙️','⏱️','⏲️','⏰','🕰️','⌛','📡','🔋','🔌','💡',
|
||||
'🔦','🕯️','🪔','🧯','🛢️','💸','💵','💴','💶','💷',
|
||||
'🪙','💰','💳','💎','⚖️','🪜','🧰','🪛','🔧','🔨',
|
||||
'⛏️','🪚','🔩','⚙️','🪤','🧱','⛓️','🧲','🔫','💣',
|
||||
'🪓','🔪','🗡️','⚔️','🛡️','🚬','⚰️','🏺','🔮','📿',
|
||||
'🧿','💈','⚗️','🔭','🔬','🕳️','🩹','🩺','💊','💉',
|
||||
'🩸','🧬','🦠','🧫','🧪','🌡️','🧹','🪠','🧺','🧻',
|
||||
]},
|
||||
{ key: 'symbols', icon: '❤️', label: 'Symbols', emoji: [
|
||||
'❤️','🧡','💛','💚','💙','💜','🖤','🤍','🤎','💔',
|
||||
'❣️','💕','💞','💓','💗','💖','💘','💝','💟','☮️',
|
||||
'✝️','☪️','🕉️','☸️','✡️','🔯','🕎','☯️','☦️','🛐',
|
||||
'⛎','♈','♉','♊','♋','♌','♍','♎','♏','♐',
|
||||
'♑','♒','♓','🆔','⚛️','🉑','☢️','☣️','📴','📳',
|
||||
'🈶','🈚','🈸','🈺','🈷️','✴️','🆚','💮','🉐','㊙️',
|
||||
'㊗️','🈴','🈵','🈹','🈲','🅰️','🅱️','🆎','🆑','🅾️',
|
||||
'🆘','❌','⭕','🛑','⛔','📛','🚫','💯','💢','♨️',
|
||||
'🚷','🚯','🚳','🚱','🔞','📵','🚭','❗','❓','‼️',
|
||||
'⁉️','🔅','🔆','〽️','⚠️','🚸','🔱','⚜️','🔰','♻️',
|
||||
'✅','🈯','💹','❎','🌐','💠','Ⓜ️','🌀','💤','🏧',
|
||||
'🎦','🈁','🔣','ℹ️','🔤','🔡','🔠','🆖','🆗','🆙',
|
||||
'🆒','🆕','🆓','0️⃣','1️⃣','2️⃣','3️⃣','4️⃣','5️⃣','6️⃣',
|
||||
'7️⃣','8️⃣','9️⃣','🔟','🔢','#️⃣','*️⃣','⏏️','▶️','⏸️',
|
||||
'⏹️','⏺️','⏭️','⏮️','⏩','⏪','🔀','🔁','🔂','◀️',
|
||||
'🔼','🔽','➡️','⬅️','⬆️','⬇️','↗️','↘️','↙️','↖️',
|
||||
'↕️','↔️','↩️','↪️','⤴️','⤵️','🔃','🔄','🔙','🔚',
|
||||
'🔛','🔜','🔝',
|
||||
]},
|
||||
];
|
||||
|
||||
const RECENTS_KEY = 'sw-emoji-recent';
|
||||
const MAX_RECENTS = 24;
|
||||
|
||||
function _loadRecents() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(RECENTS_KEY)) || [];
|
||||
} catch (_) { return []; }
|
||||
}
|
||||
|
||||
function _saveRecent(emoji) {
|
||||
const recents = _loadRecents().filter(e => e !== emoji);
|
||||
recents.unshift(emoji);
|
||||
if (recents.length > MAX_RECENTS) recents.length = MAX_RECENTS;
|
||||
try { localStorage.setItem(RECENTS_KEY, JSON.stringify(recents)); } catch (_) {}
|
||||
return recents;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ open: boolean, onSelect: (emoji: string) => void, onClose: () => void }} props
|
||||
*/
|
||||
export function EmojiPicker({ open, onSelect, onClose }) {
|
||||
const [activeCategory, setActiveCategory] = useState('smileys');
|
||||
const [search, setSearch] = useState('');
|
||||
const [recents, setRecents] = useState(_loadRecents);
|
||||
const searchRef = useRef(null);
|
||||
const panelRef = useRef(null);
|
||||
|
||||
// Focus search on open
|
||||
useEffect(() => {
|
||||
if (open && searchRef.current) {
|
||||
setTimeout(() => searchRef.current?.focus(), 50);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function _onKey(e) {
|
||||
if (e.key === 'Escape') { e.stopPropagation(); onClose(); }
|
||||
}
|
||||
document.addEventListener('keydown', _onKey, true);
|
||||
return () => document.removeEventListener('keydown', _onKey, true);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function _onClick(e) {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
setTimeout(() => document.addEventListener('click', _onClick), 0);
|
||||
return () => document.removeEventListener('click', _onClick);
|
||||
}, [open, onClose]);
|
||||
|
||||
const _pick = useCallback((emoji) => {
|
||||
setRecents(_saveRecent(emoji));
|
||||
onSelect(emoji);
|
||||
}, [onSelect]);
|
||||
|
||||
// Build active emoji list
|
||||
const activeEmoji = useMemo(() => {
|
||||
if (search) {
|
||||
// Flat search across all categories (emoji chars don't have searchable names,
|
||||
// so this just filters for exact substring — limited but functional)
|
||||
const all = CATEGORIES.flatMap(c => c.emoji);
|
||||
return all.filter(e => e.includes(search));
|
||||
}
|
||||
if (activeCategory === 'recent') return recents;
|
||||
const cat = CATEGORIES.find(c => c.key === activeCategory);
|
||||
return cat ? cat.emoji : [];
|
||||
}, [activeCategory, search, recents]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return html`
|
||||
<div class="sw-emoji-picker" ref=${panelRef} role="dialog" aria-label="Emoji picker">
|
||||
<div class="sw-emoji-picker__tabs">
|
||||
${CATEGORIES.map(c => html`
|
||||
<button key=${c.key}
|
||||
class=${'sw-emoji-picker__tab' + (activeCategory === c.key ? ' sw-emoji-picker__tab--active' : '')}
|
||||
title=${c.label}
|
||||
onClick=${() => { setActiveCategory(c.key); setSearch(''); }}
|
||||
type="button">
|
||||
${c.icon}
|
||||
</button>`)}
|
||||
</div>
|
||||
<input ref=${searchRef}
|
||||
class="sw-emoji-picker__search"
|
||||
type="text"
|
||||
placeholder="Search emoji..."
|
||||
value=${search}
|
||||
onInput=${e => setSearch(e.target.value)}
|
||||
aria-label="Search emoji" />
|
||||
<div class="sw-emoji-picker__grid" role="listbox">
|
||||
${activeEmoji.length === 0 && html`
|
||||
<div class="sw-emoji-picker__empty">
|
||||
${activeCategory === 'recent' ? 'No recent emoji' : 'No results'}
|
||||
</div>`}
|
||||
${activeEmoji.map(e => html`
|
||||
<button key=${e}
|
||||
class="sw-emoji-picker__emoji"
|
||||
onClick=${() => _pick(e)}
|
||||
role="option"
|
||||
type="button"
|
||||
title=${e}>
|
||||
${e}
|
||||
</button>`)}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -5,6 +5,9 @@
|
||||
// 1-on-1 chat experience. Surfaces that need a different
|
||||
// layout import individual pieces instead.
|
||||
//
|
||||
// v0.37.10: Error banner, stop button, regenerate, pagination,
|
||||
// markdown toolbar, emoji picker, code block copy.
|
||||
//
|
||||
// Usage:
|
||||
// import { ChatPane } from './components/chat-pane/index.js';
|
||||
// html`<${ChatPane} handleRef=${ref} standalone=${true} getContext=${fn} />`
|
||||
@@ -20,8 +23,12 @@ import { MessageInput } from './message-input.js';
|
||||
import { ModelSelector } from './model-selector.js';
|
||||
import { ChatHistory } from './chat-history.js';
|
||||
import { renderMarkdown } from './markdown.js';
|
||||
import { CodeBlock, extractCodeBlocks } from './code-block.js';
|
||||
import { MessageActions } from './message-actions.js';
|
||||
import { MarkdownToolbar, handleFormatShortcut } from './markdown-toolbar.js';
|
||||
import { EmojiPicker } from './emoji-picker.js';
|
||||
|
||||
const { useRef, useEffect } = window.hooks;
|
||||
const { useRef, useEffect, useCallback } = window.hooks;
|
||||
const html = window.html;
|
||||
|
||||
/**
|
||||
@@ -70,6 +77,8 @@ export function ChatPane(props) {
|
||||
}
|
||||
});
|
||||
|
||||
const _dismissError = useCallback(() => chat.clearError(), [chat.clearError]);
|
||||
|
||||
return html`
|
||||
<div class=${'sw-chat-pane' + (className ? ' ' + className : '')}>
|
||||
${standalone && html`
|
||||
@@ -84,23 +93,41 @@ export function ChatPane(props) {
|
||||
onChange=${chat.setModel} />
|
||||
</div>
|
||||
</div>`}
|
||||
${chat.error && html`
|
||||
<div class="sw-chat-pane__error" role="alert">
|
||||
<span class="sw-chat-pane__error-text">${chat.error}</span>
|
||||
<button class="sw-chat-pane__error-dismiss"
|
||||
onClick=${_dismissError}
|
||||
aria-label="Dismiss error">\u00d7</button>
|
||||
</div>`}
|
||||
<${MessageList}
|
||||
messages=${chat.messages}
|
||||
streamContent=${chat.streamContent}
|
||||
streaming=${chat.streaming} />
|
||||
streaming=${chat.streaming}
|
||||
hasMore=${chat.hasMore}
|
||||
loadingMore=${chat.loadingMore}
|
||||
onLoadMore=${chat.loadMore}
|
||||
onRegenerate=${chat.regenerate}
|
||||
onDelete=${chat.deleteMessage} />
|
||||
<${MessageInput}
|
||||
handleRef=${inputHandle}
|
||||
onSend=${chat.sendMessage}
|
||||
disabled=${chat.sending} />
|
||||
onStop=${chat.stop}
|
||||
disabled=${chat.sending}
|
||||
streaming=${chat.streaming} />
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Kit re-exports ─────────────────────────
|
||||
export { useChat } from './use-chat.js';
|
||||
export { useStream } from './use-stream.js';
|
||||
export { MessageList } from './message-list.js';
|
||||
export { MessageBubble } from './message-bubble.js';
|
||||
export { MessageInput } from './message-input.js';
|
||||
export { ModelSelector } from './model-selector.js';
|
||||
export { ChatHistory } from './chat-history.js';
|
||||
export { renderMarkdown } from './markdown.js';
|
||||
export { useChat } from './use-chat.js';
|
||||
export { useStream } from './use-stream.js';
|
||||
export { MessageList } from './message-list.js';
|
||||
export { MessageBubble } from './message-bubble.js';
|
||||
export { MessageInput } from './message-input.js';
|
||||
export { ModelSelector } from './model-selector.js';
|
||||
export { ChatHistory } from './chat-history.js';
|
||||
export { renderMarkdown } from './markdown.js';
|
||||
export { CodeBlock, extractCodeBlocks } from './code-block.js';
|
||||
export { MessageActions } from './message-actions.js';
|
||||
export { MarkdownToolbar, handleFormatShortcut } from './markdown-toolbar.js';
|
||||
export { EmojiPicker } from './emoji-picker.js';
|
||||
|
||||
110
src/js/sw/components/chat-pane/markdown-toolbar.js
Normal file
110
src/js/sw/components/chat-pane/markdown-toolbar.js
Normal file
@@ -0,0 +1,110 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — MarkdownToolbar Component
|
||||
// ==========================================
|
||||
// Formatting toolbar for the message input.
|
||||
// Wraps selection with markdown syntax. Independently importable.
|
||||
//
|
||||
// Usage:
|
||||
// html`<${MarkdownToolbar} textareaRef=${ref} onInput=${fn} />`
|
||||
|
||||
const html = window.html;
|
||||
|
||||
/**
|
||||
* Insert markdown wrapper around the textarea's selection.
|
||||
*
|
||||
* @param {HTMLTextAreaElement} ta
|
||||
* @param {string} before — prefix (e.g. '**')
|
||||
* @param {string} after — suffix (e.g. '**')
|
||||
* @param {string} placeholder — text if nothing selected
|
||||
* @param {Function} onInput — trigger resize after edit
|
||||
*/
|
||||
function _wrap(ta, before, after, placeholder, onInput) {
|
||||
if (!ta) return;
|
||||
ta.focus();
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const selected = ta.value.slice(start, end) || placeholder;
|
||||
const replacement = before + selected + after;
|
||||
|
||||
// Use execCommand for undo support, fallback to direct edit
|
||||
const hasExecCommand = document.queryCommandSupported?.('insertText');
|
||||
if (hasExecCommand) {
|
||||
document.execCommand('insertText', false, replacement);
|
||||
} else {
|
||||
ta.value = ta.value.slice(0, start) + replacement + ta.value.slice(end);
|
||||
}
|
||||
|
||||
// Select the inserted text (without wrappers) for quick overtype
|
||||
const selStart = start + before.length;
|
||||
const selEnd = selStart + selected.length;
|
||||
ta.setSelectionRange(selStart, selEnd);
|
||||
if (onInput) onInput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert text at cursor (no wrapping).
|
||||
*/
|
||||
function _insert(ta, text, onInput) {
|
||||
if (!ta) return;
|
||||
ta.focus();
|
||||
const start = ta.selectionStart;
|
||||
const hasExecCommand = document.queryCommandSupported?.('insertText');
|
||||
if (hasExecCommand) {
|
||||
document.execCommand('insertText', false, text);
|
||||
} else {
|
||||
ta.value = ta.value.slice(0, start) + text + ta.value.slice(ta.selectionEnd);
|
||||
}
|
||||
const pos = start + text.length;
|
||||
ta.setSelectionRange(pos, pos);
|
||||
if (onInput) onInput();
|
||||
}
|
||||
|
||||
const ACTIONS = [
|
||||
{ key: 'bold', label: 'B', title: 'Bold (Ctrl+B)', fn: (ta, cb) => _wrap(ta, '**', '**', 'bold text', cb) },
|
||||
{ key: 'italic', label: 'I', title: 'Italic (Ctrl+I)', fn: (ta, cb) => _wrap(ta, '_', '_', 'italic text', cb), style: 'font-style:italic' },
|
||||
{ key: 'code', label: '<>', title: 'Inline code', fn: (ta, cb) => _wrap(ta, '`', '`', 'code', cb), style: 'font-family:var(--mono, monospace);font-size:11px' },
|
||||
{ key: 'codeblk', label: '```',title: 'Code block', fn: (ta, cb) => _wrap(ta, '```\n', '\n```', 'code', cb), style: 'font-family:var(--mono, monospace);font-size:10px' },
|
||||
{ key: 'link', label: '🔗', title: 'Link', fn: (ta, cb) => _wrap(ta, '[', '](url)', 'link text', cb) },
|
||||
{ key: 'list', label: '•', title: 'Bullet list', fn: (ta, cb) => _insert(ta, '\n- ', cb) },
|
||||
{ key: 'olist', label: '1.', title: 'Numbered list', fn: (ta, cb) => _insert(ta, '\n1. ', cb) },
|
||||
{ key: 'heading', label: 'H', title: 'Heading', fn: (ta, cb) => _insert(ta, '\n## ', cb), style: 'font-weight:700' },
|
||||
{ key: 'quote', label: '>', title: 'Blockquote', fn: (ta, cb) => _insert(ta, '\n> ', cb) },
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {{ textareaRef: { current: HTMLTextAreaElement }, onInput?: () => void }} props
|
||||
*/
|
||||
export function MarkdownToolbar({ textareaRef, onInput }) {
|
||||
return html`
|
||||
<div class="sw-md-toolbar" role="toolbar" aria-label="Formatting">
|
||||
${ACTIONS.map(a => html`
|
||||
<button key=${a.key}
|
||||
class="sw-md-toolbar__btn"
|
||||
title=${a.title}
|
||||
style=${a.style || ''}
|
||||
onClick=${() => a.fn(textareaRef.current, onInput)}
|
||||
tabindex="-1"
|
||||
type="button">
|
||||
${a.label}
|
||||
</button>`)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard shortcuts for formatting.
|
||||
* Call from textarea onKeyDown handler.
|
||||
*
|
||||
* @param {KeyboardEvent} e
|
||||
* @param {HTMLTextAreaElement} ta
|
||||
* @param {Function} onInput
|
||||
* @returns {boolean} true if handled
|
||||
*/
|
||||
export function handleFormatShortcut(e, ta, onInput) {
|
||||
if (!(e.ctrlKey || e.metaKey)) return false;
|
||||
switch (e.key) {
|
||||
case 'b': e.preventDefault(); _wrap(ta, '**', '**', 'bold', onInput); return true;
|
||||
case 'i': e.preventDefault(); _wrap(ta, '_', '_', 'italic', onInput); return true;
|
||||
case 'e': e.preventDefault(); _wrap(ta, '`', '`', 'code', onInput); return true;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
// Wraps window.marked + window.DOMPurify with fallback.
|
||||
// Independently importable utility.
|
||||
//
|
||||
// v0.37.10: Task list rendering, code block language extraction.
|
||||
//
|
||||
// Usage:
|
||||
// import { renderMarkdown } from './markdown.js';
|
||||
// const html = renderMarkdown('**bold** text');
|
||||
@@ -17,6 +19,37 @@ function _escapeHtml(text) {
|
||||
return text.replace(/[&<>"']/g, c => _escMap[c]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure marked with task list support if available.
|
||||
*/
|
||||
let _configured = false;
|
||||
function _ensureConfigured() {
|
||||
if (_configured) return;
|
||||
_configured = true;
|
||||
if (typeof marked === 'undefined') return;
|
||||
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
});
|
||||
|
||||
// Custom renderer for task list items
|
||||
const renderer = new marked.Renderer();
|
||||
const origListItem = renderer.listitem?.bind(renderer);
|
||||
renderer.listitem = function(text, task, checked) {
|
||||
if (task) {
|
||||
const checkbox = checked
|
||||
? '<input type="checkbox" checked disabled class="sw-task-checkbox" />'
|
||||
: '<input type="checkbox" disabled class="sw-task-checkbox" />';
|
||||
return '<li class="sw-task-item">' + checkbox + ' ' + text + '</li>\n';
|
||||
}
|
||||
if (origListItem) return origListItem(text, task, checked);
|
||||
return '<li>' + text + '</li>\n';
|
||||
};
|
||||
|
||||
marked.use({ renderer });
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown text to sanitized HTML.
|
||||
* Falls back to escaped plaintext when marked/DOMPurify unavailable.
|
||||
@@ -30,6 +63,7 @@ export function renderMarkdown(text) {
|
||||
|
||||
let result;
|
||||
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
|
||||
_ensureConfigured();
|
||||
result = DOMPurify.sanitize(marked.parse(text));
|
||||
} else {
|
||||
result = _escapeHtml(text);
|
||||
|
||||
91
src/js/sw/components/chat-pane/message-actions.js
Normal file
91
src/js/sw/components/chat-pane/message-actions.js
Normal file
@@ -0,0 +1,91 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — MessageActions Component
|
||||
// ==========================================
|
||||
// Per-message action toolbar: regenerate, copy, delete.
|
||||
// Appears on hover/focus. Independently importable.
|
||||
//
|
||||
// Usage:
|
||||
// html`<${MessageActions} role="assistant" content=${text}
|
||||
// onRegenerate=${fn} onDelete=${fn} />`
|
||||
|
||||
const html = window.html;
|
||||
const { useState, useCallback } = window.hooks;
|
||||
|
||||
const REGEN_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="1 4 1 10 7 10"/>
|
||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
|
||||
</svg>`;
|
||||
|
||||
const COPY_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>`;
|
||||
|
||||
const CHECK_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>`;
|
||||
|
||||
const DELETE_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>`;
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* role: string,
|
||||
* content: string,
|
||||
* onRegenerate?: () => void,
|
||||
* onDelete?: () => void,
|
||||
* }} props
|
||||
*/
|
||||
export function MessageActions({ role, content, onRegenerate, onDelete }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const _copy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content || '');
|
||||
} catch (_) {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = content || '';
|
||||
ta.style.cssText = 'position:fixed;left:-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [content]);
|
||||
|
||||
return html`
|
||||
<div class="sw-chat-msg__actions" role="toolbar" aria-label="Message actions">
|
||||
${role === 'assistant' && onRegenerate && html`
|
||||
<button class="sw-chat-msg__action-btn"
|
||||
title="Regenerate"
|
||||
onClick=${onRegenerate}
|
||||
aria-label="Regenerate response">
|
||||
${REGEN_SVG}
|
||||
</button>`}
|
||||
<button class="sw-chat-msg__action-btn"
|
||||
title=${copied ? 'Copied!' : 'Copy'}
|
||||
onClick=${_copy}
|
||||
aria-label="Copy message text">
|
||||
${copied ? CHECK_SVG : COPY_SVG}
|
||||
</button>
|
||||
${onDelete && html`
|
||||
<button class="sw-chat-msg__action-btn sw-chat-msg__action-btn--danger"
|
||||
title="Delete"
|
||||
onClick=${onDelete}
|
||||
aria-label="Delete message">
|
||||
${DELETE_SVG}
|
||||
</button>`}
|
||||
</div>`;
|
||||
}
|
||||
@@ -1,35 +1,79 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — MessageBubble Component
|
||||
// ==========================================
|
||||
// Single message rendering with role styling and markdown.
|
||||
// Single message rendering with role styling, markdown,
|
||||
// action toolbar, and enhanced code blocks.
|
||||
// Independently importable.
|
||||
//
|
||||
// v0.37.10: Added message actions, timestamps, code block copy.
|
||||
//
|
||||
// Usage:
|
||||
// html`<${MessageBubble} role="assistant" content=${text} />`
|
||||
// html`<${MessageBubble} role="assistant" content=${text}
|
||||
// onRegenerate=${fn} onDelete=${fn} />`
|
||||
|
||||
import { renderMarkdown } from './markdown.js';
|
||||
import { CodeBlock, extractCodeBlocks } from './code-block.js';
|
||||
import { MessageActions } from './message-actions.js';
|
||||
|
||||
const html = window.html;
|
||||
const { useMemo } = window.hooks;
|
||||
|
||||
/**
|
||||
* @param {{ role: string, content: string, streaming?: boolean }} props
|
||||
* Format a relative time string from an ISO date.
|
||||
*/
|
||||
export function MessageBubble({ role, content, streaming }) {
|
||||
function _relTime(isoDate) {
|
||||
if (!isoDate) return '';
|
||||
try {
|
||||
const d = new Date(isoDate);
|
||||
const now = Date.now();
|
||||
const sec = Math.floor((now - d.getTime()) / 1000);
|
||||
if (sec < 60) return 'just now';
|
||||
if (sec < 3600) return Math.floor(sec / 60) + 'm ago';
|
||||
if (sec < 86400) return Math.floor(sec / 3600) + 'h ago';
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
} catch (_) { return ''; }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* role: string,
|
||||
* content: string,
|
||||
* id?: string,
|
||||
* created_at?: string,
|
||||
* streaming?: boolean,
|
||||
* onRegenerate?: () => void,
|
||||
* onDelete?: () => void,
|
||||
* }} props
|
||||
*/
|
||||
export function MessageBubble({ role, content, id, created_at, streaming, onRegenerate, onDelete }) {
|
||||
const cls = 'sw-chat-msg sw-chat-msg--' + role
|
||||
+ (streaming ? ' sw-chat-msg--streaming' : '');
|
||||
|
||||
if (role === 'user') {
|
||||
// User messages use escaped text (no markdown)
|
||||
return html`
|
||||
<div class=${cls}>
|
||||
<div class="sw-chat-msg__content"
|
||||
dangerouslySetInnerHTML=${{ __html: renderMarkdown(content || '') }} />
|
||||
</div>`;
|
||||
}
|
||||
// Parse markdown and extract code blocks for enhanced rendering
|
||||
const segments = useMemo(() => {
|
||||
const rendered = renderMarkdown(content || '');
|
||||
return extractCodeBlocks(rendered);
|
||||
}, [content]);
|
||||
|
||||
const timeStr = _relTime(created_at);
|
||||
|
||||
return html`
|
||||
<div class=${cls}>
|
||||
<div class="sw-chat-msg__content"
|
||||
dangerouslySetInnerHTML=${{ __html: renderMarkdown(content || '') }} />
|
||||
<div class="sw-chat-msg__content">
|
||||
${segments.map((seg, i) =>
|
||||
seg.type === 'code'
|
||||
? html`<${CodeBlock} key=${i} code=${seg.code} language=${seg.language} />`
|
||||
: html`<span key=${i} dangerouslySetInnerHTML=${{ __html: seg.html }} />`
|
||||
)}
|
||||
</div>
|
||||
${!streaming && html`
|
||||
<div class="sw-chat-msg__footer">
|
||||
${timeStr && html`<span class="sw-chat-msg__time">${timeStr}</span>`}
|
||||
<${MessageActions}
|
||||
role=${role}
|
||||
content=${content}
|
||||
onRegenerate=${onRegenerate}
|
||||
onDelete=${onDelete} />
|
||||
</div>`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — MessageInput Component
|
||||
// ==========================================
|
||||
// Auto-resize textarea with Enter-to-send.
|
||||
// Auto-resize textarea with Enter-to-send, markdown toolbar,
|
||||
// emoji picker, and stop button.
|
||||
// Independently importable.
|
||||
//
|
||||
// v0.37.10: Added MarkdownToolbar, EmojiPicker, stop overlay, aria.
|
||||
//
|
||||
// Usage:
|
||||
// const inputHandle = useRef(null);
|
||||
// html`<${MessageInput} onSend=${fn} disabled=${false} handleRef=${inputHandle} />`
|
||||
// inputHandle.current.focus();
|
||||
// html`<${MessageInput} onSend=${fn} onStop=${fn} disabled=${false}
|
||||
// streaming=${false} handleRef=${inputHandle} />`
|
||||
|
||||
const { useRef, useCallback, useEffect } = window.hooks;
|
||||
import { MarkdownToolbar, handleFormatShortcut } from './markdown-toolbar.js';
|
||||
import { EmojiPicker } from './emoji-picker.js';
|
||||
|
||||
const { useRef, useState, useCallback, useEffect } = window.hooks;
|
||||
const html = window.html;
|
||||
|
||||
const SEND_SVG = html`
|
||||
@@ -19,11 +25,33 @@ const SEND_SVG = html`
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>`;
|
||||
|
||||
const STOP_SVG = html`
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2"/>
|
||||
</svg>`;
|
||||
|
||||
const EMOJI_SVG = html`
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M8 14s1.5 2 4 2 4-2 4-2"/>
|
||||
<line x1="9" y1="9" x2="9.01" y2="9"/>
|
||||
<line x1="15" y1="9" x2="15.01" y2="9"/>
|
||||
</svg>`;
|
||||
|
||||
/**
|
||||
* @param {{ onSend: (text: string) => void, disabled?: boolean, handleRef?: { current: any } }} props
|
||||
* @param {{
|
||||
* onSend: (text: string) => void,
|
||||
* onStop?: () => void,
|
||||
* disabled?: boolean,
|
||||
* streaming?: boolean,
|
||||
* handleRef?: { current: any },
|
||||
* }} props
|
||||
*/
|
||||
export function MessageInput({ onSend, disabled, handleRef }) {
|
||||
export function MessageInput({ onSend, onStop, disabled, streaming, handleRef }) {
|
||||
const taRef = useRef(null);
|
||||
const [emojiOpen, setEmojiOpen] = useState(false);
|
||||
|
||||
function _autoResize() {
|
||||
const ta = taRef.current;
|
||||
@@ -40,6 +68,7 @@ export function MessageInput({ onSend, disabled, handleRef }) {
|
||||
setValue(val) { if (taRef.current) { taRef.current.value = val; _autoResize(); } },
|
||||
focus() { taRef.current?.focus(); },
|
||||
clear() { if (taRef.current) { taRef.current.value = ''; _autoResize(); } },
|
||||
getTextarea() { return taRef.current; },
|
||||
};
|
||||
}
|
||||
return () => { if (handleRef) handleRef.current = null; };
|
||||
@@ -48,6 +77,9 @@ export function MessageInput({ onSend, disabled, handleRef }) {
|
||||
const _onInput = useCallback(() => _autoResize(), []);
|
||||
|
||||
const _onKeyDown = useCallback((e) => {
|
||||
// Formatting shortcuts (Ctrl+B, Ctrl+I, Ctrl+E)
|
||||
if (handleFormatShortcut(e, taRef.current, _autoResize)) return;
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const text = taRef.current?.value?.trim();
|
||||
@@ -66,8 +98,28 @@ export function MessageInput({ onSend, disabled, handleRef }) {
|
||||
}
|
||||
}, [onSend, disabled]);
|
||||
|
||||
const _insertEmoji = useCallback((emoji) => {
|
||||
const ta = taRef.current;
|
||||
if (!ta) return;
|
||||
ta.focus();
|
||||
const start = ta.selectionStart;
|
||||
const hasExecCommand = document.queryCommandSupported?.('insertText');
|
||||
if (hasExecCommand) {
|
||||
document.execCommand('insertText', false, emoji);
|
||||
} else {
|
||||
ta.value = ta.value.slice(0, start) + emoji + ta.value.slice(ta.selectionEnd);
|
||||
}
|
||||
const pos = start + emoji.length;
|
||||
ta.setSelectionRange(pos, pos);
|
||||
_autoResize();
|
||||
}, []);
|
||||
|
||||
const _toggleEmoji = useCallback(() => setEmojiOpen(v => !v), []);
|
||||
const _closeEmoji = useCallback(() => setEmojiOpen(false), []);
|
||||
|
||||
return html`
|
||||
<div class="sw-chat-pane__input-bar">
|
||||
<${MarkdownToolbar} textareaRef=${taRef} onInput=${_autoResize} />
|
||||
<div class="sw-msg-input__wrap">
|
||||
<textarea
|
||||
ref=${taRef}
|
||||
@@ -77,13 +129,38 @@ export function MessageInput({ onSend, disabled, handleRef }) {
|
||||
disabled=${disabled}
|
||||
onInput=${_onInput}
|
||||
onKeyDown=${_onKeyDown}
|
||||
aria-label="Message input"
|
||||
/>
|
||||
<button
|
||||
class="sw-msg-input__send"
|
||||
title="Send"
|
||||
disabled=${disabled}
|
||||
onClick=${_onClickSend}
|
||||
>${SEND_SVG}</button>
|
||||
<div class="sw-msg-input__buttons">
|
||||
<div class="sw-msg-input__emoji-wrap">
|
||||
<button
|
||||
class="sw-msg-input__emoji-btn"
|
||||
title="Emoji"
|
||||
onClick=${_toggleEmoji}
|
||||
type="button"
|
||||
aria-label="Open emoji picker"
|
||||
aria-expanded=${emojiOpen}
|
||||
>${EMOJI_SVG}</button>
|
||||
<${EmojiPicker}
|
||||
open=${emojiOpen}
|
||||
onSelect=${_insertEmoji}
|
||||
onClose=${_closeEmoji} />
|
||||
</div>
|
||||
${streaming && onStop
|
||||
? html`<button
|
||||
class="sw-msg-input__stop"
|
||||
title="Stop generating"
|
||||
onClick=${onStop}
|
||||
aria-label="Stop generating"
|
||||
>${STOP_SVG}</button>`
|
||||
: html`<button
|
||||
class="sw-msg-input__send"
|
||||
title="Send"
|
||||
disabled=${disabled}
|
||||
onClick=${_onClickSend}
|
||||
aria-label="Send message"
|
||||
>${SEND_SVG}</button>`}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,44 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — MessageList Component
|
||||
// ==========================================
|
||||
// Scrollable message list with auto-scroll-to-bottom.
|
||||
// Scrollable message list with auto-scroll-to-bottom
|
||||
// and "load older" pagination.
|
||||
// Independently importable.
|
||||
//
|
||||
// v0.37.10: Added pagination (load more), regenerate/delete wiring.
|
||||
//
|
||||
// Usage:
|
||||
// html`<${MessageList} messages=${msgs} streamContent=${text} streaming=${true} />`
|
||||
// html`<${MessageList} messages=${msgs} streamContent=${text}
|
||||
// streaming=${true} hasMore=${true} loadingMore=${false}
|
||||
// onLoadMore=${fn} onRegenerate=${fn} onDelete=${fn} />`
|
||||
|
||||
import { MessageBubble } from './message-bubble.js';
|
||||
|
||||
const { useRef, useEffect } = window.hooks;
|
||||
const { useRef, useEffect, useCallback } = window.hooks;
|
||||
const html = window.html;
|
||||
|
||||
const SCROLL_THRESHOLD = 60;
|
||||
|
||||
/**
|
||||
* @param {{ messages: Array<{role: string, content: string}>, streamContent?: string, streaming?: boolean, className?: string }} props
|
||||
* @param {{
|
||||
* messages: Array<{role: string, content: string, id?: string, created_at?: string}>,
|
||||
* streamContent?: string,
|
||||
* streaming?: boolean,
|
||||
* hasMore?: boolean,
|
||||
* loadingMore?: boolean,
|
||||
* onLoadMore?: () => void,
|
||||
* onRegenerate?: (msgId: string) => void,
|
||||
* onDelete?: (msgId: string) => void,
|
||||
* className?: string,
|
||||
* }} props
|
||||
*/
|
||||
export function MessageList({ messages, streamContent, streaming, className }) {
|
||||
export function MessageList({
|
||||
messages, streamContent, streaming, hasMore, loadingMore,
|
||||
onLoadMore, onRegenerate, onDelete, className,
|
||||
}) {
|
||||
const scrollRef = useRef(null);
|
||||
const wasAtBottom = useRef(true);
|
||||
const prevScrollHeightRef = useRef(0);
|
||||
|
||||
// Track scroll position
|
||||
function _onScroll() {
|
||||
@@ -36,19 +55,50 @@ export function MessageList({ messages, streamContent, streaming, className }) {
|
||||
}
|
||||
}, [messages, streamContent]);
|
||||
|
||||
// Preserve scroll position when prepending older messages
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const prevH = prevScrollHeightRef.current;
|
||||
if (prevH && el.scrollHeight > prevH && !wasAtBottom.current) {
|
||||
// Older messages were prepended — maintain relative position
|
||||
el.scrollTop += (el.scrollHeight - prevH);
|
||||
}
|
||||
prevScrollHeightRef.current = el.scrollHeight;
|
||||
}, [messages.length]);
|
||||
|
||||
const hasContent = messages.length > 0 || streaming;
|
||||
|
||||
return html`
|
||||
<div class=${'sw-chat-pane__messages' + (className ? ' ' + className : '')}
|
||||
ref=${scrollRef}
|
||||
onScroll=${_onScroll}>
|
||||
onScroll=${_onScroll}
|
||||
role="log"
|
||||
aria-label="Chat messages"
|
||||
aria-live="polite">
|
||||
${hasMore && html`
|
||||
<div class="sw-chat-pane__load-more">
|
||||
<button class="sw-chat-pane__load-more-btn"
|
||||
onClick=${onLoadMore}
|
||||
disabled=${loadingMore}
|
||||
aria-label="Load older messages">
|
||||
${loadingMore ? 'Loading\u2026' : 'Load older messages'}
|
||||
</button>
|
||||
</div>`}
|
||||
${!hasContent && html`
|
||||
<div class="sw-chat-welcome">
|
||||
<p class="sw-chat-welcome__title">Start a conversation</p>
|
||||
<p class="sw-chat-welcome__hint">Type a message below to get started.</p>
|
||||
</div>`}
|
||||
${messages.map((m, i) => html`
|
||||
<${MessageBubble} key=${m.id || i} role=${m.role} content=${m.content} />`)}
|
||||
<${MessageBubble}
|
||||
key=${m.id || i}
|
||||
role=${m.role}
|
||||
content=${m.content}
|
||||
id=${m.id}
|
||||
created_at=${m.created_at}
|
||||
onRegenerate=${m.role === 'assistant' && m.id && onRegenerate ? () => onRegenerate(m.id) : undefined}
|
||||
onDelete=${m.id && onDelete ? () => onDelete(m.id) : undefined} />`)}
|
||||
${streaming && html`
|
||||
<${MessageBubble} role="assistant" content=${streamContent} streaming=${true} />`}
|
||||
</div>`;
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
// Core chat state machine: messages, channel CRUD, send/receive,
|
||||
// model selection. Independently importable.
|
||||
//
|
||||
// v0.37.10: Added regenerate(), deleteMessage(), pagination.
|
||||
//
|
||||
// Usage:
|
||||
// const chat = useChat({ onChannelChange });
|
||||
// chat.sendMessage('Hello');
|
||||
@@ -12,6 +14,8 @@ import { useStream } from './use-stream.js';
|
||||
|
||||
const { useState, useRef, useCallback } = window.hooks;
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* @param {{ initialChannelId?: string, getContext?: () => {path: string, content: string}|null, onChannelChange?: (id: string) => void }} opts
|
||||
*/
|
||||
@@ -23,21 +27,22 @@ export function useChat(opts = {}) {
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [model, setModel] = useState('');
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
|
||||
const channelRef = useRef(channelId);
|
||||
const pageRef = useRef(1);
|
||||
const { streamContent, streaming, startStream, stopStream } = useStream();
|
||||
|
||||
// Keep ref in sync
|
||||
channelRef.current = channelId;
|
||||
|
||||
// ── Init model from first enabled ──────
|
||||
// Deferred — set on first render if not already set
|
||||
// ── Init model from session ─────────────
|
||||
if (!model && window.sw?.auth?.session?.settings?.model) {
|
||||
// Will be picked up on next render cycle via setModel
|
||||
setTimeout(() => setModel(window.sw.auth.session.settings.model), 0);
|
||||
}
|
||||
|
||||
// ── Send Message ───────────────────────
|
||||
// ── Send Message ────────────────────────
|
||||
const sendMessage = useCallback(async (text) => {
|
||||
if (!text.trim() || sending) return;
|
||||
setSending(true);
|
||||
@@ -78,9 +83,6 @@ export function useChat(opts = {}) {
|
||||
const data = { channel_id: cid, content, model };
|
||||
const resp = await window.sw.api.channels.complete(data);
|
||||
await startStream(resp);
|
||||
|
||||
// After stream completes, append assistant message
|
||||
// (streamContent will have the final text via useStream)
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
setError('Error: ' + e.message);
|
||||
@@ -94,15 +96,54 @@ export function useChat(opts = {}) {
|
||||
const prevStreamingRef = useRef(false);
|
||||
if (prevStreamingRef.current && !streaming && streamContent) {
|
||||
prevStreamingRef.current = false;
|
||||
// Use functional update to avoid stale closure
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: streamContent }]);
|
||||
}
|
||||
prevStreamingRef.current = streaming;
|
||||
|
||||
// ── Switch Channel ─────────────────────
|
||||
// ── Regenerate ──────────────────────────
|
||||
const regenerate = useCallback(async (msgId) => {
|
||||
const cid = channelRef.current;
|
||||
if (!cid || !msgId || sending) return;
|
||||
|
||||
setSending(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const resp = await window.sw.api.channels.regenerate(cid, msgId, { model });
|
||||
// Remove the old assistant message being regenerated
|
||||
setMessages(prev => {
|
||||
const idx = prev.findIndex(m => m.id === msgId);
|
||||
return idx >= 0 ? prev.slice(0, idx) : prev;
|
||||
});
|
||||
await startStream(resp);
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
setError('Regenerate failed: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
setSending(false);
|
||||
}, [sending, model, startStream]);
|
||||
|
||||
// ── Delete Message ──────────────────────
|
||||
const deleteMessage = useCallback(async (msgId) => {
|
||||
// Optimistic remove from UI
|
||||
setMessages(prev => prev.filter(m => m.id !== msgId));
|
||||
|
||||
// If API supports delete, fire it (no-op if not available)
|
||||
if (window.sw?.api?.channels?.deleteMessage) {
|
||||
try {
|
||||
await window.sw.api.channels.deleteMessage(channelRef.current, msgId);
|
||||
} catch (_) { /* swallow — already removed from UI */ }
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Switch Channel ──────────────────────
|
||||
const switchChannel = useCallback(async (id) => {
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
setHasMore(false);
|
||||
pageRef.current = 1;
|
||||
channelRef.current = id;
|
||||
setChannelId(id);
|
||||
if (onChannelChange) onChannelChange(id);
|
||||
@@ -110,30 +151,60 @@ export function useChat(opts = {}) {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const resp = await window.sw.api.channels.messages(id, { page: 1, per_page: 100 });
|
||||
setMessages(resp?.data || resp || []);
|
||||
const resp = await window.sw.api.channels.messages(id, { page: 1, per_page: PAGE_SIZE });
|
||||
const data = resp?.data || resp || [];
|
||||
setMessages(data);
|
||||
setHasMore(data.length >= PAGE_SIZE);
|
||||
} catch (e) {
|
||||
setError('Failed to load messages: ' + e.message);
|
||||
}
|
||||
}, [onChannelChange]);
|
||||
|
||||
// ── New Chat ───────────────────────────
|
||||
// ── Load More (pagination) ──────────────
|
||||
const loadMore = useCallback(async () => {
|
||||
const cid = channelRef.current;
|
||||
if (!cid || loadingMore || !hasMore) return;
|
||||
|
||||
setLoadingMore(true);
|
||||
const nextPage = pageRef.current + 1;
|
||||
|
||||
try {
|
||||
const resp = await window.sw.api.channels.messages(cid, { page: nextPage, per_page: PAGE_SIZE });
|
||||
const data = resp?.data || resp || [];
|
||||
if (data.length > 0) {
|
||||
pageRef.current = nextPage;
|
||||
// Prepend older messages
|
||||
setMessages(prev => [...data, ...prev]);
|
||||
setHasMore(data.length >= PAGE_SIZE);
|
||||
} else {
|
||||
setHasMore(false);
|
||||
}
|
||||
} catch (e) {
|
||||
setError('Failed to load older messages: ' + e.message);
|
||||
}
|
||||
|
||||
setLoadingMore(false);
|
||||
}, [loadingMore, hasMore]);
|
||||
|
||||
// ── New Chat ────────────────────────────
|
||||
const newChat = useCallback(() => {
|
||||
stopStream();
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
setHasMore(false);
|
||||
pageRef.current = 1;
|
||||
channelRef.current = null;
|
||||
setChannelId(null);
|
||||
if (onChannelChange) onChannelChange(null);
|
||||
}, [stopStream, onChannelChange]);
|
||||
|
||||
// ── Stop ───────────────────────────────
|
||||
// ── Stop ────────────────────────────────
|
||||
const stop = useCallback(() => {
|
||||
stopStream();
|
||||
setSending(false);
|
||||
}, [stopStream]);
|
||||
|
||||
// ── Clear Error ────────────────────────
|
||||
// ── Clear Error ─────────────────────────
|
||||
const clearError = useCallback(() => setError(null), []);
|
||||
|
||||
return {
|
||||
@@ -151,5 +222,11 @@ export function useChat(opts = {}) {
|
||||
// Streaming state (passthrough from useStream)
|
||||
streamContent,
|
||||
streaming,
|
||||
// v0.37.10: new methods
|
||||
regenerate,
|
||||
deleteMessage,
|
||||
loadMore,
|
||||
hasMore,
|
||||
loadingMore,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
* RBAC-gated items based on sw.can() / sw.isAdmin / sw.isTeamAdmin().
|
||||
* Surfaces mount this wherever they want; the shell does not own placement.
|
||||
*
|
||||
* Surface links (Notes, extensions) are auto-included and the CURRENT
|
||||
* surface is filtered out so every surface gets the same menu minus itself.
|
||||
*
|
||||
* Props:
|
||||
* placement — Menu direction: 'down-right' (default) | 'down-left'
|
||||
* onAction — Override handler; if omitted, uses default routing
|
||||
* placement — Menu direction: 'down-right' (default) | 'up-right' etc.
|
||||
* onAction — Override handler; if omitted, navigates via location.href
|
||||
* extraItems — Additional menu items inserted after surface links
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useRef, useMemo } = hooks;
|
||||
@@ -14,7 +18,14 @@ const { useState, useRef, useMemo } = hooks;
|
||||
import { Avatar } from '../primitives/avatar.js';
|
||||
import { Menu } from '../primitives/menu.js';
|
||||
|
||||
export function UserMenu({ placement = 'down-right', onAction }) {
|
||||
/**
|
||||
* Detect current surface from body[data-surface].
|
||||
*/
|
||||
function _currentSurface() {
|
||||
return document.body?.dataset?.surface || '';
|
||||
}
|
||||
|
||||
export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const btnRef = useRef(null);
|
||||
|
||||
@@ -23,18 +34,51 @@ export function UserMenu({ placement = 'down-right', onAction }) {
|
||||
const authenticated = sw?.auth?.isAuthenticated;
|
||||
|
||||
const items = useMemo(() => {
|
||||
const list = [
|
||||
{ label: 'Settings', action: 'settings', icon: '\u2699\ufe0f' },
|
||||
const current = _currentSurface();
|
||||
const list = [];
|
||||
|
||||
// ── Surface links (auto-filtered) ──────
|
||||
// Each surface that exists gets a menu item, except the current one.
|
||||
const surfaces = [
|
||||
{ key: 'chat', label: 'Chat', icon: '\ud83d\udcac' },
|
||||
{ key: 'notes', label: 'Notes', icon: '\ud83d\udcdd' },
|
||||
];
|
||||
|
||||
// Add extension surfaces from page data if available
|
||||
const extSurfaces = window.__EXTENSION_SURFACES__ || [];
|
||||
for (const ext of extSurfaces) {
|
||||
surfaces.push({ key: ext.id || ext.route, label: ext.title, icon: '\ud83e\udde9' });
|
||||
}
|
||||
|
||||
const surfaceItems = surfaces
|
||||
.filter(s => s.key !== current)
|
||||
.map(s => ({ label: s.label, action: s.key, icon: s.icon }));
|
||||
|
||||
if (surfaceItems.length) {
|
||||
list.push(...surfaceItems);
|
||||
list.push({ divider: true });
|
||||
}
|
||||
|
||||
// ── Extra items from caller ────────────
|
||||
if (extraItems && extraItems.length) {
|
||||
list.push(...extraItems);
|
||||
list.push({ divider: true });
|
||||
}
|
||||
|
||||
// ── Standard items ─────────────────────
|
||||
// Settings — skip if current surface IS settings
|
||||
if (current !== 'settings') {
|
||||
list.push({ label: 'Settings', action: 'settings', icon: '\u2699\ufe0f' });
|
||||
}
|
||||
|
||||
// Admin — requires admin role
|
||||
if (!authenticated || sw?.isAdmin) {
|
||||
if ((!authenticated || sw?.isAdmin) && current !== 'admin') {
|
||||
list.push({ label: 'Admin', action: 'admin', icon: '\ud83d\udee1\ufe0f' });
|
||||
}
|
||||
|
||||
// Team Admin — requires admin role on at least one team
|
||||
const hasTeamAdmin = sw?.auth?.teams?.some(t => t.my_role === 'admin');
|
||||
if (!authenticated || hasTeamAdmin) {
|
||||
if ((!authenticated || hasTeamAdmin) && current !== 'team-admin') {
|
||||
list.push({ label: 'Team Admin', action: 'team-admin', icon: '\ud83d\udc65' });
|
||||
}
|
||||
|
||||
@@ -47,15 +91,27 @@ export function UserMenu({ placement = 'down-right', onAction }) {
|
||||
list.push({ label: 'Sign Out', action: 'sign-out' });
|
||||
|
||||
return list;
|
||||
}, [user, authenticated]);
|
||||
}, [user, authenticated, extraItems]);
|
||||
|
||||
function handleSelect(action) {
|
||||
setOpen(false);
|
||||
if (onAction) return onAction(action);
|
||||
if (action === 'sign-out') {
|
||||
sw?.auth?.logout();
|
||||
} else {
|
||||
sw?.emit('shell.navigate', { target: action });
|
||||
|
||||
// Default routing
|
||||
const BASE = window.__BASE__ || '';
|
||||
switch (action) {
|
||||
case 'sign-out':
|
||||
window.sw?.auth?.logout();
|
||||
break;
|
||||
case 'debug':
|
||||
const dbg = document.getElementById('debugModal');
|
||||
if (dbg) dbg.classList.toggle('active');
|
||||
break;
|
||||
case 'chat':
|
||||
location.href = BASE + '/';
|
||||
break;
|
||||
default:
|
||||
location.href = BASE + '/' + action;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
66
src/js/sw/surfaces/chat/chat-workspace.js
Normal file
66
src/js/sw/surfaces/chat/chat-workspace.js
Normal file
@@ -0,0 +1,66 @@
|
||||
// ==========================================
|
||||
// Chat Surface — ChatWorkspace Component
|
||||
// ==========================================
|
||||
// Header bar (model selector, tools, sidebar toggle) + ChatPane.
|
||||
// ChatPane runs standalone=false — the surface manages navigation.
|
||||
|
||||
import { ChatPane, ModelSelector, useChat } from '../../components/chat-pane/index.js';
|
||||
|
||||
const html = window.html;
|
||||
const { useRef, useEffect } = window.hooks;
|
||||
|
||||
const PANEL_SVG = html`
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<line x1="9" y1="3" x2="9" y2="21"/>
|
||||
</svg>`;
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* activeId: string|null,
|
||||
* activeType: 'chat'|'channel'|null,
|
||||
* onChannelChange: (id: string) => void,
|
||||
* onNewChat: () => void,
|
||||
* sidebarOpen: boolean,
|
||||
* onToggleSidebar: () => void,
|
||||
* toolsButton?: any,
|
||||
* }} props
|
||||
*/
|
||||
export function ChatWorkspace({
|
||||
activeId, activeType, onChannelChange, onNewChat,
|
||||
sidebarOpen, onToggleSidebar, toolsButton,
|
||||
}) {
|
||||
const chatRef = useRef(null);
|
||||
|
||||
// When activeId changes, tell ChatPane to switch channel
|
||||
useEffect(() => {
|
||||
if (chatRef.current) {
|
||||
chatRef.current.setChannel(activeId);
|
||||
}
|
||||
}, [activeId]);
|
||||
|
||||
return html`
|
||||
<div class="sw-chat-surface__workspace">
|
||||
<div class="sw-chat-surface__workspace-header">
|
||||
<div class="sw-chat-surface__workspace-header-left">
|
||||
${!sidebarOpen && html`
|
||||
<button class="sw-chat-surface__sidebar-toggle"
|
||||
onClick=${onToggleSidebar}
|
||||
title="Show sidebar"
|
||||
aria-label="Show sidebar">
|
||||
${PANEL_SVG}
|
||||
</button>`}
|
||||
</div>
|
||||
<div class="sw-chat-surface__workspace-header-right">
|
||||
${toolsButton}
|
||||
</div>
|
||||
</div>
|
||||
<${ChatPane}
|
||||
channelId=${activeId}
|
||||
standalone=${false}
|
||||
handleRef=${chatRef}
|
||||
onChannelChange=${onChannelChange}
|
||||
className="sw-chat-surface__chat-pane" />
|
||||
</div>`;
|
||||
}
|
||||
115
src/js/sw/surfaces/chat/index.js
Normal file
115
src/js/sw/surfaces/chat/index.js
Normal file
@@ -0,0 +1,115 @@
|
||||
// ==========================================
|
||||
// Chat Surface — Root Component
|
||||
// ==========================================
|
||||
// Main chat surface: sidebar + workspace.
|
||||
// Follows the settings/admin mount pattern.
|
||||
//
|
||||
// v0.37.10: New Preact surface replacing the legacy SPA.
|
||||
|
||||
import { ToastContainer } from '../../primitives/toast.js';
|
||||
import { DialogStack } from '../../shell/dialog-stack.js';
|
||||
import { useWorkspace } from './use-workspace.js';
|
||||
import { useSidebar } from './use-sidebar.js';
|
||||
import { Sidebar } from './sidebar.js';
|
||||
import { ChatWorkspace } from './chat-workspace.js';
|
||||
import { useTools, ToolsPopup } from './tools-popup.js';
|
||||
|
||||
const { render } = window.preact;
|
||||
const html = window.html;
|
||||
const { useState, useCallback } = window.hooks;
|
||||
|
||||
const WRENCH_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0
|
||||
0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1
|
||||
7.94-7.94l-3.76 3.76z"/>
|
||||
</svg>`;
|
||||
|
||||
function ChatSurface() {
|
||||
const workspace = useWorkspace();
|
||||
const sidebar = useSidebar({ activeId: workspace.activeId });
|
||||
const tools = useTools();
|
||||
const [toolsOpen, setToolsOpen] = useState(false);
|
||||
|
||||
const _onNewChat = useCallback(() => {
|
||||
workspace.clearSelection();
|
||||
}, [workspace.clearSelection]);
|
||||
|
||||
const _onChannelChange = useCallback((id) => {
|
||||
if (id) workspace.selectChat(id);
|
||||
}, [workspace.selectChat]);
|
||||
|
||||
const _toggleSidebar = useCallback(() => {
|
||||
workspace.setSidebarOpen(!workspace.sidebarOpen);
|
||||
}, [workspace.sidebarOpen, workspace.setSidebarOpen]);
|
||||
|
||||
const _toggleTools = useCallback(() => {
|
||||
setToolsOpen(v => !v);
|
||||
}, []);
|
||||
|
||||
const _closeTools = useCallback(() => {
|
||||
setToolsOpen(false);
|
||||
}, []);
|
||||
|
||||
// Tools button — shared between mobile bar and workspace header
|
||||
const toolsButton = html`
|
||||
<button class="sw-chat-surface__tools-btn"
|
||||
onClick=${_toggleTools}
|
||||
title="Tools"
|
||||
aria-label="Toggle tools"
|
||||
aria-expanded=${toolsOpen}>
|
||||
${WRENCH_SVG}
|
||||
${tools.disabled.size > 0 && html`
|
||||
<span class="sw-chat-surface__tools-badge">${tools.disabled.size}</span>`}
|
||||
</button>`;
|
||||
|
||||
return html`
|
||||
<div class="sw-chat-surface">
|
||||
${/* Mobile sidebar overlay */''}
|
||||
${workspace.sidebarOpen && html`
|
||||
<div class="sw-chat-surface__sidebar-overlay"
|
||||
onClick=${_toggleSidebar} />`}
|
||||
|
||||
${/* Sidebar */''}
|
||||
${workspace.sidebarOpen && html`
|
||||
<${Sidebar}
|
||||
sidebar=${sidebar}
|
||||
activeId=${workspace.activeId}
|
||||
onSelectChat=${workspace.selectChat}
|
||||
onSelectChannel=${workspace.selectChannel}
|
||||
onNewChat=${_onNewChat}
|
||||
onCollapse=${_toggleSidebar} />`}
|
||||
|
||||
${/* Main workspace */''}
|
||||
<div class="sw-chat-surface__main">
|
||||
<${ChatWorkspace}
|
||||
activeId=${workspace.activeId}
|
||||
activeType=${workspace.activeType}
|
||||
onChannelChange=${_onChannelChange}
|
||||
onNewChat=${_onNewChat}
|
||||
sidebarOpen=${workspace.sidebarOpen}
|
||||
onToggleSidebar=${_toggleSidebar}
|
||||
toolsButton=${toolsButton} />
|
||||
<${ToolsPopup}
|
||||
open=${toolsOpen}
|
||||
onClose=${_closeTools}
|
||||
categories=${tools.categories}
|
||||
disabled=${tools.disabled}
|
||||
onToggle=${tools.toggle}
|
||||
onToggleCategory=${tools.toggleCategory} />
|
||||
</div>
|
||||
</div>
|
||||
<${ToastContainer} />
|
||||
<${DialogStack} />
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Mount ────────────────────────────────────
|
||||
const mount = document.getElementById('chat-mount');
|
||||
if (mount) {
|
||||
render(html`<${ChatSurface} />`, mount);
|
||||
console.log('[chat] Surface mounted (v0.37.10)');
|
||||
}
|
||||
|
||||
export { ChatSurface };
|
||||
65
src/js/sw/surfaces/chat/sidebar-channels.js
Normal file
65
src/js/sw/surfaces/chat/sidebar-channels.js
Normal file
@@ -0,0 +1,65 @@
|
||||
// ==========================================
|
||||
// Chat Surface — SidebarChannels Component
|
||||
// ==========================================
|
||||
// Collapsible channels section: DMs and named channels.
|
||||
|
||||
const html = window.html;
|
||||
|
||||
const CHEVRON_SVG = html`
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>`;
|
||||
|
||||
const HASH_SVG = html`
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="4" y1="9" x2="20" y2="9"/>
|
||||
<line x1="4" y1="15" x2="20" y2="15"/>
|
||||
<line x1="10" y1="3" x2="8" y2="21"/>
|
||||
<line x1="16" y1="3" x2="14" y2="21"/>
|
||||
</svg>`;
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* channels: Array<{id: string, title: string, unread_count?: number}>,
|
||||
* activeId: string|null,
|
||||
* collapsed: boolean,
|
||||
* onToggle: () => void,
|
||||
* onSelect: (id: string) => void,
|
||||
* }} props
|
||||
*/
|
||||
export function SidebarChannels({ channels, activeId, collapsed, onToggle, onSelect }) {
|
||||
if (!channels.length && collapsed) return null;
|
||||
|
||||
return html`
|
||||
<div class="sw-chat-surface__section">
|
||||
<button class="sw-chat-surface__section-header"
|
||||
onClick=${onToggle}
|
||||
aria-expanded=${!collapsed}>
|
||||
<span class=${'sw-chat-surface__chevron' + (collapsed ? '' : ' sw-chat-surface__chevron--open')}>
|
||||
${CHEVRON_SVG}
|
||||
</span>
|
||||
<span class="sw-chat-surface__section-label">Channels</span>
|
||||
${channels.length > 0 && html`
|
||||
<span class="sw-chat-surface__section-count">${channels.length}</span>`}
|
||||
</button>
|
||||
${!collapsed && html`
|
||||
<div class="sw-chat-surface__section-body" role="listbox">
|
||||
${channels.length === 0 && html`
|
||||
<div class="sw-chat-surface__empty">No channels</div>`}
|
||||
${channels.map(ch => html`
|
||||
<button key=${ch.id}
|
||||
class=${'sw-chat-surface__item' + (ch.id === activeId ? ' sw-chat-surface__item--active' : '')}
|
||||
onClick=${() => onSelect(ch.id)}
|
||||
role="option"
|
||||
aria-selected=${ch.id === activeId}
|
||||
title=${ch.title || 'Untitled'}>
|
||||
<span class="sw-chat-surface__item-icon">${HASH_SVG}</span>
|
||||
<span class="sw-chat-surface__item-title">${ch.title || 'Untitled'}</span>
|
||||
${(ch.unread_count || 0) > 0 && html`
|
||||
<span class="sw-chat-surface__unread">${ch.unread_count}</span>`}
|
||||
</button>`)}
|
||||
</div>`}
|
||||
</div>`;
|
||||
}
|
||||
199
src/js/sw/surfaces/chat/sidebar-chats.js
Normal file
199
src/js/sw/surfaces/chat/sidebar-chats.js
Normal file
@@ -0,0 +1,199 @@
|
||||
// ==========================================
|
||||
// Chat Surface — SidebarChats Component
|
||||
// ==========================================
|
||||
// Personal AI chats with folder grouping and context menus.
|
||||
|
||||
const html = window.html;
|
||||
const { useState, useCallback, useRef, useEffect } = window.hooks;
|
||||
|
||||
const CHEVRON_SVG = html`
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>`;
|
||||
|
||||
const CHAT_SVG = html`
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>`;
|
||||
|
||||
const FOLDER_SVG = html`
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>`;
|
||||
|
||||
const DOTS_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/>
|
||||
</svg>`;
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* chatsByFolder: Record<string, Array>,
|
||||
* folders: Array<{id: string, name: string}>,
|
||||
* activeId: string|null,
|
||||
* collapsed: boolean,
|
||||
* onToggle: () => void,
|
||||
* onSelect: (id: string) => void,
|
||||
* onRename: (id: string, title: string) => void,
|
||||
* onDelete: (id: string) => void,
|
||||
* onMoveToFolder: (chatId: string, folderId: string|null) => void,
|
||||
* onCreateFolder: (name: string) => void,
|
||||
* onRenameFolder: (id: string, name: string) => void,
|
||||
* onDeleteFolder: (id: string) => void,
|
||||
* }} props
|
||||
*/
|
||||
export function SidebarChats({
|
||||
chatsByFolder, folders, activeId, collapsed, onToggle, onSelect,
|
||||
onRename, onDelete, onMoveToFolder, onCreateFolder,
|
||||
onRenameFolder, onDeleteFolder,
|
||||
}) {
|
||||
const [contextMenu, setContextMenu] = useState(null);
|
||||
const menuRef = useRef(null);
|
||||
|
||||
// Close context menu on outside click or Escape
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return;
|
||||
function _close(e) {
|
||||
if (e.type === 'keydown' && e.key !== 'Escape') return;
|
||||
if (e.type === 'click' && menuRef.current?.contains(e.target)) return;
|
||||
setContextMenu(null);
|
||||
}
|
||||
document.addEventListener('click', _close);
|
||||
document.addEventListener('keydown', _close);
|
||||
return () => {
|
||||
document.removeEventListener('click', _close);
|
||||
document.removeEventListener('keydown', _close);
|
||||
};
|
||||
}, [contextMenu]);
|
||||
|
||||
const _openMenu = useCallback((e, type, item) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setContextMenu({ type, item, x: rect.right, y: rect.bottom });
|
||||
}, []);
|
||||
|
||||
const _action = useCallback(async (action, item) => {
|
||||
setContextMenu(null);
|
||||
if (action === 'rename') {
|
||||
const newTitle = prompt('Rename:', item.title || item.name || '');
|
||||
if (newTitle && newTitle.trim()) {
|
||||
if (item.name !== undefined) await onRenameFolder(item.id, newTitle.trim());
|
||||
else await onRename(item.id, newTitle.trim());
|
||||
}
|
||||
} else if (action === 'delete') {
|
||||
const label = item.name !== undefined ? 'folder' : 'chat';
|
||||
if (confirm('Delete this ' + label + '?')) {
|
||||
if (item.name !== undefined) await onDeleteFolder(item.id);
|
||||
else await onDelete(item.id);
|
||||
}
|
||||
} else if (action === 'move-out') {
|
||||
await onMoveToFolder(item.id, null);
|
||||
} else if (action === 'new-folder') {
|
||||
const name = prompt('Folder name:');
|
||||
if (name && name.trim()) await onCreateFolder(name.trim());
|
||||
} else if (action.startsWith('move-to:')) {
|
||||
const fid = action.slice(8);
|
||||
await onMoveToFolder(item.id, fid);
|
||||
}
|
||||
}, [onRename, onDelete, onMoveToFolder, onCreateFolder, onRenameFolder, onDeleteFolder]);
|
||||
|
||||
// Count total chats
|
||||
const totalChats = Object.values(chatsByFolder).reduce((sum, arr) => sum + arr.length, 0);
|
||||
const unfolderedChats = chatsByFolder[''] || [];
|
||||
|
||||
return html`
|
||||
<div class="sw-chat-surface__section">
|
||||
<button class="sw-chat-surface__section-header"
|
||||
onClick=${onToggle}
|
||||
aria-expanded=${!collapsed}>
|
||||
<span class=${'sw-chat-surface__chevron' + (collapsed ? '' : ' sw-chat-surface__chevron--open')}>
|
||||
${CHEVRON_SVG}
|
||||
</span>
|
||||
<span class="sw-chat-surface__section-label">Chats</span>
|
||||
${totalChats > 0 && html`
|
||||
<span class="sw-chat-surface__section-count">${totalChats}</span>`}
|
||||
</button>
|
||||
${!collapsed && html`
|
||||
<div class="sw-chat-surface__section-body" role="listbox">
|
||||
${/* Folders first */''}
|
||||
${folders.map(folder => {
|
||||
const folderChats = chatsByFolder[folder.id] || [];
|
||||
return html`
|
||||
<div key=${'f-' + folder.id} class="sw-chat-surface__folder">
|
||||
<div class="sw-chat-surface__folder-header">
|
||||
<span class="sw-chat-surface__item-icon">${FOLDER_SVG}</span>
|
||||
<span class="sw-chat-surface__folder-name">${folder.name}</span>
|
||||
<span class="sw-chat-surface__folder-count">${folderChats.length}</span>
|
||||
<button class="sw-chat-surface__item-menu"
|
||||
onClick=${(e) => _openMenu(e, 'folder', folder)}
|
||||
title="Folder actions"
|
||||
aria-label="Folder actions">
|
||||
${DOTS_SVG}
|
||||
</button>
|
||||
</div>
|
||||
${folderChats.map(ch => html`
|
||||
<${ChatItem} key=${ch.id} chat=${ch} activeId=${activeId}
|
||||
onSelect=${onSelect} onOpenMenu=${_openMenu}
|
||||
indent=${true} />`)}
|
||||
</div>`;
|
||||
})}
|
||||
${/* Unfoldered chats */''}
|
||||
${unfolderedChats.map(ch => html`
|
||||
<${ChatItem} key=${ch.id} chat=${ch} activeId=${activeId}
|
||||
onSelect=${onSelect} onOpenMenu=${_openMenu} />`)}
|
||||
${totalChats === 0 && html`
|
||||
<div class="sw-chat-surface__empty">No chats yet</div>`}
|
||||
</div>`}
|
||||
|
||||
${/* Context Menu */''}
|
||||
${contextMenu && html`
|
||||
<div class="sw-chat-surface__context-menu" ref=${menuRef}
|
||||
style=${'left:' + contextMenu.x + 'px;top:' + contextMenu.y + 'px;'}>
|
||||
${contextMenu.type === 'chat' && html`
|
||||
<button onClick=${() => _action('rename', contextMenu.item)}>Rename</button>
|
||||
${folders.length > 0 && folders.map(f => html`
|
||||
<button key=${f.id} onClick=${() => _action('move-to:' + f.id, contextMenu.item)}>
|
||||
Move to ${f.name}
|
||||
</button>`)}
|
||||
${contextMenu.item.folder_id && html`
|
||||
<button onClick=${() => _action('move-out', contextMenu.item)}>Remove from folder</button>`}
|
||||
<button class="sw-chat-surface__ctx-danger"
|
||||
onClick=${() => _action('delete', contextMenu.item)}>Delete</button>
|
||||
`}
|
||||
${contextMenu.type === 'folder' && html`
|
||||
<button onClick=${() => _action('rename', contextMenu.item)}>Rename</button>
|
||||
<button class="sw-chat-surface__ctx-danger"
|
||||
onClick=${() => _action('delete', contextMenu.item)}>Delete</button>
|
||||
`}
|
||||
</div>`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function ChatItem({ chat, activeId, onSelect, onOpenMenu, indent }) {
|
||||
return html`
|
||||
<button class=${'sw-chat-surface__item' + (chat.id === activeId ? ' sw-chat-surface__item--active' : '') + (indent ? ' sw-chat-surface__item--indent' : '')}
|
||||
onClick=${() => onSelect(chat.id)}
|
||||
onContextMenu=${(e) => { e.preventDefault(); onOpenMenu(e, 'chat', chat); }}
|
||||
role="option"
|
||||
aria-selected=${chat.id === activeId}
|
||||
title=${chat.title || 'Untitled'}>
|
||||
<span class="sw-chat-surface__item-icon">${CHAT_SVG}</span>
|
||||
<span class="sw-chat-surface__item-title">${chat.title || 'Untitled'}</span>
|
||||
<button class="sw-chat-surface__item-menu"
|
||||
onClick=${(e) => _stopAndOpen(e, onOpenMenu, 'chat', chat)}
|
||||
title="Chat actions"
|
||||
aria-label="Chat actions">
|
||||
${DOTS_SVG}
|
||||
</button>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function _stopAndOpen(e, onOpenMenu, type, item) {
|
||||
e.stopPropagation();
|
||||
onOpenMenu(e, type, item);
|
||||
}
|
||||
109
src/js/sw/surfaces/chat/sidebar.js
Normal file
109
src/js/sw/surfaces/chat/sidebar.js
Normal file
@@ -0,0 +1,109 @@
|
||||
// ==========================================
|
||||
// Chat Surface — Sidebar Component
|
||||
// ==========================================
|
||||
// Search bar, new chat, channels section, chats section with folders.
|
||||
// Footer has UserMenu (avatar) — all navigation lives in the menu flyout.
|
||||
|
||||
import { SidebarChannels } from './sidebar-channels.js';
|
||||
import { SidebarChats } from './sidebar-chats.js';
|
||||
import { UserMenu } from '../../shell/user-menu.js';
|
||||
|
||||
const html = window.html;
|
||||
const { useCallback } = window.hooks;
|
||||
|
||||
const PLUS_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>`;
|
||||
|
||||
const SEARCH_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>`;
|
||||
|
||||
const COLLAPSE_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<line x1="9" y1="3" x2="9" y2="21"/>
|
||||
</svg>`;
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* sidebar: ReturnType<import('./use-sidebar.js').useSidebar>,
|
||||
* activeId: string|null,
|
||||
* onSelectChat: (id: string) => void,
|
||||
* onSelectChannel: (id: string) => void,
|
||||
* onNewChat: () => void,
|
||||
* onCollapse: () => void,
|
||||
* }} props
|
||||
*/
|
||||
export function Sidebar({ sidebar, activeId, onSelectChat, onSelectChannel, onNewChat, onCollapse }) {
|
||||
const _onSearch = useCallback((e) => {
|
||||
sidebar.setSearch(e.target.value);
|
||||
}, [sidebar.setSearch]);
|
||||
|
||||
return html`
|
||||
<div class="sw-chat-surface__sidebar" role="navigation" aria-label="Chat sidebar">
|
||||
${/* Header: new chat + collapse */''}
|
||||
<div class="sw-chat-surface__sidebar-header">
|
||||
<button class="sw-chat-surface__new-chat-btn"
|
||||
onClick=${onNewChat}
|
||||
title="New chat"
|
||||
aria-label="Start new chat">
|
||||
${PLUS_SVG}
|
||||
<span>New Chat</span>
|
||||
</button>
|
||||
<button class="sw-chat-surface__collapse-btn"
|
||||
onClick=${onCollapse}
|
||||
title="Collapse sidebar"
|
||||
aria-label="Collapse sidebar">
|
||||
${COLLAPSE_SVG}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${/* Search */''}
|
||||
<div class="sw-chat-surface__search-wrap">
|
||||
<span class="sw-chat-surface__search-icon">${SEARCH_SVG}</span>
|
||||
<input class="sw-chat-surface__search"
|
||||
type="text"
|
||||
placeholder="Search\u2026"
|
||||
value=${sidebar.search}
|
||||
onInput=${_onSearch}
|
||||
aria-label="Search chats and channels" />
|
||||
</div>
|
||||
|
||||
${/* Scrollable content */''}
|
||||
<div class="sw-chat-surface__sidebar-body">
|
||||
<${SidebarChannels}
|
||||
channels=${sidebar.channels}
|
||||
activeId=${activeId}
|
||||
collapsed=${sidebar.collapsed.channels}
|
||||
onToggle=${() => sidebar.toggleCollapsed('channels')}
|
||||
onSelect=${onSelectChannel} />
|
||||
|
||||
<${SidebarChats}
|
||||
chatsByFolder=${sidebar.chatsByFolder}
|
||||
folders=${sidebar.folders}
|
||||
activeId=${activeId}
|
||||
collapsed=${sidebar.collapsed.chats}
|
||||
onToggle=${() => sidebar.toggleCollapsed('chats')}
|
||||
onSelect=${onSelectChat}
|
||||
onRename=${sidebar.renameChat}
|
||||
onDelete=${sidebar.deleteChat}
|
||||
onMoveToFolder=${sidebar.moveToFolder}
|
||||
onCreateFolder=${sidebar.createFolder}
|
||||
onRenameFolder=${sidebar.renameFolder}
|
||||
onDeleteFolder=${sidebar.deleteFolder} />
|
||||
</div>
|
||||
|
||||
${/* Footer: UserMenu only */''}
|
||||
<div class="sw-chat-surface__sidebar-footer">
|
||||
<${UserMenu} placement="up-right" />
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
171
src/js/sw/surfaces/chat/tools-popup.js
Normal file
171
src/js/sw/surfaces/chat/tools-popup.js
Normal file
@@ -0,0 +1,171 @@
|
||||
// ==========================================
|
||||
// Chat Surface — ToolsPopup Component
|
||||
// ==========================================
|
||||
// Scrollable popup anchored to a toolbar icon.
|
||||
// Category-based tool toggles with persistence to localStorage.
|
||||
|
||||
const html = window.html;
|
||||
const { useState, useEffect, useRef, useCallback, useMemo } = window.hooks;
|
||||
|
||||
const STORAGE_KEY = 'sw-disabled-tools';
|
||||
|
||||
/**
|
||||
* Load tools list from API and organize by category.
|
||||
* @returns {{ categories: Array, disabled: Set, toggle: Function, getDisabled: Function }}
|
||||
*/
|
||||
export function useTools() {
|
||||
const [tools, setTools] = useState([]);
|
||||
const [disabled, setDisabled] = useState(_loadDisabled);
|
||||
|
||||
useEffect(() => {
|
||||
// Load available tools from API
|
||||
if (window.sw?.api?.admin?.tools?.list) {
|
||||
window.sw.api.admin.tools.list().then(resp => {
|
||||
setTools(resp?.data || resp || []);
|
||||
}).catch(() => {});
|
||||
} else if (window.sw?.api?.channels?.tools) {
|
||||
// Fallback — try the channels tools endpoint
|
||||
window.sw.api.channels.tools?.().then(resp => {
|
||||
setTools(resp?.data || resp || []);
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const map = {};
|
||||
for (const t of tools) {
|
||||
const cat = t.category || 'Other';
|
||||
if (!map[cat]) map[cat] = [];
|
||||
map[cat].push(t);
|
||||
}
|
||||
return Object.entries(map).map(([name, items]) => ({ name, items }));
|
||||
}, [tools]);
|
||||
|
||||
const toggle = useCallback((toolName) => {
|
||||
setDisabled(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(toolName)) next.delete(toolName);
|
||||
else next.add(toolName);
|
||||
_saveDisabled(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleCategory = useCallback((catName) => {
|
||||
setDisabled(prev => {
|
||||
const cat = categories.find(c => c.name === catName);
|
||||
if (!cat) return prev;
|
||||
const next = new Set(prev);
|
||||
const allDisabled = cat.items.every(t => next.has(t.name));
|
||||
for (const t of cat.items) {
|
||||
if (allDisabled) next.delete(t.name);
|
||||
else next.add(t.name);
|
||||
}
|
||||
_saveDisabled(next);
|
||||
return next;
|
||||
});
|
||||
}, [categories]);
|
||||
|
||||
const getDisabled = useCallback(() => [...disabled], [disabled]);
|
||||
|
||||
return { categories, disabled, toggle, toggleCategory, getDisabled };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* open: boolean,
|
||||
* onClose: () => void,
|
||||
* categories: Array<{name: string, items: Array<{name: string, description?: string}>}>,
|
||||
* disabled: Set<string>,
|
||||
* onToggle: (name: string) => void,
|
||||
* onToggleCategory: (catName: string) => void,
|
||||
* }} props
|
||||
*/
|
||||
export function ToolsPopup({ open, onClose, categories, disabled, onToggle, onToggleCategory }) {
|
||||
const panelRef = useRef(null);
|
||||
const [expandedCats, setExpandedCats] = useState({});
|
||||
|
||||
// Close on Escape or outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function _onKey(e) {
|
||||
if (e.key === 'Escape') { e.stopPropagation(); onClose(); }
|
||||
}
|
||||
function _onClick(e) {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target)) onClose();
|
||||
}
|
||||
document.addEventListener('keydown', _onKey, true);
|
||||
setTimeout(() => document.addEventListener('click', _onClick), 0);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', _onKey, true);
|
||||
document.removeEventListener('click', _onClick);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
const _toggleCat = useCallback((name) => {
|
||||
setExpandedCats(prev => ({ ...prev, [name]: !prev[name] }));
|
||||
}, []);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const disabledCount = disabled.size;
|
||||
|
||||
return html`
|
||||
<div class="sw-tools-popup" ref=${panelRef} role="dialog" aria-label="Tools toggle">
|
||||
<div class="sw-tools-popup__header">
|
||||
<span class="sw-tools-popup__title">Tools</span>
|
||||
${disabledCount > 0 && html`
|
||||
<span class="sw-tools-popup__badge">${disabledCount} disabled</span>`}
|
||||
</div>
|
||||
<div class="sw-tools-popup__body">
|
||||
${categories.length === 0 && html`
|
||||
<div class="sw-tools-popup__empty">No tools available</div>`}
|
||||
${categories.map(cat => {
|
||||
const allDisabled = cat.items.every(t => disabled.has(t.name));
|
||||
const someDisabled = cat.items.some(t => disabled.has(t.name));
|
||||
const isExpanded = expandedCats[cat.name] !== false; // default open
|
||||
|
||||
return html`
|
||||
<div key=${cat.name} class="sw-tools-popup__category">
|
||||
<div class="sw-tools-popup__cat-header">
|
||||
<button class="sw-tools-popup__cat-toggle"
|
||||
onClick=${() => _toggleCat(cat.name)}
|
||||
aria-expanded=${isExpanded}>
|
||||
<span class=${'sw-tools-popup__chevron' + (isExpanded ? ' sw-tools-popup__chevron--open' : '')}>
|
||||
\u25B6
|
||||
</span>
|
||||
<span>${cat.name}</span>
|
||||
<span class="sw-tools-popup__cat-count">${cat.items.length}</span>
|
||||
</button>
|
||||
<label class="sw-tools-popup__cat-switch">
|
||||
<input type="checkbox"
|
||||
checked=${!allDisabled}
|
||||
indeterminate=${someDisabled && !allDisabled}
|
||||
onChange=${() => onToggleCategory(cat.name)} />
|
||||
</label>
|
||||
</div>
|
||||
${isExpanded && cat.items.map(tool => html`
|
||||
<label key=${tool.name} class="sw-tools-popup__tool">
|
||||
<input type="checkbox"
|
||||
checked=${!disabled.has(tool.name)}
|
||||
onChange=${() => onToggle(tool.name)} />
|
||||
<span class="sw-tools-popup__tool-name">${tool.name}</span>
|
||||
${tool.description && html`
|
||||
<span class="sw-tools-popup__tool-desc">${tool.description}</span>`}
|
||||
</label>`)}
|
||||
</div>`;
|
||||
})}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _loadDisabled() {
|
||||
try {
|
||||
const arr = JSON.parse(localStorage.getItem(STORAGE_KEY));
|
||||
return new Set(arr || []);
|
||||
} catch (_) { return new Set(); }
|
||||
}
|
||||
|
||||
function _saveDisabled(set) {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify([...set])); } catch (_) {}
|
||||
}
|
||||
175
src/js/sw/surfaces/chat/use-sidebar.js
Normal file
175
src/js/sw/surfaces/chat/use-sidebar.js
Normal file
@@ -0,0 +1,175 @@
|
||||
// ==========================================
|
||||
// Chat Surface — useSidebar Hook
|
||||
// ==========================================
|
||||
// Loads and manages sidebar data: channels (DMs + named),
|
||||
// personal chats, and folders. Subscribes to WS events
|
||||
// for live updates.
|
||||
|
||||
const { useState, useEffect, useCallback, useMemo } = window.hooks;
|
||||
|
||||
const COLLAPSE_KEY = 'sw-chat-sidebar-collapsed';
|
||||
|
||||
/**
|
||||
* @param {{ activeId: string|null }} opts
|
||||
*/
|
||||
export function useSidebar(opts = {}) {
|
||||
const { activeId } = opts;
|
||||
|
||||
const [channels, setChannels] = useState([]); // named channels + DMs
|
||||
const [chats, setChats] = useState([]); // personal AI chats (direct, no participants)
|
||||
const [folders, setFolders] = useState([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [collapsed, setCollapsed] = useState(_loadCollapsed);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// ── Load data ───────────────────────────
|
||||
const reload = useCallback(async () => {
|
||||
if (!window.sw?.api?.channels?.list) return;
|
||||
|
||||
try {
|
||||
const foldersPromise = window.sw.api.folders?.list
|
||||
? window.sw.api.folders.list().catch(() => [])
|
||||
: Promise.resolve([]);
|
||||
|
||||
const [chResp, dmResp, fResp] = await Promise.all([
|
||||
// Named channels (group chats, DMs with participants)
|
||||
window.sw.api.channels.list({ page: 1, per_page: 100, channel_type: 'channel' }).catch(() => []),
|
||||
// Personal AI chats
|
||||
window.sw.api.channels.list({ page: 1, per_page: 100, channel_type: 'direct' }).catch(() => []),
|
||||
// Folders
|
||||
foldersPromise,
|
||||
]);
|
||||
|
||||
setChannels(_extract(chResp));
|
||||
setChats(_extract(dmResp));
|
||||
setFolders(_extract(fResp));
|
||||
} catch (_) {}
|
||||
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
// ── WebSocket live updates ──────────────
|
||||
useEffect(() => {
|
||||
if (!window.sw?.on) return;
|
||||
|
||||
const handlers = {
|
||||
'channel.created': reload,
|
||||
'channel.updated': reload,
|
||||
'channel.deleted': reload,
|
||||
'folder.created': reload,
|
||||
'folder.updated': reload,
|
||||
'folder.deleted': reload,
|
||||
};
|
||||
|
||||
Object.entries(handlers).forEach(([ev, fn]) => window.sw.on(ev, fn));
|
||||
return () => {
|
||||
Object.entries(handlers).forEach(([ev, fn]) => window.sw.off?.(ev, fn));
|
||||
};
|
||||
}, [reload]);
|
||||
|
||||
// ── Search filter ───────────────────────
|
||||
const filteredChannels = useMemo(() => {
|
||||
if (!search) return channels;
|
||||
const q = search.toLowerCase();
|
||||
return channels.filter(c => (c.title || '').toLowerCase().includes(q));
|
||||
}, [channels, search]);
|
||||
|
||||
const filteredChats = useMemo(() => {
|
||||
if (!search) return chats;
|
||||
const q = search.toLowerCase();
|
||||
return chats.filter(c => (c.title || '').toLowerCase().includes(q));
|
||||
}, [chats, search]);
|
||||
|
||||
// Group chats by folder
|
||||
const chatsByFolder = useMemo(() => {
|
||||
const map = { '': [] };
|
||||
for (const f of (folders || [])) map[f.id] = [];
|
||||
for (const c of filteredChats) {
|
||||
const fid = c.folder_id || '';
|
||||
if (!map[fid]) map[fid] = [];
|
||||
map[fid].push(c);
|
||||
}
|
||||
return map;
|
||||
}, [filteredChats, folders]);
|
||||
|
||||
// ── Collapse sections ───────────────────
|
||||
const toggleCollapsed = useCallback((key) => {
|
||||
setCollapsed(prev => {
|
||||
const next = { ...prev, [key]: !prev[key] };
|
||||
try { localStorage.setItem(COLLAPSE_KEY, JSON.stringify(next)); } catch (_) {}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── CRUD helpers ────────────────────────
|
||||
const createFolder = useCallback(async (name) => {
|
||||
if (!window.sw?.api?.folders?.create) return;
|
||||
await window.sw.api.folders.create(name);
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
const renameChat = useCallback(async (id, title) => {
|
||||
await window.sw.api.channels.update(id, { title });
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
const deleteChat = useCallback(async (id) => {
|
||||
await window.sw.api.channels.del(id);
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
const moveToFolder = useCallback(async (chatId, folderId) => {
|
||||
await window.sw.api.channels.update(chatId, { folder_id: folderId || null });
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
const renameFolder = useCallback(async (id, name) => {
|
||||
await window.sw.api.folders.update(id, { name });
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
const deleteFolder = useCallback(async (id) => {
|
||||
await window.sw.api.folders.del(id);
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
return {
|
||||
channels: filteredChannels,
|
||||
chats: filteredChats,
|
||||
chatsByFolder,
|
||||
folders,
|
||||
search,
|
||||
setSearch,
|
||||
collapsed,
|
||||
toggleCollapsed,
|
||||
loading,
|
||||
reload,
|
||||
// CRUD
|
||||
createFolder,
|
||||
renameChat,
|
||||
deleteChat,
|
||||
moveToFolder,
|
||||
renameFolder,
|
||||
deleteFolder,
|
||||
};
|
||||
}
|
||||
|
||||
function _extract(resp) {
|
||||
if (!resp) return [];
|
||||
if (Array.isArray(resp)) return resp;
|
||||
if (Array.isArray(resp.data)) return resp.data;
|
||||
// Handle wrapped responses like { folders: [], channels: [] }
|
||||
const vals = Object.values(resp);
|
||||
for (const v of vals) {
|
||||
if (Array.isArray(v)) return v;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function _loadCollapsed() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(COLLAPSE_KEY)) || {};
|
||||
} catch (_) { return {}; }
|
||||
}
|
||||
78
src/js/sw/surfaces/chat/use-workspace.js
Normal file
78
src/js/sw/surfaces/chat/use-workspace.js
Normal file
@@ -0,0 +1,78 @@
|
||||
// ==========================================
|
||||
// Chat Surface — useWorkspace Hook
|
||||
// ==========================================
|
||||
// Coordinator hook: manages active conversation, sidebar state.
|
||||
// Persists active selection to sessionStorage for reload restore.
|
||||
|
||||
const { useState, useCallback, useEffect } = window.hooks;
|
||||
|
||||
const STORAGE_KEY = 'sw-chat-active';
|
||||
|
||||
/**
|
||||
* @returns {{
|
||||
* activeId: string|null,
|
||||
* activeType: 'chat'|'channel'|null,
|
||||
* sidebarOpen: boolean,
|
||||
* setSidebarOpen: (v: boolean) => void,
|
||||
* selectChat: (id: string) => void,
|
||||
* selectChannel: (id: string) => void,
|
||||
* clearSelection: () => void,
|
||||
* }}
|
||||
*/
|
||||
export function useWorkspace() {
|
||||
// Restore from sessionStorage
|
||||
const stored = _loadStored();
|
||||
const [activeId, setActiveId] = useState(stored.id);
|
||||
const [activeType, setActiveType] = useState(stored.type);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
|
||||
// Persist on change
|
||||
useEffect(() => {
|
||||
if (activeId) {
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ id: activeId, type: activeType }));
|
||||
} catch (_) {}
|
||||
} else {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}, [activeId, activeType]);
|
||||
|
||||
const selectChat = useCallback((id) => {
|
||||
setActiveId(id);
|
||||
setActiveType('chat');
|
||||
// Auto-close sidebar on mobile
|
||||
if (window.innerWidth < 768) setSidebarOpen(false);
|
||||
}, []);
|
||||
|
||||
const selectChannel = useCallback((id) => {
|
||||
setActiveId(id);
|
||||
setActiveType('channel');
|
||||
if (window.innerWidth < 768) setSidebarOpen(false);
|
||||
}, []);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setActiveId(null);
|
||||
setActiveType(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
activeId,
|
||||
activeType,
|
||||
sidebarOpen,
|
||||
setSidebarOpen,
|
||||
selectChat,
|
||||
selectChannel,
|
||||
clearSelection,
|
||||
};
|
||||
}
|
||||
|
||||
function _loadStored() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const obj = JSON.parse(raw);
|
||||
return { id: obj.id || null, type: obj.type || null };
|
||||
}
|
||||
} catch (_) {}
|
||||
return { id: null, type: null };
|
||||
}
|
||||
Reference in New Issue
Block a user