Changeset 0.31.0 (#203)

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
2026-03-19 00:06:16 +00:00
committed by xcaliber
parent 5883cb50e2
commit 071dea8904
33 changed files with 1693 additions and 1562 deletions

View File

@@ -146,6 +146,53 @@ 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 =
'<div class="chat-pane-header" id="' + pfx + 'ChatHeader" style="display:none;">' +
'<div class="chat-pane-header-left">' +
'<select class="chat-pane-chat-select" id="' + pfx + 'ChatSelect" title="Switch chat">' +
'<option value="">New conversation</option>' +
'</select>' +
'<button class="chat-pane-new-btn" id="' + pfx + 'ChatNewBtn" title="New chat">' +
'<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>' +
'</button>' +
'</div>' +
'<div class="chat-pane-header-right">' +
'<div class="chat-pane-model-sel" id="' + pfx + 'ModelSel"></div>' +
'</div>' +
'</div>' +
'<div class="chat-pane-messages" id="' + pfx + 'ChatMessages"></div>' +
'<div class="chat-pane-input-bar">' +
'<div class="chat-pane-input-wrap">' +
'<div class="chat-pane-input" id="' + pfx + 'ChatInput"></div>' +
'<button class="chat-pane-send" id="' + pfx + 'SendBtn" title="Send">' +
'<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>' +
'</button>' +
'</div>' +
'<div class="chat-pane-toolbar" id="' + pfx + 'Toolbar"></div>' +
'</div>';
container.appendChild(el);
// ChatPane.create expects element refs, not ID prefix
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,
});
return instance;
},
};

View File

@@ -289,6 +289,36 @@ const CodeEditor = {
CodeEditor._register(pfx, instance);
return instance;
},
// v0.31.0: Self-mount — creates DOM + binds in one call.
mount(container, opts) {
const pfx = opts.id || 'ce';
const el = document.createElement('div');
el.className = 'code-editor';
el.id = pfx + 'CodeEditor';
el.innerHTML =
'<div class="code-editor-tabs" id="' + pfx + 'EditorTabs"></div>' +
'<div class="code-editor-content" id="' + pfx + 'EditorContent">' +
'<div class="code-editor-welcome" id="' + pfx + 'EditorWelcome">' +
'<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-3);">' +
'<div style="text-align:center;">' +
'<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity:0.12;display:inline-block;margin-bottom:10px;"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>' +
'<p style="margin:0;font-size:14px;">Open a file to start editing</p>' +
'<p style="margin:6px 0 0;font-size:12px;">Select from the file tree or Ctrl+P</p>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="code-editor-statusbar" id="' + pfx + 'EditorStatus">' +
'<span id="' + pfx + 'EditorStatusFile"></span>' +
'<span id="' + pfx + 'EditorStatusLang"></span>' +
'<span id="' + pfx + 'EditorStatusBranch"></span>' +
'</div>';
container.appendChild(el);
const instance = this.create(opts);
instance.bind();
return instance;
},
};
// ── Helpers ─────────────────────────────────

View File

@@ -1,641 +0,0 @@
// ==========================================
// Chat Switchboard — Editor Surface (v0.25.0)
// ==========================================
// Uses PaneContainer to mount a three-pane layout and moves
// server-rendered component partials into pane slots.
// Components are rendered by Go templates into #editorComponents
// (hidden) — this JS moves them into the right pane slots.
// ==========================================
document.addEventListener('DOMContentLoaded', () => {
if (window.__SURFACE__ !== 'editor') return;
const pageData = window.__PAGE_DATA__ || {};
const wsId = pageData.WorkspaceID;
const wsName = pageData.WorkspaceName || 'Workspace';
// v0.28.0: UserMenu hydration moved to base.html universal init.
// Wire workspace selector
_initWsSelector(wsId);
// No workspace — show bootstrap
if (!wsId) {
_showBootstrap();
return;
}
_mountEditor(wsId, wsName);
});
// ── Workspace Selector ──────────────────
function _initWsSelector(currentWsId) {
const btn = document.getElementById('editorWsSelectorBtn');
const dropdown = document.getElementById('editorWsDropdown');
if (!btn || !dropdown) return;
btn.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = dropdown.classList.toggle('open');
if (isOpen) _loadWsDropdown(currentWsId);
});
document.addEventListener('click', (e) => {
if (!e.target.closest('#editorWsSelector')) dropdown.classList.remove('open');
});
// New workspace button
document.getElementById('editorWsNewBtn')?.addEventListener('click', async () => {
dropdown.classList.remove('open');
const name = window.prompt('Workspace name:');
if (!name) return;
try {
const userId = sw.user?.id || window.__USER__?.id;
if (!userId) throw new Error('Not authenticated');
const resp = await API.createWorkspace({ name: name.trim(), owner_type: 'user', owner_id: userId });
const newId = resp.id || resp.data?.id;
if (newId) {
window.location.href = (window.__BASE__ || '') + '/editor/' + newId;
}
} catch (e) {
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
}
});
}
async function _loadWsDropdown(currentWsId) {
const listEl = document.getElementById('editorWsList');
if (!listEl) return;
listEl.innerHTML = '<div style="padding:6px 12px;font-size:11px;color:var(--text-3)">Loading…</div>';
try {
const resp = await API._get('/api/v1/workspaces');
const workspaces = resp.data || resp || [];
listEl.innerHTML = '';
if (workspaces.length === 0) {
listEl.innerHTML = '<div style="padding:6px 12px;font-size:11px;color:var(--text-3)">No workspaces</div>';
return;
}
workspaces.forEach(ws => {
const item = document.createElement('button');
item.className = 'editor-ws-dropdown-item' + (ws.id === currentWsId ? ' active' : '');
item.textContent = ws.name || ws.id?.slice(0, 8);
item.addEventListener('click', () => {
window.location.href = (window.__BASE__ || '') + '/editor/' + ws.id;
});
listEl.appendChild(item);
});
} catch (_) {
listEl.innerHTML = '<div style="padding:6px 12px;font-size:11px;color:var(--text-3)">Failed to load</div>';
}
}
// ── Bootstrap (no workspace) ────────────
function _showBootstrap() {
const body = document.getElementById('editorBody');
const bootstrap = document.getElementById('editorBootstrap');
if (body) body.style.display = 'none';
if (bootstrap) bootstrap.style.display = '';
_loadBootstrapList();
const btn = document.getElementById('editorBootstrapBtn');
const input = document.getElementById('editorBootstrapName');
if (!btn || !input) return;
btn.addEventListener('click', async () => {
const name = input.value.trim() || 'workspace';
btn.disabled = true;
btn.textContent = 'Creating…';
try {
const userId = sw.user?.id || window.__USER__?.id;
if (!userId) throw new Error('Not authenticated');
const resp = await API.createWorkspace({ name, owner_type: 'user', owner_id: userId });
const newId = resp.id || resp.data?.id;
if (!newId) throw new Error('No workspace ID returned');
// Navigate to the new workspace
window.location.href = (window.__BASE__ || '') + '/editor/' + newId;
} catch (e) {
btn.disabled = false;
btn.textContent = 'Create Workspace';
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
}
});
}
async function _loadBootstrapList() {
const listEl = document.getElementById('editorBootstrapList');
if (!listEl) return;
try {
const resp = await API._get('/api/v1/workspaces');
const workspaces = resp.data || resp || [];
if (workspaces.length === 0) {
listEl.innerHTML = '<div style="font-size:12px;color:var(--text-3);">No workspaces yet</div>';
return;
}
listEl.innerHTML = '';
workspaces.forEach(ws => {
const item = document.createElement('button');
item.className = 'editor-bootstrap-ws-item';
item.innerHTML =
'<span class="editor-bootstrap-ws-name">' + esc(ws.name || ws.id?.slice(0, 8)) + '</span>' +
'<span class="editor-bootstrap-ws-date">' + esc(ws.created_at ? new Date(ws.created_at).toLocaleDateString() : '') + '</span>';
item.addEventListener('click', () => {
window.location.href = (window.__BASE__ || '') + '/editor/' + ws.id;
});
listEl.appendChild(item);
});
} catch (_) {
listEl.innerHTML = '<div style="font-size:12px;color:var(--text-3);">Failed to load workspaces</div>';
}
}
// ── Mount Pane Layout ───────────────────
function _mountEditor(wsId, wsName) {
const body = document.getElementById('editorBody');
if (!body || typeof PaneContainer === 'undefined') {
console.error('[EditorSurface] Missing body or PaneContainer');
return;
}
// Mount the 'editor' preset: files | editor | <chat, notes>
const layout = PaneContainer.mount(body, 'editor', { workspaceId: wsId });
if (!layout) return;
// ── Move server-rendered components into pane slots ──
// Components were rendered by Go templates into #editorComponents (hidden).
// We move them into the pane slots created by PaneContainer.
const components = document.getElementById('editorComponents');
// File Tree → files pane
const filesPaneInfo = layout._panes.get('files');
const fileTreeEl = document.getElementById('edFileTree');
if (filesPaneInfo?.el && fileTreeEl) {
filesPaneInfo.el.appendChild(fileTreeEl);
}
const fileTree = FileTree.create({ id: 'ed', workspaceId: wsId,
onSelect: (path) => codeEditor.openFile(path),
onDelete: (path) => _deleteFile(wsId, path, fileTree, codeEditor),
onNewFile: () => _createNewFile(wsId, fileTree),
});
filesPaneInfo.component = fileTree;
fileTree.bind();
// Code Editor → editor pane
const editorPaneInfo = layout._panes.get('editor');
const codeEditorEl = document.getElementById('edCodeEditor');
if (editorPaneInfo?.el && codeEditorEl) {
editorPaneInfo.el.appendChild(codeEditorEl);
}
const codeEditor = CodeEditor.create({ id: 'ed', workspaceId: wsId,
onSave: () => fileTree.refresh(),
onActivate: (path) => fileTree.setActiveFile(path),
});
editorPaneInfo.component = codeEditor;
codeEditor.bind();
// Assist pane (tabbed: chat + notes)
const assistPaneInfo = layout._panes.get('assist');
if (assistPaneInfo?.tabs) {
// Chat tab — move server-rendered ChatPane partial
const chatPanel = assistPaneInfo.getTabPanel('chat');
const chatPaneEl = document.getElementById('edChatPane');
if (chatPanel && chatPaneEl) {
chatPanel.appendChild(chatPaneEl);
// Create ChatPane instance from the server-rendered mount points
if (typeof ChatPane !== 'undefined') {
const chatPane = ChatPane.create({
id: 'ed',
messagesEl: document.getElementById('edChatMessages'),
inputEl: document.getElementById('edChatInput'),
sendBtnEl: document.getElementById('edSendBtn'),
modelSelEl: document.getElementById('edModelSel'),
toolbarEl: document.getElementById('edToolbar'),
standalone: true,
});
chatPane.showWelcome();
_initAssistChat(chatPane, codeEditor);
const chatTab = assistPaneInfo.tabs.find(t => t.id === 'chat');
if (chatTab) chatTab.instance = chatPane;
}
}
// Notes tab — move server-rendered NoteEditor partial
const notesPanel = assistPaneInfo.getTabPanel('notes');
const noteEditorEl = document.getElementById('edNotesNoteEditor');
if (notesPanel && noteEditorEl) {
notesPanel.appendChild(noteEditorEl);
if (typeof NoteEditor !== 'undefined') {
const noteEditor = NoteEditor.create({ id: 'edNotes', onOpenGraph: null });
noteEditor.bind();
const notesTab = assistPaneInfo.tabs.find(t => t.id === 'notes');
if (notesTab) {
notesTab.instance = noteEditor;
// Lazy-load on first tab click
notesTab.btn.addEventListener('click', () => {
if (!notesTab._loaded) {
notesTab._loaded = true;
noteEditor.loadNotes();
noteEditor.loadFolders();
}
});
}
}
}
}
// Remove the hidden container
if (components) components.remove();
// Git branch
_refreshGitBranch(wsId, codeEditor);
// Toolbar
document.getElementById('editorRefreshBtn')?.addEventListener('click', () => {
fileTree.refresh();
_refreshGitBranch(wsId, codeEditor);
});
// Initial load
fileTree.refresh();
console.log('[EditorSurface] Mounted for workspace', wsId);
}
// ── File Operations ─────────────────────
async function _deleteFile(wsId, path, fileTree, codeEditor) {
const ok = typeof showConfirm === 'function'
? await showConfirm('Delete ' + path + '?')
: window.confirm('Delete ' + path + '?');
if (!ok) return;
try {
await API.deleteWorkspaceFile(wsId, path);
if (codeEditor.getOpenFiles().includes(path)) await codeEditor.closeFile(path);
fileTree.refresh();
} catch (e) {
if (typeof UI !== 'undefined') UI.toast('Delete failed: ' + e.message, 'error');
}
}
async function _createNewFile(wsId, fileTree) {
const name = window.prompt('File name (e.g. src/main.go):');
if (!name) return;
try {
await API.writeWorkspaceFile(wsId, name.trim(), '');
fileTree.refresh();
if (typeof UI !== 'undefined') UI.toast('Created ' + name.trim(), 'success');
} catch (e) {
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
}
}
// ── Git Branch ──────────────────────────
async function _refreshGitBranch(wsId, codeEditor) {
try {
const resp = await API.getWorkspaceGitBranches(wsId);
const branch = resp.current || null;
if (branch) {
const badge = document.getElementById('editorBranchBadge');
const name = document.getElementById('editorBranchName');
if (badge) badge.style.display = '';
if (name) name.textContent = branch;
}
if (codeEditor) codeEditor.setBranch(branch);
} catch (_) {
// Git not configured — hide branch badge
}
}
// ── Helpers ──────────────────────────────
// ── Assist Chat (channel-based, standalone) ──
// Full chat pane for the editor: model selector, chat history,
// new chat, SSE streaming. Uses real channels/messages API.
// No dependency on chat.js — fully standalone.
function _initAssistChat(chatPane, codeEditor) {
const inputEl = chatPane.inputEl;
const sendBtn = chatPane.sendBtnEl;
const headerEl = document.getElementById('edChatHeader');
const selectEl = document.getElementById('edChatSelect');
const newBtnEl = document.getElementById('edChatNewBtn');
const modelSelEl = document.getElementById('edModelSel');
if (!inputEl) return;
// Show the header bar
if (headerEl) headerEl.style.display = '';
// State
let _channelId = null;
let _messages = [];
let _sending = false;
let _abortController = null;
let _selectedModel = App.settings?.model || '';
// ── Model Selector ──────────────────────
async function _initModelSelector() {
if (!modelSelEl) return;
// Ensure models are loaded
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(m => {
if (m.hidden) return;
const opt = document.createElement('option');
opt.value = m.isPersona ? (m.personaId || m.id) : m.id;
const prefix = m.isPersona ? '🎭 ' : '';
opt.textContent = prefix + (m.name || m.id);
if (m.id === _selectedModel || m.personaId === _selectedModel) {
opt.selected = true;
}
sel.appendChild(opt);
});
sel.addEventListener('change', () => {
_selectedModel = sel.value;
});
modelSelEl.innerHTML = '';
modelSelEl.appendChild(sel);
}
// ── Chat History Selector ───────────────
async function _loadChatHistory() {
if (!selectEl) return;
try {
const resp = await API.listChannels(1, 20, 'direct');
const channels = resp.data || resp || [];
selectEl.innerHTML = '<option value="">New conversation</option>';
channels.forEach(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 (e) {
console.warn('[AssistChat] Failed to load history:', e.message);
}
}
if (selectEl) {
selectEl.addEventListener('change', () => {
const id = selectEl.value;
if (id) {
_switchToChannel(id);
} else {
_newChat();
}
});
}
if (newBtnEl) {
newBtnEl.addEventListener('click', _newChat);
}
// ── Switch Channel ──────────────────────
async function _switchToChannel(channelId) {
_channelId = channelId;
_messages = [];
chatPane.clear();
chatPane.appendTyping();
try {
const resp = await API._get('/api/v1/channels/' + channelId + '/messages?page=1&per_page=100');
const msgs = resp.data || resp || [];
_messages = msgs;
chatPane.removeTyping();
_renderAllMessages();
} catch (e) {
chatPane.removeTyping();
_appendSystemMsg('Failed to load messages: ' + e.message);
}
}
function _newChat() {
_channelId = null;
_messages = [];
chatPane.showWelcome();
if (selectEl) selectEl.value = '';
}
// ── Create Textarea ─────────────────────
const ta = document.createElement('textarea');
ta.placeholder = 'Ask about your code…';
ta.rows = 1;
inputEl.appendChild(ta);
ta.addEventListener('input', () => {
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, 160) + 'px';
});
// ── Send ────────────────────────────────
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';
// Create channel if new conversation
if (!_channelId) {
try {
const title = text.slice(0, 50) + (text.length > 50 ? '…' : '');
const resp = await API.createChannel(title, _selectedModel, '', 'direct');
_channelId = resp.id;
// Update dropdown
_loadChatHistory();
} catch (e) {
_appendSystemMsg('Failed to create chat: ' + e.message);
_sending = false;
if (sendBtn) sendBtn.disabled = false;
return;
}
}
// Render user message
_messages.push({ role: 'user', content: text });
_renderAllMessages();
// Build context from active file
const fileCtx = _getFileContext(codeEditor);
// Stream the completion
try {
_abortController = new AbortController();
// If we have file context, prepend it to the message
const content = fileCtx
? text + '\n\n[Context: Currently editing ' + fileCtx.path + ']\n```\n' + fileCtx.content + '\n```'
: text;
const resp = await API.streamCompletion(
_channelId, content, _selectedModel,
_abortController.signal
);
await _handleStream(resp, chatPane);
} 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', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// ── SSE Stream Handler ──────────────────
async function _handleStream(response, pane) {
const messagesEl = pane.messagesEl;
if (!messagesEl) return;
// Create assistant bubble
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 parsed = JSON.parse(data);
const delta = parsed.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 (_) { /* skip unparseable lines */ }
}
}
} catch (e) {
if (e.name !== 'AbortError') {
content += '\n\n**[Stream error: ' + e.message + ']**';
}
}
div.classList.remove('chat-msg--streaming');
_messages.push({ role: 'assistant', content });
}
// ── Rendering ───────────────────────────
function _renderAllMessages() {
if (!chatPane.messagesEl) return;
chatPane.messagesEl.innerHTML = '';
_messages.forEach(m => {
const div = document.createElement('div');
div.className = 'chat-msg chat-msg--' + m.role;
const content = document.createElement('div');
content.className = 'chat-msg__content';
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
content.innerHTML = DOMPurify.sanitize(marked.parse(m.content || ''));
} else {
content.textContent = m.content || '';
}
div.appendChild(content);
chatPane.messagesEl.appendChild(div);
});
chatPane.scrollToBottom();
}
function _appendSystemMsg(text) {
const div = document.createElement('div');
div.className = 'chat-msg chat-msg--system';
div.innerHTML = '<div class="chat-msg__content" style="color:var(--danger,#f44336);font-size:12px;">' + esc(text) + '</div>';
chatPane.messagesEl?.appendChild(div);
chatPane.scrollToBottom();
}
function _getFileContext(editor) {
if (!editor) return null;
try {
const openFiles = editor.getOpenFiles();
if (!openFiles.length) return null;
// Find active tab
const activeTab = document.querySelector('.code-editor-tab.active');
const path = activeTab?.dataset?.path || openFiles[0];
// Get content from the editor instances
const inst = CodeEditor._instances?.get('ed');
if (inst?._files) {
const file = inst._files.get(path);
if (file?.view) {
const content = file.view.state.doc.toString();
return { path, content: content.slice(0, 4000) };
}
}
} catch (_) {}
return null;
}
// ── Init ────────────────────────────────
// Defer model selector and chat history loading until app common init
// completes (fetchModels, auth, etc.). The sb:ready event fires from
// startApp() after App.models is populated.
function _deferredInit() {
_initModelSelector();
_loadChatHistory();
}
if (App.models?.length) {
// Already loaded (unlikely but possible)
_deferredInit();
} else {
document.addEventListener('sb:ready', _deferredInit, { once: true });
}
}

View File

@@ -245,6 +245,27 @@ const FileTree = {
FileTree._register(pfx, instance);
return instance;
},
// v0.31.0: Self-mount — creates DOM + binds in one call.
mount(container, opts) {
const pfx = opts.id || 'ft';
const el = document.createElement('div');
el.className = 'file-tree';
el.id = pfx + 'FileTree';
el.innerHTML =
'<div class="file-tree-header" id="' + pfx + 'TreeHeader">' +
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>' +
'<span class="file-tree-title">Files</span>' +
'<button class="icon-btn" id="' + pfx + 'TreeNewFile" title="New file">' +
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>' +
'</button>' +
'</div>' +
'<div class="file-tree-items" id="' + pfx + 'TreeItems"></div>';
container.appendChild(el);
const instance = this.create(opts);
instance.bind();
return instance;
},
};
// ── Helpers ─────────────────────────────────

View File

@@ -1,441 +0,0 @@
// ==========================================
// Chat Switchboard — NoteEditor Component
// ==========================================
// v0.25.0: Component wrapper for notes list + editor.
// Hydrates the server-rendered note-editor.html partial.
// Can run standalone (in editor tabbed pane) or coexist with
// the existing notes.js panel system (on chat/notes surfaces).
//
// Usage:
// const notes = NoteEditor.create({
// id: 'main', // matches template prefix
// onOpenGraph: () => { ... },
// });
// notes.bind();
// await notes.loadNotes();
// notes.destroy();
//
// The existing notes.js + PanelRegistry integration continues to
// work on the chat surface. This component is for NEW mount points
// (editor pane, future notes-studio layout).
//
// Exports: window.NoteEditor
const NoteEditor = {
...createComponentRegistry('NoteEditor'),
create(opts) {
const pfx = opts.id || 'main';
const instance = componentMixin({
id: pfx,
rootEl: document.getElementById(pfx + 'NoteEditor'),
listViewEl: document.getElementById(pfx + 'NotesListView'),
editorViewEl: document.getElementById(pfx + 'NotesEditorView'),
graphViewEl: document.getElementById(pfx + 'NotesGraphView'),
listEl: document.getElementById(pfx + 'NotesList'),
searchEl: document.getElementById(pfx + 'NotesSearchInput'),
folderFilterEl: document.getElementById(pfx + 'NotesFolderFilter'),
sortEl: document.getElementById(pfx + 'NotesSortSelect'),
titleEl: document.getElementById(pfx + 'NoteEditorTitle'),
folderEl: document.getElementById(pfx + 'NoteEditorFolder'),
tagsEl: document.getElementById(pfx + 'NoteEditorTags'),
contentContainerEl: document.getElementById(pfx + 'NoteEditorContentContainer'),
readTitleEl: document.getElementById(pfx + 'NoteReadTitle'),
readMetaEl: document.getElementById(pfx + 'NoteReadMeta'),
readContentEl: document.getElementById(pfx + 'NoteReadContent'),
editModeEl: document.getElementById(pfx + 'NoteEditMode'),
readModeEl: document.getElementById(pfx + 'NoteReadMode'),
backlinksEl: document.getElementById(pfx + 'NoteBacklinks'),
backlinksListEl: document.getElementById(pfx + 'NoteBacklinksList'),
backlinksCountEl: document.getElementById(pfx + 'NoteBacklinksCount'),
onOpenGraph: opts.onOpenGraph || null,
_editingNoteId: null,
_currentNote: null,
_sort: 'updated_desc',
_cmEditor: null,
// ── View Switching ───────────────────
showList() {
if (this.listViewEl) this.listViewEl.style.display = '';
if (this.editorViewEl) this.editorViewEl.style.display = 'none';
if (this.graphViewEl) this.graphViewEl.style.display = 'none';
this._destroyCmEditor();
},
showEditor() {
if (this.listViewEl) this.listViewEl.style.display = 'none';
if (this.editorViewEl) this.editorViewEl.style.display = '';
if (this.graphViewEl) this.graphViewEl.style.display = 'none';
},
showGraph() {
if (this.listViewEl) this.listViewEl.style.display = 'none';
if (this.editorViewEl) this.editorViewEl.style.display = 'none';
if (this.graphViewEl) this.graphViewEl.style.display = '';
},
// ── Note List ───────────────────────
async loadNotes(folder, searchQuery) {
if (!this.listEl) return;
this.listEl.innerHTML = '<div class="loading">Loading…</div>';
try {
let notes;
if (searchQuery) {
const resp = await API.searchNotes(searchQuery);
notes = resp.data || [];
} else {
const folderVal = folder || this.folderFilterEl?.value || '';
const resp = await API.listNotes(100, 0, folderVal, '', this._sort);
notes = resp.data || [];
}
if (notes.length === 0) {
this.listEl.innerHTML = '<div class="empty-hint">' +
(searchQuery ? 'No results found' : 'No notes yet') + '</div>';
return;
}
this.listEl.innerHTML = '';
const self = this;
notes.forEach(note => {
const item = document.createElement('div');
item.className = 'note-item';
item.dataset.noteId = note.id;
const title = note.title || 'Untitled';
const preview = (note.content || '').slice(0, 80).replace(/\n/g, ' ');
const date = note.updated_at ? new Date(note.updated_at).toLocaleDateString() : '';
item.innerHTML =
'<div class="note-content-col">' +
'<div class="note-title">' + esc(title) + '</div>' +
'<div class="note-preview">' + esc(preview) + '</div>' +
'<div class="note-date">' + esc(date) + '</div>' +
'</div>';
item.addEventListener('click', () => self.openNote(note.id));
self.listEl.appendChild(item);
});
} catch (e) {
this.listEl.innerHTML = '<div class="empty-hint">Failed to load: ' + esc(e.message) + '</div>';
}
},
async loadFolders() {
if (!this.folderFilterEl) return;
try {
const resp = await API.listNoteFolders();
const folders = resp.data || resp || [];
// Keep "All folders" option, rebuild the rest
this.folderFilterEl.innerHTML = '<option value="">All folders</option>';
folders.forEach(f => {
const opt = document.createElement('option');
opt.value = f;
opt.textContent = f;
this.folderFilterEl.appendChild(opt);
});
} catch (_) { /* ignore */ }
},
// ── Note CRUD ───────────────────────
async openNote(noteId) {
this.showEditor();
if (noteId) {
this._editingNoteId = noteId;
try {
this._currentNote = await API.getNote(noteId);
this._populateFields(this._currentNote);
this._showReadMode();
} catch (e) {
if (typeof UI !== 'undefined') UI.toast('Failed to load note: ' + e.message, 'error');
this.showList();
}
} else {
this._editingNoteId = null;
this._currentNote = null;
this._clearFields();
this._showEditMode();
if (this.titleEl) this.titleEl.focus();
}
},
async saveNote() {
const title = this.titleEl?.value?.trim() || '';
const content = this._getContent();
const folder = this.folderEl?.value?.trim() || '';
const tagsStr = this.tagsEl?.value || '';
const tags = tagsStr.split(',').map(t => t.trim()).filter(Boolean);
if (!title && !content) {
if (typeof UI !== 'undefined') UI.toast('Note is empty', 'warning');
return;
}
try {
if (this._editingNoteId) {
await API.updateNote(this._editingNoteId, { title, content, folder_path: folder, tags });
} else {
const resp = await API.createNote(title, content, folder, tags);
this._editingNoteId = resp.id || resp.data?.id;
}
this._currentNote = { ...this._currentNote, title, content, folder_path: folder, tags };
this._showReadMode();
if (typeof UI !== 'undefined') UI.toast('Note saved', 'success');
} catch (e) {
if (typeof UI !== 'undefined') UI.toast('Save failed: ' + e.message, 'error');
}
},
async deleteNote() {
if (!this._editingNoteId) return;
const ok = typeof showConfirm === 'function'
? await showConfirm('Delete this note?')
: window.confirm('Delete this note?');
if (!ok) return;
try {
await API.deleteNote(this._editingNoteId);
if (typeof UI !== 'undefined') UI.toast('Note deleted', 'success');
this._editingNoteId = null;
this._currentNote = null;
this.showList();
this.loadNotes();
} catch (e) {
if (typeof UI !== 'undefined') UI.toast('Delete failed: ' + e.message, 'error');
}
},
// ── Read / Edit / Preview Modes ─────
_showReadMode() {
if (this.editModeEl) this.editModeEl.style.display = 'none';
if (this.readModeEl) this.readModeEl.style.display = '';
const n = this._currentNote;
if (!n) return;
if (this.readTitleEl) this.readTitleEl.textContent = n.title || 'Untitled';
if (this.readMetaEl) {
const parts = [];
if (n.folder_path) parts.push('📁 ' + n.folder_path);
if (n.tags?.length) parts.push('🏷 ' + n.tags.join(', '));
if (n.updated_at) parts.push(new Date(n.updated_at).toLocaleString());
this.readMetaEl.textContent = parts.join(' · ');
}
if (this.readContentEl) {
if (typeof marked !== 'undefined') {
const raw = typeof marked.parse === 'function' ? marked.parse(n.content || '') : marked(n.content || '');
this.readContentEl.innerHTML = typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(raw) : raw;
} else {
this.readContentEl.textContent = n.content || '';
}
}
// Show edit/delete buttons for read mode
this._setBtn('NoteEditBtn', true);
this._setBtn('NotePreviewBtn', false);
this._setBtn('NoteCancelEditBtn', false);
this._setBtn('NoteDeleteBtn', true);
},
_showEditMode() {
if (this.editModeEl) this.editModeEl.style.display = '';
if (this.readModeEl) this.readModeEl.style.display = 'none';
this._setBtn('NoteEditBtn', false);
this._setBtn('NotePreviewBtn', true);
this._setBtn('NoteCancelEditBtn', !!this._currentNote);
this._setBtn('NoteDeleteBtn', !!this._editingNoteId);
},
_showPreview() {
// Toggle between edit and read-mode preview
if (this.readModeEl?.style.display === 'none') {
// Show preview of current edits
const tempNote = {
title: this.titleEl?.value || '',
content: this._getContent(),
folder_path: this.folderEl?.value || '',
tags: (this.tagsEl?.value || '').split(',').map(t => t.trim()).filter(Boolean),
};
const saved = this._currentNote;
this._currentNote = tempNote;
this._showReadMode();
this._currentNote = saved;
// Swap buttons for preview-of-edits state
this._setBtn('NoteEditBtn', true);
this._setBtn('NotePreviewBtn', false);
} else {
this._showEditMode();
}
},
// ── CM6 Editor Management ───────────
_getContent() {
if (this._cmEditor) return this._cmEditor.getValue();
const ta = this.contentContainerEl?.querySelector('textarea');
return ta ? ta.value : '';
},
_setContent(text) {
if (this._cmEditor) { this._cmEditor.setValue(text); return; }
// Lazy-init CM6
if (this.contentContainerEl && window.CM?.noteEditor) {
this.contentContainerEl.innerHTML = '';
this._cmEditor = CM.noteEditor(this.contentContainerEl, {
value: text,
darkMode: document.documentElement.getAttribute('data-theme') !== 'light',
onLink: (title) => this._navigateToLink(title),
linkCompleter: async (query) => {
if (!query || query.length < 1) return [];
try {
const resp = await API.searchNoteTitles(query, 8);
return (resp.data || []).map(n => ({ label: n.title, id: n.id }));
} catch { return []; }
},
});
return;
}
// Textarea fallback
const ta = this.contentContainerEl?.querySelector('textarea');
if (ta) ta.value = text;
},
_destroyCmEditor() {
if (this._cmEditor?.destroy) this._cmEditor.destroy();
this._cmEditor = null;
},
async _navigateToLink(title) {
try {
const resp = await API.searchNotes(title);
const notes = resp.data || [];
const match = notes.find(n => n.title?.toLowerCase() === title.toLowerCase());
if (match) {
this.openNote(match.id);
} else {
// Create new note with this title
this._editingNoteId = null;
this._currentNote = null;
this._clearFields();
if (this.titleEl) this.titleEl.value = title;
this._showEditMode();
}
} catch (_) {}
},
// ── Field Helpers ───────────────────
_populateFields(note) {
if (this.titleEl) this.titleEl.value = note.title || '';
if (this.folderEl) this.folderEl.value = note.folder_path || '';
if (this.tagsEl) this.tagsEl.value = (note.tags || []).join(', ');
this._setContent(note.content || '');
},
_clearFields() {
if (this.titleEl) this.titleEl.value = '';
if (this.folderEl) this.folderEl.value = '';
if (this.tagsEl) this.tagsEl.value = '';
this._setContent('');
},
_setBtn(suffix, show) {
const el = document.getElementById(pfx + suffix);
if (el) el.style.display = show ? '' : 'none';
},
// ── Event Wiring ────────────────────
bind() {
const $ = (id) => document.getElementById(pfx + id);
const self = this;
// Toolbar
this._on($('NotesNewBtn'), 'click', () => self.openNote(null));
this._on($('NotesTodayBtn'), 'click', () => self._openDailyNote());
this._on($('NotesGraphBtn'), 'click', () => {
if (self.onOpenGraph) self.onOpenGraph();
});
// Editor header
this._on($('NotesBackBtn'), 'click', () => { self.showList(); self.loadNotes(); self.loadFolders(); });
this._on($('NoteSaveBtn'), 'click', () => self.saveNote());
this._on($('NoteDeleteBtn'), 'click', () => self.deleteNote());
this._on($('NoteEditBtn'), 'click', () => self._showEditMode());
this._on($('NotePreviewBtn'), 'click', () => self._showPreview());
this._on($('NoteCancelEditBtn'), 'click', () => {
if (self._currentNote) { self._populateFields(self._currentNote); self._showReadMode(); }
else self.showList();
});
// Filters
this._on(this.folderFilterEl, 'change', () => {
if (self.searchEl) self.searchEl.value = '';
self.loadNotes(self.folderFilterEl.value);
});
this._on(this.sortEl, 'change', () => {
self._sort = self.sortEl.value;
self.loadNotes();
});
// Search with debounce
let timer;
this._on(this.searchEl, 'input', () => {
clearTimeout(timer);
const q = self.searchEl.value.trim();
timer = setTimeout(() => {
if (q.length >= 2) {
if (self.folderFilterEl) self.folderFilterEl.value = '';
self.loadNotes(null, q);
} else if (q.length === 0) {
self.loadNotes();
}
}, 300);
});
},
// ── Daily Note ──────────────────────
async _openDailyNote() {
const today = new Date().toISOString().slice(0, 10);
try {
const resp = await API.searchNotes(today);
const notes = resp.data || [];
const daily = notes.find(n => n.title === today || n.title?.startsWith(today));
if (daily) {
this.openNote(daily.id);
} else {
this._editingNoteId = null;
this._currentNote = null;
this._clearFields();
if (this.titleEl) this.titleEl.value = today;
this._setContent('# ' + today + '\n\n');
this.showEditor();
this._showEditMode();
}
} catch (e) {
if (typeof UI !== 'undefined') UI.toast('Failed to open daily note: ' + e.message, 'error');
}
},
_cleanup() {
this._destroyCmEditor();
this._editingNoteId = null;
this._currentNote = null;
},
}, NoteEditor);
NoteEditor._register(pfx, instance);
return instance;
},
};
// ── Exports ─────────────────────────────────
sb.ns('NoteEditor', NoteEditor);

View File

@@ -32,6 +32,9 @@ const NotePanel = {
_noteEditor: null,
_currentNote: null,
_searchTimer: null,
_page: 1,
_pageSize: 50,
_hasMore: false,
// ── DOM query helper ──
$(sel) { return this.container.querySelector(sel); },
@@ -39,33 +42,56 @@ const NotePanel = {
// ── Notes List ──
async loadNotesList(folder, searchQuery) {
async loadNotesList(folder, searchQuery, append) {
const list = this.$('#notesList');
if (!list) return;
list.innerHTML = '<div class="loading">Loading\u2026</div>';
if (!append) {
list.innerHTML = '<div class="loading">Loading\u2026</div>';
this._page = 1;
}
try {
let data;
let notes, isSearch = false;
if (searchQuery) {
data = await API.searchNotes(searchQuery);
const results = data.data || [];
if (results.length === 0) {
list.innerHTML = '<div class="empty-hint">No results found</div>';
return;
}
list.innerHTML = results.map(n => this._noteListItem(n, true)).join('');
isSearch = true;
const data = await API.searchNotes(searchQuery);
notes = data.data || [];
this._hasMore = false;
} else {
const folderVal = folder || this.$('#notesFolderFilter')?.value || '';
data = await API.listNotes(100, 0, folderVal, '', this._notesSort);
const notes = data.data || [];
if (notes.length === 0) {
list.innerHTML = `<div class="empty-hint">${folderVal ? 'No notes in this folder' : 'No notes yet. Create one or ask the AI to save a note.'}</div>`;
return;
}
list.innerHTML = notes.map(n => this._noteListItem(n, false)).join('');
const offset = (this._page - 1) * this._pageSize;
const data = await API.listNotes(this._pageSize, offset, folderVal, '', this._notesSort);
notes = data.data || [];
this._hasMore = notes.length >= this._pageSize;
}
if (!append) list.innerHTML = '';
if (notes.length === 0 && !append) {
const folderVal = this.$('#notesFolderFilter')?.value || '';
list.innerHTML = `<div class="empty-hint">${isSearch ? 'No results found' : (folderVal ? 'No notes in this folder' : 'No notes yet. Create one or ask the AI to save a note.')}</div>`;
return;
}
// Remove old "Load more" button if present
list.querySelector('.notes-load-more')?.remove();
list.insertAdjacentHTML('beforeend', notes.map(n => this._noteListItem(n, isSearch)).join(''));
// Pagination: "Load more" button
if (this._hasMore) {
const btn = document.createElement('button');
btn.className = 'btn-small notes-load-more';
btn.textContent = 'Load more\u2026';
btn.style.cssText = 'display:block;margin:8px auto;';
btn.addEventListener('click', () => {
this._page++;
this.loadNotesList(null, null, true);
});
list.appendChild(btn);
}
} catch (e) {
list.innerHTML = `<div class="empty-hint">Failed to load: ${esc(e.message)}</div>`;
if (!append) list.innerHTML = `<div class="empty-hint">Failed to load: ${esc(e.message)}</div>`;
}
},
@@ -783,6 +809,168 @@ const NotePanel = {
},
};
// ── Self-mount: build DOM + create + wire in one call ──
// Canonical entry point for all consumers (notes.js sidebar, editor package, sw.notes()).
// Uses the same IDs that NotePanel.create() expects — $() is container-scoped so no collisions.
NotePanel.mount = function (container, opts) {
const _opts = opts || {};
const el = document.createElement('div');
el.className = 'note-panel-root';
el.innerHTML =
'<!-- List view -->' +
'<div id="notesListView">' +
'<div class="notes-toolbar">' +
'<button id="notesNewBtn" class="btn-small" title="New note">+ New</button>' +
'<button id="notesTodayBtn" class="btn-small" title="Open daily note">\ud83d\udcc5 Today</button>' +
'<button id="notesGraphBtn" class="btn-small" title="Note graph">\ud83d\udd78 Graph</button>' +
'<button id="notesSelectModeBtn" class="btn-small" title="Select mode">\u2611 Select</button>' +
'</div>' +
'<div class="notes-search-row">' +
'<input type="text" id="notesSearchInput" class="notes-search-input" placeholder="Search notes\u2026" autocomplete="off">' +
'</div>' +
'<div class="notes-filter-row">' +
'<select id="notesFolderFilter" class="notes-filter-select"><option value="">All folders</option></select>' +
'<select id="notesSortSelect" class="notes-filter-select">' +
'<option value="updated_desc">Last updated</option>' +
'<option value="created_desc">Created</option>' +
'<option value="alpha">Alphabetical</option>' +
'</select>' +
'</div>' +
'<div id="notesList" class="notes-list"></div>' +
'<div id="notesSelectionBar" class="notes-selection-bar" style="display:none;">' +
'<label class="notes-select-all"><input type="checkbox" id="notesSelectAll"> All</label>' +
'<span id="notesSelectedCount">0</span> selected' +
'<button id="notesDeleteSelectedBtn" class="btn-small btn-danger">Delete</button>' +
'<button id="notesCancelSelectBtn" class="btn-small">Cancel</button>' +
'</div>' +
'</div>' +
'<!-- Editor view -->' +
'<div id="notesEditorView" style="display:none;">' +
'<div class="notes-editor-header">' +
'<button id="notesBackBtn" class="btn-small" title="Back to list">\u2190 Back</button>' +
'<div style="flex:1"></div>' +
'<button id="noteEditBtn" class="btn-small" style="display:none;">Edit</button>' +
'<button id="notePreviewBtn" class="btn-small" style="display:none;">Preview</button>' +
'<button id="noteCancelEditBtn" class="btn-small" style="display:none;">Cancel</button>' +
'<button id="noteDeleteBtn" class="btn-small btn-danger" style="display:none;">Delete</button>' +
'<button id="noteSaveBtn" class="btn-small btn-primary">Save</button>' +
'</div>' +
'<div id="noteEditMode" class="notes-editor">' +
'<input type="text" id="noteEditorTitle" class="notes-title-input" placeholder="Note title">' +
'<div class="notes-meta-row">' +
'<input type="text" id="noteEditorFolder" class="notes-folder-input" placeholder="Folder (e.g. work/project)">' +
'<input type="text" id="noteEditorTags" class="notes-tags-input" placeholder="Tags (comma-separated)">' +
'</div>' +
'<div id="noteEditorContentContainer" class="notes-content-container">' +
'<textarea id="noteEditorContent" rows="12" class="notes-content-input" placeholder="Write your note in markdown\u2026"></textarea>' +
'</div>' +
'</div>' +
'<div id="noteReadMode" style="display:none;">' +
'<div class="notes-read-view">' +
'<h3 id="noteReadTitle" class="note-read-title"></h3>' +
'<div id="noteReadMeta" class="note-read-meta"></div>' +
'<div id="noteReadContent" class="note-read-content msg-content"></div>' +
'<button id="noteDeleteBtn2" class="btn-small btn-danger" style="display:none;margin-top:8px;">Delete</button>' +
'</div>' +
'</div>' +
'<div id="noteBacklinks" class="note-backlinks" style="display:none;">' +
'<div class="note-backlinks-header" data-action="toggleBacklinks">' +
'Backlinks <span id="noteBacklinksCount" class="badge">0</span>' +
'</div>' +
'<div id="noteBacklinksList" class="note-backlinks-list"></div>' +
'</div>' +
'</div>' +
'<!-- Graph view -->' +
'<div id="notesGraphView" class="notes-graph-view" style="display:none;">' +
'<div class="notes-graph-toolbar">' +
'<button id="notesGraphBackBtn" class="btn-small" title="Back to list">\u2190 Back</button>' +
'<span class="notes-graph-stats">' +
'<span id="graphNodeCount">0</span> notes \u00b7 <span id="graphEdgeCount">0</span> links' +
'</span>' +
'<div style="flex:1"></div>' +
'<button class="btn-small" data-action="_graphToggleOrphans" title="Toggle orphan nodes">Orphans</button>' +
'<button class="btn-small" data-action="_graphResetZoom" title="Reset zoom">Reset</button>' +
'</div>' +
'<canvas id="noteGraphCanvas" style="width:100%;display:block;"></canvas>' +
'</div>';
container.appendChild(el);
// Create NotePanel instance — $() is container-scoped, so the IDs above "just work"
const np = this.create({ container: el, projectId: _opts.projectId || null });
// ── Wire all events (same as notes.js _registerNotesPanel) ──
const q = (sel) => el.querySelector(sel);
q('#notesNewBtn').addEventListener('click', () => np.openNoteEditor(null));
q('#notesTodayBtn').addEventListener('click', () => np.openDailyNote());
q('#notesGraphBtn').addEventListener('click', () => {
np.showGraph();
if (typeof openNoteGraph === 'function') openNoteGraph();
if (_opts.onOpenGraph) _opts.onOpenGraph();
});
q('#notesGraphBackBtn').addEventListener('click', () => {
np.showNotesList();
if (typeof closeNoteGraph === 'function') closeNoteGraph();
});
q('#notesSelectModeBtn').addEventListener('click', () => {
if (np._notesSelectMode) np._exitSelectMode();
else np._enterSelectMode();
});
q('#notesBackBtn').addEventListener('click', () => { np.showNotesList(); np.loadNotesList(); np.loadNoteFolders(); });
q('#noteSaveBtn').addEventListener('click', () => np.saveNote());
q('#noteDeleteBtn').addEventListener('click', () => np.deleteNote());
q('#noteDeleteBtn2').addEventListener('click', () => np.deleteNote());
q('#noteEditBtn').addEventListener('click', () => np._showNoteEditMode());
q('#notePreviewBtn').addEventListener('click', () => np._showNotePreview());
q('#noteCancelEditBtn').addEventListener('click', () => {
if (np._currentNote) { np._populateEditFields(np._currentNote); np._showNoteReadMode(); }
else np.showNotesList();
});
q('#notesFolderFilter').addEventListener('change', (e) => {
np.$('#notesSearchInput').value = '';
np._exitSelectMode();
np.loadNotesList(e.target.value);
});
q('#notesSortSelect').addEventListener('change', (e) => {
np._notesSort = e.target.value;
np._exitSelectMode();
np.loadNotesList();
});
q('#notesSelectAll').addEventListener('change', (e) => np._toggleSelectAll(e.target.checked));
q('#notesDeleteSelectedBtn').addEventListener('click', () => np._bulkDeleteSelected());
q('#notesCancelSelectBtn').addEventListener('click', () => np._exitSelectMode());
let _searchTimer;
q('#notesSearchInput').addEventListener('input', (e) => {
clearTimeout(_searchTimer);
const query = e.target.value.trim();
_searchTimer = setTimeout(() => {
np._exitSelectMode();
if (query.length >= 2) {
np.$('#notesFolderFilter').value = '';
np.loadNotesList(null, query);
} else if (query.length === 0) {
np.loadNotesList();
}
}, 300);
});
// Add showGraph view switcher
np.showGraph = function () {
const lv = np.$('#notesListView');
const ev = np.$('#notesEditorView');
const gv = np.$('#notesGraphView');
if (lv) lv.style.display = 'none';
if (ev) ev.style.display = 'none';
if (gv) gv.style.display = '';
};
return np;
};
// ── Exports ─────────────────────────────────
sb.ns('NotePanel', NotePanel);

View File

@@ -86,159 +86,23 @@ function _registerNotesPanel() {
const body = document.getElementById('sidePanelBody');
if (!body || typeof PanelRegistry === 'undefined' || typeof NotePanel === 'undefined') return;
// ── Create the panel element dynamically ──
const el = document.createElement('div');
el.className = 'side-panel-page';
el.id = 'sidePanelNotes';
el.style.display = 'none';
el.innerHTML = `
<!-- List view -->
<div id="notesListView">
<div class="notes-toolbar">
<button id="notesNewBtn" class="btn-small" title="New note">+ New</button>
<button id="notesTodayBtn" class="btn-small" title="Open daily note">\ud83d\udcc5 Today</button>
<button id="notesGraphBtn" class="btn-small" title="Note graph">\ud83d\udd78 Graph</button>
<button id="notesSelectModeBtn" class="btn-small" title="Select mode">\u2611 Select</button>
</div>
<div class="notes-search-row">
<input type="text" id="notesSearchInput" class="notes-search-input" placeholder="Search notes\u2026" autocomplete="off">
</div>
<div class="notes-filter-row">
<select id="notesFolderFilter" class="notes-filter-select"><option value="">All folders</option></select>
<select id="notesSortSelect" class="notes-filter-select">
<option value="updated_desc">Last updated</option>
<option value="created_desc">Created</option>
<option value="alpha">Alphabetical</option>
</select>
</div>
<div id="notesList" class="notes-list"></div>
<div id="notesSelectionBar" class="notes-selection-bar" style="display:none;">
<label class="notes-select-all"><input type="checkbox" id="notesSelectAll"> All</label>
<span id="notesSelectedCount">0</span> selected
<button id="notesDeleteSelectedBtn" class="btn-small btn-danger">Delete</button>
<button id="notesCancelSelectBtn" class="btn-small">Cancel</button>
</div>
</div>
<!-- Editor view -->
<div id="notesEditorView" style="display:none;">
<div class="notes-editor-header">
<button id="notesBackBtn" class="btn-small" title="Back to list">\u2190 Back</button>
<div style="flex:1"></div>
<button id="noteEditBtn" class="btn-small" style="display:none;">Edit</button>
<button id="notePreviewBtn" class="btn-small" style="display:none;">Preview</button>
<button id="noteCancelEditBtn" class="btn-small" style="display:none;">Cancel</button>
<button id="noteDeleteBtn" class="btn-small btn-danger" style="display:none;">Delete</button>
<button id="noteSaveBtn" class="btn-small btn-primary">Save</button>
</div>
<!-- Edit mode -->
<div id="noteEditMode" class="notes-editor">
<input type="text" id="noteEditorTitle" class="notes-title-input" placeholder="Note title">
<div class="notes-meta-row">
<input type="text" id="noteEditorFolder" class="notes-folder-input" placeholder="Folder (e.g. work/project)">
<input type="text" id="noteEditorTags" class="notes-tags-input" placeholder="Tags (comma-separated)">
</div>
<div id="noteEditorContentContainer" class="notes-content-container">
<textarea id="noteEditorContent" rows="12" class="notes-content-input" placeholder="Write your note in markdown\u2026"></textarea>
</div>
</div>
<!-- Read mode -->
<div id="noteReadMode" style="display:none;">
<div class="notes-read-view">
<h3 id="noteReadTitle" class="note-read-title"></h3>
<div id="noteReadMeta" class="note-read-meta"></div>
<div id="noteReadContent" class="note-read-content msg-content"></div>
<button id="noteDeleteBtn2" class="btn-small btn-danger" style="display:none;margin-top:8px;">Delete</button>
</div>
</div>
<!-- Backlinks -->
<div id="noteBacklinks" class="note-backlinks" style="display:none;">
<div class="note-backlinks-header" data-action="toggleBacklinks">
Backlinks <span id="noteBacklinksCount" class="badge">0</span>
</div>
<div id="noteBacklinksList" class="note-backlinks-list"></div>
</div>
</div>
<!-- Graph view -->
<div id="notesGraphView" class="notes-graph-view" style="display:none;">
<div class="notes-graph-toolbar">
<button class="btn-small" id="notesGraphBackBtn" title="Back to list">\u2190 Back</button>
<span class="notes-graph-stats">
<span id="graphNodeCount">0</span> notes \u00b7 <span id="graphEdgeCount">0</span> links
</span>
<div style="flex:1"></div>
<button class="btn-small" data-action="_graphToggleOrphans" title="Toggle orphan nodes">Orphans</button>
<button class="btn-small" data-action="_graphResetZoom" title="Reset zoom">Reset</button>
</div>
<canvas id="noteGraphCanvas" style="width:100%;display:block;"></canvas>
</div>`;
body.appendChild(el);
// ── Create wrapper element ──
const wrapper = document.createElement('div');
wrapper.id = 'sidePanelNotes';
wrapper.style.display = 'none';
wrapper.style.height = '100%';
body.appendChild(wrapper);
// ── Create NotePanel singleton ──
const np = NotePanel.create({ container: el });
// ── Mount NotePanel (DOM + create + wire — single source of truth) ──
const np = NotePanel.mount(wrapper, {});
NotePanel.primary = np;
// ── Wire listeners ──
// ── Wire sidebar button ──
document.getElementById('notesBtn')?.addEventListener('click', openNotes);
el.querySelector('#notesNewBtn').addEventListener('click', () => np.openNoteEditor(null));
el.querySelector('#notesGraphBtn').addEventListener('click', () => {
if (typeof openNoteGraph === 'function') openNoteGraph();
});
el.querySelector('#notesTodayBtn').addEventListener('click', () => np.openDailyNote());
el.querySelector('#notesGraphBackBtn').addEventListener('click', () => {
if (typeof closeNoteGraph === 'function') closeNoteGraph();
});
el.querySelector('#notesSelectModeBtn').addEventListener('click', () => {
if (np._notesSelectMode) np._exitSelectMode();
else np._enterSelectMode();
});
el.querySelector('#notesBackBtn').addEventListener('click', () => { np.showNotesList(); np.loadNotesList(); np.loadNoteFolders(); });
el.querySelector('#noteSaveBtn').addEventListener('click', () => np.saveNote());
el.querySelector('#noteDeleteBtn').addEventListener('click', () => np.deleteNote());
el.querySelector('#noteDeleteBtn2').addEventListener('click', () => np.deleteNote());
el.querySelector('#noteEditBtn').addEventListener('click', () => np._showNoteEditMode());
el.querySelector('#notePreviewBtn').addEventListener('click', () => np._showNotePreview());
el.querySelector('#noteCancelEditBtn').addEventListener('click', () => {
if (np._currentNote) { np._populateEditFields(np._currentNote); np._showNoteReadMode(); }
else np.showNotesList();
});
el.querySelector('#notesFolderFilter').addEventListener('change', (e) => {
np.$('#notesSearchInput').value = '';
np._exitSelectMode();
np.loadNotesList(e.target.value);
});
el.querySelector('#notesSortSelect').addEventListener('change', (e) => {
np._notesSort = e.target.value;
np._exitSelectMode();
np.loadNotesList();
});
el.querySelector('#notesSelectAll').addEventListener('change', (e) => {
np._toggleSelectAll(e.target.checked);
});
el.querySelector('#notesDeleteSelectedBtn').addEventListener('click', () => np._bulkDeleteSelected());
el.querySelector('#notesCancelSelectBtn').addEventListener('click', () => np._exitSelectMode());
let _notesSearchTimer;
el.querySelector('#notesSearchInput').addEventListener('input', (e) => {
clearTimeout(_notesSearchTimer);
const q = e.target.value.trim();
_notesSearchTimer = setTimeout(() => {
np._exitSelectMode();
if (q.length >= 2) {
np.$('#notesFolderFilter').value = '';
np.loadNotesList(null, q);
} else if (q.length === 0) {
np.loadNotesList();
}
}, 300);
});
// ── Register with PanelRegistry ──
PanelRegistry.register('notes', {
element: el,
element: wrapper,
label: 'Notes',
onOpen() {
np.loadNotesList();

View File

@@ -217,6 +217,201 @@ const Switchboard = {
},
};
// ── UI Factories ─────────────────────────────────────
// Composable building blocks for surfaces and packages.
// Every factory returns a plain object with the DOM element + control methods.
/**
* Create a flyout menu (dropdown with action items).
* @param {HTMLElement} anchor — element the menu attaches to
* @param {object} opts — { items: [{label, icon?, danger?, onClick}], position: 'down'|'up' }
* @returns {{ el, open, close, toggle, destroy }}
*/
sw.menu = function (anchor, opts) {
const _opts = opts || {};
const items = _opts.items || [];
const pos = _opts.position || 'down';
const wrap = document.createElement('div');
wrap.className = 'sw-menu-wrap';
wrap.style.position = 'relative';
const flyout = document.createElement('div');
flyout.className = 'sw-menu-flyout';
flyout.dataset.position = pos;
items.forEach(item => {
if (item.divider) {
const hr = document.createElement('hr');
hr.className = 'flyout-divider';
flyout.appendChild(hr);
return;
}
const btn = document.createElement('button');
btn.className = 'flyout-item' + (item.danger ? ' flyout-danger' : '');
btn.innerHTML = (item.icon || '') + '<span>' + (typeof esc === 'function' ? esc(item.label) : item.label) + '</span>';
btn.addEventListener('click', () => {
result.close();
if (item.onClick) item.onClick();
});
flyout.appendChild(btn);
});
wrap.appendChild(flyout);
anchor.parentElement.insertBefore(wrap, anchor.nextSibling);
wrap.insertBefore(anchor, flyout);
let _open = false;
const result = {
el: wrap,
flyoutEl: flyout,
open() { flyout.classList.add('open'); _open = true; },
close() { flyout.classList.remove('open'); _open = false; },
toggle() { _open ? result.close() : result.open(); },
isOpen() { return _open; },
destroy() { wrap.remove(); },
};
anchor.addEventListener('click', (e) => { e.stopPropagation(); result.toggle(); });
document.addEventListener('click', (e) => { if (_open && !e.target.closest('.sw-menu-wrap')) result.close(); });
return result;
};
/**
* Create a dropdown selector.
* @param {HTMLElement} container — mount target
* @param {object} opts — { items: [{value, label}], value?, onChange?, placeholder? }
* @returns {{ el, getValue, setValue, setItems, destroy }}
*/
sw.dropdown = function (container, opts) {
const _opts = opts || {};
const el = document.createElement('select');
el.className = 'sw-dropdown';
function _render(items) {
el.innerHTML = '';
if (_opts.placeholder) {
const ph = document.createElement('option');
ph.value = '';
ph.textContent = _opts.placeholder;
ph.disabled = true;
ph.selected = !_opts.value;
el.appendChild(ph);
}
(items || []).forEach(item => {
const opt = document.createElement('option');
opt.value = item.value;
opt.textContent = item.label || item.value;
if (item.value === _opts.value) opt.selected = true;
el.appendChild(opt);
});
}
_render(_opts.items);
container.appendChild(el);
if (_opts.onChange) el.addEventListener('change', () => _opts.onChange(el.value));
return {
el,
getValue() { return el.value; },
setValue(v) { el.value = v; },
setItems(items) { const cur = el.value; _render(items); el.value = cur; },
destroy() { el.remove(); },
};
};
/**
* Create a toolbar (horizontal button row).
* @param {HTMLElement} container — mount target
* @param {object} opts — { items: [{label, icon?, title?, onClick?, id?}], class? }
* @returns {{ el, getButton, destroy }}
*/
sw.toolbar = function (container, opts) {
const _opts = opts || {};
const el = document.createElement('div');
el.className = 'sw-toolbar' + (_opts.class ? ' ' + _opts.class : '');
const buttons = new Map();
(_opts.items || []).forEach(item => {
const btn = document.createElement('button');
btn.className = 'btn-small';
btn.title = item.title || item.label || '';
btn.innerHTML = (item.icon || '') + (item.label || '');
if (item.onClick) btn.addEventListener('click', item.onClick);
if (item.id) buttons.set(item.id, btn);
el.appendChild(btn);
});
container.appendChild(el);
return {
el,
getButton(id) { return buttons.get(id); },
destroy() { el.remove(); },
};
};
/**
* Create a tabbed content container.
* @param {HTMLElement} container — mount target
* @param {object} opts — { tabs: [{id, label}], activeTab?, onActivate? }
* @returns {{ el, activateTab, getPanel, activeTab, destroy }}
*/
sw.tabs = function (container, opts) {
const _opts = opts || {};
const tabDefs = _opts.tabs || [];
let _active = _opts.activeTab || (tabDefs[0]?.id);
const el = document.createElement('div');
el.className = 'sw-tabs';
const bar = document.createElement('div');
bar.className = 'sw-tabs-bar';
el.appendChild(bar);
const content = document.createElement('div');
content.className = 'sw-tabs-content';
el.appendChild(content);
const tabMap = new Map();
tabDefs.forEach(td => {
const btn = document.createElement('button');
btn.className = 'sw-tab-btn' + (td.id === _active ? ' sw-tab-btn--active' : '');
btn.textContent = td.label;
btn.addEventListener('click', () => result.activateTab(td.id));
bar.appendChild(btn);
const panel = document.createElement('div');
panel.className = 'sw-tab-panel';
panel.style.display = td.id === _active ? '' : 'none';
content.appendChild(panel);
tabMap.set(td.id, { btn, panel });
});
container.appendChild(el);
const result = {
el,
activateTab(id) {
_active = id;
tabMap.forEach((t, tid) => {
t.btn.classList.toggle('sw-tab-btn--active', tid === id);
t.panel.style.display = tid === id ? '' : 'none';
});
if (_opts.onActivate) _opts.onActivate(id);
},
getPanel(id) { return tabMap.get(id)?.panel || null; },
get activeTab() { return _active; },
destroy() { el.remove(); },
};
return result;
};
// ── Components ───────────────────────────────────────
/**
@@ -232,34 +427,15 @@ const Switchboard = {
console.error('[Switchboard] ChatPane not available');
return null;
}
const _opts = opts || {};
// Find or create required child elements
let messagesEl = container.querySelector('.sw-chat-messages');
let inputEl = container.querySelector('.sw-chat-input');
if (!messagesEl) {
messagesEl = document.createElement('div');
messagesEl.className = 'sw-chat-messages chat-messages';
container.appendChild(messagesEl);
}
if (!inputEl) {
inputEl = document.createElement('div');
inputEl.className = 'sw-chat-input';
container.appendChild(inputEl);
}
return ChatPane.create({
messagesEl,
inputEl,
channelId: _opts.channelId || null,
standalone: _opts.standalone !== false,
});
return ChatPane.mount(container, opts || {});
};
/**
* Create a NotePanel instance in a container element.
* Wraps NotePanel.create() with SDK-aware defaults.
* Mount a NotePanel instance into a container element.
* Builds DOM + creates + wires events in one call.
*
* @param {HTMLElement} container — mount target
* @param {object} opts — { projectId }
* @param {object} opts — { projectId, onOpenGraph }
* @returns {object} NotePanel instance (loadNotesList, openNoteEditor, destroy, etc.)
*/
sw.notes = function (container, opts) {
@@ -267,11 +443,7 @@ const Switchboard = {
console.error('[Switchboard] NotePanel not available');
return null;
}
const _opts = opts || {};
return NotePanel.create({
container,
projectId: _opts.projectId || null,
});
return NotePanel.mount(container, opts || {});
};
/**
@@ -296,6 +468,49 @@ const Switchboard = {
};
};
/**
* Mount a FileTree into a container.
* @param {HTMLElement} container — mount target
* @param {object} opts — { id, workspaceId, onSelect, onDelete, onNewFile }
* @returns {object|null} FileTree instance
*/
sw.fileTree = function (container, opts) {
if (typeof FileTree === 'undefined') {
console.error('[Switchboard] FileTree not available');
return null;
}
return FileTree.mount(container, opts || {});
};
/**
* Mount a CodeEditor into a container.
* @param {HTMLElement} container — mount target
* @param {object} opts — { id, workspaceId, onSave, onActivate }
* @returns {object|null} CodeEditor instance
*/
sw.codeEditor = function (container, opts) {
if (typeof CodeEditor === 'undefined') {
console.error('[Switchboard] CodeEditor not available');
return null;
}
return CodeEditor.mount(container, opts || {});
};
/**
* Mount a PaneContainer layout into a container.
* @param {HTMLElement} container — mount target
* @param {string} layoutId — layout preset name (e.g. 'editor')
* @param {object} opts — { workspaceId }
* @returns {object|null} PaneContainer layout instance
*/
sw.layout = function (container, layoutId, opts) {
if (typeof PaneContainer === 'undefined') {
console.error('[Switchboard] PaneContainer not available');
return null;
}
return PaneContainer.mount(container, layoutId, opts || {});
};
// ── Workflow ─────────────────────────────────────────
// v0.30.2: Workflow stage surface management.
@@ -524,36 +739,65 @@ const Switchboard = {
_runRender(ctx) { return _runChain('render', ctx); },
};
// ── UserMenu Hydration ───────────────────────────────
// Absorbs the cs15 band-aid from base.html. Every surface
// gets UserMenu wired up through the SDK, not through an
// inline <script> block that duplicates logic.
// ── UserMenu ─────────────────────────────────────────
const _defaultMenuHandlers = {
onSettings: () => { window.location.href = (window.__BASE__ || '') + '/settings'; },
onAdmin: () => { window.location.href = (window.__BASE__ || '') + '/admin'; },
onDebug: () => { sb.call('openDebugModal'); },
onSignout: () => { sb.call('handleLogout'); },
};
/**
* Mount a UserMenu into a container.
* @param {HTMLElement} container — mount target
* @param {object} [opts] — { flyout: 'down'|'up', compact, user, handlers }
* @returns {object} UserMenu instance
*/
sw.userMenu = function (container, opts) {
if (typeof UserMenu === 'undefined') {
console.error('[Switchboard] UserMenu not available');
return null;
}
const _opts = Object.assign({
user: window.__USER__ || null,
handlers: _defaultMenuHandlers,
}, opts || {});
const instance = UserMenu.mount(container, _opts);
if (!UserMenu.primary) UserMenu.primary = instance;
return instance;
};
/**
* Hydrate server-rendered UserMenu (non-chat surfaces).
* Falls back to mount() if server-rendered DOM not found.
*/
function _hydrateUserMenu() {
if (typeof UserMenu === 'undefined') return;
if (UserMenu.primary) return; // already created by a surface
if (UserMenu.primary) return;
// Chat surface manages its own UserMenu via UI.toggleUserMenu()
const surface = window.__SURFACE__ || '';
if (surface === 'chat') return;
// Extension surfaces: package owns its own menu via sw.userMenu().
// Hide the server-rendered placeholder so the package can mount fresh.
const isExtension = document.querySelector('.extension-surface') !== null;
if (isExtension) {
const placeholder = document.getElementById('userMenuWrap');
if (placeholder) placeholder.style.display = 'none';
return;
}
// Non-extension surfaces (admin, settings, notes): hydrate server-rendered DOM
const btn = document.getElementById('userMenuBtn');
if (!btn) return;
const menu = UserMenu.create({ id: '' });
UserMenu.primary = menu;
const user = window.__USER__ || {};
menu.setUser(user);
menu.showAdmin(user.role === 'admin');
const BASE = window.__BASE__ || '';
menu.bind({
onSettings: () => { window.location.href = BASE + '/settings'; },
onAdmin: () => { window.location.href = BASE + '/admin'; },
onDebug: () => { sb.call('openDebugModal'); },
onSignout: () => { sb.call('handleLogout'); },
});
if (btn) {
const menu = UserMenu.create({ id: '' });
UserMenu.primary = menu;
const user = window.__USER__ || {};
menu.setUser(user);
menu.showAdmin(user.role === 'admin');
menu.bind(_defaultMenuHandlers);
}
}
// ── Perform Init ─────────────────────────────────────

View File

@@ -135,5 +135,62 @@ const UserMenu = {
},
};
// Self-mount: creates DOM + create + bind in one call.
// opts.flyout: 'down' (default) or 'up' (sidebar-style)
// opts.compact: hide name label
// opts.user: user object for setUser()
// opts.handlers: { onSettings, onAdmin, onDebug, onSignout }
UserMenu.mount = function (container, opts) {
const _opts = opts || {};
const pfx = _opts.id || 'um' + Date.now().toString(36).slice(-4);
const flyout = _opts.flyout || 'down';
const el = document.createElement('div');
el.className = 'user-menu-wrap';
el.id = pfx + 'userMenuWrap';
el.dataset.flyout = flyout;
el.innerHTML =
'<button id="' + pfx + 'userMenuBtn" class="user-btn">' +
'<div id="' + pfx + 'userAvatar" class="user-avatar">' +
'<span id="' + pfx + 'avatarLetter">?</span>' +
'</div>' +
'<span id="' + pfx + 'userName" class="sb-label"' + (_opts.compact ? ' style="display:none"' : '') + '>User</span>' +
'</button>' +
'<div id="' + pfx + 'userFlyout" class="user-flyout">' +
'<button id="' + pfx + 'menuSettings" class="flyout-item">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.32 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>' +
'<span>Settings</span>' +
'</button>' +
'<button id="' + pfx + 'menuAdmin" class="flyout-item" style="display:none;">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>' +
'<span>Admin</span>' +
'</button>' +
'<button id="' + pfx + 'menuTeamAdmin" class="flyout-item" style="display:none;">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>' +
'<span>Team Admin</span>' +
'</button>' +
'<button id="' + pfx + 'menuDebug" class="flyout-item" style="display:none;">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"/><path d="M12 6V12L16 14"/></svg>' +
'<span>Debug Log</span>' +
'</button>' +
'<hr class="flyout-divider">' +
'<button id="' + pfx + 'menuSignout" class="flyout-item flyout-danger">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>' +
'<span>Sign Out</span>' +
'</button>' +
'</div>';
container.appendChild(el);
const instance = this.create({ id: pfx });
instance.bind(_opts.handlers || {});
if (_opts.user) {
instance.setUser(_opts.user);
instance.showAdmin(_opts.user.role === 'admin');
}
return instance;
};
// ── Exports ─────────────────────────────────
sb.ns('UserMenu', UserMenu);