// ========================================== // Chat Switchboard — Editor Package (v0.31.0) // ========================================== // Installable .pkg that provides the code editor surface. // Mounts into #extension-mount (surface-extension template). // // Uses Component.mount() for all sub-components — single source // of truth for DOM structure. No duplicated template partials. // // Dependencies (loaded by base.html): // FileTree, CodeEditor, ChatPane, NotePanel, note-graph, PaneContainer // API, UI, App, sw, esc (ui-primitives) // // Dynamically loaded: // codemirror.bundle.js // ========================================== (function () { 'use strict'; const SURFACE_ID = 'editor'; const PREFIX = 'ed'; const NOTES_PREFIX = 'edNotes'; const STATE_DEBOUNCE_MS = 2000; if (window.__SURFACE__ !== SURFACE_ID) return; const base = window.__BASE__ || ''; // ── Dynamic Script Loader ──────────────────── function _loadScript(src) { return new Promise((resolve, reject) => { if (document.querySelector('script[src*="' + src.split('?')[0] + '"]')) { resolve(); return; } const s = document.createElement('script'); s.src = base + src; s.type = 'module'; s.onload = resolve; s.onerror = () => { resolve(); }; // Non-fatal document.head.appendChild(s); }); } // ── Init ───────────────────────────────────── async function _init() { const mount = document.getElementById('extension-mount'); if (!mount) return; // Hide server-rendered user menu (we mount our own in the topbar) const serverMenu = document.getElementById('userMenuWrap'); if (serverMenu) serverMenu.style.display = 'none'; // Wrap in surface container for CSS scoping const surface = document.createElement('div'); surface.className = 'surface-editor'; surface.id = 'editorSurface'; mount.appendChild(surface); // Read workspace ID from query param const params = new URL(window.location.href).searchParams; const wsId = params.get('ws') || ''; // Load workspace name let wsName = 'Editor'; if (wsId && typeof API !== 'undefined') { try { const ws = await API._get('/api/v1/workspaces/' + wsId); wsName = ws?.name || ws?.data?.name || wsName; } catch (_) {} } // Build topbar const topbar = _buildTopbar(wsName); surface.appendChild(topbar); // Mount user menu via SDK (flyout drops down from topbar) if (typeof sw !== 'undefined' && sw.userMenu) { sw.userMenu(topbar, { flyout: 'down' }); } // Build body + bootstrap const body = document.createElement('div'); body.className = 'editor-body'; body.id = 'editorBody'; surface.appendChild(body); const bootstrap = _buildBootstrap(); surface.appendChild(bootstrap); // Load dynamic dependencies (codemirror only — ChatPane, NotePanel, note-graph are platform scripts in base.html) const ver = window.__VERSION__ || ''; const verQ = ver ? '?v=' + ver : ''; await _loadScript('/vendor/codemirror/codemirror.bundle.js' + verQ); _initWsSelector(wsId); if (!wsId) { body.style.display = 'none'; bootstrap.style.display = ''; _loadBootstrapList(); _initBootstrapCreate(); return; } _mountEditor(wsId, wsName); } // ── Topbar ────────────────────────────────── function _buildTopbar(wsName) { const el = document.createElement('div'); el.className = 'editor-topbar'; el.id = 'editorTopbar'; el.innerHTML = '' + '' + 'Back' + '' + '
' + '' + '
' + '' + '
' + '
' + '
' + '' + '
' + '
' + '' + '
' + ''; return el; } // ── Bootstrap (no workspace) ──────────────── function _buildBootstrap() { const el = document.createElement('div'); el.className = 'editor-bootstrap'; el.id = 'editorBootstrap'; el.style.display = 'none'; el.innerHTML = '
' + '' + '' + '' + '

Open a Workspace

' + '
' + '
Loading workspaces\u2026
' + '
' + '
' + '
' + 'or create new' + '
' + '
' + '' + '' + '
'; return el; } // ── 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(); if (dropdown.classList.toggle('open')) _loadWsDropdown(currentWsId); }); document.addEventListener('click', (e) => { if (!e.target.closest('#editorWsSelector')) dropdown.classList.remove('open'); }); 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 = base + '/s/editor?ws=' + 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 = '
Loading\u2026
'; try { const resp = await API._get('/api/v1/workspaces'); const workspaces = resp.data || resp || []; listEl.innerHTML = ''; if (!workspaces.length) { listEl.innerHTML = '
No workspaces
'; 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 = base + '/s/editor?ws=' + ws.id; }); listEl.appendChild(item); }); } catch (_) { listEl.innerHTML = '
Failed to load
'; } } // ── Bootstrap ──────────────────────────────── 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) { listEl.innerHTML = '
No workspaces yet
'; return; } listEl.innerHTML = ''; workspaces.forEach(ws => { const item = document.createElement('button'); item.className = 'editor-bootstrap-ws-item'; item.innerHTML = '' + esc(ws.name || ws.id?.slice(0, 8)) + '' + '' + esc(ws.created_at ? new Date(ws.created_at).toLocaleDateString() : '') + ''; item.addEventListener('click', () => { window.location.href = base + '/s/editor?ws=' + ws.id; }); listEl.appendChild(item); }); } catch (_) { listEl.innerHTML = '
Failed to load workspaces
'; } } function _initBootstrapCreate() { 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\u2026'; 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'); window.location.href = base + '/s/editor?ws=' + newId; } catch (e) { btn.disabled = false; btn.textContent = 'Create Workspace'; if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error'); } }); } // ── Mount Pane Layout ─────────────────────── function _mountEditor(wsId, wsName) { const body = document.getElementById('editorBody'); if (!body) return; // ── Layout via SDK ── const layout = sw.layout(body, 'editor', { workspaceId: wsId }); if (!layout) return; const filesPaneEl = layout._panes.get('files')?.el; const editorPaneEl = layout._panes.get('editor')?.el; const assistPaneInfo = layout._panes.get('assist'); // ── FileTree via SDK ── let fileTree; if (filesPaneEl) { fileTree = sw.fileTree(filesPaneEl, { id: PREFIX, workspaceId: wsId, onSelect: (path) => { codeEditor?.openFile(path); _saveState(wsId, codeEditor); }, onDelete: (path) => _deleteFile(wsId, path, fileTree, codeEditor), onNewFile: () => _createNewFile(wsId, fileTree), }); layout._panes.get('files').component = fileTree; } // ── CodeEditor via SDK ── let codeEditor; if (editorPaneEl) { codeEditor = sw.codeEditor(editorPaneEl, { id: PREFIX, workspaceId: wsId, onSave: () => fileTree?.refresh(), onActivate: (path) => { fileTree?.setActiveFile(path); _saveState(wsId, codeEditor); }, }); layout._panes.get('editor').component = codeEditor; } // ── Assist pane (tabbed: chat + notes) via SDK ── if (assistPaneInfo?.tabs) { // Chat tab const chatPanel = assistPaneInfo.getTabPanel('chat'); if (chatPanel) { const chatPane = sw.chat(chatPanel, { id: PREFIX, standalone: true }); if (chatPane) { chatPane.showWelcome(); _initAssistChat(chatPane, codeEditor); const chatTab = assistPaneInfo.tabs.find(t => t.id === 'chat'); if (chatTab) chatTab.instance = chatPane; } } // Notes tab const notesPanel = assistPaneInfo.getTabPanel('notes'); if (notesPanel) { const notePanel = sw.notes(notesPanel, { projectId: null }); if (notePanel) { const notesTab = assistPaneInfo.tabs.find(t => t.id === 'notes'); if (notesTab) { notesTab.instance = notePanel; const _loadNotes = () => { if (!notesTab._loaded) { notesTab._loaded = true; notePanel.loadNotesList(); notePanel.loadNoteFolders(); } }; notesTab.btn.addEventListener('click', _loadNotes); if (notesTab._activated) _loadNotes(); } } } } // Git branch _refreshGitBranch(wsId, codeEditor); // Toolbar document.getElementById('editorRefreshBtn')?.addEventListener('click', () => { fileTree?.refresh(); _refreshGitBranch(wsId, codeEditor); }); // Initial load fileTree?.refresh(); _restoreState(wsId, codeEditor); console.log('[EditorPkg] 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'); } } 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 (_) {} } // ── Assist Chat ───────────────────────────── function _initAssistChat(chatPane, codeEditor) { const inputEl = chatPane.inputEl; const sendBtn = chatPane.sendBtnEl; const headerEl = document.getElementById(PREFIX + 'ChatHeader'); const selectEl = document.getElementById(PREFIX + 'ChatSelect'); const newBtnEl = document.getElementById(PREFIX + 'ChatNewBtn'); const modelSelEl = document.getElementById(PREFIX + 'ModelSel'); if (!inputEl) return; if (headerEl) headerEl.style.display = ''; let _channelId = null, _messages = [], _sending = false, _abortController = null; let _selectedModel = App.settings?.model || ''; async function _initModelSelector() { if (!modelSelEl) 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(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', () => { _selectedModel = sel.value; }); modelSelEl.innerHTML = ''; modelSelEl.appendChild(sel); } async function _loadChatHistory() { if (!selectEl) return; try { const resp = await API.listChannels(1, 20, 'direct'); const channels = resp.data || resp || []; selectEl.innerHTML = ''; 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 (_) {} } if (selectEl) selectEl.addEventListener('change', () => { selectEl.value ? _switchToChannel(selectEl.value) : _newChat(); }); if (newBtnEl) newBtnEl.addEventListener('click', _newChat); 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'); _messages = resp.data || resp || []; 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 = ''; } const ta = document.createElement('textarea'); ta.placeholder = 'Ask about your code\u2026'; ta.rows = 1; inputEl.appendChild(ta); ta.addEventListener('input', () => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 160) + 'px'; }); 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(); const fileCtx = _getFileContext(codeEditor); 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(); } }); async function _handleStream(response, pane) { 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 }); } 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 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); chatPane.messagesEl.appendChild(div); }); chatPane.scrollToBottom(); } function _appendSystemMsg(text) { const div = document.createElement('div'); div.className = 'chat-msg chat-msg--system'; div.innerHTML = '
' + esc(text) + '
'; chatPane.messagesEl?.appendChild(div); chatPane.scrollToBottom(); } function _getFileContext(editor) { if (!editor) return null; try { const openFiles = editor.getOpenFiles(); if (!openFiles.length) return null; const activeTab = document.querySelector('.code-editor-tab.active'); const path = activeTab?.dataset?.path || openFiles[0]; const inst = CodeEditor._instances?.get(PREFIX); if (inst?._files) { const file = inst._files.get(path); if (file?.view) return { path, content: file.view.state.doc.toString().slice(0, 4000) }; } } catch (_) {} return null; } function _deferredInit() { _initModelSelector(); _loadChatHistory(); } if (App.models?.length) _deferredInit(); else document.addEventListener('sb:ready', _deferredInit, { once: true }); } // ── State Persistence (localStorage) ───────── const STATE_KEY_PREFIX = 'sb:editor:state:'; let _stateSaveTimer = null; function _stateKey(wsId) { return STATE_KEY_PREFIX + (sw?.user?.id || '') + ':' + wsId; } function _restoreState(wsId, codeEditor) { if (!wsId) return; try { const raw = localStorage.getItem(_stateKey(wsId)); if (!raw) return; const state = JSON.parse(raw); if (state.open_tabs && Array.isArray(state.open_tabs)) { for (const path of state.open_tabs) codeEditor.openFile(path); if (state.active_tab) codeEditor.activateFile(state.active_tab); } } catch (_) {} } function _saveState(wsId, codeEditor) { if (_stateSaveTimer) clearTimeout(_stateSaveTimer); _stateSaveTimer = setTimeout(() => { if (!wsId) return; try { const openFiles = codeEditor.getOpenFiles(); const activeTab = document.querySelector('.code-editor-tab.active'); localStorage.setItem(_stateKey(wsId), JSON.stringify({ open_tabs: openFiles, active_tab: activeTab?.dataset?.path || '', updated_at: new Date().toISOString(), })); } catch (_) {} }, STATE_DEBOUNCE_MS); } // ── Boot ───────────────────────────────────── document.addEventListener('DOMContentLoaded', _init); })();