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"}} -
-

Model Catalog

-

All models synced from providers. Toggle visibility to control what users can access.

- -
- - - -
- - {{if .Data.Models}} - - - - - - - - - - - - {{range .Data.Models}} - - - - - - - - {{end}} - -
ModelProviderTypeCapabilities
- {{.DisplayName}} - {{if ne .DisplayName .ModelID}}
{{.ModelID}}{{end}} -
{{.ProviderName}}{{.ModelType}}
- {{else}} -
No models in catalog. Sync a provider to populate.
- {{end}} -
-{{end}} diff --git a/server/pages/templates/admin/overview.html b/server/pages/templates/admin/overview.html deleted file mode 100644 index b7f05d2..0000000 --- a/server/pages/templates/admin/overview.html +++ /dev/null @@ -1,6 +0,0 @@ -{{define "admin-overview"}} -
-

Admin Overview

-

System administration. Select a section from the navigation.

-
-{{end}} diff --git a/server/pages/templates/admin/providers.html b/server/pages/templates/admin/providers.html deleted file mode 100644 index c7e28ce..0000000 --- a/server/pages/templates/admin/providers.html +++ /dev/null @@ -1,97 +0,0 @@ -{{/* Admin providers — server-rendered provider list with status. */}} - -{{define "admin-providers"}} -
-

Providers

-

AI provider configurations. Each provider connects to an API endpoint.

- -
- -
- - - - {{if .Data.Providers}} - - - - - - - - - - - - - {{range .Data.ProviderDetails}} - - - - - - - - - {{end}} - -
NameTypeScopeModelsEndpoint
{{.Name}}{{.Provider}}{{.Scope}}{{.ModelCount}}{{.Endpoint}} - - -
- {{else}} -
No providers configured. Add one to get started.
- {{end}} -
-{{end}} - -{{define "admin-provider-form"}} -
-

Add Provider

- -
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - {{template "team-select" (dict "FieldName" "providerFormTeamId" "Label" "" "Teams" .Data.Teams "Selected" "" "AllowEmpty" false)}} -
-
-
- - -
-
-{{end}} diff --git a/server/pages/templates/admin/roles.html b/server/pages/templates/admin/roles.html deleted file mode 100644 index f993a66..0000000 --- a/server/pages/templates/admin/roles.html +++ /dev/null @@ -1,94 +0,0 @@ -{{/* - admin/roles.html — Model Roles configuration page. - Fixes bug #1: embedding models now appear via model_type filtering. -*/}} -{{define "admin-roles"}} -
-

Model Roles

-

Configure which models serve background tasks. Each role has a primary and optional fallback.

- - {{$providers := .Data.Providers}} - {{$models := .Data.Models}} - - {{range .Data.Roles}} -
-

{{.Name}} Role

-

- {{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}} - -
-
- -
- - -
-
- -
- -
- - -
-
- - -
-
- {{end}} -
-{{end}} diff --git a/server/pages/templates/admin/routing.html b/server/pages/templates/admin/routing.html deleted file mode 100644 index 6c2d1c2..0000000 --- a/server/pages/templates/admin/routing.html +++ /dev/null @@ -1,80 +0,0 @@ -{{/* - admin/routing.html — Routing Policy management. - Fixes bug #2: team scope uses a proper dropdown, not raw text. -*/}} -{{define "admin-routing"}} -
-

Routing Policies

-

Define rules for model selection, cost limits, and team-specific routing.

- -
- -
- - - -
- {{if not .Data.Policies}} -
No routing policies configured.
- {{end}} -
-
-{{end}} diff --git a/server/pages/templates/admin/settings.html b/server/pages/templates/admin/settings.html deleted file mode 100644 index 5de8fe4..0000000 --- a/server/pages/templates/admin/settings.html +++ /dev/null @@ -1,83 +0,0 @@ -{{/* Admin settings — core system configuration. */}} - -{{define "admin-settings"}} -
-

Settings

-

Core system configuration. Changes take effect immediately.

- - {{/* ── Registration ───────────────────── */}} -
-

Registration

-
- -
-
- - -
-
- - {{/* ── User Permissions ─────────────────── */}} -
-

User Permissions

-
- -
-
- -
-
- -
-
- - {{/* ── Banner ─────────────────────────── */}} -
-

Environment Banner

-
- -
- -
- - {{/* ── System Prompt ──────────────────── */}} -
-

Default System Prompt

-
- -
-
- -
- -
-
-{{end}} diff --git a/server/pages/templates/admin/teams.html b/server/pages/templates/admin/teams.html deleted file mode 100644 index a7b58c0..0000000 --- a/server/pages/templates/admin/teams.html +++ /dev/null @@ -1,62 +0,0 @@ -{{/* Admin teams — team list with member counts. */}} - -{{define "admin-teams"}} -
-

Teams

-

Organize users into teams for scoped access to providers, personas, and policies.

- -
- -
- - - - {{if .Data.Teams}} - - - - - - - - - - - - {{range .Data.Teams}} - - - - - - - - {{end}} - -
NameDescriptionMembersStatus
{{.Name}}{{.Description}}{{.MemberCount}}{{if .IsActive}}active{{else}}inactive{{end}} - -
- {{else}} -
No teams created yet.
- {{end}} -
-{{end}} diff --git a/server/pages/templates/admin/users.html b/server/pages/templates/admin/users.html deleted file mode 100644 index 397df91..0000000 --- a/server/pages/templates/admin/users.html +++ /dev/null @@ -1,48 +0,0 @@ -{{/* Admin users — user management table. */}} - -{{define "admin-users"}} -
-

Users

-

Manage user accounts, roles, and access.

- -
- -
- - {{if .Data.Users}} - - - - - - - - - - - - - {{range .Data.Users}} - - - - - - - - - {{end}} - -
UsernameEmailDisplay NameRoleStatus
{{.Username}}{{.Email}}{{.DisplayName}}{{.Role}}{{if .IsActive}}active{{else}}inactive{{end}} - - {{if .IsActive}} - - {{else}} - - {{end}} -
- {{else}} -
No users found.
- {{end}} -
-{{end}} diff --git a/server/pages/templates/base.html b/server/pages/templates/base.html index 0db94b0..d2977bf 100644 --- a/server/pages/templates/base.html +++ b/server/pages/templates/base.html @@ -15,7 +15,7 @@ {{/* chat.css removed in v0.37.10 — replaced by sw-chat-surface.css + sw-chat-pane.css */}} {{/* panels.css, pane-container.css, chat-pane.css removed in v0.37.10 */}} - + {{/* splash.css removed in v0.37.12 — login is Preact with sw-login.css */}} @@ -98,18 +98,15 @@ should migrate to the Preact SDK (sw/sdk/). */}} - - - - + {{/* marked.min.js + purify.min.js removed from base in v0.37.12 — surfaces load their own copies */}} + {{/* ui-format.js, virtual-scroll.js removed in v0.37.12 */}} {{/* ui-core.js + pages.js removed in v0.37.10 */}} {{/* v0.25.0: Component scripts — available on all surfaces */}} - {{/* chat-pane.js + pane-container.js removed in v0.37.10 — see sw/components/chat-pane/ */}} - + {{/* model-selector.js, drag-resize.js removed in v0.37.12 */}} {{/* v0.28.5: SDK — composition layer over globals. Must load after all primitives/components it wraps, before extensions.js. */}} diff --git a/server/version.go b/server/version.go index d41d66b..5a60975 100644 --- a/server/version.go +++ b/server/version.go @@ -1,3 +1,3 @@ package main -const Version = "0.37.11" +const Version = "0.37.12" diff --git a/src/css/channel-models.css b/src/css/channel-models.css deleted file mode 100644 index 1c4d816..0000000 --- a/src/css/channel-models.css +++ /dev/null @@ -1,247 +0,0 @@ -/* ========================================== - Channel Models — v0.20.0 Phase 2 - Model pills, @mention autocomplete, add dialog - ========================================== */ - -/* ── Model Pills (chat header) ───────────── */ - -.ch-model-pills { - display: none; /* shown by JS when roster > 1 */ - align-items: center; - gap: 4px; - padding: 0 8px; - flex-shrink: 1; - overflow-x: auto; - scrollbar-width: none; -} -.ch-model-pills::-webkit-scrollbar { display: none; } - -.ch-model-pill { - display: inline-flex; - align-items: center; - gap: 2px; - padding: 2px 8px; - border-radius: 12px; - background: var(--bg-raised); - border: 1px solid var(--border); - font-size: 0.75rem; - white-space: nowrap; - cursor: default; - transition: background 0.15s; -} -.ch-model-pill.ch-model-default { - background: var(--accent-bg, #e8f0fe); - border-color: var(--accent); -} -.ch-model-pill-name { - cursor: pointer; - user-select: none; -} -.ch-model-pill-name:hover { - text-decoration: underline; -} -.ch-model-pill-remove { - background: none; - border: none; - cursor: pointer; - font-size: 0.65rem; - color: var(--text-3); - padding: 0 2px; - line-height: 1; - opacity: 0.6; - transition: opacity 0.15s; -} -.ch-model-pill-remove:hover { - opacity: 1; - color: var(--danger); -} - -.ch-model-add-btn { - background: none; - border: 1px dashed var(--border); - border-radius: 12px; - padding: 2px 8px; - font-size: 0.75rem; - cursor: pointer; - color: var(--text-3); - white-space: nowrap; - transition: color 0.15s, border-color 0.15s; -} -.ch-model-add-btn:hover { - color: var(--text); - border-color: var(--text); -} - -/* ── Add Model Dialog ────────────────────── */ - -.ch-model-add-dialog { - position: fixed; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - background: rgba(0,0,0,0.4); - z-index: 1100; -} -.ch-model-add-inner { - background: var(--bg-surface); - border-radius: 12px; - padding: 24px; - min-width: 320px; - max-width: 420px; - box-shadow: 0 8px 32px rgba(0,0,0,0.2); -} -.ch-model-add-inner h3 { - margin: 0 0 16px; - font-size: 1rem; -} -.ch-model-add-inner label { - display: block; - margin-bottom: 12px; - font-size: 0.85rem; - color: var(--text-3); -} -.ch-model-add-inner select, -.ch-model-add-inner input[type="text"] { - display: block; - width: 100%; - margin-top: 4px; - padding: 8px; - border: 1px solid var(--border); - border-radius: 6px; - font-size: 0.9rem; - background: var(--bg-surface); - color: var(--text); - box-sizing: border-box; -} -.ch-model-add-actions { - display: flex; - gap: 8px; - justify-content: flex-end; - margin-top: 16px; -} - -/* ── @mention Autocomplete ───────────────── */ - -.mention-ac { - position: fixed; - z-index: 1050; - background: var(--bg-raised); - border: 1px solid var(--border-light); - border-radius: 8px; - box-shadow: 0 4px 16px rgba(0,0,0,0.3); - max-height: 260px; - overflow-y: auto; - padding: 4px 0; -} -.mention-ac-item:hover, -.mention-ac-item.mention-ac-active { - background: var(--bg-hover); -} -.mention-ac-name { - font-weight: 500; - font-size: 0.85rem; -} -.mention-ac-info { - display: flex; - flex-direction: column; - flex: 1; - min-width: 0; -} -.mention-ac-handle { - font-size: 0.72rem; - color: var(--accent, #6c9fff); - font-family: var(--mono); - opacity: 0.8; -} -.mention-ac-model { - font-size: 0.75rem; - color: var(--text-3); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 160px; -} - -/* ── Autocomplete avatars (v0.23.0) ──────────── */ -.mention-ac-avatar { - width: 22px; height: 22px; border-radius: 50%; flex-shrink: 0; - object-fit: cover; -} -.mention-ac-avatar-fallback { - display: inline-flex; align-items: center; justify-content: center; - background: var(--bg-hover); font-size: 12px; line-height: 1; -} -.mention-ac-item { - display: flex; - align-items: center; - padding: 8px 12px; - cursor: pointer; - gap: 8px; - transition: background 0.1s; -} - -/* ── @mention pills in messages (v0.23.0) ──── */ -.mention-pill { - display: inline; - color: var(--accent, #6c9fff); - font-weight: 600; - background: var(--accent-dim, rgba(108,159,255,0.12)); - padding: 1px 5px; - border-radius: 4px; - font-size: 0.92em; -} -/* v0.23.2: User mention pill — teal to distinguish from AI mentions */ -.mention-pill.mention-user { - color: #2dd4bf; - background: rgba(45,212,191,0.12); -} - -/* ── Model Attribution (multi-model messages) ─ */ - -.msg-model-label { - display: inline-block; - font-size: 0.7rem; - font-weight: 600; - padding: 1px 6px; - border-radius: 4px; - background: var(--bg-raised); - color: var(--text-3); - margin-left: 6px; - vertical-align: middle; -} - -/* Model separator in multi-model stream */ -.model-stream-divider { - display: flex; - align-items: center; - gap: 8px; - padding: 12px 0 4px; - font-size: 0.8rem; - color: var(--text-3); - font-weight: 500; -} -.model-stream-divider::before, -.model-stream-divider::after { - content: ''; - flex: 1; - height: 1px; - background: var(--border); -} - -/* ── Mobile ──────────────────────────────── */ - -@media (max-width: 768px) { - .ch-model-pills { - max-width: 50vw; - } - .ch-model-add-inner { - min-width: auto; - margin: 0 16px; - } - .mention-ac { - left: 8px !important; - right: 8px; - min-width: auto !important; - } -} diff --git a/src/css/memory.css b/src/css/memory.css deleted file mode 100644 index 4abbd04..0000000 --- a/src/css/memory.css +++ /dev/null @@ -1,298 +0,0 @@ -/* ========================================== - * Chat Switchboard – Memory UI (v0.18.0) - * ========================================== */ - -/* ── Summary Stats ─────────────────────── */ - -.memory-summary { - display: flex; - gap: 16px; - margin-bottom: 12px; - padding: 10px 0; -} - -.memory-stat { - display: flex; - flex-direction: column; - align-items: center; - padding: 10px 20px; - background: var(--bg-raised); - border-radius: 8px; - min-width: 80px; -} - -.memory-stat-value { - font-size: 1.5rem; - font-weight: 600; - color: var(--text-1); - line-height: 1.2; -} - -.memory-stat-label { - font-size: 0.75rem; - color: var(--text-3); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -/* ── Toolbar ───────────────────────────── */ - -.memory-toolbar { - display: flex; - flex-direction: column; - gap: 8px; - margin-bottom: 12px; -} - -.memory-filter-row { - display: flex; - gap: 8px; -} - -.memory-select { - padding: 5px 8px; - border-radius: 6px; - border: 1px solid var(--border); - background: var(--bg-1); - color: var(--text-1); - font-size: 0.82rem; - min-width: 120px; -} - -.memory-search { - flex: 1; - padding: 5px 10px; - border-radius: 6px; - border: 1px solid var(--border); - background: var(--bg-1); - color: var(--text-1); - font-size: 0.82rem; -} - -.memory-search::placeholder { - color: var(--text-3); -} - -.memory-actions-row { - display: flex; - gap: 6px; - align-items: center; -} - -/* ── Memory List ───────────────────────── */ - -.memory-list { - display: flex; - flex-direction: column; - gap: 6px; - max-height: 400px; - overflow-y: auto; - padding-right: 4px; -} - -/* ── Memory Card ───────────────────────── */ - -.memory-card { - background: var(--bg-raised); - border: 1px solid var(--border); - border-radius: 8px; - padding: 10px 12px; - transition: border-color 0.15s; -} - -.memory-card:hover { - border-color: var(--accent); -} - -.memory-card-pending { - border-left: 3px solid var(--warning, #f0ad4e); -} - -.memory-card-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 4px; - gap: 8px; -} - -.memory-key { - font-weight: 600; - font-size: 0.85rem; - color: var(--text-1); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.memory-badges { - display: flex; - gap: 4px; - align-items: center; - flex-shrink: 0; -} - -.memory-scope-badge { - font-size: 0.68rem; - padding: 1px 6px; - border-radius: 4px; - text-transform: uppercase; - letter-spacing: 0.3px; - font-weight: 500; -} - -.memory-scope-user { background: var(--accent-dim, #1a3a5c); color: var(--accent-text, #6db3f2); } -.memory-scope-persona { background: #3a2a1a; color: #f0ad4e; } -.memory-scope-persona_user { background: #2a1a3a; color: #c084fc; } - -.memory-confidence { - font-size: 0.7rem; - padding: 1px 5px; - border-radius: 4px; - font-weight: 500; - background: var(--bg-3); - color: var(--text-2); -} - -.memory-confidence-high { color: var(--success, #5cb85c); } -.memory-confidence-medium { color: var(--warning, #f0ad4e); } -.memory-confidence-low { color: var(--text-3); } - -.memory-owner { - font-size: 0.68rem; - color: var(--text-3); - font-family: monospace; -} - -.memory-card-value { - font-size: 0.82rem; - color: var(--text-2); - line-height: 1.4; - margin-bottom: 6px; - word-break: break-word; -} - -.memory-card-footer { - display: flex; - justify-content: space-between; - align-items: center; -} - -.memory-date { - font-size: 0.7rem; - color: var(--text-3); -} - -.memory-card-actions { - display: flex; - gap: 4px; -} - -/* ── Tiny Action Buttons ───────────────── */ - -.btn-tiny { - padding: 2px 8px; - font-size: 0.72rem; - border-radius: 4px; - border: 1px solid var(--border); - background: var(--bg-1); - color: var(--text-2); - cursor: pointer; - line-height: 1.4; - transition: background 0.12s, color 0.12s; -} - -.btn-tiny:hover { - background: var(--bg-3); - color: var(--text-1); -} - -.btn-tiny.btn-approve { - color: var(--success, #5cb85c); - border-color: var(--success, #5cb85c); -} - -.btn-tiny.btn-approve:hover { - background: var(--success, #5cb85c); - color: #fff; -} - -.btn-tiny.btn-reject { - color: var(--danger, #d9534f); - border-color: var(--danger, #d9534f); -} - -.btn-tiny.btn-reject:hover { - background: var(--danger, #d9534f); - color: #fff; -} - -/* ── Edit Form ─────────────────────────── */ - -.memory-edit-form { - padding: 4px 0; -} - -.memory-edit-form .form-group { - margin-bottom: 6px; -} - -.memory-edit-input { - width: 100%; - padding: 5px 8px; - border-radius: 6px; - border: 1px solid var(--border); - background: var(--bg-1); - color: var(--text-1); - font-size: 0.82rem; - font-family: inherit; -} - -.memory-edit-input:focus { - outline: none; - border-color: var(--accent); -} - -/* ── Persona Memory Section ────────────── */ - -.memory-persona-section { - margin-top: 8px; -} - -.form-divider { - border-top: 1px solid var(--border); - margin: 10px 0; -} - -.form-section-title { - font-size: 0.85rem; - font-weight: 600; - color: var(--text-2); - margin: 0 0 6px; -} - -/* ── Admin Toolbar ─────────────────────── */ - -.admin-toolbar-label { - font-size: 0.82rem; - color: var(--text-2); - margin-right: auto; -} - -/* ── Responsive ────────────────────────── */ - -@media (max-width: 640px) { - .memory-summary { - gap: 8px; - } - .memory-stat { - padding: 8px 12px; - } - .memory-filter-row { - flex-direction: column; - } - .memory-card-header { - flex-direction: column; - align-items: flex-start; - } -} diff --git a/src/css/notification-prefs.css b/src/css/notification-prefs.css deleted file mode 100644 index 1182af0..0000000 --- a/src/css/notification-prefs.css +++ /dev/null @@ -1,64 +0,0 @@ -/* ========================================== - Notification Preferences — v0.20.0 Phase 3 - ========================================== */ - -.notif-pref-table { - width: 100%; - border-collapse: collapse; - font-size: 0.9rem; -} -.notif-pref-table th { - text-align: left; - padding: 6px 8px; - border-bottom: 2px solid var(--border); - font-size: 0.8rem; - color: var(--text-2); - font-weight: 500; -} -.notif-pref-table th:nth-child(2), -.notif-pref-table th:nth-child(3) { - text-align: center; - width: 70px; -} -.notif-pref-table td { - padding: 8px; - border-bottom: 1px solid var(--border); -} -.notif-pref-toggle { - text-align: center; -} -.notif-pref-label { - font-size: 0.85rem; -} - -/* ── SMTP Config (Admin panel) ────────────── */ - -.smtp-config-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; -} -.smtp-config-grid .form-group { - margin-bottom: 0; -} -.smtp-config-grid .form-group.full-width { - grid-column: 1 / -1; -} -.smtp-test-row { - display: flex; - align-items: center; - gap: 12px; - margin-top: 12px; -} -.smtp-test-status { - font-size: 0.85rem; - color: var(--text-2); -} -.smtp-test-status.success { color: var(--success); } -.smtp-test-status.error { color: var(--danger); } - -@media (max-width: 600px) { - .smtp-config-grid { - grid-template-columns: 1fr; - } -} diff --git a/src/css/persona-kb.css b/src/css/persona-kb.css deleted file mode 100644 index 1b7163c..0000000 --- a/src/css/persona-kb.css +++ /dev/null @@ -1,135 +0,0 @@ -/* persona-kb.css — Persona–Knowledge Base picker styles (v0.17.0) */ - -.persona-kb-picker { - margin-top: 0.75rem; -} - -.persona-kb-picker > label { - font-weight: 500; - margin-bottom: 0.25rem; - display: block; -} - -.persona-kb-list { - max-height: 200px; - overflow-y: auto; - border: 1px solid var(--border); - border-radius: var(--radius, 6px); - padding: 0.25rem; -} - -.persona-kb-item { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.375rem 0.5rem; - border-radius: 4px; - cursor: pointer; - font-size: 0.85rem; - transition: background 0.15s; -} - -.persona-kb-item:hover { - background: var(--hover-bg, rgba(255,255,255,0.05)); -} - -.persona-kb-item.selected { - background: var(--selected-bg, rgba(100,160,255,0.1)); -} - -.persona-kb-item input[type="checkbox"] { - flex-shrink: 0; -} - -.persona-kb-item .kb-name { - flex: 1; - font-weight: 500; -} - -.persona-kb-item .kb-meta { - color: var(--text-muted, #888); - font-size: 0.75rem; - white-space: nowrap; -} - -.auto-search-toggle { - display: flex; - align-items: center; - gap: 0.25rem; - font-size: 0.75rem; - color: var(--text-muted, #888); - cursor: pointer; - white-space: nowrap; -} - -.auto-search-toggle.hidden { - display: none; -} - -.auto-search-toggle span { - opacity: 0.8; -} - -/* ── Persona-KB Section (in persona form) ─── */ - -.persona-kb-section { - margin: 0.75rem 0; - padding-top: 0.5rem; - border-top: 1px solid var(--border); -} - -/* ── Role Fallback Alert Banner ───────────── */ - -.role-fallback-banner { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.75rem; - background: var(--warning-bg, #fff3cd); - border: 1px solid var(--warning-border, #ffc107); - border-radius: 4px; - margin: 0.5rem 0.75rem; - font-size: 0.85rem; -} - -.role-fallback-banner .fallback-icon { - font-size: 1.1rem; - flex-shrink: 0; -} - -.role-fallback-banner .fallback-msg { - flex: 1; -} - -.role-fallback-banner .btn-small { - flex-shrink: 0; - font-size: 0.75rem; - padding: 0.15rem 0.5rem; -} - -/* ── Chat Rename Inline Input ────────────── */ - -.chat-rename-input { - width: 100%; - background: var(--input-bg, #1a1a2e); - color: var(--text-color, #e0e0e0); - border: 1px solid var(--accent-color, #4a9eff); - border-radius: 3px; - padding: 1px 4px; - font-size: inherit; - font-family: inherit; - outline: none; -} - -/* ── Chat Header Token Count ─────────────── */ - -.chat-token-count { - font-size: 0.75rem; - color: var(--text-muted, #888); - white-space: nowrap; - padding: 0 0.5rem; - flex-shrink: 0; -} - -.chat-token-count.warning { color: var(--warning-color, #f0ad4e); } -.chat-token-count.danger { color: var(--danger-color, #d9534f); } diff --git a/src/css/splash.css b/src/css/splash.css deleted file mode 100644 index c2eec73..0000000 --- a/src/css/splash.css +++ /dev/null @@ -1,168 +0,0 @@ -/* ── splash.css ─────────────────────────────── - Auth splash, login, PWA install banner - ──────────────────────────────────────────── */ - -/* ── Auth Splash ─────────────────────────── */ - -.splash { - display: flex; - min-height: 100vh; - min-height: 100dvh; -} - -/* Hero Panel (left) */ -.splash-hero { - flex: 1; - display: flex; - flex-direction: column; - justify-content: center; - padding: 4rem 3.5rem; - position: relative; - overflow: hidden; - background: - radial-gradient(ellipse 80% 60% at 30% 40%, rgba(108,159,255,0.06), transparent), - radial-gradient(ellipse 60% 50% at 70% 70%, rgba(167,139,250,0.05), transparent), - var(--bg); -} -.splash-hero::before { - content: ''; - position: absolute; - inset: -50%; - background-image: - linear-gradient(rgba(108,159,255,0.04) 1px, transparent 1px), - linear-gradient(90deg, rgba(108,159,255,0.04) 1px, transparent 1px); - background-size: 48px 48px; - animation: gridDrift 30s linear infinite; - pointer-events: none; -} -@keyframes gridDrift { to { transform: translate(48px, 48px); } } -.splash-hero::after { - content: ''; - position: absolute; - width: 320px; height: 320px; - bottom: -80px; right: -60px; - background: radial-gradient(circle, rgba(167,139,250,0.08), transparent 70%); - border-radius: 50%; - pointer-events: none; -} - -.hero-content { position: relative; z-index: 1; max-width: 520px; } -.hero-logo-row { display: flex; align-items: center; gap: 14px; margin-bottom: 1.5rem; } -.hero-logo-mark { width: 48px; height: 48px; flex-shrink: 0; } -.hero-logo-img { width: 48px; height: 48px; object-fit: contain; border-radius: 8px; } -.hero-wordmark { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.03em; line-height: 1.1; } -.hero-wordmark span { color: var(--accent); } - -.hero-headline { - font-size: 2.4rem; font-weight: 700; line-height: 1.15; - letter-spacing: -0.03em; margin-bottom: 1rem; - background: linear-gradient(135deg, var(--text) 0%, var(--text-2) 100%); - -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; -} -.hero-sub { font-size: 1.05rem; color: var(--text-2); line-height: 1.6; margin-bottom: 2.5rem; max-width: 440px; } - -.hero-features { display: flex; flex-wrap: wrap; gap: 10px; } -.hero-pill { - display: inline-flex; align-items: center; gap: 7px; - padding: 7px 14px; background: var(--bg-surface); - border: 1px solid var(--border); border-radius: 100px; - font-size: 0.8rem; font-weight: 500; color: var(--text-2); - transition: all var(--transition); -} -.hero-pill:hover { border-color: var(--border-light); color: var(--text); } -.hero-pill .pill-icon { font-size: 0.95rem; line-height: 1; } -.hero-pill.accent { border-color: rgba(108,159,255,0.2); color: var(--accent); } -.hero-pill.purple { border-color: rgba(167,139,250,0.2); color: var(--purple); } -.hero-version { margin-top: 3rem; font-size: 0.72rem; font-family: var(--mono); color: var(--text-3); letter-spacing: 0.04em; } - -/* Auth Panel (right) */ -.splash-auth { - width: 440px; flex-shrink: 0; - display: flex; align-items: center; justify-content: center; - padding: 2rem; background: var(--bg-surface); - border-left: 1px solid var(--border); -} -.auth-card { width: 100%; max-width: 340px; } -.auth-card-header { margin-bottom: 2rem; } -.auth-card-header h2 { font-size: 1.25rem; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 4px; } -.auth-card-header p { font-size: 0.85rem; color: var(--text-3); } - -.auth-tabs { display: flex; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border); } -.auth-tab { - flex: 1; background: none; border: none; color: var(--text-3); - padding: 10px 0; cursor: pointer; font-family: var(--font); - font-size: 0.85rem; font-weight: 500; - border-bottom: 2px solid transparent; - transition: color var(--transition); -} -.auth-tab:hover { color: var(--text-2); } -.auth-tab.active { color: var(--accent); border-bottom-color: var(--accent); } -.auth-error { color: var(--danger); font-size: 0.78rem; min-height: 1.2em; margin: 0.5rem 0; } -.splash-error { color: var(--danger); font-size: 0.78rem; text-align: center; margin-top: 0.5rem; line-height: 1.5; } -.splash-error strong { display: block; font-size: 0.85rem; margin-bottom: 0.25rem; } -.splash-error-hint { color: var(--text-3); font-size: 0.72rem; } -.splash-error-hint a { color: var(--accent); text-decoration: underline; cursor: pointer; } -.auth-actions { margin-top: 1.5rem; } -.auth-footer { margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border); text-align: center; } -.auth-footer p { font-size: 0.75rem; color: var(--text-3); line-height: 1.6; } - -/* Splash entrance animation */ -.hero-logo-row, .hero-headline, .hero-sub, .hero-features, .hero-version, -.auth-card-header, .splash .auth-tabs, #authLoginForm, .splash .auth-actions { - opacity: 0; transform: translateY(12px); - animation: fadeUp 0.5s ease forwards; -} -.hero-logo-row { animation-delay: 0.05s; } -.hero-headline { animation-delay: 0.12s; } -.hero-sub { animation-delay: 0.19s; } -.hero-features { animation-delay: 0.26s; } -.hero-version { animation-delay: 0.33s; } -.auth-card-header { animation-delay: 0.15s; } -.splash .auth-tabs { animation-delay: 0.22s; } -#authLoginForm { animation-delay: 0.29s; } -.splash .auth-actions { animation-delay: 0.36s; } -@keyframes fadeUp { to { opacity: 1; transform: translateY(0); } } -@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } - -/* Splash responsive */ -@media (max-width: 860px) { - .splash { flex-direction: column; min-height: 100vh; min-height: 100dvh; overflow-y: auto; } - .splash-hero { padding: 2.5rem 1.75rem 2rem; min-height: auto; flex: none; } - .hero-headline { font-size: 1.8rem; } - .hero-sub { font-size: 0.95rem; margin-bottom: 1.5rem; } - .hero-version { margin-top: 1.5rem; } - .splash-auth { width: 100%; border-left: none; border-top: 1px solid var(--border); padding: 2rem 1.75rem; } - .auth-card { max-width: 400px; } -} -@media (max-width: 480px) { - .splash-hero { padding: 1.5rem 1.25rem 1rem; } - .hero-logo-row { gap: 10px; margin-bottom: 0.75rem; } - .hero-logo-mark { width: 40px; height: 40px; } - .hero-logo-img { width: 40px; height: 40px; } - .hero-wordmark { font-size: 1.3rem; } - .hero-headline { font-size: 1.35rem; margin-bottom: 0.5rem; } - .hero-sub { font-size: 0.85rem; margin-bottom: 1rem; line-height: 1.5; } - .hero-features { gap: 6px; } - .hero-pill { padding: 5px 10px; font-size: 0.72rem; } - .hero-version { margin-top: 1rem; } - .splash-auth { padding: 1.5rem 1.25rem; } - .auth-card-header { margin-bottom: 1rem; } - .auth-card-header h2 { font-size: 1.1rem; } - .auth-actions { margin-top: 1rem; } -} - - -/* ── PWA Install Banner ──────────────────── */ - -.pwa-install-banner { - position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); - background: var(--bg-raised); border: 1px solid var(--border-light); - border-radius: var(--radius-lg); padding: 10px 16px; - display: flex; align-items: center; gap: 12px; - box-shadow: 0 8px 32px rgba(0,0,0,0.5); z-index: 3000; - font-size: 13px; color: var(--text-2); - animation: pwa-banner-in 0.3s ease; - max-width: 90vw; -} -@keyframes pwa-banner-in { from { opacity: 0; transform: translateX(-50%) translateY(20px); } } - diff --git a/src/css/styles.css b/src/css/styles.css deleted file mode 100644 index 4a90e40..0000000 --- a/src/css/styles.css +++ /dev/null @@ -1,13 +0,0 @@ -/* ── styles.css ────────────────────────────── - DECOMPOSED in v0.22.9 into: - variables.css - Theme vars, reset, base - layout.css - App shell, workspace, sidebar, responsive - primitives.css - Buttons, forms, toasts, popups, toggles, badges - modals.css - Modal system, debug, command palette, confirm - chat.css - Chat area, markdown, input, files, lightbox - panels.css - Side panel, preview, notes, graph - surfaces.css - Admin, settings, editor, projects - splash.css - Auth splash, PWA banner - - All loaded via base.html. This file is no longer used. - ──────────────────────────────────────────── */ diff --git a/src/js/__tests__/policy-gating.test.js b/src/js/__tests__/policy-gating.test.js index 302f453..447b988 100644 --- a/src/js/__tests__/policy-gating.test.js +++ b/src/js/__tests__/policy-gating.test.js @@ -13,6 +13,9 @@ // v0.37.10: Legacy SPA source audit removed (ui-core.js, app.js, // pages.js, settings-handlers.js, ui-admin.js all deleted). // Policy gating now verified via Preact surfaces + admin templates. +// v0.37.12: Admin Go templates deleted (Preact since v0.37.6). Template +// element ID assertions removed — Preact component source audits +// at the bottom of this file cover the same policy keys. // // Run: node --test src/js/__tests__/policy-gating.test.js // ========================================== @@ -133,49 +136,6 @@ describe('Team member dropdown population', () => { }); }); -// ── Admin settings field mapping ───────────── -// v0.22.5: Server-rendered admin settings template uses new element IDs. -// v0.37.10: pages.js deleted — admin settings save now handled by -// Preact admin surface. Template elements still required. - -describe('Admin settings field mapping (server templates)', () => { - const templateSrc = readAllTemplates(); - - const requiredElements = [ - 'settRegEnabled', - 'settRegDefaultState', - 'settUserBYOK', - 'settUserPersonas', - 'settBannerEnabled', - ]; - - for (const id of requiredElements) { - it(`template has element #${id}`, () => { - assert.ok(templateSrc.includes(`id="${id}"`), - `MISSING: #${id} in server templates — admin settings incomplete`); - }); - } -}); - -// ── Critical HTML elements in templates ─────── - -describe('Critical HTML elements exist in server templates', () => { - const templateSrc = readAllTemplates(); - - const requiredElements = [ - // Admin settings — policy toggles (still in Go templates) - 'settUserBYOK', // Admin toggle for BYOK - 'settUserPersonas', // Admin toggle for personas - ]; - - for (const id of requiredElements) { - it(`#${id} exists in server templates`, () => { - assert.ok(templateSrc.includes(`id="${id}"`), - `MISSING element: #${id} — UI feature will break`); - }); - } -}); - // ── Chat surface template ──────────────────── // v0.37.10: Chat surface is now Preact-rendered. Verify the mount // point exists and the old SPA scaffold is gone. diff --git a/src/js/drag-resize.js b/src/js/drag-resize.js deleted file mode 100644 index 66d36b5..0000000 --- a/src/js/drag-resize.js +++ /dev/null @@ -1,100 +0,0 @@ -// ========================================== -// Chat Switchboard — Drag Resize Utility -// ========================================== -// v0.25.2: Single drag-resize primitive used by both the workspace -// handle (panels.js) and PaneContainer split handles (pane-container.js). -// -// Fixes vs the two prior implementations: -// - Full-viewport overlay during drag prevents iframes / CM6 from -// swallowing mousemove events (the "jump to middle" bug). -// - Touch support (touchstart / touchmove / touchend) everywhere. -// - Transitions on dragged elements are killed on grab and restored -// only after release — no mid-animation capture of wrong startW. -// -// Usage: -// initDragResize(handleEl, { -// direction: 'horizontal', // or 'vertical' -// onStart(clientPos) → ctx | false, // return drag context or false to cancel -// onMove(delta, ctx), // delta = px from start (signed) -// onEnd(ctx), // cleanup / persist -// }); -// -// Exports: window.initDragResize - - -/** - * Attach drag-resize behavior to a handle element. - * - * @param {HTMLElement} handleEl — the drag grip element - * @param {object} opts - * @param {'horizontal'|'vertical'} opts.direction - * @param {function} opts.onStart — receives initial clientX/Y, returns context or false - * @param {function} opts.onMove — receives (delta, ctx) - * @param {function} opts.onEnd — receives (ctx) - * @param {function} [opts.canDrag] — optional guard; return false to ignore mousedown - */ -function initDragResize(handleEl, opts) { - if (!handleEl) return; - - const isHoriz = (opts.direction || 'horizontal') === 'horizontal'; - - const onPointerDown = (e) => { - // Optional guard (e.g. "only when .active class is present") - if (opts.canDrag && !opts.canDrag()) return; - - e.preventDefault(); - const clientPos = _clientPos(e, isHoriz); - if (clientPos == null) return; - - const ctx = opts.onStart(clientPos); - if (ctx === false) return; - - // ── Overlay: blocks iframes / CodeMirror from eating events ── - const overlay = document.createElement('div'); - overlay.style.cssText = - 'position:fixed;inset:0;z-index:99999;' + - 'cursor:' + (isHoriz ? 'col-resize' : 'row-resize') + ';'; - document.body.appendChild(overlay); - document.body.style.cursor = isHoriz ? 'col-resize' : 'row-resize'; - document.body.style.userSelect = 'none'; - - const onMove = (e) => { - const pos = _clientPos(e, isHoriz); - if (pos == null) return; - opts.onMove(pos - clientPos, ctx); - }; - - const onUp = () => { - overlay.remove(); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - document.removeEventListener('touchmove', onMove); - document.removeEventListener('touchend', onUp); - if (opts.onEnd) opts.onEnd(ctx); - }; - - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - document.addEventListener('touchmove', onMove, { passive: false }); - document.addEventListener('touchend', onUp); - }; - - handleEl.addEventListener('mousedown', onPointerDown); - handleEl.addEventListener('touchstart', onPointerDown, { passive: false }); -} - -/** Extract the relevant coordinate from a mouse or touch event. */ -function _clientPos(e, isHoriz) { - if (e.touches?.length > 0) { - return isHoriz ? e.touches[0].clientX : e.touches[0].clientY; - } - if (e.changedTouches?.length > 0) { - return isHoriz ? e.changedTouches[0].clientX : e.changedTouches[0].clientY; - } - return isHoriz ? e.clientX : e.clientY; -} - -// ── Exports ───────────────────────────────── -sb.register('initDragResize', initDragResize); diff --git a/src/js/model-selector.js b/src/js/model-selector.js deleted file mode 100644 index 79a4f2b..0000000 --- a/src/js/model-selector.js +++ /dev/null @@ -1,175 +0,0 @@ -// ========================================== -// Chat Switchboard — ModelSelector Component -// ========================================== -// v0.25.0: Extracted from ui-core.js. -// Grouped dropdown: personas (global, team, personal), models, BYOK. -// Pattern: Go template partial (model-selector.html) + JS factory (this file). -// -// Usage: -// const sel = ModelSelector.create({ id: 'main', onChange: (id, label) => { ... } }); -// sel.setModels(App.models); -// sel.select(modelId); -// sel.getSelected(); // → current model ID -// sel.destroy(); -// -// Exports: window.ModelSelector - - -const ModelSelector = { - ...createComponentRegistry('ModelSelector'), - - create(opts) { - const pfx = opts.id || ''; - const instance = componentMixin({ - id: pfx, - dropdownEl: document.getElementById(pfx + 'modelDropdown'), - btnEl: document.getElementById(pfx + 'modelDropdownBtn'), - labelEl: document.getElementById(pfx + 'modelDropdownLabel'), - menuEl: document.getElementById(pfx + 'modelDropdownMenu'), - capsEl: document.getElementById(pfx + 'modelCaps'), - onChange: opts.onChange || null, - _value: '', - _models: [], - - // ── Selection ─────────────────────── - - getSelected() { return this._value; }, - - select(id, label) { - this._value = id; - if (this.labelEl) this.labelEl.textContent = label || id || 'Select a model'; - // Highlight - if (this.menuEl) { - this.menuEl.querySelectorAll('.model-dropdown-item').forEach(el => { - el.classList.toggle('selected', el.dataset.value === id); - }); - } - this._updateCaps(); - if (this.onChange) this.onChange(id, label); - }, - - // ── Model list ────────────────────── - - setModels(models) { - this._models = models || []; - this._rebuild(); - }, - - _rebuild() { - if (!this.menuEl) return; - this.menuEl.innerHTML = ''; - - if (this._models.length === 0) { - this.menuEl.innerHTML = '
No models loaded
'; - this.select('', 'No models loaded'); - return; - } - - const globalPersonas = this._models.filter(m => m.isPersona && m.personaScope === 'global'); - const teamPersonas = this._models.filter(m => m.isPersona && m.personaScope === 'team'); - const personalPersonas = this._models.filter(m => m.isPersona && m.personaScope === 'personal'); - const models = this._models.filter(m => !m.isPersona && !m.hidden); - - const addGroup = (label, items) => { - if (items.length === 0) return; - const hdr = document.createElement('div'); - hdr.className = 'model-dropdown-group'; - hdr.textContent = label; - this.menuEl.appendChild(hdr); - items.forEach(m => this.menuEl.appendChild(this._createItem(m))); - }; - - addGroup('\u26A1 Personas', globalPersonas); - - // Group team personas by team name - const teamGroups = {}; - teamPersonas.forEach(m => { - const tn = m.personaTeamName || 'Team'; - (teamGroups[tn] = teamGroups[tn] || []).push(m); - }); - Object.keys(teamGroups).sort().forEach(tn => { - addGroup('\uD83D\uDC65 ' + tn, teamGroups[tn]); - }); - - addGroup('\uD83D\uDD27 My Personas', personalPersonas); - addGroup('Models', models.filter(m => m.source !== 'personal')); - addGroup('\uD83D\uDD11 My Providers', models.filter(m => m.source === 'personal')); - }, - - _createItem(m) { - const self = this; - const div = document.createElement('div'); - div.className = 'model-dropdown-item'; - div.dataset.value = m.id; - const avatar = m.personaAvatar ? '' : ''; - const handle = m.isPersona && m.personaHandle ? '@' + esc(m.personaHandle) + '' : ''; - const teamBadge = m.source === 'team' && m.teamName ? '\uD83D\uDC65 ' + esc(m.teamName) + '' : ''; - const provider = m.provider ? '' + esc(m.provider) + '' : ''; - div.innerHTML = avatar + '' + esc(m.name || m.id) + handle + '' + teamBadge + provider; - div.addEventListener('click', () => { - self.select(m.id, (m.name || m.id) + (m.provider ? ' (' + m.provider + ')' : '')); - self.close(); - }); - return div; - }, - - // ── Restore selection ─────────────── - - /** - * Restore selection from a preferred ID, falling back through - * admin default → first visible. - */ - restore(preferredId, adminDefault) { - const visible = this._models.filter(m => !m.hidden); - if (visible.length === 0) { this.select('', 'No visible models'); return; } - - const byId = (id) => visible.find(m => m.id === id || m.baseModelId === id); - const match = byId(preferredId) || byId(adminDefault) || visible[0]; - this.select(match.id, (match.name || match.id) + (match.provider ? ' (' + match.provider + ')' : '')); - }, - - // ── Dropdown toggle ───────────────── - - open() { if (this.menuEl) this.menuEl.classList.add('open'); }, - close() { if (this.menuEl) this.menuEl.classList.remove('open'); }, - toggle() { if (this.menuEl) this.menuEl.classList.toggle('open'); }, - - // ── Capability badges ─────────────── - - _updateCaps() { - if (!this.capsEl) return; - if (!this._value) { this.capsEl.innerHTML = ''; return; } - const m = this._models.find(m => m.id === this._value); - const caps = m?.capabilities; - if (!caps || Object.keys(caps).length === 0) { this.capsEl.innerHTML = ''; return; } - // Use global renderCapBadges if available (from ui-core.js) - if (typeof renderCapBadges === 'function') { - this.capsEl.innerHTML = renderCapBadges(caps); - } - }, - - getSelectedCaps() { - const m = this._models.find(m => m.id === this._value); - return m?.capabilities || {}; - }, - - // ── Event wiring ──────────────────── - - bind() { - this._on(this.btnEl, 'click', (e) => { - e.stopPropagation(); - this.toggle(); - }); - this._on(document, 'click', (e) => { - if (!e.target.closest('#' + pfx + 'modelDropdown')) this.close(); - }); - }, - }, ModelSelector); - - ModelSelector._register(pfx, instance); - return instance; - }, -}; - -// ── Exports ───────────────────────────────── -sb.ns('ModelSelector', ModelSelector); diff --git a/src/js/pages-splash.js b/src/js/pages-splash.js deleted file mode 100644 index 574caa0..0000000 --- a/src/js/pages-splash.js +++ /dev/null @@ -1,186 +0,0 @@ -// ========================================== -// Chat Switchboard - Splash Surface (pages-splash.js) -// ========================================== -// v0.22.7: Splash init, login, register, animated grid. - - var base = window.__BASE__ || ''; - - Pages.initSplash = function() { - _initAuthTabs(); - _initLoginForm(); - _initRegisterForm(); - _initGridCanvas(); - if (typeof Theme !== 'undefined') Theme.init(); - }; - - // -- Save tokens via API singleton (sets sb_token cookie) -- - function _saveAuth(data) { - if (typeof API !== 'undefined') { - API.accessToken = data.access_token || null; - API.refreshToken = data.refresh_token || null; - API.user = data.user || null; - API.saveTokens(); - } else { - // Fallback: at minimum set the cookie so page auth works - if (data.access_token) { - localStorage.setItem('token', data.access_token); - document.cookie = 'sb_token=' + data.access_token + '; path=/; max-age=900; SameSite=Strict'; - } - } - } - - // -- Redirect after auth -- - function _redirectAfterAuth() { - // Check for saved redirect (e.g. deep link that bounced to /login) - var cookies = document.cookie.split(';'); - for (var i = 0; i < cookies.length; i++) { - var c = cookies[i].trim(); - if (c.startsWith('redirect_after_login=')) { - var dest = decodeURIComponent(c.substring('redirect_after_login='.length)); - document.cookie = 'redirect_after_login=; path=/; max-age=0'; - if (dest && dest !== '/login' && dest !== base + '/login') { - window.location.href = dest; - return; - } - } - } - window.location.href = base + '/'; - } - - function _initAuthTabs() { - var tabs = document.querySelectorAll('.splash-tab'); - var loginForm = document.getElementById('authLogin'); - var regForm = document.getElementById('authRegister'); - if (!tabs.length) return; - tabs.forEach(function(tab) { - tab.addEventListener('click', function() { - tabs.forEach(function(t) { t.classList.remove('active'); }); - tab.classList.add('active'); - var target = tab.dataset.tab; - if (loginForm) loginForm.style.display = target === 'login' ? '' : 'none'; - if (regForm) regForm.style.display = target === 'register' ? '' : 'none'; - }); - }); - } - - function _initLoginForm() { - var btn = document.getElementById('loginBtn'); - if (!btn) return; - var form = document.getElementById('authLogin'); - if (form) form.addEventListener('keydown', function(e) { if (e.key === 'Enter') { e.preventDefault(); btn.click(); } }); - - btn.addEventListener('click', async function() { - var username = _val('loginUsername'); - var password = _val('loginPassword'); - var errEl = document.getElementById('loginError'); - if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; } - if (!username || !password) { _showErr(errEl, 'Username and password are required.'); return; } - - btn.disabled = true; - btn.textContent = 'Logging in\u2026'; - try { - var resp = await fetch(base + '/api/v1/auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ login: username, password: password }), - }); - if (!resp.ok) { - var errData = await resp.json().catch(function() { return {}; }); - throw new Error(errData.error || 'Invalid credentials'); - } - var data = await resp.json(); - _saveAuth(data); - _redirectAfterAuth(); - } catch (e) { - _showErr(errEl, e.message || 'Login failed'); - btn.disabled = false; - btn.textContent = 'Log In'; - } - }); - } - - function _initRegisterForm() { - var btn = document.getElementById('regBtn'); - if (!btn) return; - var avatarEl = document.getElementById('avatarUpload'); - var avatarFile = null; - if (avatarEl && typeof mountAvatarUpload === 'function') { - mountAvatarUpload(avatarEl, { onUpload: function(file) { avatarFile = file; } }); - } - var form = document.getElementById('authRegister'); - if (form) form.addEventListener('keydown', function(e) { if (e.key === 'Enter') { e.preventDefault(); btn.click(); } }); - - btn.addEventListener('click', async function() { - var username = _val('regUsername'); - var displayName = _val('regDisplayName'); - var email = _val('regEmail'); - var password = _val('regPassword'); - var confirm = _val('regConfirm'); - var errEl = document.getElementById('regError'); - if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; } - if (!username || !password) { _showErr(errEl, 'Username and password are required.'); return; } - if (password !== confirm) { _showErr(errEl, 'Passwords do not match.'); return; } - - btn.disabled = true; - btn.textContent = 'Creating account\u2026'; - try { - var body = { username: username, password: password, display_name: displayName, email: email }; - var resp = await fetch(base + '/api/v1/auth/register', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - if (!resp.ok) { - var errData = await resp.json().catch(function() { return {}; }); - throw new Error(errData.error || 'Registration failed'); - } - var data = await resp.json(); - // Upload avatar before redirect (token needed for auth) - if (avatarFile && data.access_token) { - try { - var fd = new FormData(); - fd.append('avatar', avatarFile); - await fetch(base + '/api/v1/users/me/avatar', { - method: 'PUT', - headers: { 'Authorization': 'Bearer ' + data.access_token }, - body: fd, - }); - } catch (e) { console.warn('Avatar upload failed:', e); } - } - _saveAuth(data); - _redirectAfterAuth(); - } catch (e) { - _showErr(errEl, e.message || 'Registration failed'); - btn.disabled = false; - btn.textContent = 'Create Account'; - } - }); - } - - function _initGridCanvas() { - var canvas = document.getElementById('gridCanvas'); - if (!canvas) return; - var ctx = canvas.getContext('2d'); - var w, h, offset = 0; - function resize() { - var rect = canvas.parentElement.getBoundingClientRect(); - w = canvas.width = rect.width; - h = canvas.height = rect.height; - } - function draw() { - ctx.clearRect(0, 0, w, h); - var gap = 40; - ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--border').trim() || '#2e2e35'; - ctx.lineWidth = 0.5; - for (var x = (offset % gap); x < w; x += gap) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); } - for (var y = (offset % gap); y < h; y += gap) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } - offset += 0.15; - requestAnimationFrame(draw); - } - window.addEventListener('resize', resize); - resize(); - draw(); - } - - function _val(id) { var el = document.getElementById(id); return el ? el.value.trim() : ''; } - function _showErr(el, msg) { if (!el) return; el.textContent = msg; el.style.display = ''; } diff --git a/src/js/task-sidebar.js b/src/js/task-sidebar.js deleted file mode 100644 index 633ca9e..0000000 --- a/src/js/task-sidebar.js +++ /dev/null @@ -1,141 +0,0 @@ -// ========================================== -// Chat Switchboard — Task Sidebar Section -// ========================================== -// Sidebar section showing user's scheduled tasks with status indicators. -// Click opens the task's output channel. Same pattern as WorkflowQueue. - - - const TaskSidebar = { - - _loaded: false, - - // ── Init ──────────────────────────── - - async init() { - if (this._loaded) return; - this._loaded = true; - - // Insert after Queue section (or after Channels if no queue) - var anchor = document.getElementById('sbSectionQueue') || - document.getElementById('sbSectionChannels'); - if (!anchor) return; - - var section = document.createElement('div'); - section.className = 'sb-section'; - section.id = 'sbSectionTasks'; - section.innerHTML = - '
' + - '\u25be' + - 'Tasks' + - '' + - '
' + - '
'; - - anchor.parentNode.insertBefore(section, anchor.nextSibling); - this.refresh(); - }, - - // ── Refresh ───────────────────────── - - async refresh() { - var body = document.getElementById('sbBodyTasks'); - var badge = document.getElementById('sbTaskBadge'); - if (!body) return; - - try { - // Personal tasks - var resp = await API._get('/api/v1/tasks'); - var tasks = (resp.data || []).filter(function(t) { return t.is_active; }); - - // Team tasks (v0.27.5) - try { - var teamsResp = await API._get('/api/v1/teams/mine'); - var teams = teamsResp.data || teamsResp || []; - var seen = {}; - tasks.forEach(function(t) { seen[t.id] = true; }); - for (var i = 0; i < teams.length; i++) { - try { - var tr = await API._get('/api/v1/teams/' + teams[i].id + '/tasks'); - (tr.data || []).forEach(function(t) { - if (!seen[t.id] && t.is_active) { - t._teamName = teams[i].name; - tasks.push(t); - seen[t.id] = true; - } - }); - } catch (_) {} - } - } catch (_) {} - - if (tasks.length === 0) { - body.innerHTML = ''; - if (badge) badge.style.display = 'none'; - return; - } - - if (badge) { - badge.textContent = tasks.length; - badge.style.display = ''; - } - - var html = ''; - tasks.forEach(function(t) { - // Status indicator - var icon = '\u25f7'; // default: clock - var statusTip = 'Scheduled'; - if (t.last_run_at) { - // Check if most recent run was recent (within 2x schedule interval) - icon = '\u2713'; // checkmark - statusTip = 'Last: ' + new Date(t.last_run_at).toLocaleString(); - } - var nextTip = t.next_run_at - ? 'Next: ' + new Date(t.next_run_at).toLocaleString() - : ''; - - var channelId = t.output_channel_id || ''; - var onclick = channelId - ? 'selectChannel(\'' + channelId + '\')' - : 'UI.toast(\'No output channel yet — task has not run\',\'info\')'; - - var label = _esc(t.name); - if (t._teamName) label = '[' + _esc(t._teamName) + '] ' + label; - - html += '
' + - '' + icon + '' + - '' + label + '' + - '' + - '
'; - }); - - body.innerHTML = html; - } catch (err) { - body.innerHTML = ''; - if (badge) badge.style.display = 'none'; - } - }, - - // ── Run Now ───────────────────────── - - async runNow(taskId) { - try { - await API._post('/api/v1/tasks/' + taskId + '/run', {}); - UI.toast('Task triggered', 'success'); - } catch (err) { - UI.toast(err.message || 'Failed to run task', 'error'); - } - } - }; - sb.ns('TaskSidebar', TaskSidebar); - - function _esc(s) { return s ? s.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"') : ''; } - - // Auto-init when chat surface loads - document.addEventListener('DOMContentLoaded', function() { - setTimeout(function() { - if (typeof API !== 'undefined' && API.accessToken) { - TaskSidebar.init(); - } - }, 2200); // After WorkflowQueue (2000ms) - }); diff --git a/src/js/ui-format.js b/src/js/ui-format.js deleted file mode 100644 index 6321210..0000000 --- a/src/js/ui-format.js +++ /dev/null @@ -1,628 +0,0 @@ -// ========================================== -// Chat Switchboard – UI Formatting & Helpers -// ========================================== -// Markdown rendering, code blocks, preview panel, time formatting. -// esc() is in ui-primitives.js (loaded globally in base.html). -// -// Exports: formatMessage, clearPreview, runExtensionPostRender, -// _livePreviewUpdate, _relativeTime, _renderToolCallsHTML, -// _registerPreviewPanel, -// toggleCodeCollapse, toggleHTMLPreview, downloadCode, -// popOutExtBlock (onclick handlers) -// ========================================== - - -// ── Message Formatting ────────────────────── - -function formatMessage(content) { - if (!content) return ''; - - const thinkingBlocks = []; - let text = content; - - text = text.replace(/<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi, (_, inner) => { - const id = 'think-' + Math.random().toString(36).slice(2, 9); - thinkingBlocks.push({ id, content: inner.trim() }); - // Newlines ensure the placeholder is its own markdown block so adjacent - // code fences (e.g. "```html") start on a fresh line for marked. - return `\n\nTHINK_PLACEHOLDER_${id}\n\n`; - }); - - let html; - if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') { - html = _formatMarked(text); - } else { - html = _formatBasic(text); - } - - for (const b of thinkingBlocks) { - const inner = esc(b.content).replace(/\n/g, '
'); - const openAttr = App.settings.showThinking ? ' open' : ''; - const thinkHTML = `
💭 Thinking
${inner}
`; - html = html.replace(new RegExp(`

\\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}
${toolbar}
`; - }); - - 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()}
${toolbar}
`; - }); - 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 += ''; - html += ''; - html += ''; - - for (const inst of instances) { - const age = formatDuration(inst.age_seconds); - const sla = renderSLA(inst); - html += ``; - html += ``; - html += ``; - html += ``; - html += ``; - html += ''; - } - html += '
WorkflowStageAgeSLA
${esc(inst.workflow_name)}
${esc(inst.channel_title)}
${esc(inst.stage_name)} (${inst.current_stage})${age}${sla}
'; - 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 = - '
' + - '' + - '' + - '' + - '
' + - '
'; - - 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 += ''; - uItems.forEach(function(a) { - html += '' + - '' + - ''; - }); - html += '
StageCreated
Stage ' + a.stage + '' + new Date(a.created_at).toLocaleDateString() + '
'; - } - - if (cItems.length > 0) { - html += '
In Progress (' + cItems.length + ')
'; - html += ''; - cItems.forEach(function(a) { - html += '' + - '' + - ''; - }); - html += '
StageAssigned
Stage ' + a.stage + '' + (a.assigned_to || '—') + '
'; - } - - 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',