diff --git a/CHANGELOG.md b/CHANGELOG.md index fc0b20d..aee3bb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,58 @@ # Changelog +## [0.37.8] — 2026-03-21 + +### Summary + +Preact ChatPane kit — composable chat components replacing the old +imperative `chat-pane.js` standalone mode. Every piece (hooks, +components, utilities) independently importable. `ChatPane` is the +default assembly; surfaces import individual pieces for custom layouts. + +### New + +- **ChatPane kit** (`src/js/sw/components/chat-pane/`) — 9 files: + - `ChatPane` — default assembly: header + messages + input + - `useChat(opts)` — channel state machine (messages, send/receive, + model selection, channel CRUD) + - `useStream()` — SSE ReadableStream parser with rAF-batched updates + - `MessageList` — scrollable messages with auto-scroll-to-bottom + - `MessageBubble` — single message with role styling + markdown + - `MessageInput` — auto-resize textarea, Enter-to-send, handleRef + - `ModelSelector` — model dropdown from `sw.api.models.enabled()` + - `ChatHistory` — channel history select + new-chat button + - `renderMarkdown` — `marked` + `DOMPurify` wrapper with fallback +- **`sw.chatPane(container, opts)`** — SDK mount helper, lazy-imports + Preact ChatPane, returns imperative handle +- **`sw-chat-pane.css`** — component styles with `sw-` prefix convention +- **Layer 1.5: Components** — `src/js/sw/components/` directory + established for reusable compound components (between SDK and surfaces) + +### Changed + +- `switchboard-sdk.js` — `sw.chat()` now prefers `window.sw.chatPane()` + when new SDK is loaded, falls back to legacy `ChatPane.mount()` +- `chat-pane.js` — stripped standalone mode (446 → 160 lines). Removed + `mount()`, `_initStandalone()`, and all streaming/model/history logic. + Retained DOM-binding `create()` for legacy chat surface (`app.js`) +- `server/version.go` — version `0.37.6` → `0.37.8` +- `VERSION` — `0.37.6` → `0.37.8` +- `src/js/sw/sdk/index.js` — `sw.chatPane()` helper, `sw._sdk` → `0.37.8` +- `server/pages/templates/base.html` — added `sw-chat-pane.css` link + +### Design Notes + +- **Kit of Parts + Default Assembly** — every hook and component is + independently importable. Future Chat surface (v0.37.10) will import + `useChat`, `useStream`, `MessageList`, `MessageInput` individually + with a completely different layout. +- **handleRef pattern** — core Preact only exposes `{ h, render }`; + `forwardRef` requires `preact/compat`. Components use a `handleRef` + prop pattern instead: caller passes `{ current: null }`, component + populates with imperative methods via `useEffect`. + +--- + ## [0.37.7] — 2026-03-21 ### Summary diff --git a/VERSION b/VERSION index bb5d745..331be98 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.37.7 \ No newline at end of file +0.37.8 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 10e2433..32e3986 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -632,7 +632,7 @@ Each surface rebuilt as Preact component tree. Old JS deleted per surface. |---------|---------|-------| | v0.37.6 | Admin | Largest section count, no real-time | | v0.37.7 | Team Admin | Inherits from Admin patterns | -| v0.37.8 | ChatPane | Reusable component, NOT a surface | +| v0.37.8 | ChatPane ✅ | Composable kit (9 files), standalone stripped from old chat-pane.js | | v0.37.9 | NotesPane | Reusable component, NOT a surface | | v0.37.10 | Chat surface | Composes ChatPane + sidebar + panels | | v0.37.11 | Notes surface | Composes NotesPane + graph | diff --git a/server/pages/templates/base.html b/server/pages/templates/base.html index e5bbbae..4e6f85e 100644 --- a/server/pages/templates/base.html +++ b/server/pages/templates/base.html @@ -18,6 +18,7 @@ + diff --git a/server/version.go b/server/version.go index 1b55ad4..6e724fb 100644 --- a/server/version.go +++ b/server/version.go @@ -1,3 +1,3 @@ package main -const Version = "0.37.7" +const Version = "0.37.8" diff --git a/src/css/sw-chat-pane.css b/src/css/sw-chat-pane.css new file mode 100644 index 0000000..ff834a3 --- /dev/null +++ b/src/css/sw-chat-pane.css @@ -0,0 +1,379 @@ +/* ========================================== + Chat Switchboard — Preact ChatPane Kit CSS + ========================================== + v0.37.8: Styles for the composable ChatPane kit. + sw- prefixed, no conflicts with legacy chat-pane.css. + ========================================== */ + +/* ── Container ─────────────────────────────── */ + +.sw-chat-pane { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + background: var(--bg, #0e0e10); +} + +/* ── Header Bar (standalone mode) ──────────── */ + +.sw-chat-pane__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + padding: 6px 10px; + border-bottom: 1px solid var(--border, #2a2a2e); + background: var(--bg-secondary, #151517); + flex-shrink: 0; + min-height: 36px; +} + +.sw-chat-pane__header-left { + display: flex; + align-items: center; + gap: 4px; + flex: 1; + min-width: 0; +} + +.sw-chat-pane__header-right { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +/* ── Chat History Select ──────────────────── */ + +.sw-chat-history__select { + flex: 1; + min-width: 0; + background: var(--bg, #0e0e10); + border: 1px solid var(--border, #2a2a2e); + border-radius: 4px; + color: var(--text, #eee); + font-size: 11px; + font-family: inherit; + padding: 4px 6px; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; +} + +.sw-chat-history__select:focus { + border-color: var(--accent, #b38a4e); + outline: none; +} + +.sw-chat-history__new-btn { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + background: none; + border: 1px solid var(--border, #2a2a2e); + border-radius: 4px; + color: var(--text-2, #999); + cursor: pointer; + flex-shrink: 0; + transition: color 0.15s, border-color 0.15s, background 0.15s; +} + +.sw-chat-history__new-btn:hover { + color: var(--accent, #b38a4e); + border-color: var(--accent, #b38a4e); + background: var(--accent-dim, rgba(179, 138, 78, 0.1)); +} + +/* ── Model Selector ───────────────────────── */ + +.sw-model-selector__select { + background: var(--bg, #0e0e10); + border: 1px solid var(--border, #2a2a2e); + border-radius: 4px; + color: var(--text-2, #999); + font-size: 11px; + font-family: inherit; + padding: 4px 6px; + cursor: pointer; + max-width: 140px; + overflow: hidden; + text-overflow: ellipsis; +} + +.sw-model-selector__select:focus { + border-color: var(--accent, #b38a4e); + outline: none; + color: var(--text, #eee); +} + +/* ── Messages Area ─────────────────────────── */ + +.sw-chat-pane__messages { + flex: 1; + overflow-y: auto; + scroll-behavior: smooth; + padding: 12px 0; + min-height: 0; +} + +/* ── Message Bubbles ───────────────────────── */ + +.sw-chat-msg { + padding: 8px 16px; + font-size: var(--msg-font, 14px); + line-height: 1.65; + color: var(--text, #eee); +} + +.sw-chat-msg + .sw-chat-msg { + margin-top: 2px; +} + +/* User messages — right-aligned bubble */ +.sw-chat-msg--user { + display: flex; + justify-content: flex-end; + padding: 8px 16px; +} + +.sw-chat-msg--user .sw-chat-msg__content { + background: var(--accent-dim, rgba(179, 138, 78, 0.15)); + color: var(--text, #eee); + padding: 8px 14px; + border-radius: 12px 12px 2px 12px; + max-width: 85%; + word-wrap: break-word; +} + +/* Assistant messages — left-aligned */ +.sw-chat-msg--assistant { + padding: 8px 16px; +} + +.sw-chat-msg--assistant .sw-chat-msg__content { + max-width: 100%; + word-wrap: break-word; +} + +/* System/error messages */ +.sw-chat-msg--system { + padding: 6px 16px; + font-size: 12px; +} + +.sw-chat-msg--system .sw-chat-msg__content { + color: var(--danger, #f44336); + font-size: 12px; +} + +/* ── Streaming indicator ───────────────────── */ + +.sw-chat-msg--streaming .sw-chat-msg__content::after { + content: ''; + display: inline-block; + width: 6px; + height: 14px; + background: var(--accent, #b38a4e); + margin-left: 2px; + vertical-align: text-bottom; + animation: sw-cursor-blink 0.8s step-end infinite; +} + +@keyframes sw-cursor-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +/* ── Typing dots ───────────────────────────── */ + +.sw-chat-msg--typing { + padding: 8px 16px; +} + +.sw-chat-msg--typing .sw-typing-dots { + display: flex; + gap: 4px; + padding: 4px 0; +} + +.sw-chat-msg--typing .sw-typing-dots span { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-3, #555); + animation: sw-dot-bounce 1.2s ease-in-out infinite; +} + +.sw-chat-msg--typing .sw-typing-dots span:nth-child(2) { animation-delay: 0.2s; } +.sw-chat-msg--typing .sw-typing-dots span:nth-child(3) { animation-delay: 0.4s; } + +@keyframes sw-dot-bounce { + 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; } + 30% { transform: translateY(-4px); opacity: 1; } +} + +/* ── Markdown content ──────────────────────── */ + +.sw-chat-msg__content p { margin: 0 0 0.5em; } +.sw-chat-msg__content p:last-child { margin-bottom: 0; } + +.sw-chat-msg__content pre { + background: var(--bg-secondary, #151517); + border: 1px solid var(--border, #2a2a2e); + border-radius: 6px; + padding: 10px 12px; + overflow-x: auto; + margin: 8px 0; + font-size: 12px; + line-height: 1.5; + font-family: var(--mono, 'SF Mono', monospace); +} + +.sw-chat-msg__content code { + background: var(--bg-secondary, #151517); + padding: 1px 5px; + border-radius: 3px; + font-size: 0.88em; + font-family: var(--mono, 'SF Mono', monospace); +} + +.sw-chat-msg__content pre code { + background: none; + padding: 0; + border-radius: 0; + font-size: inherit; +} + +.sw-chat-msg__content ul, .sw-chat-msg__content ol { + margin: 4px 0; + padding-left: 1.5em; +} + +.sw-chat-msg__content blockquote { + border-left: 3px solid var(--accent, #b38a4e); + padding: 4px 12px; + margin: 8px 0; + color: var(--text-2, #999); +} + +.sw-chat-msg__content a { + color: var(--accent, #b38a4e); + text-decoration: none; +} + +.sw-chat-msg__content a:hover { + text-decoration: underline; +} + +.sw-chat-msg__content table { + border-collapse: collapse; + margin: 8px 0; + font-size: 13px; +} + +.sw-chat-msg__content th, +.sw-chat-msg__content td { + border: 1px solid var(--border, #2a2a2e); + padding: 4px 8px; +} + +.sw-chat-msg__content th { + background: var(--bg-secondary, #151517); + font-weight: 600; +} + +/* ── Welcome / Empty State ─────────────────── */ + +.sw-chat-welcome { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + text-align: center; + padding: 40px 20px; + opacity: 0.6; +} + +.sw-chat-welcome__title { + font-size: 15px; + font-weight: 600; + color: var(--text, #eee); + margin: 0 0 6px; +} + +.sw-chat-welcome__hint { + font-size: 13px; + color: var(--text-3, #555); + margin: 0; +} + +/* ── Input Bar ─────────────────────────────── */ + +.sw-chat-pane__input-bar { + flex-shrink: 0; + border-top: 1px solid var(--border, #2a2a2e); + background: var(--bg-secondary, #151517); + padding: 8px 12px; +} + +.sw-msg-input__wrap { + display: flex; + align-items: flex-end; + gap: 6px; + background: var(--bg, #0e0e10); + border: 1px solid var(--border, #2a2a2e); + border-radius: 8px; + padding: 6px 8px; + transition: border-color 0.15s; +} + +.sw-msg-input__wrap:focus-within { + border-color: var(--accent, #b38a4e); +} + +.sw-msg-input__textarea { + flex: 1; + min-height: 20px; + max-height: 160px; + width: 100%; + background: transparent; + border: none; + color: var(--text, #eee); + font-size: 13px; + font-family: inherit; + line-height: 1.5; + resize: none; + outline: none; + padding: 0; +} + +/* ── Send Button ───────────────────────────── */ + +.sw-msg-input__send { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + background: none; + border: none; + border-radius: 6px; + color: var(--accent, #b38a4e); + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.sw-msg-input__send:hover { + background: var(--accent-dim, rgba(179, 138, 78, 0.15)); + color: var(--accent-hover, #c9a05e); +} + +.sw-msg-input__send:disabled { + opacity: 0.3; + cursor: not-allowed; +} diff --git a/src/js/chat-pane.js b/src/js/chat-pane.js index f9335ac..e614168 100644 --- a/src/js/chat-pane.js +++ b/src/js/chat-pane.js @@ -1,9 +1,10 @@ // ========================================== -// Chat Switchboard - ChatPane Component +// Chat Switchboard - ChatPane Component (DOM-binding mode) // ========================================== -// v0.31.1: Self-contained chat pane with optional standalone mode. -// When standalone: true, provides full chat functionality (streaming, -// model selector, history, channel creation) with zero external wiring. +// v0.37.8: DOM-binding mode only. Standalone mode (mount + streaming) +// moved to Preact ChatPane kit (src/js/sw/components/chat-pane/). +// This file is retained for app.js ChatPane.primary on the legacy +// chat surface. Will be deleted in v0.37.10. // // Exports: window.ChatPane @@ -147,298 +148,12 @@ const ChatPane = { active() { return this.primary; }, - // v0.31.0: Self-mount — creates DOM + binds in one call. - mount(container, opts) { - const pfx = opts.id || 'cp'; - const el = document.createElement('div'); - el.className = 'chat-pane'; - el.id = pfx + 'ChatPane'; - el.innerHTML = - '' + - '
' + - '
' + - '
' + - '
' + - '' + - '
' + - '
' + - '
'; - container.appendChild(el); - const instance = this.create({ - id: pfx, - messagesEl: document.getElementById(pfx + 'ChatMessages'), - inputEl: document.getElementById(pfx + 'ChatInput'), - sendBtnEl: document.getElementById(pfx + 'SendBtn'), - modelSelEl: document.getElementById(pfx + 'ModelSel'), - toolbarEl: document.getElementById(pfx + 'Toolbar'), - channelId: opts.channelId || null, - standalone: opts.standalone !== false, - }); - - // v0.31.1: Standalone mode — self-contained chat with streaming - if (opts.standalone) { - _initStandalone(instance, pfx, opts); - } - - return instance; - }, }; - -// ── Standalone Chat Logic ───────────────────── -// Absorbed from editor package v0.31.0. Provides full chat functionality -// (textarea, model selector, history, streaming, channel management) -// so that sw.chat(el, {standalone: true}) works with zero external wiring. - -function _initStandalone(pane, pfx, opts) { - const headerEl = document.getElementById(pfx + 'ChatHeader'); - const selectEl = document.getElementById(pfx + 'ChatSelect'); - const newBtnEl = document.getElementById(pfx + 'ChatNewBtn'); - const modelSelEl = document.getElementById(pfx + 'ModelSel'); - const inputEl = pane.inputEl; - const sendBtn = pane.sendBtnEl; - if (!inputEl) return; - - // Show header (chat-select + model-selector + new-chat button) - if (headerEl) headerEl.style.display = ''; - - // Show welcome - pane.showWelcome(); - - let _channelId = null, _messages = [], _sending = false, _abortController = null; - let _selectedModel = (typeof App !== 'undefined' && App.settings?.model) || ''; - const _getContext = opts.getContext || null; - - // ── Model Selector ────────────────────── - - async function _initModelSelector() { - if (!modelSelEl) return; - if (typeof App === 'undefined') return; - if (!App.models?.length && typeof fetchModels === 'function') await fetchModels(); - const models = App.models || []; - if (!models.length) return; - const sel = document.createElement('select'); - sel.className = 'chat-pane-model-select'; - sel.title = 'Select model'; - models.forEach(function (m) { - if (m.hidden) return; - const opt = document.createElement('option'); - opt.value = m.isPersona ? (m.personaId || m.id) : m.id; - opt.textContent = (m.isPersona ? '\ud83c\udfad ' : '') + (m.name || m.id); - if (m.id === _selectedModel || m.personaId === _selectedModel) opt.selected = true; - sel.appendChild(opt); - }); - sel.addEventListener('change', function () { _selectedModel = sel.value; }); - modelSelEl.innerHTML = ''; - modelSelEl.appendChild(sel); - } - - // ── Chat History ──────────────────────── - - async function _loadChatHistory() { - if (!selectEl || typeof API === 'undefined') return; - try { - const resp = await API.listChannels(1, 20, 'direct'); - const channels = resp.data || resp || []; - selectEl.innerHTML = ''; - channels.forEach(function (ch) { - const opt = document.createElement('option'); - opt.value = ch.id; - opt.textContent = ch.title || 'Untitled'; - if (ch.id === _channelId) opt.selected = true; - selectEl.appendChild(opt); - }); - } catch (_) {} - } - - if (selectEl) selectEl.addEventListener('change', function () { - selectEl.value ? _switchToChannel(selectEl.value) : _newChat(); - }); - if (newBtnEl) newBtnEl.addEventListener('click', _newChat); - - async function _switchToChannel(channelId) { - _channelId = channelId; - _messages = []; - pane.clear(); - pane.appendTyping(); - try { - const resp = await API._get('/api/v1/channels/' + channelId + '/messages?page=1&per_page=100'); - _messages = resp.data || resp || []; - pane.removeTyping(); - _renderAllMessages(); - } catch (e) { - pane.removeTyping(); - _appendSystemMsg('Failed to load messages: ' + e.message); - } - } - - function _newChat() { - _channelId = null; - _messages = []; - pane.showWelcome(); - if (selectEl) selectEl.value = ''; - } - - // ── Textarea ──────────────────────────── - - const ta = document.createElement('textarea'); - ta.placeholder = 'Type a message\u2026'; - ta.rows = 1; - inputEl.appendChild(ta); - ta.addEventListener('input', function () { - ta.style.height = 'auto'; - ta.style.height = Math.min(ta.scrollHeight, 160) + 'px'; - }); - - // ── Send + Streaming ──────────────────── - - async function _sendMessage() { - const text = ta.value.trim(); - if (!text || _sending) return; - _sending = true; - if (sendBtn) sendBtn.disabled = true; - ta.value = ''; - ta.style.height = 'auto'; - - if (!_channelId) { - try { - const title = text.slice(0, 50) + (text.length > 50 ? '\u2026' : ''); - const resp = await API.createChannel(title, _selectedModel, '', 'direct'); - _channelId = resp.id; - _loadChatHistory(); - } catch (e) { - _appendSystemMsg('Failed to create chat: ' + e.message); - _sending = false; - if (sendBtn) sendBtn.disabled = false; - return; - } - } - - _messages.push({ role: 'user', content: text }); - _renderAllMessages(); - - try { - _abortController = new AbortController(); - let content = text; - if (_getContext) { - const ctx = _getContext(); - if (ctx) content = text + '\n\n[Context: Currently editing ' + ctx.path + ']\n```\n' + ctx.content + '\n```'; - } - const resp = await API.streamCompletion(_channelId, content, _selectedModel, _abortController.signal); - await _handleStream(resp); - } catch (e) { - if (e.name !== 'AbortError') _appendSystemMsg('Error: ' + e.message); - } - _abortController = null; - _sending = false; - if (sendBtn) sendBtn.disabled = false; - ta.focus(); - } - - if (sendBtn) sendBtn.addEventListener('click', _sendMessage); - ta.addEventListener('keydown', function (e) { - if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); _sendMessage(); } - }); - - // ── Stream Handler ────────────────────── - - async function _handleStream(response) { - const messagesEl = pane.messagesEl; - if (!messagesEl) return; - const div = document.createElement('div'); - div.className = 'chat-msg chat-msg--assistant chat-msg--streaming'; - const contentEl = document.createElement('div'); - contentEl.className = 'chat-msg__content'; - div.appendChild(contentEl); - messagesEl.appendChild(div); - let content = ''; - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - for (const line of lines) { - if (!line.startsWith('data: ')) continue; - const data = line.slice(6); - if (data === '[DONE]') continue; - try { - const delta = JSON.parse(data).choices?.[0]?.delta?.content; - if (delta) { - content += delta; - if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') { - contentEl.innerHTML = DOMPurify.sanitize(marked.parse(content)); - } else { - contentEl.textContent = content; - } - pane.scrollToBottom(); - } - } catch (_) {} - } - } - } catch (e) { - if (e.name !== 'AbortError') content += '\n\n**[Stream error: ' + e.message + ']**'; - } - div.classList.remove('chat-msg--streaming'); - _messages.push({ role: 'assistant', content: content }); - } - - // ── Message Rendering ─────────────────── - - function _renderAllMessages() { - if (!pane.messagesEl) return; - pane.messagesEl.innerHTML = ''; - _messages.forEach(function (m) { - const div = document.createElement('div'); - div.className = 'chat-msg chat-msg--' + m.role; - const c = document.createElement('div'); - c.className = 'chat-msg__content'; - if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') { - c.innerHTML = DOMPurify.sanitize(marked.parse(m.content || '')); - } else { - c.textContent = m.content || ''; - } - div.appendChild(c); - pane.messagesEl.appendChild(div); - }); - pane.scrollToBottom(); - } - - function _appendSystemMsg(text) { - const div = document.createElement('div'); - div.className = 'chat-msg chat-msg--system'; - div.innerHTML = '
' + - (typeof esc === 'function' ? esc(text) : text) + '
'; - pane.messagesEl?.appendChild(div); - pane.scrollToBottom(); - } - - // ── Deferred Init ─────────────────────── - - function _deferredInit() { _initModelSelector(); _loadChatHistory(); } - if (typeof App !== 'undefined' && App.models?.length) _deferredInit(); - else document.addEventListener('sb:ready', _deferredInit, { once: true }); -} +// v0.37.8: Standalone mode (mount + streaming + model selector + history) +// moved to Preact ChatPane kit at src/js/sw/components/chat-pane/. +// sw.chat() now prefers the new Preact path; this file retains only +// DOM-binding mode for the legacy chat surface (app.js ChatPane.primary). // ── Exports ───────────────────────────────── diff --git a/src/js/sw/components/chat-pane/chat-history.js b/src/js/sw/components/chat-pane/chat-history.js new file mode 100644 index 0000000..187d44f --- /dev/null +++ b/src/js/sw/components/chat-pane/chat-history.js @@ -0,0 +1,57 @@ +// ========================================== +// ChatPane Kit — ChatHistory Component +// ========================================== +// Channel history select + new-chat button. +// Independently importable. +// +// Usage: +// html`<${ChatHistory} channelId=${id} onSelect=${fn} onNew=${fn} />` + +const { useState, useEffect } = window.hooks; +const html = window.html; + +const PLUS_SVG = html` + + + + `; + +/** + * @param {{ channelId: string|null, onSelect: (id: string) => void, onNew: () => void }} props + */ +export function ChatHistory({ channelId, onSelect, onNew }) { + const [channels, setChannels] = useState([]); + + function _load() { + if (!window.sw?.api?.channels?.list) return; + window.sw.api.channels.list({ page: 1, per_page: 20, channel_type: 'direct' }) + .then(resp => { + setChannels(resp?.data || resp || []); + }) + .catch(() => {}); + } + + useEffect(() => { _load(); }, [channelId]); + + function _onChange(e) { + const val = e.target.value; + if (val) onSelect(val); + else onNew(); + } + + return html` +
+ + +
`; +} diff --git a/src/js/sw/components/chat-pane/index.js b/src/js/sw/components/chat-pane/index.js new file mode 100644 index 0000000..c00e840 --- /dev/null +++ b/src/js/sw/components/chat-pane/index.js @@ -0,0 +1,106 @@ +// ========================================== +// ChatPane Kit — Default Assembly +// ========================================== +// Wires useChat + all sub-components into the standard +// 1-on-1 chat experience. Surfaces that need a different +// layout import individual pieces instead. +// +// Usage: +// import { ChatPane } from './components/chat-pane/index.js'; +// html`<${ChatPane} handleRef=${ref} standalone=${true} getContext=${fn} />` +// +// Re-exports all pieces for kit consumers: +// import { useChat, useStream, MessageList, MessageInput, ... } from './components/chat-pane/index.js'; + +import { useChat } from './use-chat.js'; +import { useStream } from './use-stream.js'; +import { MessageList } from './message-list.js'; +import { MessageBubble } from './message-bubble.js'; +import { MessageInput } from './message-input.js'; +import { ModelSelector } from './model-selector.js'; +import { ChatHistory } from './chat-history.js'; +import { renderMarkdown } from './markdown.js'; + +const { useRef, useEffect } = window.hooks; +const html = window.html; + +/** + * Default ChatPane assembly. + * + * @param {{ + * channelId?: string, + * standalone?: boolean, + * getContext?: () => {path: string, content: string}|null, + * onChannelChange?: (id: string) => void, + * handleRef?: { current: any }, + * className?: string, + * }} props + */ +export function ChatPane(props) { + const { + channelId: initialChannelId, + standalone = true, + getContext, + onChannelChange, + handleRef, + className, + } = props; + + const inputHandle = useRef(null); + + const chat = useChat({ + initialChannelId, + getContext, + onChannelChange, + }); + + // Expose imperative handle via handleRef prop + useEffect(() => { + if (handleRef) { + handleRef.current = { + getChannel() { return chat.channelId; }, + setChannel(id) { chat.switchChannel(id); }, + focusInput() { inputHandle.current?.focus(); }, + getInputValue() { return inputHandle.current?.getValue() || ''; }, + setInputValue(v) { inputHandle.current?.setValue(v); }, + clearInput() { inputHandle.current?.clear(); }, + clear() { chat.newChat(); }, + stop() { chat.stop(); }, + }; + } + }); + + return html` +
+ ${standalone && html` +
+ <${ChatHistory} + channelId=${chat.channelId} + onSelect=${chat.switchChannel} + onNew=${chat.newChat} /> +
+ <${ModelSelector} + value=${chat.model} + onChange=${chat.setModel} /> +
+
`} + <${MessageList} + messages=${chat.messages} + streamContent=${chat.streamContent} + streaming=${chat.streaming} /> + <${MessageInput} + handleRef=${inputHandle} + onSend=${chat.sendMessage} + disabled=${chat.sending} /> +
`; +} + +// ── 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'; diff --git a/src/js/sw/components/chat-pane/markdown.js b/src/js/sw/components/chat-pane/markdown.js new file mode 100644 index 0000000..bf6ca1e --- /dev/null +++ b/src/js/sw/components/chat-pane/markdown.js @@ -0,0 +1,41 @@ +// ========================================== +// ChatPane Kit — Markdown Renderer +// ========================================== +// Wraps window.marked + window.DOMPurify with fallback. +// Independently importable utility. +// +// Usage: +// import { renderMarkdown } from './markdown.js'; +// const html = renderMarkdown('**bold** text'); + +let _lastInput = ''; +let _lastOutput = ''; + +const _escMap = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; + +function _escapeHtml(text) { + return text.replace(/[&<>"']/g, c => _escMap[c]); +} + +/** + * Render markdown text to sanitized HTML. + * Falls back to escaped plaintext when marked/DOMPurify unavailable. + * + * @param {string} text — raw markdown + * @returns {string} sanitized HTML + */ +export function renderMarkdown(text) { + if (!text) return ''; + if (text === _lastInput) return _lastOutput; + + let result; + if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') { + result = DOMPurify.sanitize(marked.parse(text)); + } else { + result = _escapeHtml(text); + } + + _lastInput = text; + _lastOutput = result; + return result; +} diff --git a/src/js/sw/components/chat-pane/message-bubble.js b/src/js/sw/components/chat-pane/message-bubble.js new file mode 100644 index 0000000..29160d9 --- /dev/null +++ b/src/js/sw/components/chat-pane/message-bubble.js @@ -0,0 +1,35 @@ +// ========================================== +// ChatPane Kit — MessageBubble Component +// ========================================== +// Single message rendering with role styling and markdown. +// Independently importable. +// +// Usage: +// html`<${MessageBubble} role="assistant" content=${text} />` + +import { renderMarkdown } from './markdown.js'; + +const html = window.html; + +/** + * @param {{ role: string, content: string, streaming?: boolean }} props + */ +export function MessageBubble({ role, content, streaming }) { + 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` +
+
+
`; + } + + return html` +
+
+
`; +} diff --git a/src/js/sw/components/chat-pane/message-input.js b/src/js/sw/components/chat-pane/message-input.js new file mode 100644 index 0000000..8607c4f --- /dev/null +++ b/src/js/sw/components/chat-pane/message-input.js @@ -0,0 +1,89 @@ +// ========================================== +// ChatPane Kit — MessageInput Component +// ========================================== +// Auto-resize textarea with Enter-to-send. +// Independently importable. +// +// Usage: +// const inputHandle = useRef(null); +// html`<${MessageInput} onSend=${fn} disabled=${false} handleRef=${inputHandle} />` +// inputHandle.current.focus(); + +const { useRef, useCallback, useEffect } = window.hooks; +const html = window.html; + +const SEND_SVG = html` + + + + `; + +/** + * @param {{ onSend: (text: string) => void, disabled?: boolean, handleRef?: { current: any } }} props + */ +export function MessageInput({ onSend, disabled, handleRef }) { + const taRef = useRef(null); + + function _autoResize() { + const ta = taRef.current; + if (!ta) return; + ta.style.height = 'auto'; + ta.style.height = Math.min(ta.scrollHeight, 160) + 'px'; + } + + // Expose imperative handle via handleRef prop + useEffect(() => { + if (handleRef) { + handleRef.current = { + getValue() { return taRef.current?.value || ''; }, + setValue(val) { if (taRef.current) { taRef.current.value = val; _autoResize(); } }, + focus() { taRef.current?.focus(); }, + clear() { if (taRef.current) { taRef.current.value = ''; _autoResize(); } }, + }; + } + return () => { if (handleRef) handleRef.current = null; }; + }, [handleRef]); + + const _onInput = useCallback(() => _autoResize(), []); + + const _onKeyDown = useCallback((e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + const text = taRef.current?.value?.trim(); + if (text && !disabled) { + onSend(text); + if (taRef.current) { taRef.current.value = ''; _autoResize(); } + } + } + }, [onSend, disabled]); + + const _onClickSend = useCallback(() => { + const text = taRef.current?.value?.trim(); + if (text && !disabled) { + onSend(text); + if (taRef.current) { taRef.current.value = ''; _autoResize(); } + } + }, [onSend, disabled]); + + return html` +
+
+