diff --git a/CHANGELOG.md b/CHANGELOG.md index 14df1fc..05df59f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,58 @@ # Changelog +## [0.37.12] — 2026-03-22 + +### Summary + +Scorched Earth II — second dead-code purge. 23 files deleted (~2,600 lines). +All six core surfaces are confirmed Preact-only; old vanilla JS, CSS, and +admin templates removed. Service worker cache manifest overhauled. + +### Removed + +- `src/js/ui-format.js` — old markdown renderer (replaced by `sw/components/chat-pane/markdown.js`) +- `src/js/virtual-scroll.js` — only consumer was deleted `chat.js` +- `src/js/model-selector.js` — Preact ChatPane has its own `ModelSelector` +- `src/js/drag-resize.js` — only consumer was deleted `PaneContainer` +- `src/js/pages-splash.js` — login is Preact since v0.37.5 +- `src/js/task-sidebar.js` — no template loads this +- `src/js/workflow-api.js` — new SDK has `sw.api.workflows.*` +- `src/js/workflow-queue.js` — references deleted `App.chats` +- `src/js/workflow-monitor.js` — ES module export, never imported +- `src/css/splash.css` — replaced by `sw-login.css` +- `src/css/styles.css` — 13-line decomposition comment from v0.22.9 +- `src/css/persona-kb.css` — old admin/settings UI +- `src/css/notification-prefs.css` — old settings UI +- `src/css/memory.css` — old admin/settings UI +- `src/css/channel-models.css` — old chat autocomplete +- `server/pages/templates/admin/` — 8 dead templates (overview, users, + models, providers, settings, teams, roles, routing); admin is Preact + since v0.37.6 + +### Changed + +- `server/pages/templates/base.html` — removed 7 script/link tags: + `splash.css`, `marked.min.js`, `purify.min.js`, `ui-format.js`, + `virtual-scroll.js`, `model-selector.js`, `drag-resize.js`. + `marked`/`purify` now loaded only by surfaces that need them (chat, notes). +- `src/sw.js` — replaced entire `SHELL_FILES` array; removed 17 stale + entries referencing files deleted in v0.37.10, added all `sw-*.css` files +- `server/pages/pages.go` — removed `templates/admin/*.html` from + `//go:embed` directive and `ParseFS` call + +### Design Notes + +- **11 old JS files survive:** `sb.js`, `app-state.js`, `events.js`, + `ui-primitives.js`, `user-menu.js`, `file-tree.js`, `code-editor.js`, + `switchboard-sdk.js`, `debug.js`, `repl.js`, `workflow-surfaces.js`. + These provide globals consumed by extension surfaces (`Events.on()`, + `Theme.resolved()`, `sw.fileTree()`) and the debug modal (`sb.call()`). + Targeted for removal when extensions migrate to Preact SDK. +- **`workflow-surfaces.js` deferred:** still loaded by `workflow.html` + standalone page; removed when workflow surfaces rebuilt (v0.37.13). + +--- + ## [0.37.11] — 2026-03-22 ### Summary diff --git a/VERSION b/VERSION index 6979856..f0e92bc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.37.11 +0.37.12 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 36b2203..aea58a9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -48,8 +48,8 @@ v0.9.x–v0.28.7 Foundation through Platform Polish ✅ │ v0.37.2 Primitives ✅ │ │ v0.37.3 SDK │ │ v0.37.4 Shell │ - │ v0.37.5–11 Surfaces ✅ │ - │ v0.37.12–18 Surfaces │ + │ v0.37.5–12 Surfaces ✅ │ + │ v0.37.13–18 Surfaces │ │ │ │ ══════╪════════════╪═════════════════╪══════ │ MVP v0.50.0 │ @@ -619,11 +619,10 @@ Depends on: v0.37.4. - [x] `sw-login.css` extracted from inline styles **Known issues (tracked in CHANGELOG, resolve before v0.37.# tag):** -- Bridge sections Workflows + Git Keys stuck on "Loading..." (old JS - depends on globals not loaded in new surface) -- `ui-settings.js` + `settings-handlers.js` not yet deleted (some - bridge sections may reference helpers) - BYOK sections not tested with policy enabled +- ~~Bridge sections~~ Resolved in v0.37.7 (all settings sections native Preact) +- ~~`ui-settings.js` + `settings-handlers.js`~~ Confirmed deleted, stale `sw.js` + references cleaned in v0.37.12 ### v0.37.6–v0.37.18 — Remaining Surface Rebuilds @@ -637,7 +636,7 @@ Each surface rebuilt as Preact component tree. Old JS deleted per surface. | v0.37.9 | NotesPane ✅ | Obsidian-grade kit (10 files), old notes code scorched (~1,630 lines) | | v0.37.10 | Chat surface ✅ | Scorched earth: 21 files deleted (−12,841 net), Preact surface + ChatPane kit upgrades | | v0.37.11 | Notes surface ✅ | Preact surface (7 JS + 1 CSS), sidebar folders/tags, UserMenu, template rewrite, Menu primitive fix | -| v0.37.12 | Extension surface container | `.pkg` mount point | +| v0.37.12 | Scorched Earth II ✅ | 23 files deleted (~2,600 lines): 9 JS, 6 CSS, 8 admin templates; sw.js + base.html cleaned | | v0.37.13 | Workflow surfaces | Form renderer, review views | | v0.37.14 | Pane audit | Collaborative walkthrough: each pane checked for form and function | | v0.37.15 | Projects surface | Project views, note/channel associations | @@ -673,6 +672,29 @@ Polish (deferred to v0.37.18 tag): - [ ] Note word count goal (optional target with progress bar) - [ ] Server-side favorites via note metadata (cross-device pinning) +**v0.37.12 — Scorched Earth II ✅:** + +Second dead-code purge. All six core surfaces confirmed Preact-only — old +vanilla JS, CSS, and admin templates that survived v0.37.10 are now removed. +Service worker cache manifest overhauled (17 stale entries → clean). + +Deleted (23 files): +- [x] 9 JS: `ui-format.js`, `virtual-scroll.js`, `model-selector.js`, + `drag-resize.js`, `pages-splash.js`, `task-sidebar.js`, + `workflow-api.js`, `workflow-queue.js`, `workflow-monitor.js` +- [x] 6 CSS: `splash.css`, `styles.css`, `persona-kb.css`, + `notification-prefs.css`, `memory.css`, `channel-models.css` +- [x] 8 admin templates: `server/pages/templates/admin/` directory +- [x] 7 script/link tags removed from `base.html` (includes + `marked.min.js` + `purify.min.js` — surfaces load their own copies) +- [x] `sw.js` SHELL_FILES array replaced with actual file inventory +- [x] `pages.go` admin template embed directive removed + +Kept (11 JS files — extension surface + debug dependencies): +`sb.js`, `app-state.js`, `events.js`, `ui-primitives.js`, `user-menu.js`, +`file-tree.js`, `code-editor.js`, `switchboard-sdk.js`, `debug.js`, +`repl.js`, `workflow-surfaces.js` + **v0.37.14 — Pane Audit:** Collaborative walkthrough of each pane (ChatPane, NotesPane) and surface diff --git a/server/pages/pages.go b/server/pages/pages.go index 6e1bc51..27623d1 100644 --- a/server/pages/pages.go +++ b/server/pages/pages.go @@ -6,7 +6,6 @@ // templates/login.html — standalone login page // templates/components/*.html — reusable partials (model-select, team-select, chat-pane, etc.) // templates/surfaces/*.html — one per surface (chat, editor, notes, admin, etc.) -// templates/admin/*.html — admin sub-pages (roles, routing, providers, etc.) package pages import ( @@ -29,7 +28,7 @@ import ( "chat-switchboard/store" ) -//go:embed templates/*.html templates/components/*.html templates/surfaces/*.html templates/admin/*.html +//go:embed templates/*.html templates/components/*.html templates/surfaces/*.html var templateFS embed.FS // Engine holds parsed templates and rendering state. @@ -271,7 +270,6 @@ func (e *Engine) parseTemplates() { "templates/*.html", "templates/components/*.html", "templates/surfaces/*.html", - "templates/admin/*.html", ) if err != nil { log.Fatalf("❌ Failed to parse templates: %v", err) diff --git a/server/pages/templates/admin/models.html b/server/pages/templates/admin/models.html deleted file mode 100644 index e596a25..0000000 --- a/server/pages/templates/admin/models.html +++ /dev/null @@ -1,54 +0,0 @@ -{{/* Admin models — model catalog table. */}} - -{{define "admin-models"}} -
All models synced from providers. Toggle visibility to control what users can access.
- - - - {{if .Data.Models}} -| Model | -Provider | -Type | -Capabilities | -- |
|---|---|---|---|---|
|
- {{.DisplayName}}
- {{if ne .DisplayName .ModelID}} {{.ModelID}}{{end}} - |
- {{.ProviderName}} | -{{.ModelType}} | -— | -- |
System administration. Select a section from the navigation.
-AI provider configurations. Each provider connects to an API endpoint.
- - - - - - {{if .Data.Providers}} -| Name | -Type | -Scope | -Models | -Endpoint | -- |
|---|---|---|---|---|---|
| {{.Name}} | -{{.Provider}} | -{{.Scope}} | -{{.ModelCount}} | - -- - - | -
Configure which models serve background tasks. Each role has a primary and optional fallback.
- - {{$providers := .Data.Providers}} - {{$models := .Data.Models}} - - {{range .Data.Roles}} -- {{if eq .Name "utility"}}Summarization, classification, routing — typically a fast, cheap chat model.{{end}} - {{if eq .Name "embedding"}}Vector generation for knowledge base search and semantic note search.{{end}} -
- - {{$roleName := .Name}} - {{$filterType := roleFilterType .Name}} - {{$primaryProvID := ""}} - {{$primaryModelID := ""}} - {{$fallbackProvID := ""}} - {{$fallbackModelID := ""}} - {{if .Primary}} - {{$primaryProvID = .Primary.ProviderID}} - {{$primaryModelID = .Primary.ModelID}} - {{end}} - {{if .Fallback}} - {{$fallbackProvID = .Fallback.ProviderID}} - {{$fallbackModelID = .Fallback.ModelID}} - {{end}} - -Define rules for model selection, cost limits, and team-specific routing.
- -Core system configuration. Changes take effect immediately.
- - {{/* ── Registration ───────────────────── */}} -Organize users into teams for scoped access to providers, personas, and policies.
- - - - - - {{if .Data.Teams}} -| Name | -Description | -Members | -Status | -- |
|---|---|---|---|---|
| {{.Name}} | -{{.Description}} | -{{.MemberCount}} | -{{if .IsActive}}active{{else}}inactive{{end}} | -- - | -
Manage user accounts, roles, and access.
- - - - {{if .Data.Users}} -| Username | -Display Name | -Role | -Status | -- | |
|---|---|---|---|---|---|
| {{.Username}} | -{{.Email}} | -{{.DisplayName}} | -{{.Role}} | -{{if .IsActive}}active{{else}}inactive{{end}} | -- - {{if .IsActive}} - - {{else}} - - {{end}} - | -
\\s*THINK_PLACEHOLDER_${b.id}\\s*
`, 'g'), thinkHTML); - html = html.replace(`THINK_PLACEHOLDER_${b.id}`, thinkHTML); - } - - // @mention highlighting (v0.23.0): style @Name references as pills - html = _highlightMentions(html); - - return html; -} - -/** - * Highlight @mentions in rendered HTML as styled pills. - * Matches @Name patterns against the channel model roster. - * Only highlights inside text nodes (not inside code/pre/a tags). - */ -function _highlightMentions(html) { - if (typeof ChannelModels === 'undefined') return html; - const roster = ChannelModels.getRoster(); - - // Build patterns from roster handles and display names (AI mentions) - const aiPatterns = []; - const seen = new Set(); - - if (roster && roster.length >= 2) { - for (const m of roster) { - if (m.handle) { - const escaped = m.handle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - if (!seen.has(escaped)) { - aiPatterns.push(new RegExp('(@' + escaped + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi')); - seen.add(escaped); - } - } - if (m.display_name) { - const hyphenated = m.display_name.replace(/\s+/g, '-'); - const escaped = hyphenated.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - if (!seen.has(escaped.toLowerCase())) { - const flexible = escaped.replace(/[-]+/g, '[\\s-]+'); - aiPatterns.push(new RegExp('(@' + flexible + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi')); - seen.add(escaped.toLowerCase()); - } - } - } - } - - // @all is special - aiPatterns.push(new RegExp('(@all)(?=[\\s.,!?;:)\\]}<]|$)', 'gi')); - - // v0.23.2: Build user mention patterns - const userPatterns = []; - for (const u of (App.users || [])) { - const escaped = u.username.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - if (!seen.has(escaped.toLowerCase())) { - userPatterns.push(new RegExp('(@' + escaped + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi')); - seen.add(escaped.toLowerCase()); - } - } - - // Don't highlight inside, , tags
- const parts = html.split(/(<[^>]+>)/);
- let inCode = false;
- for (let i = 0; i < parts.length; i++) {
- const part = parts[i];
- if (part.startsWith('<')) {
- const tag = part.toLowerCase();
- if (tag.startsWith('$1');
- }
- // User mentions (different color)
- for (const re of userPatterns) {
- text = text.replace(re, '$1');
- }
- parts[i] = text;
- }
- return parts.join('');
-}
-
-function _formatMarked(text) {
- // ── Unwrap outer ```markdown wrappers ────────────────────────
- // LLMs frequently wrap entire responses in ```markdown ... ``` which is
- // semantically "this content IS markdown". Standard markdown doesn't support
- // nested fences with the same delimiter, so an inner ```mermaid ... ```
- // prematurely closes the outer block, splitting the content into broken
- // fragments. Fix: if the outer fence is ```markdown/```md and contains
- // nested fences, strip the wrapper and render contents as markdown directly.
- text = _unwrapMarkdownFence(text);
-
- // Close any unclosed code fences to prevent raw HTML injection during
- // streaming (closing ``` hasn't arrived yet) or when the model forgets
- // to close a fence. An odd number of ``` markers means one is unclosed.
- const fences = text.match(/^```/gm);
- if (fences && fences.length % 2 !== 0) {
- text += '\n```';
- }
-
- const rendered = marked.parse(text, { breaks: true, gfm: true });
- let html = DOMPurify.sanitize(rendered, {
- // Strict allowlist: only elements that marked.js actually produces.
- // This prevents ANY raw HTML (canvas, style, script, iframe, form, etc.)
- // from rendering live even if a code fence fails to parse.
- ALLOWED_TAGS: [
- // Block elements
- 'p', 'br', 'hr', 'pre', 'code', 'blockquote',
- 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
- // Lists
- 'ul', 'ol', 'li',
- // Tables
- 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
- // Inline formatting
- 'strong', 'em', 'b', 'i', 'u', 's', 'del', 'sub', 'sup',
- 'a', 'img', 'span', 'div',
- // Think blocks (injected post-sanitize, but placeholders may be in )
- 'details', 'summary',
- ],
- ALLOWED_ATTR: ['id', 'class', 'href', 'target', 'rel', 'src', 'alt', 'title',
- 'colspan', 'rowspan', 'align'],
- });
-
- // Process code blocks: add copy button, collapse toggle, HTML preview
- html = html.replace(/
]*)>([\s\S]*?)<\/code><\/pre>/g, (_, attrs, code) => {
- const codeId = 'code-' + Math.random().toString(36).slice(2, 9);
- const langMatch = attrs.match(/class="language-(\w+)"/);
- const lang = langMatch ? langMatch[1].toLowerCase() : '';
-
- // Extension hook: let extensions claim specific code blocks (mermaid, latex, etc.)
- // Extensions mark blocks with data-ext-render for post-render DOM processing.
- if (lang && typeof Extensions !== 'undefined' && Extensions.hasRenderers('block')) {
- try {
- const fakeContainer = document.createElement('div');
- const decoded = _decodeHTML(code);
- const handled = Extensions.runBlockRenderers(lang, decoded, fakeContainer);
- if (handled && fakeContainer.innerHTML) {
- const popBtn = ``;
- return `${popBtn}${fakeContainer.innerHTML}`;
- }
- if (handled && !fakeContainer.innerHTML) {
- console.warn(`[Extensions] Block renderer matched lang="${lang}" but produced empty output`);
- }
- } catch (e) {
- console.error(`[Extensions] Block renderer hook error for lang="${lang}":`, e);
- }
- }
-
- // Count lines for collapse decision
- const lineCount = (code.match(/\n/g) || []).length + 1;
- const COLLAPSE_THRESHOLD = 15;
- const shouldCollapse = lineCount > COLLAPSE_THRESHOLD;
-
- // Build toolbar buttons
- const collapseBtn = lineCount > 5
- ? ``
- : '';
-
- let toolbar = collapseBtn;
- toolbar += ``;
- toolbar += ``;
-
- // HTML preview button (only for HTML-like content)
- if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) {
- toolbar += ``;
- }
-
- const langLabel = lang ? `${lang}` : '';
- const collapsedClass = shouldCollapse ? ' code-collapsed' : '';
- return `${langLabel}${code}`;
- });
-
- return html;
-}
-
-function _formatBasic(text) {
- let html = esc(text);
- html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
- const id = 'code-' + Math.random().toString(36).slice(2, 9);
- const lineCount = (code.match(/\n/g) || []).length + 1;
- const COLLAPSE_THRESHOLD = 15;
- const shouldCollapse = lineCount > COLLAPSE_THRESHOLD;
-
- const collapseBtn = lineCount > 5
- ? ``
- : '';
-
- let toolbar = collapseBtn;
- toolbar += ``;
- toolbar += ``;
-
- if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) {
- toolbar += ``;
- }
-
- const langLabel = lang ? `${lang}` : '';
- const collapsedClass = shouldCollapse ? ' code-collapsed' : '';
- return `${langLabel}${code.trim()}`;
- });
- html = html.replace(/`([^`]+)`/g, '$1');
- html = html.replace(/\*\*([^*]+)\*\*/g, '$1');
- html = html.replace(/\*([^*]+)\*/g, '$1');
- html = html.replace(/\n/g, '
');
- return html;
-}
-
-// ── Markdown Fence Unwrapper ──────────────────
-// LLMs often wrap entire responses in ```markdown ... ```. This is semantically
-// a no-op ("this markdown IS markdown") but breaks parsing when the content
-// contains nested code fences — the inner closing ``` prematurely terminates
-// the outer block, splitting the response into fragments.
-//
-// Strategy: if the text is wrapped in ```markdown/```md and the body contains
-// at least one nested ``` fence, strip the outer wrapper. If there are no
-// nested fences, leave it alone (the user/LLM may genuinely want to show
-// markdown source in a code block).
-function _unwrapMarkdownFence(text) {
- // The text may start with THINK_PLACEHOLDER_xxx blocks (extracted earlier).
- // We need to find the ```markdown fence after any placeholders/whitespace.
- const openMatch = text.match(/^((?:\s|THINK_PLACEHOLDER_\w+)*)(```(?:markdown|md)\s*\n)/i);
- if (!openMatch) return text;
-
- const prefix = openMatch[1]; // placeholders + whitespace before the fence
- const fence = openMatch[2]; // the ```markdown\n itself
- const afterOpen = prefix.length + fence.length;
-
- // Find the LAST bare ``` on its own line — this is the outer closing fence.
- // There may be trailing text (LLM explanations) after it.
- let closeIdx = -1;
- const searchRegex = /\n```[ \t]*(?:\n|$)/g;
- let m;
- while ((m = searchRegex.exec(text)) !== null) {
- closeIdx = m.index;
- }
- if (closeIdx < afterOpen) return text;
-
- // Extract inner content and any trailing text after the close
- const inner = text.slice(afterOpen, closeIdx);
- const trailing = text.slice(closeIdx).replace(/^\n```[ \t]*\n?/, '\n');
-
- // Only unwrap if the inner content contains nested fences —
- // otherwise this might be intentional "show me the markdown source"
- if (/^```/m.test(inner)) {
- return prefix + inner + trailing;
- }
-
- return text;
-}
-
-// Decode HTML entities in code block content (for extension renderers that need raw text)
-function _decodeHTML(html) {
- const txt = document.createElement('textarea');
- txt.innerHTML = html;
- return txt.value;
-}
-
-// Heuristic: does this code block look like HTML?
-function _looksLikeHTML(code) {
- const decoded = code.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
- return / ',
- title: 'Clear preview',
- onClickName: 'clearPreview()',
- visible: _hasPreviewContent,
- },
- ],
- saveState() {
- const frame = document.getElementById('previewFrame');
- return {
- srcdoc: frame?.srcdoc || '',
- hasContent: frame?.style.display !== 'none',
- };
- },
- restoreState(state) {
- const frame = document.getElementById('previewFrame');
- const empty = document.getElementById('previewEmpty');
- if (frame && state.srcdoc) {
- frame.srcdoc = state.srcdoc;
- frame.style.display = state.hasContent ? '' : 'none';
- if (empty) empty.style.display = state.hasContent ? 'none' : '';
- }
- },
- });
-}
-
-function _hasPreviewContent() {
- const frame = document.getElementById('previewFrame');
- return frame && frame.style.display !== 'none' && frame.srcdoc;
-}
-
-// ── Pop-out for Extension-Rendered Blocks ───
-
-/**
- * Open an extension-rendered block (Mermaid, KaTeX, etc.) in the
- * preview panel at full size.
- */
-function popOutExtBlock(blockId) {
- const block = document.querySelector(`[data-ext-block="${blockId}"]`);
- if (!block) return;
-
- // Clone rendered content (skip the pop-out button itself)
- const clone = block.cloneNode(true);
- const btn = clone.querySelector('.ext-popout-btn');
- if (btn) btn.remove();
-
- const lang = block.getAttribute('data-ext-lang') || '';
-
- // Read current theme colors so the iframe respects light/dark mode
- const cs = getComputedStyle(document.documentElement);
- const bgColor = cs.getPropertyValue('--bg-surface').trim() || '#fff';
- const textColor = cs.getPropertyValue('--text').trim() || '#1a1a2e';
-
- // Wrap in a minimal HTML document for the iframe
- const html = `
-
-
-
-${clone.innerHTML}`;
-
- const frame = document.getElementById('previewFrame');
- const empty = document.getElementById('previewEmpty');
- if (frame) {
- frame.srcdoc = html;
- frame.style.display = '';
- }
- if (empty) empty.style.display = 'none';
-
- PanelRegistry.open('preview');
- PanelRegistry.refreshActions();
-}
-
-// ── Live Preview During Streaming ───────────
-
-let _livePreviewTimer = null;
-
-/**
- * Called on each streaming delta with the accumulated raw markdown
- * content. If the preview panel is open, extracts the last completed
- * HTML code block and updates the iframe. Debounced at 500ms.
- */
-function _livePreviewUpdate(rawContent) {
- // Only live-update if preview is the active panel
- if (!PanelRegistry.isOpen('preview')) return;
-
- clearTimeout(_livePreviewTimer);
- _livePreviewTimer = setTimeout(() => {
- const html = _extractLastHTMLBlock(rawContent);
- if (!html) return;
-
- const frame = document.getElementById('previewFrame');
- const empty = document.getElementById('previewEmpty');
- if (frame) {
- frame.srcdoc = html;
- frame.style.display = '';
- }
- if (empty) empty.style.display = 'none';
- PanelRegistry.refreshActions();
- }, 500);
-}
-
-/**
- * Extract the last completed fenced HTML code block from raw markdown.
- * Returns the raw HTML content or null if none found.
- *
- * "Completed" means both opening ``` and closing ``` are present.
- */
-function _extractLastHTMLBlock(text) {
- if (!text) return null;
-
- // Match all completed ```html ... ``` blocks (or ```htm, or untagged that look like HTML)
- const pattern = /```(?:html|htm)?\s*\n([\s\S]*?)```/gi;
- let lastMatch = null;
- let m;
-
- while ((m = pattern.exec(text)) !== null) {
- const blockContent = m[1];
- const lang = m[0].match(/```(\w*)/)?.[1]?.toLowerCase() || '';
- if (lang === 'html' || lang === 'htm' || _looksLikeHTML(blockContent)) {
- lastMatch = blockContent;
- }
- }
-
- return lastMatch;
-}
-
-// ── Helpers ─────────────────────────────────
-
-// Render tool calls from stored message data (history view)
-function _renderToolCallsHTML(toolCalls) {
- if (!toolCalls || !Array.isArray(toolCalls) || toolCalls.length === 0) return '';
-
- const items = toolCalls.map(tc => {
- const name = tc.function?.name || tc.name || 'unknown';
- const args = tc.function?.arguments || tc.arguments || tc.input || '';
- const result = tc.result || '';
- const isError = tc.is_error || false;
- const tcId = tc.id || '';
-
- const statusCls = isError ? 'tool-error' : 'tool-done';
- const statusText = isError ? 'error' : 'done';
-
- // Check for note tool — add view link
- let noteLink = '';
- if (name.startsWith('note_') && result) {
- try {
- const parsed = typeof result === 'string' ? JSON.parse(result) : result;
- if (parsed.id) {
- noteLink = ``;
- }
- } catch (e) { /* not JSON */ }
- }
-
- // Build collapsible detail
- let detailHTML = '';
- if (args || result) {
- const argsStr = typeof args === 'string' ? args : JSON.stringify(args, null, 2);
- const resultStr = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
- detailHTML = ``;
- if (argsStr) detailHTML += `Input${esc(argsStr)}`;
- if (resultStr) detailHTML += `Output${esc(resultStr)}`;
- detailHTML += ``;
- }
-
- return `
-
-
- ${esc(name)}
- ${statusText}
- ${noteLink}
-
- ${detailHTML}
- `;
- });
-
- return `${items.join('')}`;
-}
-
-function _relativeTime(dateStr) {
- if (!dateStr) return '';
- const d = new Date(dateStr);
- const now = new Date();
- const diff = (now - d) / 1000;
-
- if (diff < 60) return 'now';
- if (diff < 3600) return `${Math.floor(diff / 60)}m`;
- if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
- if (diff < 604800) return `${Math.floor(diff / 86400)}d`;
- return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
-}
-
-/**
- * Run extension post-renderers on a container after innerHTML is set.
- * Safe to call even if extensions aren't loaded — no-ops gracefully.
- */
-function runExtensionPostRender(container) {
- // v0.28.5: Render pipe subsumes extension post-renderers.
- // The pipe includes compat-shimmed renderers from ctx.renderers.register()
- // alongside new sw.pipe.render() filters.
- if (typeof sw !== 'undefined' && sw.pipe) {
- const renderCtx = {
- element: container,
- message: null, // batch render — no single message context
- channel: {
- id: typeof App !== 'undefined' ? App.activeId : null,
- type: typeof App !== 'undefined' ? (App.activeType || 'direct') : 'direct',
- },
- };
- sw.pipe._runRender(renderCtx);
- return;
- }
- // Fallback: SDK not loaded (shouldn't happen, but defensive)
- if (typeof Extensions !== 'undefined' && Extensions._loaded) {
- Extensions.runPostRenderers(container);
- }
-}
-
-// ── Wrappers for compound onclick expressions ──
-function _copyCodeBlock(codeId) {
- const el = document.getElementById(codeId);
- if (el) navigator.clipboard.writeText(el.textContent).then(() => UI.toast('Copied', 'success'));
-}
-function _openNoteFromTool(noteId) {
- // v0.37.9: old notes.js removed — navigate to notes surface instead
- if (window.sw?.notesPane) {
- console.log('[ui-format] Note tool link — navigate to /notes');
- }
-}
-
-// ── Exports ─────────────────────────────────
-// Cross-file function calls
-sb.register('formatMessage', formatMessage);
-sb.register('clearPreview', clearPreview);
-sb.register('runExtensionPostRender', runExtensionPostRender);
-sb.register('_livePreviewUpdate', _livePreviewUpdate);
-sb.register('_relativeTime', _relativeTime);
-sb.register('_renderToolCallsHTML', _renderToolCallsHTML);
-sb.register('_registerPreviewPanel', _registerPreviewPanel);
-// data-action handlers
-sb.register('toggleCodeCollapse', toggleCodeCollapse);
-sb.register('toggleHTMLPreview', toggleHTMLPreview);
-sb.register('downloadCode', downloadCode);
-sb.register('popOutExtBlock', popOutExtBlock);
-sb.register('_copyCodeBlock', _copyCodeBlock);
-sb.register('_openNoteFromTool', _openNoteFromTool);
diff --git a/src/js/virtual-scroll.js b/src/js/virtual-scroll.js
deleted file mode 100644
index 4c3225d..0000000
--- a/src/js/virtual-scroll.js
+++ /dev/null
@@ -1,277 +0,0 @@
-// ==========================================
-// Chat Switchboard — Virtual Scroll
-// ==========================================
-// Viewport-windowed message rendering for long conversations.
-// Renders a window of messages around the viewport. As the user
-// scrolls, older/newer chunks are rendered on demand and distant
-// messages are removed from the DOM to keep memory bounded.
-//
-// Design: sentinel-based (IntersectionObserver on top/bottom markers)
-// rather than scroll-listener based. No jank, no debounce.
-//
-// Integration: wraps UI._messageHTML() — no rendering duplication.
-// The container element (#chatMessages) is unchanged.
-//
-// Streaming: the actively-streaming message is always in DOM at the
-// bottom, managed by streamResponse(). VirtualScroll leaves it alone.
-
- const WINDOW_SIZE = 80; // max messages in DOM at once
- const BUFFER = 20; // render this many beyond viewport edge
- const PREPEND_CHUNK = 40; // load this many when scrolling up
- const LOAD_MORE_THRESHOLD = 100; // only activate for conversations this long
-
- class VirtualScroll {
- constructor() {
- this._messages = []; // full message array (data, not DOM)
- this._renderFn = null; // (msg, index, dimmed) => html string
- this._summaryFn = null; // (msg) => html string
- this._postRenderFn = null; // (container) => void
- this._container = null;
- this._observer = null;
- this._topSentinel = null;
- this._renderedRange = { start: 0, end: 0 }; // indices into _messages
- this._enabled = false;
- this._scrollLock = false; // prevent observer re-entry during DOM mutation
- this._summaryIdx = -1;
- }
-
- // ── Configuration ────────────────────────
-
- /**
- * Bind to a container and rendering functions.
- * @param {HTMLElement} container - the #chatMessages element
- * @param {Function} renderFn - (msg, index, dimmed?) => html string
- * @param {Function} summaryFn - (msg) => html string
- * @param {Function} postRenderFn - (container) => void (extensions, images)
- */
- bind(container, renderFn, summaryFn, postRenderFn) {
- this._container = container;
- this._renderFn = renderFn;
- this._summaryFn = summaryFn;
- this._postRenderFn = postRenderFn;
- }
-
- // ── Public API ───────────────────────────
-
- /**
- * Set messages and render. Called by renderMessages().
- * For short conversations, returns false (caller should use
- * the original innerHTML path). For long ones, renders the
- * tail window and returns true.
- */
- setMessages(messages, summaryIdx) {
- this._messages = messages;
- this._summaryIdx = summaryIdx;
-
- if (!this._container || !this._renderFn) return false;
-
- // Only virtualize long conversations
- const visible = messages.filter(m => m.role !== 'system');
- if (visible.length < LOAD_MORE_THRESHOLD) {
- this._teardown();
- this._enabled = false;
- return false;
- }
-
- this._enabled = true;
- this._renderTail();
- return true;
- }
-
- /**
- * Whether virtual scroll is currently active.
- */
- get active() { return this._enabled; }
-
- /**
- * Clean up observers. Call when switching conversations.
- */
- destroy() {
- this._teardown();
- this._messages = [];
- this._enabled = false;
- }
-
- // ── Internal: Render ─────────────────────
-
- /**
- * Render the tail (most recent) window of messages.
- * This is the initial render — user sees the latest messages.
- */
- _renderTail() {
- const msgs = this._filteredMessages();
- const total = msgs.length;
- const start = Math.max(0, total - WINDOW_SIZE);
- const end = total;
-
- this._renderedRange = { start, end };
- this._buildDOM(msgs, start, end);
- }
-
- /**
- * Prepend older messages when user scrolls to top.
- */
- _prependChunk() {
- if (this._scrollLock) return;
- const msgs = this._filteredMessages();
- const { start } = this._renderedRange;
- if (start <= 0) return; // already showing all
-
- this._scrollLock = true;
-
- const newStart = Math.max(0, start - PREPEND_CHUNK);
- const container = this._container;
-
- // Remember scroll position to restore after prepend
- const scrollBottom = container.scrollHeight - container.scrollTop;
-
- // Build HTML for the new chunk
- let html = '';
- for (let i = newStart; i < start; i++) {
- html += this._renderOne(msgs[i], i);
- }
-
- // Insert after top sentinel, before existing messages
- const sentinel = this._topSentinel;
- if (sentinel) {
- const frag = document.createRange().createContextualFragment(html);
- sentinel.after(frag);
- }
-
- // Trim bottom if DOM is too large
- const totalRendered = this._renderedRange.end - newStart;
- if (totalRendered > WINDOW_SIZE + BUFFER) {
- const excess = totalRendered - WINDOW_SIZE;
- this._removeFromBottom(excess);
- this._renderedRange.end -= excess;
- }
-
- this._renderedRange.start = newStart;
-
- // Restore scroll position (prevent jump)
- requestAnimationFrame(() => {
- container.scrollTop = container.scrollHeight - scrollBottom;
- if (this._postRenderFn) this._postRenderFn(container);
- this._scrollLock = false;
- this._updateSentinelState();
- });
- }
-
- // ── Internal: DOM ────────────────────────
-
- _buildDOM(msgs, start, end) {
- const container = this._container;
-
- let html = '';
-
- // Top sentinel (triggers prepend on intersection)
- html += '';
-
- // "Load more" indicator
- if (start > 0) {
- html += '' +
- '' +
- start + ' earlier messages';
- }
-
- // Summary boundary
- if (this._summaryIdx >= 0) {
- const summaryMsg = this._messages[this._summaryIdx];
- if (summaryMsg && this._summaryFn) {
- html += this._summaryFn(summaryMsg);
- }
- }
-
- // Messages
- for (let i = start; i < end; i++) {
- html += this._renderOne(msgs[i], i);
- }
-
- container.innerHTML = html;
-
- // Set up intersection observer on top sentinel
- this._setupObserver();
-
- if (this._postRenderFn) this._postRenderFn(container);
- }
-
- _renderOne(msg, index) {
- if (!msg) return '';
- // Skip system messages and summary messages (summary handled separately)
- if (msg.role === 'system') return '';
- if (msg._isSummary) return '';
- const dimmed = this._summaryIdx >= 0 && index < this._summaryIdx;
- return this._renderFn(msg, index, dimmed);
- }
-
- _removeFromBottom(count) {
- const container = this._container;
- const messages = container.querySelectorAll('.message');
- const toRemove = Array.from(messages).slice(-count);
- toRemove.forEach(el => el.remove());
- }
-
- // ── Observer ─────────────────────────────
-
- _setupObserver() {
- this._teardownObserver();
-
- this._topSentinel = this._container.querySelector('#vs-top-sentinel');
- if (!this._topSentinel) return;
-
- this._observer = new IntersectionObserver((entries) => {
- for (const entry of entries) {
- if (entry.isIntersecting && entry.target.id === 'vs-top-sentinel') {
- this._prependChunk();
- }
- }
- }, {
- root: this._container,
- rootMargin: '200px 0px 0px 0px', // trigger 200px before reaching top
- });
-
- this._observer.observe(this._topSentinel);
- }
-
- _updateSentinelState() {
- const loadMore = this._container.querySelector('#vs-load-more');
- if (loadMore) {
- if (this._renderedRange.start <= 0) {
- loadMore.style.display = 'none';
- } else {
- loadMore.querySelector('span').textContent =
- this._renderedRange.start + ' earlier messages';
- }
- }
- }
-
- _teardownObserver() {
- if (this._observer) {
- this._observer.disconnect();
- this._observer = null;
- }
- this._topSentinel = null;
- }
-
- _teardown() {
- this._teardownObserver();
- this._renderedRange = { start: 0, end: 0 };
- }
-
- // ── Helpers ──────────────────────────────
-
- /**
- * Returns messages with summary detection metadata.
- * Mirrors the filtering logic from renderMessages().
- */
- _filteredMessages() {
- return this._messages.filter(m => m.role !== 'system');
- }
- }
-
- // ── Singleton ────────────────────────────
-
- const virtualScroll = new VirtualScroll();
-
- window.VirtualScroll = virtualScroll;
- sb.register('VirtualScroll', virtualScroll);
diff --git a/src/js/workflow-api.js b/src/js/workflow-api.js
deleted file mode 100644
index c1be684..0000000
--- a/src/js/workflow-api.js
+++ /dev/null
@@ -1,169 +0,0 @@
-// ==========================================
-// Chat Switchboard — Workflow API Methods
-// ==========================================
-// Extends the global API object with workflow CRUD, stage management,
-// publishing, instance lifecycle, and assignment queue operations.
-// Loaded after api.js on the chat and admin surfaces.
-
- // ── Workflow Definitions ────────────────
-
- API.listWorkflows = function(teamId) {
- const q = teamId ? `?team_id=${teamId}` : '';
- return this._get('/api/v1/workflows' + q);
- };
-
- API.getWorkflow = function(id) {
- return this._get(`/api/v1/workflows/${id}`);
- };
-
- API.createWorkflow = function(data) {
- return this._post('/api/v1/workflows', data);
- };
-
- API.updateWorkflow = function(id, patch) {
- return this._patch(`/api/v1/workflows/${id}`, patch);
- };
-
- API.deleteWorkflow = function(id) {
- return this._del(`/api/v1/workflows/${id}`);
- };
-
- // ── Stages ──────────────────────────────
-
- API.listStages = function(workflowId) {
- return this._get(`/api/v1/workflows/${workflowId}/stages`);
- };
-
- API.createStage = function(workflowId, data) {
- return this._post(`/api/v1/workflows/${workflowId}/stages`, data);
- };
-
- API.updateStage = function(workflowId, stageId, data) {
- return this._put(`/api/v1/workflows/${workflowId}/stages/${stageId}`, data);
- };
-
- API.deleteStage = function(workflowId, stageId) {
- return this._del(`/api/v1/workflows/${workflowId}/stages/${stageId}`);
- };
-
- API.reorderStages = function(workflowId, orderedIds) {
- return this._patch(`/api/v1/workflows/${workflowId}/stages/reorder`, { ordered_ids: orderedIds });
- };
-
- // ── Versioning ──────────────────────────
-
- API.publishWorkflow = function(workflowId) {
- return this._post(`/api/v1/workflows/${workflowId}/publish`, {});
- };
-
- API.getWorkflowVersion = function(workflowId, version) {
- return this._get(`/api/v1/workflows/${workflowId}/versions/${version}`);
- };
-
- // ── Instances ───────────────────────────
-
- API.startWorkflow = function(workflowId) {
- return this._post(`/api/v1/workflows/${workflowId}/start`, {});
- };
-
- API.getWorkflowStatus = function(channelId) {
- return this._get(`/api/v1/channels/${channelId}/workflow/status`);
- };
-
- API.advanceWorkflow = function(channelId, data) {
- return this._post(`/api/v1/channels/${channelId}/workflow/advance`, { data });
- };
-
- API.rejectWorkflow = function(channelId, reason) {
- return this._post(`/api/v1/channels/${channelId}/workflow/reject`, { reason });
- };
-
- // ── Forms ────────────────────────────────
-
- API.getWorkflowForm = function(channelId) {
- return this._get(`/api/v1/w/${channelId}/form`);
- };
-
- API.submitWorkflowForm = function(channelId, data) {
- return this._post(`/api/v1/w/${channelId}/form-submit`, data);
- };
-
- // ── Workflow Packages (v0.30.2) ─────────
-
- API.exportWorkflowPkg = function(workflowId) {
- // Trigger download via hidden link (binary response)
- const a = document.createElement('a');
- a.href = (window.__BASE__ || '') + `/api/v1/admin/workflows/${workflowId}/export`;
- a.download = '';
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- };
-
- // ── Assignments ─────────────────────────
-
- API.listMyAssignments = function() {
- return this._get('/api/v1/workflow-assignments/mine');
- };
-
- API.listTeamAssignments = function(teamId, status) {
- return this._get(`/api/v1/teams/${teamId}/assignments?status=${status || 'unassigned'}`);
- };
-
- API.claimAssignment = function(id) {
- return this._post(`/api/v1/workflow-assignments/${id}/claim`, {});
- };
-
- API.completeAssignment = function(id) {
- return this._post(`/api/v1/workflow-assignments/${id}/complete`, {});
- };
-
- // ── Team-Scoped Workflow CRUD (v0.31.2) ──
-
- API.listTeamWorkflows = function(teamId) {
- return this._get(`/api/v1/teams/${teamId}/workflows`);
- };
-
- API.getTeamWorkflow = function(teamId, id) {
- return this._get(`/api/v1/teams/${teamId}/workflows/${id}`);
- };
-
- API.createTeamWorkflow = function(teamId, data) {
- return this._post(`/api/v1/teams/${teamId}/workflows`, data);
- };
-
- API.updateTeamWorkflow = function(teamId, id, patch) {
- return this._patch(`/api/v1/teams/${teamId}/workflows/${id}`, patch);
- };
-
- API.deleteTeamWorkflow = function(teamId, id) {
- return this._del(`/api/v1/teams/${teamId}/workflows/${id}`);
- };
-
- API.listTeamWorkflowStages = function(teamId, workflowId) {
- return this._get(`/api/v1/teams/${teamId}/workflows/${workflowId}/stages`);
- };
-
- API.createTeamWorkflowStage = function(teamId, workflowId, data) {
- return this._post(`/api/v1/teams/${teamId}/workflows/${workflowId}/stages`, data);
- };
-
- API.updateTeamWorkflowStage = function(teamId, workflowId, stageId, data) {
- return this._put(`/api/v1/teams/${teamId}/workflows/${workflowId}/stages/${stageId}`, data);
- };
-
- API.deleteTeamWorkflowStage = function(teamId, workflowId, stageId) {
- return this._del(`/api/v1/teams/${teamId}/workflows/${workflowId}/stages/${stageId}`);
- };
-
- API.reorderTeamWorkflowStages = function(teamId, workflowId, orderedIds) {
- return this._patch(`/api/v1/teams/${teamId}/workflows/${workflowId}/stages/reorder`, { ordered_ids: orderedIds });
- };
-
- API.publishTeamWorkflow = function(teamId, workflowId) {
- return this._post(`/api/v1/teams/${teamId}/workflows/${workflowId}/publish`, {});
- };
-
- API.getTeamWorkflowVersion = function(teamId, workflowId, version) {
- return this._get(`/api/v1/teams/${teamId}/workflows/${workflowId}/versions/${version}`);
- };
diff --git a/src/js/workflow-monitor.js b/src/js/workflow-monitor.js
deleted file mode 100644
index d700991..0000000
--- a/src/js/workflow-monitor.js
+++ /dev/null
@@ -1,128 +0,0 @@
-// workflow-monitor.js — v0.35.0 Workflow Monitoring Dashboard
-//
-// Admin monitoring tab showing active instances, stage funnels,
-// SLA status indicators, and stale instance detection.
-
-export function mountMonitorTab(container, { basePath }) {
- container.innerHTML = `
-
-
- Workflow Monitor
-
-
- Loading…
-
-
- `;
-
- let refreshTimer = null;
-
- async function loadInstances() {
- try {
- const resp = await fetch(`${basePath}/api/v1/admin/workflows/monitor/instances`);
- if (!resp.ok) throw new Error(resp.statusText);
- const { data } = await resp.json();
- renderInstances(data);
- } catch (e) {
- document.getElementById('monInstances').textContent = 'Failed to load: ' + e.message;
- }
- }
-
- async function loadStale() {
- try {
- const resp = await fetch(`${basePath}/api/v1/admin/workflows/monitor/stale`);
- if (!resp.ok) return;
- const { data } = await resp.json();
- renderStale(data);
- } catch (_) {}
- }
-
- function renderInstances(instances) {
- const el = document.getElementById('monInstances');
- if (!instances.length) {
- el.innerHTML = 'No active workflow instances.
';
- return;
- }
-
- let html = '';
- html += '';
- html += 'Workflow Stage ';
- html += 'Age SLA ';
- html += ' ';
-
- for (const inst of instances) {
- const age = formatDuration(inst.age_seconds);
- const sla = renderSLA(inst);
- html += ``;
- html += `${esc(inst.workflow_name)}
${esc(inst.channel_title)} `;
- html += `${esc(inst.stage_name)} (${inst.current_stage}) `;
- html += `${age} `;
- html += `${sla} `;
- html += ' ';
- }
- html += '
';
- el.innerHTML = html;
- }
-
- function renderSLA(inst) {
- if (!inst.sla_seconds) return '—';
- if (inst.sla_breached) {
- return 'BREACHED';
- }
- if (inst.sla_remaining_seconds != null) {
- const remaining = inst.sla_remaining_seconds;
- const pct = Math.max(0, remaining / inst.sla_seconds);
- const color = pct > 0.5 ? 'var(--success,#2ecc71)' : pct > 0.2 ? 'var(--warning,#f39c12)' : 'var(--danger,#e74c3c)';
- return `${formatDuration(remaining)}`;
- }
- return '—';
- }
-
- function renderStale(instances) {
- const el = document.getElementById('monStale');
- if (!instances.length) {
- el.innerHTML = '';
- return;
- }
- let html = 'Stale Instances (' + instances.length + ')
';
- html += '';
- for (const inst of instances) {
- html += `- ${esc(inst.workflow_name)} — ${esc(inst.stage_name)} (${formatDuration(inst.age_seconds)} old)
`;
- }
- html += '
';
- el.innerHTML = html;
- }
-
- function formatDuration(seconds) {
- if (seconds < 60) return seconds + 's';
- if (seconds < 3600) return Math.floor(seconds / 60) + 'm';
- if (seconds < 86400) return Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm';
- return Math.floor(seconds / 86400) + 'd ' + Math.floor((seconds % 86400) / 3600) + 'h';
- }
-
- function esc(s) {
- const d = document.createElement('div');
- d.textContent = s || '';
- return d.innerHTML;
- }
-
- // Initial load
- loadInstances();
- loadStale();
-
- // Auto-refresh every 30s
- refreshTimer = setInterval(() => {
- loadInstances();
- loadStale();
- }, 30000);
-
- document.getElementById('monRefresh')?.addEventListener('click', () => {
- loadInstances();
- loadStale();
- });
-
- // Return cleanup function
- return () => {
- if (refreshTimer) clearInterval(refreshTimer);
- };
-}
diff --git a/src/js/workflow-queue.js b/src/js/workflow-queue.js
deleted file mode 100644
index 2d24815..0000000
--- a/src/js/workflow-queue.js
+++ /dev/null
@@ -1,307 +0,0 @@
-// ==========================================
-// Chat Switchboard — Workflow Queue UI
-// ==========================================
-// Sidebar section showing:
-// 1. Active workflow channels (type='workflow' from App.chats)
-// 2. Claimed assignments from team queues
-// Loaded on the chat surface after workflow-api.js.
-
-
- const WorkflowQueue = {
-
- _loaded: false,
- _myAssignments: [],
-
- // ── Init ────────────────────────────
-
- async init() {
- if (this._loaded) return;
- this._loaded = true;
-
- // Insert sidebar section after channels
- var channelsSec = document.getElementById('sbSectionChannels');
- if (!channelsSec) return;
-
- var section = document.createElement('div');
- section.className = 'sb-section';
- section.id = 'sbSectionQueue';
- section.innerHTML =
- '' +
- '▾' +
- 'Workflows' +
- '' +
- '' +
- '';
-
- channelsSec.parentNode.insertBefore(section, channelsSec.nextSibling);
- this.refresh();
- },
-
- // ── Pinned set ────────────────────────
- // Only show workflow channels the user has explicitly opened/pinned.
- // Stored in localStorage to persist across sessions.
-
- _getPinned() {
- try {
- return JSON.parse(localStorage.getItem('sb_wf_pinned') || '[]');
- } catch (_) { return []; }
- },
-
- _pin(channelId) {
- var pinned = this._getPinned();
- if (pinned.indexOf(channelId) === -1) {
- pinned.push(channelId);
- localStorage.setItem('sb_wf_pinned', JSON.stringify(pinned));
- }
- },
-
- _unpin(channelId) {
- var pinned = this._getPinned().filter(function(id) { return id !== channelId; });
- localStorage.setItem('sb_wf_pinned', JSON.stringify(pinned));
- this.refresh();
- },
-
- // ── Refresh ─────────────────────────
- // Called after loadChats() and on assignment changes.
-
- async refresh() {
- var body = document.getElementById('sbBodyQueue');
- var badge = document.getElementById('sbQueueBadge');
- if (!body) return;
-
- var self = this;
- var pinned = this._getPinned();
-
- // 1. Workflow channels — only show pinned ones
- var allWorkflows = (App.chats || []).filter(function(c) {
- return c.type === 'workflow';
- });
- var workflows = allWorkflows.filter(function(c) {
- return pinned.indexOf(c.id) !== -1;
- });
-
- // 2. Claimed assignments (async, best-effort)
- var assignments = [];
- try {
- var resp = await API.listMyAssignments();
- assignments = resp.data || [];
- this._myAssignments = assignments;
- // Auto-pin channels from assignments
- assignments.forEach(function(a) {
- if (a.channel_id) self._pin(a.channel_id);
- });
- } catch (e) {
- // Non-critical — show channels even if assignments fail
- }
-
- var total = workflows.length + assignments.length;
- var unpinnedCount = allWorkflows.length - workflows.length;
- if (badge) {
- badge.textContent = total;
- badge.style.display = total > 0 ? '' : 'none';
- }
-
- var html = '';
-
- // Workflow channels (pinned only)
- workflows.forEach(function(wf) {
- var isActive = App.activeId === wf.id;
- html += '' +
- '' +
- '' + esc(wf.title || 'Workflow') + '' +
- '' +
- '';
- });
-
- // Claimed assignments (deduplicated against channel list)
- if (assignments.length > 0) {
- assignments.forEach(function(a) {
- var alreadyShown = workflows.some(function(w) { return w.id === a.channel_id; });
- if (alreadyShown) return;
-
- html += '' +
- '' +
- 'Assignment: Stage ' + a.stage + '' +
- '' +
- '';
- });
- }
-
- // Browse button — shows when there are unpinned workflows available
- if (unpinnedCount > 0 || total === 0) {
- html += '' +
- '' +
- 'Browse Workflows' + (unpinnedCount > 0 ? ' (' + unpinnedCount + ')' : '') + '' +
- '';
- }
-
- body.innerHTML = html;
- },
-
- // ── Unpin ──────────────────────────
-
- unpin(channelId) {
- this._unpin(channelId);
- },
-
- // ── Browse dialog ─────────────────────
-
- browse() {
- var self = this;
- var pinned = this._getPinned();
- var allWorkflows = (App.chats || []).filter(function(c) {
- return c.type === 'workflow';
- });
- var unpinned = allWorkflows.filter(function(c) {
- return pinned.indexOf(c.id) === -1;
- });
-
- if (unpinned.length === 0) {
- UI.toast('No available workflows', 'info');
- return;
- }
-
- // Remove any existing browse overlay
- var existing = document.getElementById('wfBrowseOverlay');
- if (existing) existing.remove();
-
- var overlay = document.createElement('div');
- overlay.id = 'wfBrowseOverlay';
- overlay.className = 'confirm-overlay';
-
- var rows = '';
- unpinned.forEach(function(wf) {
- rows += '' +
- '' +
- '' + esc(wf.title || 'Workflow') + '' +
- '';
- });
-
- overlay.innerHTML =
- '' +
- 'Browse Workflows' +
- '' +
- 'Select a workflow to add to your sidebar:
' +
- '' + rows + '' +
- '' +
- '' +
- '';
-
- overlay.addEventListener('click', function(e) {
- var row = e.target.closest('.wf-browse-row');
- if (row) {
- var wfId = row.getAttribute('data-wf-id');
- overlay.remove();
- self._pin(wfId);
- self.refresh();
- if (typeof selectChannel === 'function') selectChannel(wfId);
- return;
- }
- if (e.target === overlay || e.target.closest('[data-action="cancel"]')) {
- overlay.remove();
- }
- });
-
- document.body.appendChild(overlay);
- },
-
- // ── Open Assignment ─────────────────
-
- openAssignment(assignmentId, channelId) {
- this._pin(channelId);
- if (typeof selectChannel === 'function') {
- selectChannel(channelId);
- }
- },
-
- // ── Claim ───────────────────────────
-
- async claim(assignmentId) {
- try {
- await API.claimAssignment(assignmentId);
- UI.toast('Assignment claimed', 'success');
- await this.refresh();
- } catch (e) {
- UI.toast('Failed to claim: ' + e.message, 'error');
- }
- },
-
- // ── Complete ────────────────────────
-
- async complete(assignmentId) {
- try {
- await API.completeAssignment(assignmentId);
- UI.toast('Assignment completed', 'success');
- await this.refresh();
- } catch (e) {
- UI.toast('Failed: ' + e.message, 'error');
- }
- },
-
- // ── Team Queue Panel ────────────────
-
- async openTeamPanel(teamId) {
- try {
- var unassigned = await API.listTeamAssignments(teamId, 'unassigned');
- var claimed = await API.listTeamAssignments(teamId, 'claimed');
- var uItems = unassigned.data || [];
- var cItems = claimed.data || [];
-
- if (uItems.length === 0 && cItems.length === 0) {
- UI.toast('Queue is empty', 'info');
- return;
- }
-
- var html = 'Team Queue
';
-
- if (uItems.length > 0) {
- html += 'Unassigned (' + uItems.length + ')
';
- html += 'Stage Created ';
- uItems.forEach(function(a) {
- html += 'Stage ' + a.stage + ' ' +
- '' + new Date(a.created_at).toLocaleDateString() + ' ' +
- ' ';
- });
- html += '
';
- }
-
- if (cItems.length > 0) {
- html += 'In Progress (' + cItems.length + ')
';
- html += 'Stage Assigned ';
- cItems.forEach(function(a) {
- html += 'Stage ' + a.stage + ' ' +
- '' + (a.assigned_to || '—') + ' ' +
- ' ';
- });
- html += '
';
- }
-
- html += '';
-
- if (typeof showModal === 'function') {
- showModal('Team Queue', html);
- } else {
- var el = document.getElementById('adminDynamic');
- if (el) el.innerHTML = html;
- }
- } catch (e) {
- UI.toast('Failed to load queue: ' + e.message, 'error');
- }
- },
- };
- sb.ns('WorkflowQueue', WorkflowQueue);
-
- // Auto-init when chat surface loads
- document.addEventListener('DOMContentLoaded', function() {
- setTimeout(function() {
- if (typeof API !== 'undefined' && API.accessToken) {
- WorkflowQueue.init();
- }
- }, 2000);
- });
-
diff --git a/src/sw.js b/src/sw.js
index 13aa20e..098d71a 100644
--- a/src/sw.js
+++ b/src/sw.js
@@ -11,38 +11,44 @@
const CACHE_NAME = 'switchboard-%%APP_VERSION%%-%%BUILD_HASH%%';
-// App shell files to pre-cache on install
+// App shell files to pre-cache on install (cleaned up in v0.37.12)
const SHELL_FILES = [
'./',
'./index.html',
+ // CSS — shared
'./css/variables.css',
'./css/layout.css',
'./css/primitives.css',
'./css/modals.css',
- './css/chat.css',
- './css/panels.css',
'./css/surfaces.css',
- './css/splash.css',
- './js/debug.js',
+ './css/user-menu.css',
+ './css/workflow.css',
+ './css/admin-surfaces.css',
+ './css/tool-grants.css',
+ './css/extension-surface.css',
+ // CSS — Preact (sw-*)
+ './css/sw-primitives.css',
+ './css/sw-shell.css',
+ './css/sw-login.css',
+ './css/sw-chat-pane.css',
+ './css/sw-chat-surface.css',
+ './css/sw-notes-pane.css',
+ './css/sw-notes-surface.css',
+ // JS — platform globals (extension + debug deps)
+ './js/sb.js',
+ './js/app-state.js',
'./js/events.js',
- './js/extensions.js',
- './js/api.js',
- './js/ui-format.js',
'./js/ui-primitives.js',
- './js/ui-core.js',
- './js/ui-settings.js',
- './js/ui-admin.js',
- './js/tokens.js',
- './js/notes.js',
- './js/files.js',
- './js/tools-toggle.js',
- './js/knowledge-ui.js',
- './js/chat.js',
- './js/settings-handlers.js',
- './js/admin-handlers.js',
- './js/app.js',
+ './js/user-menu.js',
+ './js/file-tree.js',
+ './js/code-editor.js',
+ './js/switchboard-sdk.js',
+ './js/debug.js',
+ './js/repl.js',
+ // Vendor
'./vendor/marked.min.js',
'./vendor/purify.min.js',
+ // Static assets
'./favicon.svg',
'./favicon.ico',
'./favicon-32.png',