Changeset 0.37.8 (#220)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-21 18:50:04 +00:00
committed by xcaliber
parent b6152fbf5e
commit 2695bb3bdc
18 changed files with 1151 additions and 299 deletions

View File

@@ -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`
<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>`;
/**
* @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`
<div class="sw-chat-pane__header-left">
<select class="sw-chat-history__select"
title="Switch chat"
value=${channelId || ''}
onChange=${_onChange}>
<option value="">New conversation</option>
${channels.map(ch => html`
<option key=${ch.id} value=${ch.id}>${ch.title || 'Untitled'}</option>`)}
</select>
<button class="sw-chat-history__new-btn" title="New chat" onClick=${onNew}>
${PLUS_SVG}
</button>
</div>`;
}

View File

@@ -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`
<div class=${'sw-chat-pane' + (className ? ' ' + className : '')}>
${standalone && html`
<div class="sw-chat-pane__header">
<${ChatHistory}
channelId=${chat.channelId}
onSelect=${chat.switchChannel}
onNew=${chat.newChat} />
<div class="sw-chat-pane__header-right">
<${ModelSelector}
value=${chat.model}
onChange=${chat.setModel} />
</div>
</div>`}
<${MessageList}
messages=${chat.messages}
streamContent=${chat.streamContent}
streaming=${chat.streaming} />
<${MessageInput}
handleRef=${inputHandle}
onSend=${chat.sendMessage}
disabled=${chat.sending} />
</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';

View File

@@ -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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
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;
}

View File

@@ -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`
<div class=${cls}>
<div class="sw-chat-msg__content"
dangerouslySetInnerHTML=${{ __html: renderMarkdown(content || '') }} />
</div>`;
}
return html`
<div class=${cls}>
<div class="sw-chat-msg__content"
dangerouslySetInnerHTML=${{ __html: renderMarkdown(content || '') }} />
</div>`;
}

View File

@@ -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`
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>`;
/**
* @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`
<div class="sw-chat-pane__input-bar">
<div class="sw-msg-input__wrap">
<textarea
ref=${taRef}
class="sw-msg-input__textarea"
placeholder="Type a message\u2026"
rows="1"
disabled=${disabled}
onInput=${_onInput}
onKeyDown=${_onKeyDown}
/>
<button
class="sw-msg-input__send"
title="Send"
disabled=${disabled}
onClick=${_onClickSend}
>${SEND_SVG}</button>
</div>
</div>`;
}

View File

@@ -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`
<div class=${'sw-chat-pane__messages' + (className ? ' ' + className : '')}
ref=${scrollRef}
onScroll=${_onScroll}>
${!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} />`)}
${streaming && html`
<${MessageBubble} role="assistant" content=${streamContent} streaming=${true} />`}
</div>`;
}

View File

@@ -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`
<select class="sw-model-selector__select"
title="Select model"
value=${value}
onChange=${_onChange}>
${models.map(m => {
const id = m.isPersona ? (m.personaId || m.id) : m.id;
const label = (m.isPersona ? '\ud83c\udfad ' : '') + (m.name || m.id);
return html`<option key=${id} value=${id}>${label}</option>`;
})}
</select>`;
}

View File

@@ -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,
};
}

View File

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

View File

@@ -116,8 +116,19 @@ export async function boot() {
});
};
// ChatPane render helper — surfaces call sw.chatPane(container, opts)
// Returns Promise<imperative handle> 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;
}