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`
+ `}
+ <${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`
+ `;
+}
diff --git a/src/js/sw/components/chat-pane/message-list.js b/src/js/sw/components/chat-pane/message-list.js
new file mode 100644
index 0000000..b3be673
--- /dev/null
+++ b/src/js/sw/components/chat-pane/message-list.js
@@ -0,0 +1,55 @@
+// ==========================================
+// ChatPane Kit — MessageList Component
+// ==========================================
+// Scrollable message list with auto-scroll-to-bottom.
+// Independently importable.
+//
+// Usage:
+// html`<${MessageList} messages=${msgs} streamContent=${text} streaming=${true} />`
+
+import { MessageBubble } from './message-bubble.js';
+
+const { useRef, useEffect } = window.hooks;
+const html = window.html;
+
+const SCROLL_THRESHOLD = 60;
+
+/**
+ * @param {{ messages: Array<{role: string, content: string}>, streamContent?: string, streaming?: boolean, className?: string }} props
+ */
+export function MessageList({ messages, streamContent, streaming, className }) {
+ const scrollRef = useRef(null);
+ const wasAtBottom = useRef(true);
+
+ // Track scroll position
+ function _onScroll() {
+ const el = scrollRef.current;
+ if (!el) return;
+ wasAtBottom.current = (el.scrollHeight - el.scrollTop - el.clientHeight) < SCROLL_THRESHOLD;
+ }
+
+ // Auto-scroll when new content arrives (only if was at bottom)
+ useEffect(() => {
+ const el = scrollRef.current;
+ if (el && wasAtBottom.current) {
+ el.scrollTop = el.scrollHeight;
+ }
+ }, [messages, streamContent]);
+
+ const hasContent = messages.length > 0 || streaming;
+
+ return html`
+
+ ${!hasContent && html`
+
+
Start a conversation
+
Type a message below to get started.
+
`}
+ ${messages.map((m, i) => html`
+ <${MessageBubble} key=${m.id || i} role=${m.role} content=${m.content} />`)}
+ ${streaming && html`
+ <${MessageBubble} role="assistant" content=${streamContent} streaming=${true} />`}
+
`;
+}
diff --git a/src/js/sw/components/chat-pane/model-selector.js b/src/js/sw/components/chat-pane/model-selector.js
new file mode 100644
index 0000000..0cc2de9
--- /dev/null
+++ b/src/js/sw/components/chat-pane/model-selector.js
@@ -0,0 +1,47 @@
+// ==========================================
+// ChatPane Kit — ModelSelector Component
+// ==========================================
+// Model dropdown loaded from sw.api.models.enabled().
+// Independently importable.
+//
+// Usage:
+// html`<${ModelSelector} value=${modelId} onChange=${fn} />`
+
+const { useState, useEffect } = window.hooks;
+const html = window.html;
+
+/**
+ * @param {{ value: string, onChange: (id: string) => void }} props
+ */
+export function ModelSelector({ value, onChange }) {
+ const [models, setModels] = useState([]);
+
+ useEffect(() => {
+ if (!window.sw?.api?.models?.enabled) return;
+ let cancelled = false;
+ window.sw.api.models.enabled().then(resp => {
+ if (cancelled) return;
+ const list = resp?.data || resp || [];
+ setModels(list.filter(m => !m.hidden));
+ }).catch(() => {});
+ return () => { cancelled = true; };
+ }, []);
+
+ if (!models.length) return null;
+
+ function _onChange(e) {
+ onChange(e.target.value);
+ }
+
+ return html`
+ `;
+}
diff --git a/src/js/sw/components/chat-pane/use-chat.js b/src/js/sw/components/chat-pane/use-chat.js
new file mode 100644
index 0000000..fd9f21e
--- /dev/null
+++ b/src/js/sw/components/chat-pane/use-chat.js
@@ -0,0 +1,155 @@
+// ==========================================
+// ChatPane Kit — useChat Hook
+// ==========================================
+// Core chat state machine: messages, channel CRUD, send/receive,
+// model selection. Independently importable.
+//
+// Usage:
+// const chat = useChat({ onChannelChange });
+// chat.sendMessage('Hello');
+
+import { useStream } from './use-stream.js';
+
+const { useState, useRef, useCallback } = window.hooks;
+
+/**
+ * @param {{ initialChannelId?: string, getContext?: () => {path: string, content: string}|null, onChannelChange?: (id: string) => void }} opts
+ */
+export function useChat(opts = {}) {
+ const { initialChannelId, getContext, onChannelChange } = opts;
+
+ const [messages, setMessages] = useState([]);
+ const [channelId, setChannelId] = useState(initialChannelId || null);
+ const [sending, setSending] = useState(false);
+ const [error, setError] = useState(null);
+ const [model, setModel] = useState('');
+
+ const channelRef = useRef(channelId);
+ 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
+ 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 ───────────────────────
+ const sendMessage = useCallback(async (text) => {
+ if (!text.trim() || sending) return;
+ setSending(true);
+ setError(null);
+
+ let cid = channelRef.current;
+
+ // Create channel if needed
+ if (!cid) {
+ try {
+ const title = text.slice(0, 50) + (text.length > 50 ? '\u2026' : '');
+ const resp = await window.sw.api.channels.create({ title, model_id: model, channel_type: 'direct' });
+ cid = resp.id;
+ channelRef.current = cid;
+ setChannelId(cid);
+ if (onChannelChange) onChannelChange(cid);
+ } catch (e) {
+ setError('Failed to create chat: ' + e.message);
+ setSending(false);
+ return;
+ }
+ }
+
+ // Append user message optimistically
+ const userMsg = { role: 'user', content: text };
+ setMessages(prev => [...prev, userMsg]);
+
+ try {
+ // Build content with optional context injection
+ 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 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);
+ }
+ }
+
+ setSending(false);
+ }, [sending, model, getContext, onChannelChange, startStream]);
+
+ // Finalize: when streaming ends, capture the final content into messages
+ 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 ─────────────────────
+ const switchChannel = useCallback(async (id) => {
+ setMessages([]);
+ setError(null);
+ channelRef.current = id;
+ setChannelId(id);
+ if (onChannelChange) onChannelChange(id);
+
+ if (!id) return;
+
+ try {
+ const resp = await window.sw.api.channels.messages(id, { page: 1, per_page: 100 });
+ setMessages(resp?.data || resp || []);
+ } catch (e) {
+ setError('Failed to load messages: ' + e.message);
+ }
+ }, [onChannelChange]);
+
+ // ── New Chat ───────────────────────────
+ const newChat = useCallback(() => {
+ stopStream();
+ setMessages([]);
+ setError(null);
+ channelRef.current = null;
+ setChannelId(null);
+ if (onChannelChange) onChannelChange(null);
+ }, [stopStream, onChannelChange]);
+
+ // ── Stop ───────────────────────────────
+ const stop = useCallback(() => {
+ stopStream();
+ setSending(false);
+ }, [stopStream]);
+
+ // ── Clear Error ────────────────────────
+ const clearError = useCallback(() => setError(null), []);
+
+ return {
+ messages,
+ channelId,
+ sending,
+ error,
+ model,
+ setModel,
+ sendMessage,
+ switchChannel,
+ newChat,
+ stop,
+ clearError,
+ // Streaming state (passthrough from useStream)
+ streamContent,
+ streaming,
+ };
+}
diff --git a/src/js/sw/components/chat-pane/use-stream.js b/src/js/sw/components/chat-pane/use-stream.js
new file mode 100644
index 0000000..4988d12
--- /dev/null
+++ b/src/js/sw/components/chat-pane/use-stream.js
@@ -0,0 +1,103 @@
+// ==========================================
+// ChatPane Kit — useStream Hook
+// ==========================================
+// SSE ReadableStream parser with rAF-batched state updates.
+// Independently importable — no ChatPane dependency.
+//
+// Usage:
+// const { streamContent, streaming, startStream, stopStream } = useStream();
+// // Pass a Response from sw.api.channels.complete():
+// startStream(response);
+
+const { useState, useRef, useCallback } = window.hooks;
+
+/**
+ * Hook that parses an SSE stream from a fetch Response.
+ *
+ * @returns {{ streamContent: string, streaming: boolean, startStream: (resp: Response) => void, stopStream: () => void }}
+ */
+export function useStream() {
+ const [streamContent, setStreamContent] = useState('');
+ const [streaming, setStreaming] = useState(false);
+ const contentRef = useRef('');
+ const abortRef = useRef(null);
+ const rafRef = useRef(null);
+
+ const _flush = useCallback(() => {
+ setStreamContent(contentRef.current);
+ rafRef.current = null;
+ }, []);
+
+ const _scheduleFlush = useCallback(() => {
+ if (!rafRef.current) {
+ rafRef.current = requestAnimationFrame(_flush);
+ }
+ }, [_flush]);
+
+ const stopStream = useCallback(() => {
+ if (abortRef.current) {
+ abortRef.current.abort();
+ abortRef.current = null;
+ }
+ if (rafRef.current) {
+ cancelAnimationFrame(rafRef.current);
+ rafRef.current = null;
+ }
+ setStreamContent(contentRef.current);
+ setStreaming(false);
+ }, []);
+
+ const startStream = useCallback(async (response) => {
+ // Reset
+ contentRef.current = '';
+ setStreamContent('');
+ setStreaming(true);
+
+ const controller = new AbortController();
+ abortRef.current = controller;
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ if (controller.signal.aborted) 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) {
+ contentRef.current += delta;
+ _scheduleFlush();
+ }
+ } catch (_) { /* skip malformed JSON */ }
+ }
+ }
+ } catch (e) {
+ if (e.name !== 'AbortError') {
+ contentRef.current += '\n\n**[Stream error: ' + e.message + ']**';
+ }
+ }
+
+ // Final flush
+ if (rafRef.current) {
+ cancelAnimationFrame(rafRef.current);
+ rafRef.current = null;
+ }
+ setStreamContent(contentRef.current);
+ setStreaming(false);
+ abortRef.current = null;
+ }, [_scheduleFlush]);
+
+ return { streamContent, streaming, startStream, stopStream };
+}
diff --git a/src/js/sw/sdk/index.js b/src/js/sw/sdk/index.js
index 8f29610..0c51982 100644
--- a/src/js/sw/sdk/index.js
+++ b/src/js/sw/sdk/index.js
@@ -116,8 +116,19 @@ export async function boot() {
});
};
+ // ChatPane render helper — surfaces call sw.chatPane(container, opts)
+ // Returns Promise for backward-compat with old ChatPane API.
+ sw.chatPane = function (container, opts = {}) {
+ return import('../components/chat-pane/index.js').then(({ ChatPane }) => {
+ const handleRef = { current: null };
+ const { render } = preact;
+ render(html`<${ChatPane} handleRef=${handleRef} ...${opts} />`, container);
+ return handleRef.current;
+ });
+ };
+
// Marker for idempotency
- sw._sdk = '0.37.6';
+ sw._sdk = '0.37.8';
// 8. Expose globally
window.sw = sw;
@@ -139,7 +150,7 @@ export async function boot() {
// 10. Signal ready
events.emit('sdk.ready', {}, { localOnly: true });
document.dispatchEvent(new CustomEvent('sw:ready', { detail: { sw } }));
- console.log('[sw] SDK v0.37.5 ready');
+ console.log('[sw] SDK v0.37.8 ready');
return sw;
}
diff --git a/src/js/switchboard-sdk.js b/src/js/switchboard-sdk.js
index 80b7215..3a9bce8 100644
--- a/src/js/switchboard-sdk.js
+++ b/src/js/switchboard-sdk.js
@@ -458,6 +458,11 @@ const Switchboard = {
* @returns {object} ChatPane instance (renderMessages, destroy, etc.)
*/
sw.chat = function (container, opts) {
+ // v0.37.8: Prefer Preact ChatPane kit when new SDK is loaded
+ if (window.sw?.chatPane) {
+ return window.sw.chatPane(container, opts || {});
+ }
+ // Legacy fallback (DOM-binding ChatPane.mount)
if (typeof ChatPane === 'undefined') {
console.error('[Switchboard] ChatPane not available');
return null;