Changeset 0.37.10 (#222)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-21 23:39:23 +00:00
committed by xcaliber
parent 37b639c9c8
commit 8d8a118232
52 changed files with 3209 additions and 13811 deletions

View File

@@ -1,5 +1,141 @@
# Changelog
## [0.37.10] — 2026-03-21
### Summary
Scorched earth rebuild of the Chat surface. The old 16-file imperative SPA
(~11,500 lines of `app.js`, `chat.js`, `ui-core.js`, `projects-ui.js`, etc.)
is replaced by a focused Preact surface (8 files) composing the ChatPane kit.
ChatPane kit upgraded with error display, stop generation, regenerate, code
block copy, markdown toolbar, emoji picker, and message pagination. Features
that don't belong on the chat surface (projects, KB management, extensions,
notifications) are deferred — each earns its way back as its own surface or
shell-level feature.
32 files changed. +774 / 13,615. **Net: 12,841 lines.**
### New
- **Chat Surface** (`src/js/sw/surfaces/chat/`) — 8 files:
- `ChatSurface` root — sidebar + workspace + ToastContainer + DialogStack
- `useWorkspace` — active conversation coordinator, sessionStorage restore
- `useSidebar` — channels, chats, folders data; search filter; WS live
updates; CRUD (rename, delete, move-to-folder, create folder)
- `Sidebar` — search bar, "New Chat" button, collapsible Channels section,
collapsible Chats section with folder grouping, context menus, desktop
collapse toggle
- `SidebarChannels` — DMs + named channels, unread badges
- `SidebarChats` — personal AI chats, folder grouping, context menus
(rename, delete, move to folder)
- `ChatWorkspace` — workspace header (sidebar toggle, tools button) +
ChatPane in `standalone=false` mode (surface manages navigation)
- `ToolsPopup` — scrollable category-based tool toggles with persistence,
`useTools` hook loads from API
- **ChatPane Kit enhancements** (4 new components in `chat-pane/`):
- `CodeBlock` — language badge + copy button, code block extraction from
rendered markdown HTML
- `MessageActions` — per-message toolbar: regenerate, copy text, delete.
Appears on hover/focus
- `MarkdownToolbar` — bold, italic, code, code block, link, bullet list,
numbered list, heading, blockquote. Wraps selection with markdown syntax.
Keyboard shortcuts: Ctrl+B, Ctrl+I, Ctrl+E
- `EmojiPicker` — categorized grid (7 categories), search, recently used
(localStorage). Inserts at cursor position
- **ChatPane Kit upgrades** (6 modified files):
- `index.js` — error banner rendered from `error` state, stop button wiring,
new kit re-exports
- `use-chat.js``regenerate(msgId)`, `deleteMessage(msgId)`, `loadMore()`
pagination, `hasMore`/`loadingMore` state
- `message-list.js` — "Load older messages" pagination button, scroll
position preservation on prepend, ARIA `role="log"`, regenerate/delete
wiring per message
- `message-bubble.js` — CodeBlock rendering with copy+language badge,
MessageActions toolbar (regenerate/copy/delete), relative timestamps
- `message-input.js` — MarkdownToolbar, EmojiPicker, stop button (replaces
send during streaming), ARIA labels, keyboard format shortcuts
- `markdown.js``marked.setOptions()` with GFM+breaks, task list checkbox
rendering via custom renderer
- **UserMenu auto-surface-filtering** — reads `body[data-surface]`, auto-
includes links to all other surfaces (Notes, extensions), filters out the
current surface. Default `handleSelect` navigates via `location.href`.
Every surface gets the same menu minus itself.
- **`sw-chat-surface.css`** — sidebar + workspace flex layout, responsive
mobile overlay, sidebar collapse, tools popup, context menus (~600 lines)
### Deleted
**16 JS files (~11,500 lines):**
- `src/js/app.js` (589) — SPA bootstrap, sidebar, init
- `src/js/api.js` (995) — REST client, auth, token management
- `src/js/chat.js` (1,344) — message rendering, streaming, CM6 input
- `src/js/chat-pane.js` (160) — legacy ChatPane wrapper
- `src/js/channel-models.js` (437) — channel model management UI
- `src/js/extensions.js` (506) — extension panel, package management
- `src/js/files.js` (857) — file upload/staging UI
- `src/js/knowledge-ui.js` (556) — knowledge base toggle popup
- `src/js/notifications.js` (431) — notification bell + dropdown
- `src/js/pages.js` (349) — admin page rendering
- `src/js/pane-container.js` (589) — split pane resize manager
- `src/js/panels.js` (454) — side panel registration + lifecycle
- `src/js/projects-ui.js` (2,090) — project sidebar section
- `src/js/tokens.js` (167) — token count display
- `src/js/tools-toggle.js` (245) — tools toggle popup
- `src/js/ui-core.js` (1,699) — core UI: sidebar, messages, context menus
**5 CSS files (~1,650 lines):**
- `src/css/chat.css` (648) — old chat area, sidebar, workspace, input
- `src/css/chat-pane.css` — legacy chat pane styles
- `src/css/panels.css` — side panel styles
- `src/css/pane-container.css` — split pane layout
- `src/css/notifications.css` — notification bell + dropdown
### Changed
- `chat.html` — rewritten from ~387-line SPA scaffold to ~50-line Preact boot
(same pattern as settings/admin/team-admin surfaces)
- `base.html` — removed `<script>` tags for deleted JS files (api.js,
ui-core.js, pages.js, chat-pane.js, pane-container.js), removed `<link>`
tags for deleted CSS files (chat.css, panels.css, pane-container.css,
chat-pane.css)
- `user-menu.js` — auto-surface-filtering via `body[data-surface]`, default
navigation handler, `extraItems` prop for caller-specific menu additions
- `sw-chat-pane.css` — added styles for code block (header, language badge,
copy button), emoji picker (tabs, grid, search), markdown toolbar, error
banner, stop button, load-more pagination, message footer (time + actions),
message action buttons, task list checkboxes
- `server/version.go``0.37.9``0.37.10`
- `VERSION``0.37.9``0.37.10`
### Design Notes
- **Surface manages navigation, ChatPane renders** — ChatPane runs in
`standalone=false` mode. The surface sidebar handles chat/channel selection;
ChatPane receives `channelId` as a prop and just renders the conversation.
No duplicate history dropdowns.
- **UserMenu is self-sufficient** — every surface imports `<UserMenu>` and
gets auto-filtered surface links, RBAC-gated admin items, and navigation
handling. No per-surface routing boilerplate.
- **Features deferred by design** — Projects, KB management, extension panels,
notification panel don't belong on the chat surface. Each earns its way back
as its own surface or shell-level feature in v0.37.12+.
- **Preview panel deferred** — the old iframe preview was fragile. Needs design
discussion for proper implementation.
- **Emoji picker is a gag for the release** — but also legitimately useful for
channels. Categorized grid with recents, ~200 emoji across 7 categories.
### Known Issues
- **Tools popup empty** — requires LLM providers configured (API keys). Works
as designed; tools list populates when providers are active.
- **ES module caching** — internal module imports use bare relative paths
(`./sidebar.js`) with no version query. Browser module cache persists across
page loads. In dev, requires version bump or browser restart to pick up
changes. Production is unaffected (fresh loads on version bump).
- **base.html legacy scripts** — `sb.js`, `app-state.js`, `events.js`,
`switchboard-sdk.js` still loaded globally for extension surface compat.
Full base.html cleanup deferred to post-v0.37.13.
## [0.37.9] — 2026-03-21
### Summary

View File

@@ -1 +1 @@
0.37.9
0.37.10

View File

@@ -48,7 +48,8 @@ v0.9.xv0.28.7 Foundation through Platform Polish ✅
│ v0.37.2 Primitives ✅ │
│ v0.37.3 SDK │
│ v0.37.4 Shell │
│ v0.37.513 Surfaces
│ v0.37.510 Surfaces
│ v0.37.1113 Surfaces │
│ │ │
══════╪════════════╪═════════════════╪══════
│ MVP v0.50.0 │
@@ -634,7 +635,7 @@ Each surface rebuilt as Preact component tree. Old JS deleted per surface.
| v0.37.7 | Team Admin | Inherits from Admin patterns |
| v0.37.8 | ChatPane ✅ | Composable kit (9 files), standalone stripped from old chat-pane.js |
| v0.37.9 | NotesPane ✅ | Obsidian-grade kit (10 files), old notes code scorched (~1,630 lines) |
| v0.37.10 | Chat surface | Composes ChatPane + sidebar + panels |
| v0.37.10 | Chat surface ✅ | Scorched earth: 21 files deleted (12,841 net), Preact surface + ChatPane kit upgrades |
| v0.37.11 | Notes surface | Composes NotesPane into full layout + enhancements (see below) |
| v0.37.12 | Extension surface container | `.pkg` mount point |
| v0.37.13 | Workflow surfaces | Form renderer, review views |

View File

@@ -12,12 +12,10 @@
<link rel="stylesheet" href="{{.BasePath}}/css/layout.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/primitives.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/modals.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/chat.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/panels.css?v={{.Version}}">
{{/* 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 */}}
<link rel="stylesheet" href="{{.BasePath}}/css/surfaces.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/splash.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/pane-container.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/chat-pane.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/sw-chat-pane.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/sw-notes-pane.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/user-menu.css?v={{.Version}}">
@@ -94,28 +92,22 @@
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/sb.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/app-state.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/api.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}">
// Hydrate auth tokens for all surfaces (chat surface also calls this in init)
API.loadTokens();
</script>
{{/* api.js removed in v0.37.10. Extension surfaces using Switchboard.init()
should migrate to the Preact SDK (sw/sdk/). */}}
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/events.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-primitives.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/marked.min.js"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/purify.min.js"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-format.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/virtual-scroll.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-core.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/pages.js?v={{.Version}}"></script>
{{/* ui-core.js + pages.js removed in v0.37.10 */}}
{{/* v0.25.0: Component scripts — available on all surfaces */}}
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/user-menu.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/model-selector.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/file-tree.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/code-editor.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/chat-pane.js?v={{.Version}}"></script>
{{/* note-panel.js + note-graph.js removed in v0.37.9 — see sw/components/notes-pane/ */}}
{{/* chat-pane.js + pane-container.js removed in v0.37.10 — see sw/components/chat-pane/ */}}
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/drag-resize.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/pane-container.js?v={{.Version}}"></script>
{{/* v0.28.5: SDK — composition layer over globals. Must load after all
primitives/components it wraps, before extensions.js. */}}
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/switchboard-sdk.js?v={{.Version}}"></script>

View File

@@ -1,52 +1,14 @@
{{/*
Chat surface (Phase 1 bridge).
Server renders the page shell with full SPA scaffold.
Existing SPA JS (app.js, ui-core.js, chat.js, etc.) populates
these pre-built DOM elements with data.
v0.22.6 fix: The original index.html contained the full scaffold
(1,296 lines). When it was replaced with a redirect stub, this
template needed the scaffold — but it was shipped empty.
This template restores the required DOM structure.
Chat surface (v0.37.10 — Preact).
Replaced the ~387-line SPA scaffold with a single mount point.
Preact ChatSurface component handles all rendering.
*/}}
{{define "surface-chat"}}
{{/* ── Splash / Auth Gate ───────────────────────
Server-rendered surfaces use /login for auth, so the splash
is hidden by default. Kept as a DOM node so showSplash()/
hideSplash() in app.js don't crash on null. */}}
<div id="splashGate" class="splash" style="display:none;">
<div class="splash-hero">
<div id="brandHeadline" class="splash-title"></div>
<div id="brandTagline" class="splash-subtitle"></div>
<div id="brandPills" class="splash-pills"></div>
</div>
<div class="splash-auth">
<div id="splashError" class="splash-error" style="display:none;"></div>
<div class="auth-tabs">
<button id="authTabLogin" class="auth-tab active" onclick="sb.call('switchAuthTab','login')">Sign In</button>
<button id="authTabRegister" class="auth-tab" onclick="sb.call('switchAuthTab','register')">Register</button>
</div>
<div id="authLoginForm">
<input type="text" id="authLogin" placeholder="Username or email" autocomplete="username">
<input type="password" id="authPassword" placeholder="Password" autocomplete="current-password">
<button id="authLoginBtn" onclick="sb.call('handleLogin')">Sign In</button>
</div>
<div id="authRegisterForm" style="display:none;">
<input type="text" id="authUsername" placeholder="Username" autocomplete="username">
<input type="text" id="authEmail" placeholder="Email" autocomplete="email">
<input type="password" id="authRegPassword" placeholder="Password" autocomplete="new-password">
<button id="authRegisterBtn" onclick="sb.call('handleRegister')">Create Account</button>
</div>
<div id="authError" class="auth-error"></div>
<div id="brandAuthFooter" class="auth-footer"></div>
</div>
</div>
{{/* ── Crash Catcher (visible without devtools) ── */}}
<div id="crashBanner" style="display:none;position:fixed;top:0;left:0;right:0;z-index:99999;background:#b00020;color:#fff;padding:12px 20px;font-family:monospace;font-size:13px;max-height:40vh;overflow-y:auto;">
<strong>Init Error</strong> <button onclick="this.parentElement.style.display='none'" style="float:right;background:none;border:1px solid #fff;color:#fff;cursor:pointer;padding:2px 8px;border-radius:3px"></button>
<strong>Init Error</strong> <button onclick="this.parentElement.style.display='none'" style="float:right;background:none;border:1px solid #fff;color:#fff;cursor:pointer;padding:2px 8px;border-radius:3px">X</button>
<pre id="crashDetail" style="margin:8px 0 0;white-space:pre-wrap;font-size:12px;"></pre>
</div>
<script nonce="{{$.CSPNonce}}">
@@ -68,354 +30,38 @@ window.addEventListener('unhandledrejection', function(e) {
});
</script>
{{/* ── App Container ───────────────────────── */}}
<div id="appContainer" class="app" style="display:none;height:100%;">
{{/* Fallback banner (admin-only, shown by Events role.fallback) */}}
<div id="roleFallbackBanner" class="role-fallback-banner" style="display:none;"></div>
<div class="app-body">
{{/* ── Sidebar ─────────────────────── */}}
<div id="sidebar" class="sidebar">
<div class="sidebar-top">
{{/* Brand — click toggles sidebar */}}
<div class="sb-brand" onclick="sb.call('UI.toggleSidebar')" role="button" tabindex="0">
<div id="brandSidebarLogo" class="sb-logo">
<img src="{{.BasePath}}/favicon-32.png" alt="" class="brand-logo-img">
</div>
<span id="brandSidebarText" class="brand-text">Switchboard</span>
<span id="brandWordmark" class="brand-text" style="display:none;"></span>
</div>
{{/* New Chat button */}}
<button id="newChatBtn" class="sb-btn" onclick="sb.call('newChat')" title="New chat">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<span class="sb-label">New Chat</span>
</button>
</div>
{{/* Search (outside sidebar-top, below brand section) */}}
<div id="sidebarSearch" class="sidebar-search">
<svg class="search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="chatSearchInput" placeholder="Search chats…" autocomplete="off">
<button id="chatSearchClear" class="sidebar-search-clear" title="Clear search"></button>
</div>
{{/* Sidebar toggle (hidden on desktop, used by JS for mobile) */}}
<button id="sidebarToggle" class="sb-btn sidebar-toggle" title="Toggle sidebar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
{{/* Sidebar Tabs (Chats / Files) */}}
{{/* Sidebar Tabs (kept hidden — only Files tab used by EditorMode) */}}
<div id="sidebarTabs" class="sidebar-tabs" style="display:none;">
<button class="sidebar-tab active" data-tab="chats">Chats</button>
<button id="sidebarFilesTab" class="sidebar-tab" data-tab="files" style="display:none;">Files</button>
</div>
{{/* Three-section nav: Projects / Channels / Chats */}}
<div id="sidebarNav" class="sidebar-nav sidebar-tab-panel" data-tab-panel="chats">
{{/* Projects section */}}
<div class="sb-section" id="sbSectionProjects">
<div class="sb-section-header" onclick="sb.call('UI.toggleSidebarSection','projects')">
<span class="sb-section-arrow" id="sbArrowProjects"></span>
<span class="sb-section-label">Projects</span>
<button class="sb-section-add" title="New project"
onclick="sb.callEvent(event,'createProject')">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
<div class="sb-section-body" id="sbBodyProjects">
{{/* Populated by UI.renderProjectsSection() */}}
</div>
</div>
{{/* Channels section (DMs + named channels) */}}
<div class="sb-section" id="sbSectionChannels">
<div class="sb-section-header" onclick="sb.call('UI.toggleSidebarSection','channels')">
<span class="sb-section-arrow" id="sbArrowChannels"></span>
<span class="sb-section-label">Channels</span>
<button class="sb-section-add" title="New channel"
onclick="sb.callEvent(event,'newChannelOrDM')">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
<div class="sb-section-body" id="sbBodyChannels">
{{/* Populated by UI.renderChannelsSection() */}}
</div>
</div>
{{/* Chats section (personal AI chats + folders) */}}
<div class="sb-section" id="sbSectionChats">
<div class="sb-section-header" onclick="sb.call('UI.toggleSidebarSection','chats')">
<span class="sb-section-arrow" id="sbArrowChats"></span>
<span class="sb-section-label">Chats</span>
<div class="sb-section-add-group" onclick="event.stopPropagation();">
<button class="sb-section-add" title="New chat" onclick="sb.call('newChat');">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="sb-section-add" title="New folder" onclick="sb.call('newFolder');">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
</button>
</div>
</div>
<div class="sb-section-body" id="sbBodyChats"
ondragover="event.preventDefault();event.dataTransfer.dropEffect='move';this.classList.add('drag-over')"
ondragleave="this.classList.remove('drag-over')"
ondrop="onChatSectionDrop(event)">
{{/* Populated by UI.renderChatList() */}}
</div>
</div>
</div>
{{/* Legacy chatHistory id kept for JS compatibility during transition */}}
<div id="chatHistory" style="display:none;"></div>
{{/* Files panel (editor mode) */}}
<div class="sidebar-tab-panel" data-tab-panel="files" style="display:none;"></div>
{{/* User / bottom */}}
<div class="sidebar-bottom">
{{if .SurfaceEnabled "notes"}}
<button id="notesBtn" class="sb-btn sidebar-notes-btn">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
<span class="sb-label">Notes</span>
</button>
{{end}}
{{/* v0.27.0: Extension surface nav items (editor is now a package) */}}
{{range .ExtensionSurfaces}}
<a href="{{$.BasePath}}{{.Route}}" class="sb-btn sidebar-extension-btn" title="{{.Title}}" data-surface-id="{{.ID}}">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
<span class="sb-label">{{.Title}}</span>
</a>
{{end}}
<div class="sidebar-bottom-divider"></div>
{{template "user-menu" dict "ID" ""}}
</div>
</div>
{{/* Sidebar overlay (mobile) */}}
<div id="sidebarOverlay" class="sidebar-overlay" style="display:none;"></div>
{{/* Mobile menu button */}}
<button id="mobileMenuBtn" class="mobile-menu-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
{{/* ── Workspace ───────────────────── */}}
<div class="workspace">
<div class="workspace-primary workspace-pane">
{{/* Chat header: model selector + caps + tokens + panel + notif */}}
<div class="chat-header">
{{template "model-selector" dict "ID" ""}}
<div class="chat-header-right">
<span id="chatTokenCount" class="token-count" title="Estimated token count"></span>
<span id="summarizedHistory" class="summarized-badge" style="display:none;" title="Earlier messages are summarized">📋 Summarized</span>
<button id="summarizeBtn" class="action-btn" style="display:none;" title="Summarize older messages">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
</button>
{{/* Panel toggle */}}
<button id="panelToggleBtn" class="action-btn" title="Toggle side panel" onclick="sb.call('PanelRegistry.toggle','preview')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
</button>
{{/* Notification bell (inline, not fixed) */}}
<div id="notifWrap" class="notif-wrap">
<button class="notif-bell" onclick="sb.call('Notifications.toggleDropdown')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<span id="notifBadge" class="notif-badge" style="display:none;">0</span>
</button>
<div id="notifDropdown" class="notif-dropdown" style="display:none;">
<div id="notifPanelList"></div>
<div id="notifPanelMore" style="text-align:center;padding:8px;"></div>
</div>
</div>
</div>
</div>
{{/* v0.23.2: Channel context banner — shown for DMs and named channels */}}
<div id="channelContextBanner" class="channel-context-banner" style="display:none;"></div>
{{/* v0.27.0: Workflow stage indicator — shown for workflow channels */}}
<div id="workflowStageBar" class="workflow-stage-bar" style="display:none;"></div>
{{/* Messages */}}
<div id="chatMessages" class="msg-container">
{{/* Populated by UI.renderMessages() */}}
</div>
{{/* Context warning */}}
<div id="contextWarning" class="context-warning" style="display:none;">
<span id="contextWarningText"></span>
</div>
{{/* File strip */}}
<div id="fileStrip" class="file-strip"></div>
{{/* Streaming tools display */}}
<div id="streamTools" class="stream-tools" style="display:none;"></div>
{{/* Input area (centered, padded bottom) */}}
<div class="chat-input-area">
{{/* Input toolbar (attach, tools toggle, KB) */}}
<div class="input-toolbar">
<button id="attachBtn" class="action-btn attach-btn" title="Attach file">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
</button>
{{/* Tools toggle */}}
<div class="tools-toggle-wrap" style="display:none;">
<button id="toolsToggleBtn" class="action-btn tools-toggle-btn" title="Tools">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
</button>
<div id="toolsPopup" class="popup tools-popup"></div>
</div>
{{/* Knowledge Bases toggle */}}
<div class="kb-toggle-wrap">
<button id="kbToggleBtn" class="action-btn kb-toggle-btn" title="Knowledge Bases">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
</button>
<div id="kbPopup" class="popup kb-popup"></div>
</div>
{{/* tools-toggle.js + knowledge-ui.js show/populate these at init */}}
</div>
{{/* Input wrap: CM6 editor + send/stop */}}
<div class="input-wrap">
<div id="messageInputWrap">
{{/* CM6 mounts here (chat.js). Fallback textarea below. */}}
<textarea id="messageInput" placeholder="Type a message…" rows="1"></textarea>
</div>
<div class="input-actions">
<span id="inputTokenCount" class="input-token-count"></span>
<button id="sendBtn" class="action-btn send-btn" title="Send message">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
<button id="stopBtn" class="action-btn stop-btn" title="Stop generating">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/></svg>
</button>
</div>
</div>
<div class="chat-footer">
<span class="chat-version">Switchboard v{{.Version}}</span>
</div>
</div>
</div>
{{/* Resize handle */}}
<div id="workspaceHandle" class="workspace-handle"></div>
{{/* Side panel (secondary pane) */}}
<div id="workspaceSecondary" class="workspace-secondary workspace-pane">
<div class="side-panel-header">
<span id="sidePanelLabel" class="side-panel-label"></span>
<div class="side-panel-header-actions">
<span id="sidePanelPanelActions"></span>
<button id="sidePanelFullscreenBtn" class="side-panel-btn" title="Toggle fullscreen" onclick="sb.call('toggleSidePanelFullscreen')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
</button>
<button class="side-panel-btn" title="Close panel" onclick="sb.call('PanelRegistry.closeAll')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
</div>
<div id="sidePanelBody" class="side-panel-body">
{{/* Preview panel page */}}
<div id="sidePanelPreview" class="side-panel-page">
<iframe id="previewFrame" class="preview-frame" sandbox="allow-scripts allow-same-origin" style="display:none;"></iframe>
<div id="previewEmpty" class="preview-empty">
<p>Open a code block preview or extension output here.</p>
</div>
</div>
{{/* Notes and project panels are registered dynamically by their JS modules */}}
</div>
</div>
</div>
{{/* ── Mount Point ────────────────────────── */}}
<div id="chat-mount" style="height:100%;display:flex;">
<div style="display:flex;align-items:center;justify-content:center;width:100%;color:var(--text-2,#999);font-size:14px;">
Loading...
</div>
</div>
{{/* Side panel overlay (mobile) */}}
<div id="sidePanelOverlay" class="side-panel-overlay" style="display:none;"></div>
{{/* ── Lightbox ────────────────────────────── */}}
<div id="lightbox" class="lightbox" onclick="sb.call('closeLightbox')">
<img id="lightboxImg" src="" alt="">
</div>
{{/* ── Command Palette ─────────────────────── */}}
<div id="cmdPalette" class="cmd-palette" style="display:none;">
<div class="cmd-palette-inner">
<input type="text" class="cmd-palette-input" placeholder="Type a command…" autocomplete="off">
<div class="cmd-palette-results"></div>
</div>
</div>
{{/* ── Hidden File Input ─────── */}}
<input type="file" id="fileInput" multiple style="display:none;">
{{/* Team Admin Modal — removed in v0.37.7. Team admin is now at /team-admin. */}}
{{/* ── Debug Modal moved to base.html (available on all surfaces) ── */}}
{{/* ── Notification bell is now inside .chat-header ── */}}
{{/* ── Routing Policy Form (shared) ────────── */}}
<div id="routingPolicyForm" style="display:none;">
<input type="hidden" id="rpId">
<div class="form-group"><label>Name</label><input type="text" id="rpName"></div>
<div class="form-group"><label>Priority</label><input type="number" id="rpPriority" value="100"></div>
<div class="form-group"><label>Type</label><select id="rpType"><option value="weighted">Weighted</option><option value="failover">Failover</option><option value="round_robin">Round Robin</option></select></div>
<div class="form-group"><label>Scope</label><select id="rpScope" onchange="Pages.routingScopeChanged(this)"><option value="global">Global</option><option value="team">Team</option></select></div>
<div id="rpTeamGroup" class="form-group" style="display:none;"><label>Team</label><select id="rpTeamId"></select></div>
<div class="form-group"><label>Config (JSON)</label><textarea id="rpConfig" rows="4">{}</textarea></div>
<div class="form-group"><label><input type="checkbox" id="rpActive" checked> Active</label></div>
<div style="display:flex;gap:8px;">
<button id="rpSaveBtn" class="btn-primary" onclick="sb.call('Pages.saveRoutingPolicy')">Save</button>
<button id="rpCancelBtn" class="btn-secondary" onclick="sb.call('Pages.hideRoutingForm')">Cancel</button>
</div>
</div>
{{/* ── Auto-Compact Toggle (chat header option) */}}
<div id="autoCompactToggle" style="display:none;">
<label><input type="checkbox" id="autoCompactCheck"> Auto-compact</label>
</div>
{{/* ── Fetch Models Button (admin) ─────────── */}}
<button id="fetchModelsBtn" style="display:none;"></button>
{{end}}
{{define "css-chat"}}
<link rel="stylesheet" href="{{.BasePath}}/css/persona-kb.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/memory.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/notifications.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/channel-models.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/notification-prefs.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/sw-chat-surface.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/branding/custom.css" onerror="this.remove()">
{{end}}
{{define "scripts-chat"}}
{{/* CM6 bundle — chat-specific (code input, extension editor) */}}
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/vendor/codemirror/codemirror.bundle.js?v={{$.Version}}" onerror="console.warn('[CM6] Bundle not available — falling back to textarea')"></script>
{{/* App JS — order matches index.html (minus api/events/ui-primitives/ui-format already in base) */}}
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/extensions.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/panels.js?v={{$.Version}}"></script>
{{/* ui-settings.js and ui-admin.js removed in v0.37.7 — team admin is now at /team-admin */}}
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/tokens.js?v={{$.Version}}"></script>
{{/* notes.js removed in v0.37.9 — see sw/components/notes-pane/ */}}
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/files.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/tools-toggle.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/knowledge-ui.js?v={{$.Version}}"></script>
{{/* memory-ui.js and persona-kb.js removed in v0.37.7 — no chat surface callers */}}
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/projects-ui.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/channel-models.js?v={{$.Version}}"></script>
{{/* notification-prefs.js removed in v0.37.7 — now native Preact in settings */}}
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/notifications.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/chat.js?v={{$.Version}}"></script>
{{/* settings-handlers.js, admin-handlers.js, workflow-admin.js removed in v0.37.7 */}}
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/workflow-api.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/workflow-queue.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/task-sidebar.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/app.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/vendor/marked.min.js"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/vendor/purify.min.js"></script>
<script type="module" nonce="{{$.CSPNonce}}">
// v0.37.10: Preact boot — same pattern as settings/admin/team-admin surfaces.
// Vendor modules: no ?v= query — hooks.module.js does a bare
// import from "./preact.module.js" internally, so the URL must match.
const { h, render } = await import('{{$.BasePath}}/js/sw/vendor/preact.module.js');
const hooksModule = await import('{{$.BasePath}}/js/sw/vendor/hooks.module.js');
const { default: htm } = await import('{{$.BasePath}}/js/sw/vendor/htm.module.js');
const html = htm.bind(h);
window.preact = window.preact || { h, render };
window.hooks = window.hooks || hooksModule;
window.html = window.html || html;
const { boot } = await import('{{$.BasePath}}/js/sw/sdk/index.js?v={{$.Version}}');
await boot();
await import('{{$.BasePath}}/js/sw/surfaces/chat/index.js?v={{$.Version}}');
</script>
{{end}}

View File

@@ -1,3 +1,3 @@
package main
const Version = "0.37.9"
const Version = "0.37.10"

View File

@@ -1,412 +0,0 @@
/* ==========================================
Chat Switchboard — ChatPane Component CSS
==========================================
v0.25.0-cs11.1: Self-contained styles for the ChatPane
component. Works in any context — sidebar chat, editor
assist pane, standalone embed. No external CSS dependencies.
========================================== */
/* ── Container ─────────────────────────────── */
.chat-pane {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
background: var(--bg, #0e0e10);
}
/* ── Header Bar (standalone mode) ──────────── */
.chat-pane-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 6px 10px;
border-bottom: 1px solid var(--border, #2a2a2e);
background: var(--bg-secondary, #151517);
flex-shrink: 0;
min-height: 36px;
}
.chat-pane-header-left {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
min-width: 0;
}
.chat-pane-header-right {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.chat-pane-chat-select {
flex: 1;
min-width: 0;
background: var(--bg, #0e0e10);
border: 1px solid var(--border, #2a2a2e);
border-radius: 4px;
color: var(--text, #eee);
font-size: 11px;
font-family: inherit;
padding: 4px 6px;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-pane-chat-select:focus {
border-color: var(--accent, #b38a4e);
outline: none;
}
.chat-pane-new-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
background: none;
border: 1px solid var(--border, #2a2a2e);
border-radius: 4px;
color: var(--text-2, #999);
cursor: pointer;
flex-shrink: 0;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.chat-pane-new-btn:hover {
color: var(--accent, #b38a4e);
border-color: var(--accent, #b38a4e);
background: var(--accent-dim, rgba(179, 138, 78, 0.1));
}
/* Model selector in header */
.chat-pane-model-select {
background: var(--bg, #0e0e10);
border: 1px solid var(--border, #2a2a2e);
border-radius: 4px;
color: var(--text-2, #999);
font-size: 11px;
font-family: inherit;
padding: 4px 6px;
cursor: pointer;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-pane-model-select:focus {
border-color: var(--accent, #b38a4e);
outline: none;
color: var(--text, #eee);
}
/* ── Messages Area ─────────────────────────── */
.chat-pane-messages {
flex: 1;
overflow-y: auto;
scroll-behavior: smooth;
padding: 12px 0;
min-height: 0;
}
/* ── Message Bubbles ───────────────────────── */
.chat-msg {
padding: 8px 16px;
font-size: var(--msg-font, 14px);
line-height: 1.65;
color: var(--text, #eee);
}
.chat-msg + .chat-msg {
margin-top: 2px;
}
/* User messages — right-aligned bubble */
.chat-msg--user {
display: flex;
justify-content: flex-end;
padding: 8px 16px;
}
.chat-msg--user .chat-msg__content {
background: var(--accent-dim, rgba(179, 138, 78, 0.15));
color: var(--text, #eee);
padding: 8px 14px;
border-radius: 12px 12px 2px 12px;
max-width: 85%;
word-wrap: break-word;
}
/* Assistant messages — left-aligned */
.chat-msg--assistant {
padding: 8px 16px;
}
.chat-msg--assistant .chat-msg__content {
max-width: 100%;
word-wrap: break-word;
}
/* ── Streaming indicator ───────────────────── */
.chat-msg--streaming .chat-msg__content::after {
content: '';
display: inline-block;
width: 6px;
height: 14px;
background: var(--accent, #b38a4e);
margin-left: 2px;
vertical-align: text-bottom;
animation: cp-cursor-blink 0.8s step-end infinite;
}
@keyframes cp-cursor-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* ── Typing dots ───────────────────────────── */
.chat-msg--typing {
padding: 8px 16px;
}
/* System/error messages */
.chat-msg--system {
padding: 6px 16px;
font-size: 12px;
}
.chat-msg--typing .typing-dots {
display: flex;
gap: 4px;
padding: 4px 0;
}
.chat-msg--typing .typing-dots span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-3, #555);
animation: cp-dot-bounce 1.2s ease-in-out infinite;
}
.chat-msg--typing .typing-dots span:nth-child(2) { animation-delay: 0.2s; }
.chat-msg--typing .typing-dots span:nth-child(3) { animation-delay: 0.4s; }
@keyframes cp-dot-bounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-4px); opacity: 1; }
}
/* ── Markdown content ──────────────────────── */
.chat-msg__content p { margin: 0 0 0.5em; }
.chat-msg__content p:last-child { margin-bottom: 0; }
.chat-msg__content pre {
background: var(--bg-secondary, #151517);
border: 1px solid var(--border, #2a2a2e);
border-radius: 6px;
padding: 10px 12px;
overflow-x: auto;
margin: 8px 0;
font-size: 12px;
line-height: 1.5;
font-family: var(--mono, 'SF Mono', monospace);
}
.chat-msg__content code {
background: var(--bg-secondary, #151517);
padding: 1px 5px;
border-radius: 3px;
font-size: 0.88em;
font-family: var(--mono, 'SF Mono', monospace);
}
.chat-msg__content pre code {
background: none;
padding: 0;
border-radius: 0;
font-size: inherit;
}
.chat-msg__content ul, .chat-msg__content ol {
margin: 4px 0;
padding-left: 1.5em;
}
.chat-msg__content blockquote {
border-left: 3px solid var(--accent, #b38a4e);
padding: 4px 12px;
margin: 8px 0;
color: var(--text-2, #999);
}
.chat-msg__content a {
color: var(--accent, #b38a4e);
text-decoration: none;
}
.chat-msg__content a:hover {
text-decoration: underline;
}
.chat-msg__content table {
border-collapse: collapse;
margin: 8px 0;
font-size: 13px;
}
.chat-msg__content th,
.chat-msg__content td {
border: 1px solid var(--border, #2a2a2e);
padding: 4px 8px;
}
.chat-msg__content th {
background: var(--bg-secondary, #151517);
font-weight: 600;
}
/* ── Welcome / Empty State ─────────────────── */
.chat-welcome {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
text-align: center;
padding: 40px 20px;
opacity: 0.6;
}
.chat-welcome__title {
font-size: 15px;
font-weight: 600;
color: var(--text, #eee);
margin: 0 0 6px;
}
.chat-welcome__hint {
font-size: 13px;
color: var(--text-3, #555);
margin: 0;
}
/* ── Input Bar ─────────────────────────────── */
.chat-pane-input-bar {
flex-shrink: 0;
border-top: 1px solid var(--border, #2a2a2e);
background: var(--bg-secondary, #151517);
padding: 8px 12px;
}
.chat-pane-input-wrap {
display: flex;
align-items: flex-end;
gap: 6px;
background: var(--bg, #0e0e10);
border: 1px solid var(--border, #2a2a2e);
border-radius: 8px;
padding: 6px 8px;
transition: border-color 0.15s;
}
.chat-pane-input-wrap:focus-within {
border-color: var(--accent, #b38a4e);
}
.chat-pane-input {
flex: 1;
min-height: 20px;
max-height: 160px;
overflow-y: auto;
font-size: 13px;
color: var(--text, #eee);
line-height: 1.5;
}
/* CM6 editor inside chat-pane-input */
.chat-pane-input .cm-editor {
max-height: 160px;
overflow-y: auto;
font-size: 13px;
}
.chat-pane-input .cm-editor .cm-content {
padding: 0;
}
.chat-pane-input .cm-editor .cm-line {
padding: 0;
}
.chat-pane-input .cm-editor.cm-focused {
outline: none;
}
/* Textarea fallback */
.chat-pane-input textarea {
width: 100%;
min-height: 20px;
max-height: 160px;
background: transparent;
border: none;
color: var(--text, #eee);
font-size: 13px;
font-family: inherit;
line-height: 1.5;
resize: none;
outline: none;
padding: 0;
}
/* ── Send Button ───────────────────────────── */
.chat-pane-send {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
background: none;
border: none;
border-radius: 6px;
color: var(--accent, #b38a4e);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.chat-pane-send:hover {
background: var(--accent-dim, rgba(179, 138, 78, 0.15));
color: var(--accent-hover, #c9a05e);
}
.chat-pane-send:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* ── Toolbar ───────────────────────────────── */
.chat-pane-toolbar {
margin-top: 6px;
}
.chat-pane-toolbar:empty {
display: none;
margin: 0;
}

View File

@@ -1,648 +0,0 @@
/* ── chat.css ────────────────────────────────
Chat area, model dropdown, markdown, input, files
──────────────────────────────────────────── */
/* ── Chat Area ───────────────────────────── */
/* .chat-area is now .workspace-primary — alias kept for compat */
.chat-area, .workspace-primary { flex: 1; display: flex; flex-direction: column; min-width: 0; background: var(--bg); }
/* Role fallback banner */
.role-fallback-banner {
background: var(--warning-dim); color: var(--warning);
text-align: center; font-size: 12px; font-weight: 500;
padding: 6px 12px; flex-shrink: 0;
}
/* Chat header (model selector + caps + token count + panel toggle) */
.chat-header {
display: flex; align-items: center; gap: 8px;
padding: 8px 16px; flex-shrink: 0;
border-bottom: 1px solid var(--border);
}
.chat-header-right {
margin-left: auto; display: flex; align-items: center; gap: 8px;
}
.token-count {
font-size: 11px; color: var(--text-3);
font-family: var(--mono); white-space: nowrap;
}
.summarized-badge {
font-size: 11px; color: var(--text-3); white-space: nowrap;
display: inline-flex; align-items: center; gap: 4px;
}
/* v0.23.2: Channel context banner */
.channel-context-banner {
padding: 6px 16px;
font-size: 12px;
color: var(--text-3);
background: var(--bg-raised);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.channel-context-banner .ctx-mode {
font-family: var(--mono);
font-size: 11px;
padding: 1px 6px;
border-radius: 3px;
background: var(--bg-surface);
color: var(--text-2);
}
.channel-context-banner .ctx-mode-btn {
cursor: pointer; border: 1px solid var(--border);
transition: background 0.15s;
}
.channel-context-banner .ctx-mode-btn:hover {
background: var(--bg-hover); color: var(--text);
}
.channel-context-banner .ctx-topic {
color: var(--text-2); font-size: 12px;
}
.channel-context-banner .ctx-hint {
color: var(--text-3);
font-style: italic;
}
.ctx-participants-btn {
margin-left: auto;
background: none; border: 1px solid var(--border); border-radius: 4px;
color: var(--text-2); font-size: 11px; padding: 2px 8px; cursor: pointer;
}
.ctx-participants-btn:hover { background: var(--bg-hover); color: var(--text); }
.hover-bg:hover { background: var(--bg-hover); }
/* Message container (scrollable) */
.msg-container {
flex: 1; overflow-y: auto; scroll-behavior: smooth;
padding-top: 12px; padding-bottom: 8px;
}
.msg-container::-webkit-scrollbar { width: 5px; }
.msg-container::-webkit-scrollbar-track { background: transparent; }
.msg-container::-webkit-scrollbar-thumb { border-radius: 3px; background: var(--border); }
/* Streaming tools display */
.stream-tools {
padding: 8px 24px; flex-shrink: 0;
font-size: 12px; color: var(--text-3);
display: flex; gap: 6px; align-items: center;
}
.model-bar {
display: flex; align-items: center; gap: 6px;
padding: 8px 16px; flex-shrink: 0;
}
.model-bar select {
background: transparent; border: 1px solid transparent;
color: var(--text); font-size: 14px; font-weight: 500;
padding: 4px 8px; border-radius: var(--radius); cursor: pointer;
font-family: var(--font); max-width: 300px;
transition: border-color var(--transition);
}
.model-bar select:hover { border-color: var(--border); }
.model-bar select:focus { outline: none; border-color: var(--accent); }
.model-bar select option { background: var(--bg-surface); color: var(--text); }
/* ── Custom Model Dropdown ───────────────── */
.model-dropdown { position: relative; }
.model-dropdown-btn {
display: flex; align-items: center; gap: 6px;
background: none; border: 1px solid transparent;
color: var(--text); font-size: 14px; font-weight: 500;
padding: 4px 8px; border-radius: var(--radius); cursor: pointer;
font-family: var(--font); max-width: 340px;
transition: border-color var(--transition);
}
.model-dropdown-btn:hover { border-color: var(--border); }
.model-dropdown-btn svg { flex-shrink: 0; opacity: 0.5; }
.model-dropdown-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.model-dropdown-menu {
display: none; position: absolute; top: 100%; left: 0; z-index: 200;
min-width: 280px; max-width: 400px; max-height: 400px; overflow-y: auto;
background: var(--bg-raised); border: 1px solid var(--border-light);
border-radius: var(--radius); padding: 4px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
margin-top: 4px;
}
.model-dropdown-menu.open { display: block; }
.model-dropdown-group {
padding: 6px 10px 4px; font-size: 11px; font-weight: 600;
color: var(--text-3); text-transform: uppercase; letter-spacing: 0.5px;
}
.model-dropdown-item {
display: flex; align-items: center; gap: 8px;
padding: 7px 10px; border-radius: 4px;
cursor: pointer; font-size: 13px;
color: var(--text-2); white-space: nowrap;
transition: background var(--transition), color var(--transition);
}
.model-dropdown-item:hover { background: var(--bg-hover); color: var(--text); }
.model-dropdown-item.selected { background: var(--bg-hover); color: var(--accent); }
.model-dropdown-item .item-label { flex: 1; overflow: hidden; text-overflow: ellipsis; }
.model-dropdown-item .item-provider { margin-left: auto; font-size: 10px; color: var(--text-3); }
.dropdown-avatar { width: 18px; height: 18px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
.model-caps {
display: flex; align-items: center; gap: 4px;
margin-left: 4px; flex-wrap: wrap;
}
.cap-badge {
display: inline-flex; align-items: center; gap: 3px;
font-size: 10px; line-height: 1; padding: 2px 6px;
border-radius: 3px; white-space: nowrap;
background: var(--bg-raised); color: var(--text-3);
border: 1px solid transparent;
}
.cap-badge.cap-accent {
color: var(--accent); border-color: color-mix(in srgb, var(--accent), transparent 80%);
background: color-mix(in srgb, var(--accent), transparent 92%);
}
.cap-badge.cap-context {
color: var(--text-3);
}
/* Messages — Bubble Layout (matching prototype) */
.messages { flex: 1; overflow-y: auto; scroll-behavior: smooth; }
.message { padding: 10px 24px; }
.msg-inner { max-width: 768px; margin: 0 auto; display: flex; gap: 10px; }
/* User messages: same fixed-width container, mirrored */
.message.user .msg-inner { flex-direction: row-reverse; }
.message.user .msg-body {
background: var(--user-bubble);
border: 1px solid transparent;
border-radius: 16px 16px 4px 16px;
padding: 10px 14px;
}
.message.user .msg-head { flex-direction: row-reverse; }
.message.user .msg-role { color: var(--accent); }
.message.user .msg-avatar { color: var(--accent); }
/* Assistant messages: left-aligned bubble */
.message.assistant .msg-body {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 16px 16px 16px 4px;
padding: 10px 14px;
}
.msg-avatar {
width: 28px; height: 28px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 14px; flex-shrink: 0; margin-top: 2px;
overflow: hidden;
}
.msg-avatar-img {
width: 100%; height: 100%; object-fit: cover; border-radius: 50%;
}
.message.user .msg-avatar { background: var(--accent-dim); }
.message.assistant .msg-avatar { background: var(--bg-raised); }
/* Other human's messages — left-aligned like assistant, teal accent */
.message.other-user .msg-body {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 16px 16px 16px 4px;
padding: 10px 14px;
}
.message.other-user .msg-role { color: #2dd4bf; }
.message.other-user .msg-avatar { background: rgba(45,212,191,0.12); color: #2dd4bf; }
.msg-body { flex: 1; min-width: 0; }
.msg-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.msg-role { font-size: 13px; font-weight: 600; }
.msg-time { font-size: 11px; color: var(--text-3); }
.msg-actions {
margin-left: auto; display: flex; gap: 2px; opacity: 0;
transition: opacity var(--transition);
}
.message:hover .msg-actions { opacity: 1; }
.msg-action-btn {
background: none; border: none; color: var(--text-3); cursor: pointer;
padding: 2px 6px; border-radius: 4px; font-size: 11px;
transition: background var(--transition), color var(--transition);
}
.msg-action-btn:hover { background: var(--bg-hover); color: var(--text); }
/* Branch navigation indicator */
.branch-nav {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 12px;
color: var(--text-3);
}
.branch-arrow {
background: none;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-2);
cursor: pointer;
padding: 0 5px;
font-size: 16px;
line-height: 18px;
transition: var(--transition);
}
.branch-arrow:hover:not(:disabled) { color: var(--accent); border-color: var(--accent); }
.branch-arrow:disabled { opacity: 0.25; cursor: default; }
.branch-pos {
font-size: 11px;
font-variant-numeric: tabular-nums;
min-width: 28px;
text-align: center;
user-select: none;
}
/* Inline message editing */
.msg-edit-input {
width: 100%;
background: var(--bg-surface);
border: 1px solid var(--accent);
border-radius: var(--radius);
color: var(--text);
padding: 10px 12px;
font-family: var(--font);
font-size: var(--msg-font, 14px);
line-height: 1.6;
resize: vertical;
min-height: 60px;
max-height: 400px;
outline: none;
}
.msg-edit-input:focus { border-color: var(--accent-hover); box-shadow: 0 0 0 2px var(--accent-dim); }
.msg-edit-actions {
display: flex;
gap: 8px;
margin-top: 8px;
justify-content: flex-end;
}
.msg-edit-cancel, .msg-edit-submit {
padding: 6px 14px;
border-radius: var(--radius);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: var(--transition);
border: 1px solid var(--border);
}
.msg-edit-cancel {
background: var(--bg-raised);
color: var(--text-2);
}
.msg-edit-cancel:hover { background: var(--bg-hover); color: var(--text); }
.msg-edit-submit {
background: var(--accent);
color: var(--text-on-color);
border-color: var(--accent);
}
.msg-edit-submit:hover { background: var(--accent-hover); }
.msg-text { font-size: var(--msg-font, 14px); line-height: 1.7; }
/* ── Markdown Content ────────────────────── */
.msg-text p { margin: 0.4em 0; }
.msg-text p:first-child { margin-top: 0; }
.msg-text p:last-child { margin-bottom: 0; }
.msg-text pre {
background: var(--bg-surface); border: 1px solid var(--border);
padding: 0.75rem 1rem; border-radius: var(--radius);
overflow-x: auto; margin: 0.75rem 0; position: relative;
font-size: 13px;
}
.msg-text code {
font-family: var(--mono); font-size: 0.9em;
background: var(--bg-raised); padding: 0.15em 0.4em;
border-radius: 4px;
}
.msg-text pre code { background: none; padding: 0; font-size: inherit; }
/* Code block toolbar */
.code-toolbar {
position: absolute; top: 6px; right: 6px;
display: flex; gap: 4px; align-items: center;
opacity: 0; transition: opacity var(--transition);
}
.msg-text pre:hover .code-toolbar { opacity: 1; }
.code-block.code-collapsed .code-toolbar { opacity: 1; }
.copy-code-btn {
background: var(--bg-raised); border: 1px solid var(--border);
color: var(--text-3); border-radius: 4px;
padding: 2px 10px; font-size: 11px; cursor: pointer;
transition: color var(--transition), border-color var(--transition);
}
.copy-code-btn:hover { color: var(--text); border-color: var(--border-light); }
.code-collapse-btn {
background: var(--bg-raised); border: 1px solid var(--border);
color: var(--text-3); border-radius: 4px;
padding: 2px 10px; font-size: 11px; cursor: pointer;
font-family: var(--mono); white-space: nowrap;
transition: color var(--transition), border-color var(--transition);
}
.code-collapse-btn:hover { color: var(--text); border-color: var(--border-light); }
/* Collapsed code block */
.code-block.code-collapsed code {
max-height: 3.6em; overflow: hidden; display: block;
mask-image: linear-gradient(to bottom, #000 40%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, #000 40%, transparent 100%);
}
/* Language label */
.code-lang {
position: absolute; top: 6px; left: 10px;
font-size: 10px; color: var(--text-3); font-family: var(--mono);
text-transform: uppercase; letter-spacing: 0.5px; user-select: none;
}
/* ── Extension-rendered blocks ──────── */
.ext-rendered {
margin: 12px 0;
position: relative;
}
.ext-popout-btn {
position: absolute; top: 4px; right: 4px;
background: var(--bg-raised); border: 1px solid var(--border);
color: var(--text-3); cursor: pointer; font-size: 14px;
width: 26px; height: 26px; border-radius: var(--radius);
display: flex; align-items: center; justify-content: center;
opacity: 0; transition: opacity 0.15s, background 0.15s;
z-index: 5;
}
.ext-rendered:hover .ext-popout-btn { opacity: 1; }
.ext-popout-btn:hover { background: var(--bg-hover); color: var(--text); }
/* HTML preview */
/* ── Input Area ──────────────────────────── */
.input-area { padding: 0 1rem 1rem; flex-shrink: 0; }
.input-area:empty { padding: 0; min-height: 0; }
/* Token counter below input */
.input-meta { display: flex; justify-content: space-between; align-items: center; padding: 2px 12px 0; min-height: 18px; }
.input-token-count { font-size: 0.68rem; color: var(--text-3); transition: color 0.2s; }
.input-token-count.warning { color: var(--warning); }
.input-token-count.danger { color: var(--danger); }
/* Context length warning banner */
.context-warning {
display: flex; align-items: center; gap: 8px;
padding: 6px 12px; margin-bottom: 8px; border-radius: 8px;
background: color-mix(in srgb, var(--warning) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--warning) 25%, transparent);
font-size: 0.78rem; color: var(--text-2); animation: fadeIn 0.2s ease;
}
.context-warning.danger {
background: color-mix(in srgb, var(--danger) 12%, transparent);
border-color: color-mix(in srgb, var(--danger) 25%, transparent);
}
.context-warning-icon { flex-shrink: 0; }
.context-warning-text { flex: 1; }
.context-warning-dismiss { background: none; border: none; color: var(--text-3); cursor: pointer; padding: 0 2px; font-size: 0.85rem; }
.context-warning-dismiss:hover { color: var(--text); }
.context-warning-action {
flex-shrink: 0; padding: 3px 10px; border-radius: 6px; border: 1px solid var(--border);
background: var(--bg-surface); color: var(--text); cursor: pointer;
font-size: 0.75rem; white-space: nowrap; transition: all var(--transition);
}
.context-warning-action:hover { background: var(--accent); color: var(--text-on-color); border-color: var(--accent); }
.context-warning-action:disabled { opacity: 0.5; cursor: not-allowed; }
.context-warning-action:disabled:hover { background: var(--bg-surface); color: var(--text); border-color: var(--border); }
.context-warning-toggle {
display: inline-flex; align-items: center; gap: 4px; flex-shrink: 0;
font-size: 0.72rem; color: var(--text-3); cursor: pointer; white-space: nowrap;
user-select: none;
}
.context-warning-toggle input[type="checkbox"] { margin: 0; cursor: pointer; }
/* Summary message node */
.message-summary {
border: 1px dashed color-mix(in srgb, var(--accent) 40%, transparent);
border-radius: 8px; padding: 10px 14px; margin: 4px 0;
background: color-mix(in srgb, var(--accent) 6%, transparent);
font-size: 0.85rem; position: relative;
}
.message-summary .summary-header {
display: flex; align-items: center; gap: 6px; margin-bottom: 6px;
font-size: 0.75rem; color: var(--text-3); font-weight: 500;
}
.message-summary .summary-toggle {
background: none; border: none; color: var(--accent); cursor: pointer;
font-size: 0.75rem; padding: 0; text-decoration: underline;
}
.message-summary .summary-toggle:hover { color: var(--text); }
.msg-dimmed { opacity: 0.5; }
.message-summary-divider {
text-align: center; padding: 8px 0; margin: 4px 0;
}
.message-summary-divider .summary-toggle {
background: none; border: none; color: var(--text-3); cursor: pointer;
font-size: 0.78rem; padding: 4px 12px; border-radius: 4px;
transition: all var(--transition);
}
.message-summary-divider .summary-toggle:hover { color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, transparent); }
.input-wrap {
max-width: 768px; margin: 0 auto; width: 100%; box-sizing: border-box;
background: var(--bg-surface); border: 1px solid var(--border);
border-radius: 12px; padding: 0;
display: flex; align-items: flex-end;
transition: border-color var(--transition);
}
.input-wrap:focus-within { border-color: var(--border-light); }
.input-wrap textarea {
flex: 1; resize: none; background: none; border: none;
color: var(--text); padding: 12px 0 12px 14px;
font-family: var(--font); font-size: 14px;
max-height: 200px; line-height: 1.5;
}
.input-wrap textarea:focus { outline: none; }
.input-wrap textarea::placeholder { color: var(--text-3); }
/* CM6 chat input — match textarea layout within .input-wrap */
#messageInputWrap {
flex: 1; min-width: 0;
}
#messageInputWrap .cm-editor {
background: transparent;
padding: 12px 0 12px 8px;
max-height: 200px;
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
}
#messageInputWrap .cm-editor.cm-focused { outline: none; }
#messageInputWrap .cm-scroller { overflow-y: auto; }
/* CM6 extension editor containers */
.ext-edit-form .cm-editor {
height: 100%;
font-size: 12px;
}
.input-actions { display: flex; align-items: center; gap: 2px; padding: 6px 8px; }
/* Chat input area wrapper (bottom pinned, padded) */
.chat-input-area {
padding: 0 16px 16px; flex-shrink: 0;
}
.chat-footer {
display: flex; justify-content: space-between; align-items: center;
padding: 4px 4px 0; max-width: 768px; margin: 0 auto;
min-height: 18px;
}
.chat-version { font-size: 10px; color: var(--text-3); }
.input-token-count {
font-size: 10px; color: var(--text-3);
font-family: var(--mono); margin-right: 4px; white-space: nowrap;
}
/* Left-side toolbar: attach, tools, KB — single container handles alignment */
.input-toolbar {
display: flex; align-items: center; gap: 2px;
padding: 0 4px 6px; flex-shrink: 0;
max-width: 768px; margin: 0 auto; width: 100%;
box-sizing: border-box;
}
.action-btn {
background: none; border: none; color: var(--text-3); cursor: pointer;
padding: 6px; border-radius: var(--radius);
transition: background var(--transition), color var(--transition);
display: flex; align-items: center; justify-content: center;
}
.action-btn:hover { background: var(--bg-hover); color: var(--text); }
.send-btn { color: var(--accent); }
.send-btn:hover { background: var(--accent-dim); color: var(--accent-hover); }
.send-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.stop-btn { display: none; color: var(--warning); }
.stop-btn.visible { display: flex; }
/* ── Files ─────────────────────────── */
.attach-btn { color: var(--text-3); }
.attach-btn:hover { color: var(--text); }
.file-strip {
max-width: 768px; margin: 0 auto; padding: 6px 8px 2px;
display: flex; gap: 6px; flex-wrap: wrap;
}
.att-chip {
display: flex; align-items: center; gap: 6px;
background: var(--bg-raised); border: 1px solid var(--border);
border-radius: var(--radius); padding: 4px 6px;
font-size: 12px; max-width: 220px; position: relative;
}
.att-chip.att-ready { border-color: color-mix(in srgb, var(--success) 40%, var(--border)); }
.att-chip.att-error { border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); }
.att-chip.att-pending { border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); }
.att-thumb {
width: 32px; height: 32px; object-fit: cover;
border-radius: 3px; flex-shrink: 0;
}
.att-icon { font-size: 18px; flex-shrink: 0; line-height: 1; }
.att-info { display: flex; flex-direction: column; min-width: 0; }
.att-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); font-weight: 500; }
.att-meta { color: var(--text-3); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.att-remove {
background: none; border: none; color: var(--text-3); cursor: pointer;
padding: 2px 4px; font-size: 13px; line-height: 1; border-radius: 3px;
flex-shrink: 0; margin-left: auto;
}
.att-remove:hover { background: var(--bg-hover); color: var(--text); }
/* Drag-and-drop overlay on chat area */
.messages.drag-over {
outline: 2px dashed var(--accent);
outline-offset: -4px;
background: color-mix(in srgb, var(--accent) 4%, var(--bg));
}
/* ── Message Files ────────────────────── */
.msg-files {
display: flex; gap: 8px; flex-wrap: wrap;
margin-top: 8px;
}
.msg-att-image {
cursor: pointer; position: relative; border-radius: var(--radius);
overflow: hidden; border: 1px solid var(--border);
transition: border-color var(--transition);
}
.msg-att-image:hover { border-color: var(--accent); }
.msg-att-image img {
display: block; max-height: 280px; border-radius: var(--radius);
background: var(--bg-raised);
}
.msg-att-image img.att-load-error {
min-width: 120px; min-height: 60px;
display: flex; align-items: center; justify-content: center;
font-size: 13px; color: var(--text-3);
}
.att-vision-hint {
position: absolute; top: 4px; right: 4px;
background: color-mix(in srgb, var(--bg) 85%, transparent);
border-radius: 4px; padding: 2px 5px; font-size: 14px;
cursor: help;
}
.msg-att-doc {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; border: 1px solid var(--border);
border-radius: var(--radius); background: var(--bg-raised);
text-decoration: none; color: var(--text);
max-width: 280px; transition: border-color var(--transition);
}
.msg-att-doc:hover { border-color: var(--accent); }
.att-doc-icon { font-size: 20px; flex-shrink: 0; }
.att-doc-info { display: flex; flex-direction: column; min-width: 0; }
.att-doc-name {
font-size: 13px; font-weight: 500;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.att-doc-size { font-size: 11px; color: var(--text-3); }
.att-doc-dl { color: var(--text-3); font-size: 14px; margin-left: auto; }
/* ── Image Lightbox ─────────────────────────── */
.lightbox-overlay {
display: none; position: fixed; inset: 0; z-index: 10000;
background: rgba(0,0,0,0.85); align-items: center; justify-content: center;
}
.lightbox-overlay.active { display: flex; }
.lightbox-overlay img {
max-width: 92vw; max-height: 90vh; object-fit: contain;
border-radius: 4px; box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
.lightbox-close {
position: absolute; top: 16px; right: 20px;
background: rgba(255,255,255,0.15); border: none; color: var(--text-on-color);
font-size: 22px; width: 40px; height: 40px; border-radius: 50%;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background 0.15s;
}
.lightbox-close:hover { background: rgba(255,255,255,0.3); }
/* ── Chat type indicators (v0.23.0) ────────── */
.chat-type-icon { font-size: 11px; opacity: 0.7; }
.mono-input { font-family: var(--mono) !important; font-size: 12px !important; }
/* Handle hints in dropdowns and pickers */
.item-handle { font-size: 10px; color: var(--accent); font-family: var(--mono); margin-left: 6px; opacity: 0.75; }
.group-persona-handle { font-size: 10px; color: var(--accent); font-family: var(--mono); margin-left: 4px; opacity: 0.75; }

View File

@@ -1,247 +0,0 @@
/* ==========================================
Notifications (v0.20.0)
========================================== */
/* ── Bell + Badge ──────────────────────────── */
.notif-wrap {
position: relative;
display: flex;
align-items: center;
}
.notif-bell {
background: none;
border: none;
cursor: pointer;
padding: 5px;
border-radius: 7px;
color: var(--text-3);
display: flex;
align-items: center;
position: relative;
transition: background 0.15s, color 0.15s;
}
.notif-bell:hover {
background: var(--bg-hover);
color: var(--text);
}
.notif-badge {
position: absolute;
top: 0;
right: 0;
min-width: 16px;
height: 16px;
line-height: 16px;
font-size: 10px;
font-weight: 700;
text-align: center;
border-radius: 8px;
background: var(--danger);
color: #fff;
padding: 0 4px;
pointer-events: none;
}
/* ── Dropdown ──────────────────────────────── */
.notif-dropdown {
display: none;
position: absolute;
top: 100%;
right: 0;
width: 360px;
max-height: 480px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
z-index: 1000;
overflow: hidden;
}
.notif-dd-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
border-bottom: 1px solid var(--border);
font-weight: 600;
font-size: 13px;
}
.notif-mark-all {
background: none;
border: none;
color: var(--accent);
cursor: pointer;
font-size: 12px;
padding: 2px 6px;
border-radius: 4px;
}
.notif-mark-all:hover {
background: var(--bg-hover);
}
.notif-dd-list {
overflow-y: auto;
max-height: 380px;
}
.notif-dd-footer {
padding: 8px 14px;
border-top: 1px solid var(--border);
text-align: center;
}
.notif-view-all {
background: none;
border: none;
color: var(--accent);
cursor: pointer;
font-size: 12px;
}
.notif-view-all:hover {
text-decoration: underline;
}
/* ── Notification Item ─────────────────────── */
.notif-item,
.notif-panel-item {
display: flex;
align-items: flex-start;
padding: 10px 14px;
cursor: pointer;
transition: background 0.15s;
border-bottom: 1px solid var(--border);
}
.notif-item:hover,
.notif-panel-item:hover {
background: var(--bg-hover);
}
.notif-item.notif-unread,
.notif-panel-item.notif-unread {
background: var(--accent-dim);
}
.notif-dot {
flex-shrink: 0;
width: 18px;
font-size: 10px;
margin-top: 3px;
color: var(--accent);
}
.notif-item:not(.notif-unread) .notif-dot,
.notif-panel-item:not(.notif-unread) .notif-dot {
color: var(--text-tertiary, #555);
}
.notif-body {
flex: 1;
min-width: 0;
}
.notif-title {
font-size: 13px;
line-height: 1.4;
color: var(--text);
}
.notif-detail {
font-size: 12px;
color: var(--text-2);
margin-top: 2px;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notif-time {
font-size: 11px;
color: var(--text-tertiary, #666);
margin-top: 3px;
}
.notif-empty {
padding: 32px 14px;
text-align: center;
color: var(--text-2);
font-size: 13px;
}
/* ── Panel ─────────────────────────────────── */
.notif-panel {
height: 100%;
display: flex;
flex-direction: column;
}
.notif-panel-toolbar {
display: flex;
gap: 6px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
.notif-panel-list {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.notif-panel-row {
display: flex;
align-items: flex-start;
width: 100%;
}
.notif-delete {
flex-shrink: 0;
background: none;
border: none;
color: var(--text-tertiary, #555);
cursor: pointer;
padding: 2px 6px;
font-size: 12px;
border-radius: 4px;
opacity: 0;
transition: opacity 0.15s;
}
.notif-panel-item:hover .notif-delete {
opacity: 1;
}
.notif-delete:hover {
color: var(--danger);
background: var(--bg-hover);
}
.notif-panel-more {
padding: 8px 12px;
text-align: center;
border-top: 1px solid var(--border);
}
/* ── Mobile ────────────────────────────────── */
@media (max-width: 768px) {
.notif-dropdown {
position: fixed;
top: 48px;
left: 8px;
right: 8px;
width: auto;
max-height: calc(100vh - 96px);
}
}

View File

@@ -1,167 +0,0 @@
/* ==========================================
Chat Switchboard — Pane Container
==========================================
v0.25.0: Styles for the multi-pane layout system.
Covers: pane container, leaf panes, tabbed panes,
drag handles, and tab bars.
========================================== */
/* ── Container ─────────────────────────────── */
.pane-container {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
/* ── Split ─────────────────────────────────── */
.pane-split--horizontal {
display: flex;
flex-direction: row;
height: 100%;
overflow: hidden;
}
.pane-split--vertical {
display: flex;
flex-direction: column;
width: 100%;
overflow: hidden;
}
/* ── Leaf Pane ─────────────────────────────── */
.pane-leaf {
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
box-sizing: border-box;
}
.pane-minimized {
max-width: 0 !important;
max-height: 0 !important;
padding: 0 !important;
border: none !important;
overflow: hidden !important;
}
/* ── Tabbed Pane ───────────────────────────── */
.pane-tabbed {
display: flex;
flex-direction: column;
overflow: hidden;
box-sizing: border-box;
}
/* Tab Bar */
.pane-tab-bar {
display: flex;
align-items: center;
gap: 0;
background: var(--bg-secondary, #1a1a1e);
border-bottom: 1px solid var(--border, #2a2a2e);
flex-shrink: 0;
height: 32px;
padding: 0 4px;
overflow-x: auto;
overflow-y: hidden;
}
.pane-tab-bar::-webkit-scrollbar { height: 0; }
.pane-tab-btn {
background: none;
border: none;
color: var(--text-3, #777);
font-size: 11px;
font-weight: 600;
font-family: inherit;
padding: 6px 12px;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: color 0.15s, border-color 0.15s;
white-space: nowrap;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.pane-tab-btn:hover {
color: var(--text, #eee);
}
.pane-tab-btn--active {
color: var(--accent, #b38a4e);
border-bottom-color: var(--accent, #b38a4e);
}
/* Tab Content */
.pane-tab-content {
flex: 1;
min-height: 0;
min-width: 0;
overflow: hidden;
position: relative;
}
.pane-tab-panel {
position: absolute;
inset: 0;
overflow: hidden;
}
/* When panel is hidden (inactive tab), override absolute positioning */
.pane-tab-panel[style*="display: none"] {
position: absolute;
}
/* ── Drag Handle ───────────────────────────── */
.pane-handle {
flex-shrink: 0;
background: var(--border, #2a2a2e);
transition: background 0.15s;
z-index: 10;
}
.pane-handle--horizontal {
width: 4px;
cursor: col-resize;
}
.pane-handle--vertical {
height: 4px;
cursor: row-resize;
}
.pane-handle:hover,
.pane-handle--active {
background: var(--accent, #b38a4e);
}
/* ── Responsive ────────────────────────────── */
@media (max-width: 768px) {
/* On mobile, splits collapse to vertical stack */
.pane-split--horizontal {
flex-direction: column;
}
.pane-handle--horizontal {
width: 100%;
height: 4px;
cursor: row-resize;
}
/* Hide non-primary panes on mobile */
.pane-split--horizontal > .pane:not(:first-child) {
display: none;
}
.pane-split--horizontal > .pane-handle {
display: none;
}
}

View File

@@ -1,216 +0,0 @@
/* ── panels.css ──────────────────────────────
Side panel, preview, notes, graph, backlinks
──────────────────────────────────────────── */
/* ── Side Panel (Preview + Notes) ──────── */
/* ── Secondary Pane (was .side-panel, now .workspace-secondary) ── */
/* Layout handled by .workspace-secondary above; these are internal styles */
.side-panel-header {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 12px; border-bottom: 1px solid var(--border);
background: var(--bg-raised); flex-shrink: 0;
}
.side-panel-actions {
display: flex; align-items: center; gap: 2px;
}
.side-panel-action-btn {
background: none; border: none; color: var(--text-3);
cursor: pointer; padding: 4px 6px; border-radius: var(--radius);
transition: all var(--transition); display: flex; align-items: center;
}
.side-panel-action-btn:hover { background: var(--bg-hover); color: var(--text); }
.side-panel-label {
font-size: 12px; font-family: var(--font); font-weight: 600;
color: var(--text-2); white-space: nowrap; font-style: normal;
}
.side-panel-btn {
background: none; border: none; color: var(--text-3);
cursor: pointer; padding: 4px 6px; border-radius: var(--radius);
transition: all var(--transition); display: flex; align-items: center;
}
.side-panel-btn:hover { background: var(--bg-hover); color: var(--text); }
.side-panel-close {
background: none; border: none; color: var(--text-3);
cursor: pointer; font-size: 16px; padding: 2px 6px;
border-radius: var(--radius); transition: all var(--transition);
}
.side-panel-close:hover { background: var(--bg-hover); color: var(--text); }
.side-panel-body {
flex: 1; overflow-y: auto; min-height: 0;
}
.side-panel-page { height: 100%; display: flex; flex-direction: column; }
/* .note-panel-root removed in v0.37.9 — see sw-notes-pane.css */
.side-panel-empty {
display: flex; align-items: center; justify-content: center;
height: 100%; color: var(--text-3); font-size: 13px;
padding: 2rem; text-align: center;
}
.preview-frame {
flex: 1; width: 100%; border: none; background: var(--bg-surface);
}
.preview-empty {
display: flex; align-items: center; justify-content: center;
height: 100%; color: var(--text-3); font-size: 13px;
padding: 2rem; text-align: center;
}
/* Notes panel CSS removed in v0.37.9 — see sw-notes-pane.css */
/* Side panel overlay (mobile tap-to-close, mirrors sidebar-overlay) */
.side-panel-overlay {
display: none; position: fixed; inset: 0; z-index: 199;
background: var(--overlay);
}
/* Mobile: full-width overlay */
@media (max-width: 768px) {
.workspace-secondary.open {
position: fixed; top: 0; right: 0; bottom: 0;
width: 100vw; min-width: 100vw; z-index: 200;
}
.workspace-handle { display: none !important; }
/* Larger touch targets on mobile */
.side-panel-close { font-size: 20px; padding: 4px 10px; }
.side-panel-action-btn { padding: 6px 8px; }
.side-panel-header { padding: 10px 12px; }
}
/* Dual-view removed in workspace refactor (v0.22.0) — workspace
handles all pane placement via primary/secondary slots. */
.msg-text ul, .msg-text ol { margin: 0.4em 0; padding-left: 1.5em; }
.msg-text li { margin: 0.2em 0; }
.msg-text li > p { margin: 0.2em 0; }
.msg-text blockquote {
border-left: 3px solid var(--accent); padding: 0.25em 0.75em; margin: 0.5em 0;
color: var(--text-2); background: var(--accent-dim); border-radius: 0 var(--radius) var(--radius) 0;
}
.msg-text hr { border: none; border-top: 1px solid var(--border); margin: 0.75em 0; }
.msg-text table { border-collapse: collapse; width: 100%; margin: 0.5em 0; font-size: 13px; }
.msg-text th, .msg-text td { border: 1px solid var(--border); padding: 0.4em 0.7em; text-align: left; }
.msg-text th { background: var(--bg-raised); font-weight: 600; }
.msg-text h1, .msg-text h2, .msg-text h3, .msg-text h4 { margin: 0.75em 0 0.3em; font-weight: 600; }
.msg-text h1 { font-size: 1.4em; }
.msg-text h2 { font-size: 1.2em; }
.msg-text h3 { font-size: 1.05em; }
.msg-text a { color: var(--accent); }
.msg-text img { max-width: 100%; border-radius: var(--radius); }
/* Thinking blocks */
.thinking-block {
border: 1px solid var(--accent-dim); border-radius: var(--radius);
margin: 0.5rem 0; background: rgba(108,159,255,0.03);
}
.thinking-block summary {
padding: 0.4rem 0.75rem; cursor: pointer; font-size: 12px;
color: var(--text-3); user-select: none;
transition: color var(--transition);
}
.thinking-block summary:hover { color: var(--text-2); }
.thinking-block[open] summary { border-bottom: 1px solid rgba(108,159,255,0.08); }
.thinking-content {
padding: 0.5rem 0.75rem; font-size: 12px; color: var(--text-3);
max-height: 300px; overflow-y: auto; line-height: 1.6;
}
/* Tool activity indicators */
.msg-tools {
display: flex; flex-direction: column; gap: 4px;
margin-bottom: 0.5rem;
}
.tool-activity {
display: flex; align-items: center; gap: 6px;
font-size: 12px; color: var(--text-3);
background: var(--bg-raised); border: 1px solid var(--border);
padding: 4px 10px; border-radius: var(--radius);
animation: tool-fade-in 0.2s ease;
}
@keyframes tool-fade-in { from { opacity: 0; transform: translateY(-4px); } }
.tool-icon { font-size: 13px; }
.tool-name { font-family: var(--mono); font-size: 11px; color: var(--text-2); }
.tool-status { font-size: 11px; margin-left: auto; }
.tool-running { color: var(--warning); }
.tool-running::after {
content: ''; display: inline-block; width: 4px; height: 4px;
border-radius: 50%; background: var(--warning); margin-left: 4px;
animation: tool-pulse 1s infinite;
}
@keyframes tool-pulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1; } }
.tool-done { color: var(--success); }
.tool-error { color: var(--danger); }
.tool-hint {
font-size: 11px; color: var(--text-3); margin-left: 6px;
max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.tool-note-link {
background: none; border: none; color: var(--accent); cursor: pointer;
font-size: 11px; margin-left: 6px; padding: 0;
text-decoration: none;
transition: color var(--transition);
}
.tool-note-link:hover { color: var(--text); text-decoration: underline; }
/* Tool calls in message history (collapsed) */
.tool-call-block {
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg-raised); font-size: 12px;
}
.tool-call-summary {
display: flex; align-items: center; gap: 6px;
padding: 4px 10px; cursor: pointer; user-select: none;
color: var(--text-3); list-style: none;
transition: color var(--transition);
}
.tool-call-summary::-webkit-details-marker { display: none; }
.tool-call-summary::before {
content: '▸'; font-size: 10px; color: var(--text-3);
transition: transform 0.15s ease;
}
.tool-call-block[open] .tool-call-summary::before { transform: rotate(90deg); }
.tool-call-summary:hover { color: var(--text-2); }
.tool-detail {
border-top: 1px solid var(--border); padding: 6px 10px;
display: flex; flex-direction: column; gap: 6px;
}
.tool-detail-label {
font-size: 10px; color: var(--text-3); text-transform: uppercase;
letter-spacing: 0.5px; font-weight: 600;
}
.tool-detail-pre {
margin: 2px 0 0; padding: 6px 8px;
background: var(--bg-surface); border-radius: 4px;
font-size: 11px; color: var(--text-2); white-space: pre-wrap;
word-break: break-all; max-height: 200px; overflow-y: auto;
}
/* Empty state */
.empty-state {
display: flex; flex-direction: column; align-items: center;
justify-content: center; height: 100%; color: var(--text-3); gap: 0.25rem;
}
.empty-logo { font-size: 3rem; margin-bottom: 0.5rem; }
.empty-logo-img { width: 64px; height: 64px; object-fit: contain; }
.empty-state h2 { font-size: 1.25rem; font-weight: 600; color: var(--text-2); }
.empty-state p { font-size: 0.85rem; }
/* Typing indicator */
.typing-dots { display: flex; gap: 4px; padding: 0.5rem 0; }
.typing-dots span {
width: 6px; height: 6px; border-radius: 50%; background: var(--text-3);
animation: dot-pulse 1.2s infinite;
}
.typing-dots span:nth-child(2) { animation-delay: 0.2s; }
.typing-dots span:nth-child(3) { animation-delay: 0.4s; }
@keyframes dot-pulse { 0%,60%,100% { opacity: 0.4; } 30% { opacity: 1; } }
/* ── Old notes CSS removed in v0.37.9 — see sw-notes-pane.css ── */

View File

@@ -377,3 +377,376 @@
opacity: 0.3;
cursor: not-allowed;
}
/* ── Stop Button ──────────────────────────── */
.sw-msg-input__stop {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
background: none;
border: none;
border-radius: 6px;
color: var(--danger, #f44336);
cursor: pointer;
transition: background 0.15s;
}
.sw-msg-input__stop:hover {
background: rgba(244, 67, 54, 0.1);
}
/* ── Input Buttons Row ────────────────────── */
.sw-msg-input__buttons {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
/* ── Emoji Button + Picker ────────────────── */
.sw-msg-input__emoji-wrap {
position: relative;
}
.sw-msg-input__emoji-btn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
background: none;
border: none;
border-radius: 6px;
color: var(--text-3, #555);
cursor: pointer;
transition: color 0.15s;
}
.sw-msg-input__emoji-btn:hover {
color: var(--text-2, #999);
}
.sw-emoji-picker {
position: absolute;
bottom: 40px;
right: 0;
width: 320px;
max-height: 360px;
background: var(--bg-secondary, #151517);
border: 1px solid var(--border, #2a2a2e);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
z-index: 100;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sw-emoji-picker__tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border, #2a2a2e);
padding: 4px 4px 0;
flex-shrink: 0;
}
.sw-emoji-picker__tab {
flex: 1;
background: none;
border: none;
border-bottom: 2px solid transparent;
padding: 6px 4px;
font-size: 16px;
cursor: pointer;
transition: border-color 0.15s;
line-height: 1;
}
.sw-emoji-picker__tab--active {
border-bottom-color: var(--accent, #b38a4e);
}
.sw-emoji-picker__search {
margin: 6px 8px;
padding: 6px 10px;
background: var(--bg, #0e0e10);
border: 1px solid var(--border, #2a2a2e);
border-radius: 6px;
color: var(--text, #eee);
font-size: 12px;
font-family: inherit;
outline: none;
flex-shrink: 0;
}
.sw-emoji-picker__search:focus {
border-color: var(--accent, #b38a4e);
}
.sw-emoji-picker__grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 2px;
padding: 6px 8px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.sw-emoji-picker__emoji {
width: 100%;
aspect-ratio: 1;
background: none;
border: none;
border-radius: 6px;
font-size: 20px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.1s;
line-height: 1;
padding: 0;
}
.sw-emoji-picker__emoji:hover {
background: var(--bg, #0e0e10);
}
.sw-emoji-picker__empty {
grid-column: 1 / -1;
text-align: center;
padding: 20px;
color: var(--text-3, #555);
font-size: 12px;
}
/* ── Markdown Toolbar ─────────────────────── */
.sw-md-toolbar {
display: flex;
gap: 1px;
padding: 4px 12px 2px;
flex-shrink: 0;
}
.sw-md-toolbar__btn {
background: none;
border: none;
color: var(--text-3, #555);
font-size: 11px;
font-weight: 600;
padding: 3px 7px;
border-radius: 4px;
cursor: pointer;
transition: color 0.15s, background 0.15s;
line-height: 1;
}
.sw-md-toolbar__btn:hover {
color: var(--text, #eee);
background: var(--bg, #0e0e10);
}
/* ── Error Banner ─────────────────────────── */
.sw-chat-pane__error {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: rgba(244, 67, 54, 0.1);
border-bottom: 1px solid rgba(244, 67, 54, 0.2);
color: var(--danger, #f44336);
font-size: 12px;
flex-shrink: 0;
}
.sw-chat-pane__error-text {
flex: 1;
min-width: 0;
}
.sw-chat-pane__error-dismiss {
background: none;
border: none;
color: var(--danger, #f44336);
font-size: 16px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
opacity: 0.7;
}
.sw-chat-pane__error-dismiss:hover {
opacity: 1;
}
/* ── Load More ────────────────────────────── */
.sw-chat-pane__load-more {
text-align: center;
padding: 8px;
}
.sw-chat-pane__load-more-btn {
background: none;
border: 1px solid var(--border, #2a2a2e);
border-radius: 6px;
color: var(--text-2, #999);
font-size: 12px;
padding: 6px 16px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.sw-chat-pane__load-more-btn:hover {
border-color: var(--accent, #b38a4e);
color: var(--text, #eee);
}
.sw-chat-pane__load-more-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Message Footer (time + actions) ──────── */
.sw-chat-msg__footer {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
opacity: 0;
transition: opacity 0.15s;
}
.sw-chat-msg:hover .sw-chat-msg__footer {
opacity: 1;
}
.sw-chat-msg__time {
font-size: 10px;
color: var(--text-3, #555);
}
/* ── Message Actions Toolbar ──────────────── */
.sw-chat-msg__actions {
display: flex;
gap: 2px;
}
.sw-chat-msg__action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: none;
border: none;
border-radius: 4px;
color: var(--text-3, #555);
cursor: pointer;
transition: color 0.15s, background 0.15s;
padding: 0;
}
.sw-chat-msg__action-btn:hover {
color: var(--text, #eee);
background: var(--bg-secondary, #151517);
}
.sw-chat-msg__action-btn--danger:hover {
color: var(--danger, #f44336);
}
/* ── Code Block ───────────────────────────── */
.sw-code-block {
position: relative;
margin: 8px 0;
border: 1px solid var(--border, #2a2a2e);
border-radius: 6px;
overflow: hidden;
}
.sw-code-block__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
background: var(--bg, #0e0e10);
border-bottom: 1px solid var(--border, #2a2a2e);
min-height: 28px;
}
.sw-code-block__lang {
font-size: 10px;
font-weight: 600;
color: var(--text-3, #555);
text-transform: uppercase;
letter-spacing: 0.5px;
font-family: var(--mono, 'SF Mono', monospace);
}
.sw-code-block__copy {
display: flex;
align-items: center;
gap: 4px;
background: none;
border: none;
color: var(--text-3, #555);
font-size: 11px;
cursor: pointer;
padding: 2px 6px;
border-radius: 3px;
transition: color 0.15s, background 0.15s;
margin-left: auto;
}
.sw-code-block__copy:hover {
color: var(--text, #eee);
background: var(--bg-secondary, #151517);
}
.sw-code-block__copy-text {
font-family: inherit;
}
.sw-code-block__pre {
background: var(--bg-secondary, #151517);
padding: 10px 12px;
overflow-x: auto;
margin: 0;
font-size: 12px;
line-height: 1.5;
font-family: var(--mono, 'SF Mono', monospace);
}
.sw-code-block__pre code {
background: none;
padding: 0;
border-radius: 0;
font-size: inherit;
font-family: inherit;
}
/* ── Task List Checkboxes ─────────────────── */
.sw-task-item {
list-style: none;
margin-left: -1.5em;
}
.sw-task-checkbox {
margin-right: 6px;
vertical-align: middle;
accent-color: var(--accent, #b38a4e);
}

608
src/css/sw-chat-surface.css Normal file
View File

@@ -0,0 +1,608 @@
/* ==========================================
Chat Switchboard — Chat Surface CSS
==========================================
v0.37.10: New Preact chat surface layout.
sw-chat-surface__ prefixed, no legacy conflicts.
========================================== */
/* ── Root Layout ──────────────────────────── */
.sw-chat-surface {
display: flex;
width: 100%;
height: 100%;
flex: 1 1 0%;
overflow: hidden;
background: var(--bg, #0e0e10);
}
/* ── Sidebar ──────────────────────────────── */
.sw-chat-surface__sidebar {
width: 260px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--bg-secondary, #151517);
border-right: 1px solid var(--border, #2a2a2e);
overflow: hidden;
z-index: 20;
}
.sw-chat-surface__sidebar-header {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 12px 6px;
flex-shrink: 0;
}
.sw-chat-surface__new-chat-btn {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
padding: 8px 12px;
background: var(--accent-dim, rgba(179, 138, 78, 0.1));
border: 1px solid var(--accent, #b38a4e);
border-radius: 8px;
color: var(--accent, #b38a4e);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.sw-chat-surface__collapse-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: none;
border: none;
border-radius: 6px;
color: var(--text-3, #555);
cursor: pointer;
flex-shrink: 0;
transition: color 0.15s, background 0.15s;
}
.sw-chat-surface__collapse-btn:hover {
color: var(--text, #eee);
background: var(--bg, #0e0e10);
}
.sw-chat-surface__new-chat-btn:hover {
background: rgba(179, 138, 78, 0.2);
}
/* ── Search ───────────────────────────────── */
.sw-chat-surface__search-wrap {
position: relative;
padding: 4px 12px 8px;
flex-shrink: 0;
}
.sw-chat-surface__search-icon {
position: absolute;
left: 20px;
top: 50%;
transform: translateY(-50%);
color: var(--text-3, #555);
pointer-events: none;
display: flex;
}
.sw-chat-surface__search {
width: 100%;
padding: 6px 8px 6px 30px;
background: var(--bg, #0e0e10);
border: 1px solid var(--border, #2a2a2e);
border-radius: 6px;
color: var(--text, #eee);
font-size: 12px;
font-family: inherit;
outline: none;
box-sizing: border-box;
}
.sw-chat-surface__search:focus {
border-color: var(--accent, #b38a4e);
}
/* ── Sidebar Body (scrollable) ────────────── */
.sw-chat-surface__sidebar-body {
flex: 1;
overflow-y: auto;
min-height: 0;
padding-bottom: 8px;
}
/* ── Section ──────────────────────────────── */
.sw-chat-surface__section {
margin-top: 4px;
}
.sw-chat-surface__section-header {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 6px 12px;
background: none;
border: none;
color: var(--text-2, #999);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
cursor: pointer;
transition: color 0.15s;
}
.sw-chat-surface__section-header:hover {
color: var(--text, #eee);
}
.sw-chat-surface__chevron {
display: flex;
transition: transform 0.15s;
}
.sw-chat-surface__chevron--open {
transform: rotate(90deg);
}
.sw-chat-surface__section-count {
margin-left: auto;
font-size: 10px;
color: var(--text-3, #555);
font-weight: 400;
}
.sw-chat-surface__section-body {
padding: 0 4px;
}
.sw-chat-surface__empty {
padding: 8px 16px;
font-size: 12px;
color: var(--text-3, #555);
}
/* ── Sidebar Item ─────────────────────────── */
.sw-chat-surface__item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 12px;
background: none;
border: none;
border-radius: 6px;
color: var(--text-2, #999);
font-size: 13px;
cursor: pointer;
text-align: left;
transition: background 0.1s, color 0.1s;
position: relative;
}
.sw-chat-surface__item:hover {
background: var(--bg, #0e0e10);
color: var(--text, #eee);
}
.sw-chat-surface__item--active {
background: var(--accent-dim, rgba(179, 138, 78, 0.1));
color: var(--text, #eee);
}
.sw-chat-surface__item--indent {
padding-left: 28px;
}
.sw-chat-surface__item-icon {
display: flex;
flex-shrink: 0;
color: var(--text-3, #555);
}
.sw-chat-surface__item-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sw-chat-surface__item-menu {
display: none;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
background: none;
border: none;
border-radius: 4px;
color: var(--text-3, #555);
cursor: pointer;
flex-shrink: 0;
padding: 0;
}
.sw-chat-surface__item:hover .sw-chat-surface__item-menu {
display: flex;
}
.sw-chat-surface__item-menu:hover {
color: var(--text, #eee);
background: var(--bg-secondary, #151517);
}
/* ── Unread Badge ─────────────────────────── */
.sw-chat-surface__unread {
background: var(--accent, #b38a4e);
color: var(--bg, #0e0e10);
font-size: 10px;
font-weight: 700;
padding: 1px 6px;
border-radius: 10px;
flex-shrink: 0;
}
/* ── Folder ───────────────────────────────── */
.sw-chat-surface__folder {
margin: 2px 0;
}
.sw-chat-surface__folder-header {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
color: var(--text-3, #555);
font-size: 11px;
}
.sw-chat-surface__folder-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
}
.sw-chat-surface__folder-count {
font-size: 10px;
color: var(--text-3, #555);
}
/* ── Context Menu ─────────────────────────── */
.sw-chat-surface__context-menu {
position: fixed;
z-index: 200;
background: var(--bg-secondary, #151517);
border: 1px solid var(--border, #2a2a2e);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
padding: 4px;
min-width: 160px;
}
.sw-chat-surface__context-menu button {
display: block;
width: 100%;
padding: 6px 12px;
background: none;
border: none;
border-radius: 4px;
color: var(--text, #eee);
font-size: 12px;
cursor: pointer;
text-align: left;
}
.sw-chat-surface__context-menu button:hover {
background: var(--bg, #0e0e10);
}
.sw-chat-surface__ctx-danger {
color: var(--danger, #f44336) !important;
}
/* ── Sidebar Footer ───────────────────────── */
.sw-chat-surface__sidebar-footer {
display: flex;
align-items: center;
padding: 8px 12px;
border-top: 1px solid var(--border, #2a2a2e);
flex-shrink: 0;
position: relative;
}
/* ── Main Area ────────────────────────────── */
.sw-chat-surface__main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
position: relative;
}
/* ── Workspace Header ─────────────────────── */
.sw-chat-surface__workspace-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 10px;
border-bottom: 1px solid var(--border, #2a2a2e);
background: var(--bg-secondary, #151517);
flex-shrink: 0;
min-height: 40px;
}
.sw-chat-surface__workspace-header-left,
.sw-chat-surface__workspace-header-right {
display: flex;
align-items: center;
gap: 6px;
}
.sw-chat-surface__sidebar-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
background: none;
border: none;
border-radius: 6px;
color: var(--text-3, #555);
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.sw-chat-surface__sidebar-toggle:hover {
color: var(--text, #eee);
background: var(--bg, #0e0e10);
}
/* ── Tools Button ─────────────────────────── */
.sw-chat-surface__tools-btn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
background: none;
border: none;
border-radius: 6px;
color: var(--text-3, #555);
cursor: pointer;
position: relative;
transition: color 0.15s;
}
.sw-chat-surface__tools-btn:hover {
color: var(--text, #eee);
}
.sw-chat-surface__tools-badge {
position: absolute;
top: 2px;
right: 2px;
background: var(--danger, #f44336);
color: #fff;
font-size: 9px;
font-weight: 700;
width: 14px;
height: 14px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
/* ── Workspace ────────────────────────────── */
.sw-chat-surface__workspace {
flex: 1 1 0%;
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
position: relative;
overflow: hidden;
}
.sw-chat-surface__chat-pane {
flex: 1 1 0%;
min-height: 0;
min-width: 0;
}
/* ── Sidebar Overlay (mobile) ─────────────── */
.sw-chat-surface__sidebar-overlay {
display: none; /* Hidden on desktop */
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 15;
}
/* ── Tools Popup ──────────────────────────── */
.sw-tools-popup {
position: absolute;
top: 44px;
right: 8px;
width: 300px;
max-height: 420px;
background: var(--bg-secondary, #151517);
border: 1px solid var(--border, #2a2a2e);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
z-index: 100;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sw-tools-popup__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px 8px;
border-bottom: 1px solid var(--border, #2a2a2e);
flex-shrink: 0;
}
.sw-tools-popup__title {
font-size: 13px;
font-weight: 600;
color: var(--text, #eee);
}
.sw-tools-popup__badge {
font-size: 10px;
color: var(--danger, #f44336);
background: rgba(244, 67, 54, 0.1);
padding: 2px 8px;
border-radius: 10px;
}
.sw-tools-popup__body {
flex: 1;
overflow-y: auto;
min-height: 0;
padding: 8px;
}
.sw-tools-popup__empty {
padding: 16px;
text-align: center;
color: var(--text-3, #555);
font-size: 12px;
}
.sw-tools-popup__category {
margin-bottom: 4px;
}
.sw-tools-popup__cat-header {
display: flex;
align-items: center;
gap: 6px;
}
.sw-tools-popup__cat-toggle {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
background: none;
border: none;
color: var(--text, #eee);
font-size: 12px;
font-weight: 600;
cursor: pointer;
padding: 4px 6px;
border-radius: 4px;
text-align: left;
}
.sw-tools-popup__cat-toggle:hover {
background: var(--bg, #0e0e10);
}
.sw-tools-popup__chevron {
font-size: 8px;
transition: transform 0.15s;
display: inline-block;
}
.sw-tools-popup__chevron--open {
transform: rotate(90deg);
}
.sw-tools-popup__cat-count {
color: var(--text-3, #555);
font-weight: 400;
font-size: 10px;
}
.sw-tools-popup__cat-switch {
flex-shrink: 0;
cursor: pointer;
}
.sw-tools-popup__cat-switch input {
accent-color: var(--accent, #b38a4e);
}
.sw-tools-popup__tool {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 4px 6px 4px 22px;
font-size: 12px;
cursor: pointer;
border-radius: 4px;
}
.sw-tools-popup__tool:hover {
background: var(--bg, #0e0e10);
}
.sw-tools-popup__tool input {
margin-top: 2px;
accent-color: var(--accent, #b38a4e);
}
.sw-tools-popup__tool-name {
color: var(--text, #eee);
font-size: 12px;
}
.sw-tools-popup__tool-desc {
color: var(--text-3, #555);
font-size: 11px;
margin-left: auto;
}
/* ── Responsive: Mobile ───────────────────── */
@media (max-width: 768px) {
.sw-chat-surface__sidebar {
position: fixed;
left: 0;
top: 0;
bottom: 0;
width: 280px;
z-index: 20;
}
.sw-chat-surface__sidebar-overlay {
display: block;
}
.sw-chat-surface__collapse-btn {
display: none;
}
}

View File

@@ -1,8 +1,15 @@
const { describe, it } = require('node:test');
const assert = require('node:assert');
const { createBrowserContext, loadSource } = require('./helpers');
const fs = require('fs');
const path = require('path');
const { createBrowserContext, loadSource, SRC } = require('./helpers');
const vm = require('vm');
// v0.37.10: extensions.js deleted — extension system moves to Preact in v0.37.12.
// Skip all tests if the source file doesn't exist.
const EXTENSIONS_EXISTS = fs.existsSync(path.join(SRC, 'extensions.js'));
const maybeDescribe = EXTENSIONS_EXISTS ? describe : describe.skip;
/**
* Load Events + Extensions into a browser context.
*/
@@ -23,7 +30,7 @@ function loadExtensions(overrides = {}) {
// KaTeX Renderer Extension
// ═══════════════════════════════════════════
describe('KaTeX renderer extension', () => {
maybeDescribe('KaTeX renderer extension', () => {
function loadWithKaTeX() {
const ctx = loadExtensions();
ctx.Extensions.register({
@@ -133,7 +140,7 @@ describe('KaTeX renderer extension', () => {
// CSV Table Extension
// ═══════════════════════════════════════════
describe('CSV Table extension', () => {
maybeDescribe('CSV Table extension', () => {
function loadWithCSV() {
const ctx = loadExtensions();
ctx.Extensions.register({
@@ -296,7 +303,7 @@ describe('CSV Table extension', () => {
// Diff Viewer Extension
// ═══════════════════════════════════════════
describe('Diff Viewer extension', () => {
maybeDescribe('Diff Viewer extension', () => {
function loadWithDiff() {
const ctx = loadExtensions();
ctx.Extensions.register({
@@ -427,7 +434,7 @@ describe('Diff Viewer extension', () => {
// JS Sandbox Extension (Tool)
// ═══════════════════════════════════════════
describe('JS Sandbox extension', () => {
maybeDescribe('JS Sandbox extension', () => {
it('registers js_eval tool handler', async () => {
const ctx = loadExtensions();
ctx.Extensions.register({
@@ -484,7 +491,7 @@ describe('JS Sandbox extension', () => {
// Regex Tester Extension (Tool)
// ═══════════════════════════════════════════
describe('Regex Tester extension', () => {
maybeDescribe('Regex Tester extension', () => {
function loadWithRegex() {
const ctx = loadExtensions();
ctx.Extensions.register({

View File

@@ -1,8 +1,15 @@
const { describe, it } = require('node:test');
const assert = require('node:assert');
const { createBrowserContext, loadSource } = require('./helpers');
const fs = require('fs');
const path = require('path');
const { createBrowserContext, loadSource, SRC } = require('./helpers');
const vm = require('vm');
// v0.37.10: extensions.js deleted — extension system moves to Preact in v0.37.12.
// Skip all tests if the source file doesn't exist.
const EXTENSIONS_EXISTS = fs.existsSync(path.join(SRC, 'extensions.js'));
const maybeDescribe = EXTENSIONS_EXISTS ? describe : describe.skip;
/**
* Load Events + Extensions into a browser context.
* Returns an object with Events, Extensions, and the sandbox.
@@ -25,7 +32,7 @@ function loadExtensions(overrides = {}) {
// ── Registration ─────────────────────────────
describe('Extensions.register', () => {
maybeDescribe('Extensions.register', () => {
it('registers an extension by id', () => {
const ctx = loadExtensions();
ctx.Extensions.register({ id: 'test-ext', init() {} });
@@ -48,7 +55,7 @@ describe('Extensions.register', () => {
// ── Lifecycle ────────────────────────────────
describe('Extensions.initAll', () => {
maybeDescribe('Extensions.initAll', () => {
it('calls init on registered extensions', async () => {
const ctx = loadExtensions();
let initialized = false;
@@ -84,7 +91,7 @@ describe('Extensions.initAll', () => {
});
});
describe('Extensions.destroyAll', () => {
maybeDescribe('Extensions.destroyAll', () => {
it('calls destroy and deactivates extensions', async () => {
const ctx = loadExtensions();
let destroyed = false;
@@ -102,7 +109,7 @@ describe('Extensions.destroyAll', () => {
// ── Context Object ──────────────────────────
describe('Extension context', () => {
maybeDescribe('Extension context', () => {
it('provides scoped events', async () => {
const ctx = loadExtensions();
let receivedPayload = null;
@@ -159,7 +166,7 @@ describe('Extension context', () => {
// ── Tool Registration ───────────────────────
describe('ctx.tools.handle', () => {
maybeDescribe('ctx.tools.handle', () => {
it('registers a tool handler', async () => {
const ctx = loadExtensions();
ctx.Extensions.register({
@@ -177,7 +184,7 @@ describe('ctx.tools.handle', () => {
// ── Tool Bridge ─────────────────────────────
describe('Tool bridge', () => {
maybeDescribe('Tool bridge', () => {
it('routes tool.call.* to registered handler and emits result', async () => {
const ctx = loadExtensions();
let resultEmitted = null;
@@ -269,7 +276,7 @@ describe('Tool bridge', () => {
// ── Renderer Pipeline ───────────────────────
describe('Renderer pipeline', () => {
maybeDescribe('Renderer pipeline', () => {
it('registers and runs block renderers', async () => {
const ctx = loadExtensions();
let rendered = false;
@@ -356,7 +363,7 @@ describe('Renderer pipeline', () => {
// ── Debug ────────────────────────────────────
describe('Extensions.debug', () => {
maybeDescribe('Extensions.debug', () => {
it('returns extension info and renderer count', async () => {
const ctx = loadExtensions();
ctx.Extensions.register({
@@ -376,7 +383,7 @@ describe('Extensions.debug', () => {
// ── Mermaid Extension ─────────────────────────────
describe('Mermaid renderer extension', () => {
maybeDescribe('Mermaid renderer extension', () => {
function loadWithMermaid() {
const ctx = loadExtensions();
// Load the mermaid extension script into the same VM context

View File

@@ -4,6 +4,8 @@
// Loads the vanilla JS source files into a
// simulated browser environment so tests run
// against the ACTUAL frontend code.
//
// v0.37.10: Removed loadAppModules (api.js + app.js deleted).
// ==========================================
const fs = require('fs');
@@ -104,23 +106,24 @@ function createBrowserContext(overrides = {}) {
* Returns the sandbox for inspection.
*/
function loadSource(sandbox, filename) {
const code = fs.readFileSync(path.join(SRC, filename), 'utf-8');
const filepath = path.join(SRC, filename);
if (!fs.existsSync(filepath)) {
throw new Error(`Source file not found: ${filename} (deleted in v0.37.10?)`);
}
const code = fs.readFileSync(filepath, 'utf-8');
const ctx = vm.createContext(sandbox);
vm.runInContext(code, ctx, { filename });
return sandbox;
}
/**
* Loads API + App modules into a fresh context.
* Returns { API, App, sandbox }.
* Safely read a source file, returning empty string if deleted.
* For tests that audit source code content.
*/
function loadAppModules(overrides = {}) {
const sandbox = createBrowserContext(overrides);
loadSource(sandbox, 'sb.js');
loadSource(sandbox, 'app-state.js');
loadSource(sandbox, 'api.js');
loadSource(sandbox, 'app.js');
return { API: sandbox.API, App: sandbox.App, sandbox };
function readSourceSafe(filename) {
const filepath = path.join(SRC, filename);
if (!fs.existsSync(filepath)) return '';
return fs.readFileSync(filepath, 'utf-8');
}
/**
@@ -158,7 +161,7 @@ function processModelsResponse(data, hiddenModels = new Set()) {
module.exports = {
createBrowserContext,
loadSource,
loadAppModules,
readSourceSafe,
processModelsResponse,
SRC,
};

View File

@@ -7,9 +7,12 @@
// policy exists but the frontend doesn't
// check it.
//
// v0.22.5: Updated for server-rendered Go
// templates. HTML is now in server/pages/
// templates/ — not in src/index.html.
// v0.22.5: Updated for server-rendered Go templates.
// v0.37.5: Settings surface moved to Preact — legacy SPA tests
// replaced with component source audits.
// 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.
//
// Run: node --test src/js/__tests__/policy-gating.test.js
// ==========================================
@@ -36,94 +39,6 @@ function readAllTemplates() {
return files.join('\n');
}
// ── Source code audits ───────────────────────
// These tests read the actual source files and verify that required
// wiring exists. If someone removes a policy check, CI breaks.
describe('Policy wiring audit — source code', () => {
// Read all UI files (ui-core.js + ui-settings.js + ui-admin.js replace old ui.js)
const uiSrc = ['ui-core.js', 'ui-settings.js', 'ui-admin.js', 'ui-format.js']
.map(f => fs.readFileSync(path.join(SRC, f), 'utf-8')).join('\n');
// Read all app-side files (app.js + extracted handler files replace old monolith app.js)
const appSrc = ['app.js', 'settings-handlers.js', 'admin-handlers.js', 'chat.js', 'tokens.js', 'notes.js']
.map(f => fs.readFileSync(path.join(SRC, f), 'utf-8')).join('\n');
// Pages.js — server-rendered page handlers (v0.22.5+)
const pagesSrc = fs.readFileSync(path.join(SRC, 'pages.js'), 'utf-8');
const templateSrc = readAllTemplates();
// ── allow_user_byok ──
it('UI has checkUserProvidersAllowed function', () => {
assert.ok(uiSrc.includes('checkUserProvidersAllowed'),
'MISSING: checkUserProvidersAllowed — provider tab will show when policy is off');
});
it('checkUserProvidersAllowed reads App.policies.allow_user_byok', () => {
assert.ok(uiSrc.includes('allow_user_byok'),
'MISSING: allow_user_byok check in UI');
});
// ── allow_user_personas ──
it('UI has checkUserPersonasAllowed function', () => {
assert.ok(uiSrc.includes('checkUserPersonasAllowed'),
'MISSING: checkUserPersonasAllowed — persona button will show when policy is off');
});
it('checkUserPersonasAllowed reads App.policies.allow_user_personas', () => {
assert.ok(uiSrc.includes('allow_user_personas'),
'MISSING: allow_user_personas check in UI');
});
it('admin settings template has user-personas toggle', () => {
assert.ok(templateSrc.includes('settUserPersonas'),
'MISSING: settUserPersonas toggle in admin settings template');
});
it('Pages.saveSettings writes allow_user_personas', () => {
assert.ok(pagesSrc.includes('allow_user_personas'),
'MISSING: allow_user_personas in Pages.saveSettings');
});
it('admin settings save writes allow_user_personas (SPA bridge)', () => {
assert.ok(appSrc.includes('allow_user_personas'),
'MISSING: handleSaveAdminSettings must write allow_user_personas');
});
// ── Settings live-apply ──
it('admin save calls initBanners for policy refresh', () => {
assert.ok(appSrc.includes('await initBanners()'),
'MISSING: initBanners call after admin save — policies won\'t refresh');
});
it('admin save calls checkUserProvidersAllowed', () => {
// Find in handleSaveAdminSettings
const saveFunc = appSrc.slice(appSrc.indexOf('handleSaveAdminSettings'));
assert.ok(saveFunc.includes('checkUserProvidersAllowed'),
'MISSING: checkUserProvidersAllowed call after admin save');
});
it('admin save calls checkUserPersonasAllowed', () => {
const saveFunc = appSrc.slice(appSrc.indexOf('handleSaveAdminSettings'));
assert.ok(saveFunc.includes('checkUserPersonasAllowed'),
'MISSING: checkUserPersonasAllowed call after admin save');
});
it('admin save calls fetchModels to refresh model list', () => {
const saveFunc = appSrc.slice(appSrc.indexOf('handleSaveAdminSettings'));
assert.ok(saveFunc.includes('fetchModels'),
'MISSING: fetchModels call after admin save — model list stale after settings change');
});
// ── Models tab calls preset check ──
it('models tab switch calls checkUserPersonasAllowed', () => {
assert.ok(uiSrc.includes('checkUserPersonasAllowed'),
'MISSING: checkUserPersonasAllowed call on models tab switch');
});
});
// ── Policy evaluation logic ──────────────────
describe('Policy evaluation logic', () => {
@@ -158,7 +73,6 @@ describe('Policy evaluation logic', () => {
});
// ── Team member dropdown logic ───────────────
// Bug: Empty dropdown because resp.data used instead of resp.users
describe('Team member dropdown population', () => {
function getAvailableUsers(usersResp, membersResp) {
@@ -221,72 +135,37 @@ describe('Team member dropdown population', () => {
// ── Admin settings field mapping ─────────────
// v0.22.5: Server-rendered admin settings template uses new element IDs.
// Pages.saveSettings() in pages.js is the primary handler.
// 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)', () => {
// New element IDs used by server-rendered admin/settings.html + pages.js
const settingsFieldMap = {
'settRegEnabled': 'allow_registration',
'settRegDefaultState': 'default_user_active',
'settUserBYOK': 'allow_user_byok',
'settUserPersonas': 'allow_user_personas',
'settBannerEnabled': 'banner',
};
const pagesSrc = fs.readFileSync(path.join(SRC, 'pages.js'), 'utf-8');
const templateSrc = readAllTemplates();
for (const [elementId, settingKey] of Object.entries(settingsFieldMap)) {
it(`template has element #${elementId}`, () => {
assert.ok(templateSrc.includes(`id="${elementId}"`),
`MISSING: #${elementId} in server templates — admin settings incomplete`);
});
it(`Pages.saveSettings writes setting "${settingKey}"`, () => {
assert.ok(pagesSrc.includes(settingKey),
`MISSING: "${settingKey}" in Pages.saveSettings`);
});
}
});
// ── SPA bridge field mapping (backward compat) ──
// The SPA chat surface still loads settings-handlers.js + ui-admin.js.
// These use legacy element IDs for handleSaveAdminSettings().
// Verified at the JS level (elements are SPA-modal DOM, not templates).
describe('SPA bridge — admin settings handler references policy keys', () => {
const appSrc = ['settings-handlers.js', 'admin-handlers.js']
.map(f => fs.readFileSync(path.join(SRC, f), 'utf-8')).join('\n');
const requiredPolicies = [
'allow_registration',
'default_user_active',
'allow_user_byok',
'allow_user_personas',
const requiredElements = [
'settRegEnabled',
'settRegDefaultState',
'settUserBYOK',
'settUserPersonas',
'settBannerEnabled',
];
for (const key of requiredPolicies) {
it(`SPA bridge writes policy "${key}"`, () => {
assert.ok(appSrc.includes(key),
`MISSING: "${key}" in SPA bridge handler — policy not saved`);
for (const id of requiredElements) {
it(`template has element #${id}`, () => {
assert.ok(templateSrc.includes(`id="${id}"`),
`MISSING: #${id} in server templates — admin settings incomplete`);
});
}
});
// ── HTML element existence checks ────────────
// v0.22.5: Elements now live in server templates, not index.html.
// v0.37.5: Settings surface elements moved to Preact components
// (src/js/sw/surfaces/settings/). Only admin template elements
// are checked here. Settings surface policy gating is handled by
// the Preact SettingsSurface component (nav gating + section routing).
// ── 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 (was adminUserProvidersToggle)
'settUserPersonas', // Admin toggle for presets (was adminUserPresetsToggle)
'settUserBYOK', // Admin toggle for BYOK
'settUserPersonas', // Admin toggle for personas
];
for (const id of requiredElements) {
@@ -297,6 +176,34 @@ describe('Critical HTML elements exist in server templates', () => {
}
});
// ── Chat surface template ────────────────────
// v0.37.10: Chat surface is now Preact-rendered. Verify the mount
// point exists and the old SPA scaffold is gone.
describe('Chat surface template (v0.37.10)', () => {
const templateSrc = readAllTemplates();
it('chat-mount div exists', () => {
assert.ok(templateSrc.includes('id="chat-mount"'),
'MISSING: #chat-mount — Preact chat surface cannot render');
});
it('old appContainer div is gone', () => {
assert.ok(!templateSrc.includes('id="appContainer"'),
'STALE: #appContainer still in templates — old SPA scaffold not removed');
});
it('chat template loads Preact surface module', () => {
assert.ok(templateSrc.includes('sw/surfaces/chat/index.js'),
'MISSING: chat surface module import in template');
});
it('chat template loads SDK boot', () => {
assert.ok(templateSrc.includes('sw/sdk/index.js'),
'MISSING: SDK boot import in chat template');
});
});
// ── Preact surface policy gating ─────────────
// v0.37.5: Settings surface elements are now rendered by Preact
// components. Verify the components handle policy gating.
@@ -326,20 +233,26 @@ describe('Settings Preact surface handles policy gating', () => {
});
});
// ── SPA bridge — dynamic DOM elements ────────
// adminMemberUser is created by ui-admin.js loadMemberUserDropdown()
// which runs inside the SPA chat surface. Verify the JS function exists.
// ── Admin Preact surface ─────────────────────
// v0.37.6: Admin surface handles settings save via Preact.
// Verify the admin surface has policy key handling.
describe('SPA bridge — dynamic element creators', () => {
const uiAdminSrc = fs.readFileSync(path.join(SRC, 'ui-admin.js'), 'utf-8');
describe('Admin Preact surface handles settings', () => {
const SURFACES = path.join(SRC, 'sw', 'surfaces', 'admin');
const settingsPath = path.join(SURFACES, 'settings.js');
it('ui-admin.js has loadMemberUserDropdown', () => {
assert.ok(uiAdminSrc.includes('loadMemberUserDropdown'),
'MISSING: loadMemberUserDropdown — team member add will break');
// Only test if the admin settings component exists
if (fs.existsSync(settingsPath)) {
const settingsSrc = fs.readFileSync(settingsPath, 'utf-8');
it('admin settings component references allow_user_byok', () => {
assert.ok(settingsSrc.includes('allow_user_byok'),
'MISSING: allow_user_byok in admin settings component');
});
it('loadMemberUserDropdown references adminMemberUser', () => {
assert.ok(uiAdminSrc.includes('adminMemberUser'),
'MISSING: adminMemberUser reference in loadMemberUserDropdown');
it('admin settings component references allow_user_personas', () => {
assert.ok(settingsSrc.includes('allow_user_personas'),
'MISSING: allow_user_personas in admin settings component');
});
}
});

View File

@@ -1,996 +0,0 @@
// ==========================================
// Chat Switchboard API Client
// ==========================================
// Backend-only mode. Handles auth tokens and
// all HTTP calls. No offline fallback.
//
// BASE_PATH: injected via index.html <script>
// at container startup. All API paths are
// prefixed automatically.
//
// Exports: window.API
// ==========================================
const BASE = window.__BASE__ || '';
const _storageKey = BASE ? `sb_auth_${BASE.replace(/\//g, '')}` : 'sb_auth';
const API = {
accessToken: null,
refreshToken: null,
user: null,
_refreshing: null,
// ── Bootstrap ────────────────────────────
loadTokens() {
try {
const saved = JSON.parse(localStorage.getItem(_storageKey) || 'null');
if (saved) {
this.accessToken = saved.accessToken || null;
this.refreshToken = saved.refreshToken || null;
this.user = saved.user || null;
// Unknown age — refresh soon to get a fresh token
if (this.refreshToken) this._scheduleRefresh(60);
}
} catch (e) { /* corrupt storage */ }
},
saveTokens() {
localStorage.setItem(_storageKey, JSON.stringify({
accessToken: this.accessToken,
refreshToken: this.refreshToken,
user: this.user
}));
// Sync to cookie for Go template page auth (v0.22.5)
if (this.accessToken) {
document.cookie = `sb_token=${this.accessToken}; path=/; max-age=900; SameSite=Strict`;
} else {
document.cookie = 'sb_token=; path=/; max-age=0';
}
},
clearTokens() {
this.accessToken = null;
this.refreshToken = null;
this.user = null;
localStorage.removeItem(_storageKey);
document.cookie = 'sb_token=; path=/; max-age=0'; // clear page auth cookie
if (this._refreshTimer) { clearTimeout(this._refreshTimer); this._refreshTimer = null; }
},
get isAuthed() { return !!this.accessToken; },
get isAdmin() { return this.user?.role === 'admin'; },
// ── Auth ─────────────────────────────────
async health() {
const resp = await fetch(BASE + '/api/v1/health', { signal: AbortSignal.timeout(8000) });
if (!resp.ok) throw new Error(`Health: ${resp.status}`);
return this._parseJSON(resp, '/api/v1/health');
},
// Detect proxy interception: HTTP 200 but HTML instead of JSON
async _parseJSON(resp, context) {
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('text/html')) {
// Proxy returned an HTML page (block page, auth wall, etc.)
let title = 'unknown';
try {
const html = await resp.clone().text();
const m = html.match(/<title[^>]*>([^<]+)<\/title>/i);
if (m) title = m[1].trim();
} catch (e) { /* best effort */ }
const err = new Error(`Proxy interception detected on ${context}: "${title}"`);
err.proxyBlocked = true;
err.proxyTitle = title;
throw err;
}
return resp.json();
},
async login(login, password) {
const data = await this._post('/api/v1/auth/login', { login, password }, true);
this._setAuth(data);
return data;
},
async register(username, email, password) {
const data = await this._post('/api/v1/auth/register', { username, email, password }, true);
if (!data.pending) this._setAuth(data);
return data;
},
async logout() {
try { await this._post('/api/v1/auth/logout', { refresh_token: this.refreshToken }, true); }
catch (e) { /* best effort */ }
this.clearTokens();
},
async refresh() {
if (this._refreshing) return this._refreshing;
this._refreshing = (async () => {
try {
const data = await this._post('/api/v1/auth/refresh', { refresh_token: this.refreshToken }, true);
this._setAuth(data);
return true;
} catch (e) {
this.clearTokens();
return false;
} finally {
this._refreshing = null;
}
})();
return this._refreshing;
},
// Centralized 401 recovery: try refresh, redirect to login on failure.
// Returns true if refresh succeeded (caller should retry), false otherwise.
async _handle401() {
if (this.refreshToken) {
if (await this.refresh()) return true;
}
// No refresh token or refresh failed — redirect to login
this.clearTokens();
const base = window.__BASE__ || '';
window.location.href = base + '/login';
return false;
},
// Alias for legacy call sites
async _refreshAccessToken() {
if (!(await this._handle401())) {
throw new Error('Session expired');
}
},
_setAuth(data) {
this.accessToken = data.access_token;
this.refreshToken = data.refresh_token;
this.user = data.user;
this.saveTokens();
this._scheduleRefresh(data.expires_in || 900);
},
_refreshTimer: null,
_scheduleRefresh(expiresIn) {
if (this._refreshTimer) clearTimeout(this._refreshTimer);
// Refresh at 80% of expiry (e.g., 12min for 15min token)
const ms = Math.max((expiresIn * 0.8) * 1000, 30000);
this._refreshTimer = setTimeout(async () => {
if (!this.refreshToken) return;
console.debug('🔄 Proactive token refresh');
try {
const data = await this._post('/api/v1/auth/refresh', { refresh_token: this.refreshToken }, true);
this._setAuth(data);
} catch (e) {
// Proactive refresh failed — keep session alive.
// Access token is still valid for ~20% of its lifetime.
// When it naturally expires, 401-retry in _authed() handles cleanup.
console.warn('⚠ Proactive refresh failed, access token still valid');
}
}, ms);
},
// ── Channels ──────────────────────────────
listChannels(page = 1, perPage = 100, type = '') {
let url = `/api/v1/channels?page=${page}&per_page=${perPage}`;
if (type) url += `&type=${type}`;
return this._get(url);
},
createChannel(title, model, systemPrompt, type = 'direct') {
const body = { title, type };
if (model) body.model = model;
if (systemPrompt) body.system_prompt = systemPrompt;
return this._post('/api/v1/channels', body);
},
getChannel(id) { return this._get(`/api/v1/channels/${id}`); },
updateChannel(id, updates) { return this._put(`/api/v1/channels/${id}`, updates); },
generateTitle(id) { return this._post(`/api/v1/channels/${id}/generate-title`, {}); },
deleteChannel(id) { return this._del(`/api/v1/channels/${id}`); },
markRead(id) { return this._post(`/api/v1/channels/${id}/mark-read`, {}); },
// Channel models (v0.20.0 — multi-model @mention routing)
listChannelModels(channelId) { return this._get(`/api/v1/channels/${channelId}/models`); },
addChannelModel(channelId, data) { return this._post(`/api/v1/channels/${channelId}/models`, data); },
updateChannelModel(channelId, modelId, data) { return this._patch(`/api/v1/channels/${channelId}/models/${modelId}`, data); },
deleteChannelModel(channelId, modelId) { return this._del(`/api/v1/channels/${channelId}/models/${modelId}`); },
// Channel participants (v0.23.0 — ICD §3.7)
listParticipants(channelId) { return this._get(`/api/v1/channels/${channelId}/participants`); },
addParticipant(channelId, data) { return this._post(`/api/v1/channels/${channelId}/participants`, data); },
updateParticipant(channelId, participantId, data) { return this._patch(`/api/v1/channels/${channelId}/participants/${participantId}`, data); },
removeParticipant(channelId, participantId) { return this._del(`/api/v1/channels/${channelId}/participants/${participantId}`); },
// Notification preferences (v0.20.0 Phase 3)
listNotifPrefs() { return this._get('/api/v1/notifications/preferences'); },
setNotifPref(type, data) { return this._put(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`, data); },
deleteNotifPref(type) { return this._del(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`); },
adminTestEmail() { return this._post('/api/v1/admin/notifications/test-email', {}); },
// Projects (v0.19.0)
listProjects(includeArchived) {
const q = includeArchived ? '?include_archived=true' : '';
return this._get(`/api/v1/projects${q}`);
},
createProject(data) { return this._post('/api/v1/projects', data); },
getProject(id) { return this._get(`/api/v1/projects/${id}`); },
updateProject(id, data) { return this._put(`/api/v1/projects/${id}`, data); },
deleteProject(id) { return this._del(`/api/v1/projects/${id}`); },
addChannelToProject(projectId, channelId, position) {
return this._post(`/api/v1/projects/${projectId}/channels`, { channel_id: channelId, position: position || 0 });
},
removeChannelFromProject(projectId, channelId) {
return this._del(`/api/v1/projects/${projectId}/channels/${channelId}`);
},
reorderProjectChannels(projectId, channelIds) {
return this._put(`/api/v1/projects/${projectId}/channels/reorder`, { channel_ids: channelIds });
},
listProjectChannels(projectId) { return this._get(`/api/v1/projects/${projectId}/channels`); },
addKBToProject(projectId, kbId, autoSearch) {
return this._post(`/api/v1/projects/${projectId}/knowledge-bases`, { kb_id: kbId, auto_search: autoSearch || false });
},
removeKBFromProject(projectId, kbId) {
return this._del(`/api/v1/projects/${projectId}/knowledge-bases/${kbId}`);
},
listProjectKBs(projectId) { return this._get(`/api/v1/projects/${projectId}/knowledge-bases`); },
addNoteToProject(projectId, noteId) {
return this._post(`/api/v1/projects/${projectId}/notes`, { note_id: noteId });
},
removeNoteFromProject(projectId, noteId) {
return this._del(`/api/v1/projects/${projectId}/notes/${noteId}`);
},
listProjectNotes(projectId) { return this._get(`/api/v1/projects/${projectId}/notes`); },
// ── Folders (v0.23.1) ────────────────────
listFolders() { return this._get('/api/v1/folders'); },
createFolder(name, sortOrder = 0) { return this._post('/api/v1/folders', { name, sort_order: sortOrder }); },
updateFolder(id, data) { return this._put(`/api/v1/folders/${id}`, data); },
deleteFolder(id) { return this._del(`/api/v1/folders/${id}`); },
// ── Sidebar helpers ───────────────────────
listSidebarChannels(page = 1, perPage = 500) {
return this._get(`/api/v1/channels?page=${page}&per_page=${perPage}&types=dm,channel`);
},
// ── Presence ──────────────────────────────
presenceHeartbeat() { return this._post('/api/v1/presence/heartbeat', {}); },
// ── Workspaces (v0.21.0) ────────────────
getWorkspace(id) { return this._get(`/api/v1/workspaces/${id}`); },
listWorkspaceFiles(wsId, path = '', recursive = false) {
let url = `/api/v1/workspaces/${wsId}/files?path=${encodeURIComponent(path)}`;
if (recursive) url += '&recursive=true';
return this._get(url);
},
async readWorkspaceFile(wsId, path) {
// Returns raw text, not JSON
const resp = await fetch(BASE + `/api/v1/workspaces/${wsId}/files/read?path=${encodeURIComponent(path)}`, {
headers: this._authHeaders(),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw Object.assign(new Error(data.error || `HTTP ${resp.status}`), { status: resp.status });
}
const ct = resp.headers.get('content-type') || '';
if (ct.includes('application/json')) {
const data = await resp.json();
return data.content || '';
}
return resp.text();
},
async writeWorkspaceFile(wsId, path, content) {
// Backend reads path from query string and content from raw request body
const url = BASE + `/api/v1/workspaces/${wsId}/files/write?path=${encodeURIComponent(path)}`;
const resp = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'text/plain',
'Authorization': `Bearer ${this.accessToken}`,
},
body: content,
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
const err = new Error(data.error || `HTTP ${resp.status}`);
err.status = resp.status;
throw err;
}
return resp.json();
},
deleteWorkspaceFile(wsId, path) {
return this._del(`/api/v1/workspaces/${wsId}/files/delete?path=${encodeURIComponent(path)}`);
},
mkdirWorkspace(wsId, path) {
return this._post(`/api/v1/workspaces/${wsId}/files/mkdir`, { path });
},
// Upload a File object to a workspace (binary-safe, v0.23.0)
async uploadWorkspaceFile(wsId, file, destPath) {
const path = destPath || file.name;
const url = BASE + `/api/v1/workspaces/${wsId}/files/write?path=${encodeURIComponent(path)}`;
const doFetch = () => fetch(url, {
method: 'PUT',
headers: {
'Content-Type': file.type || 'application/octet-stream',
'Content-Length': file.size.toString(),
'Authorization': `Bearer ${this.accessToken}`,
},
body: file,
});
let resp = await doFetch();
if (resp.status === 401) {
await this._refreshAccessToken();
resp = await doFetch();
}
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${resp.status}`);
}
return resp.json();
},
getWorkspaceGitStatus(wsId) { return this._get(`/api/v1/workspaces/${wsId}/git/status`); },
getWorkspaceGitBranches(wsId) { return this._get(`/api/v1/workspaces/${wsId}/git/branches`); },
listWorkspaces() { return this._get('/api/v1/workspaces'); },
createWorkspace(data) { return this._post('/api/v1/workspaces', data); },
updateWorkspace(id, patch) { return this._patch(`/api/v1/workspaces/${id}`, patch); },
deleteWorkspace(id) { return this._del(`/api/v1/workspaces/${id}`); },
listGitCredentials() { return this._get('/api/v1/git-credentials'); },
// ── Messages ─────────────────────────────
listMessages(channelId, page = 1, perPage = 200) {
return this._get(`/api/v1/channels/${channelId}/messages?page=${page}&per_page=${perPage}`);
},
// ── Message Tree (forking) ───────────────
getActivePath(channelId) {
return this._get(`/api/v1/channels/${channelId}/path`);
},
editMessage(channelId, messageId, content) {
return this._post(`/api/v1/channels/${channelId}/messages/${messageId}/edit`, { content });
},
async streamRegenerate(channelId, messageId, signal, model, personaId, apiConfigId, disabledTools) {
const body = {};
if (model) body.model = model;
if (personaId) body.persona_id = personaId;
if (apiConfigId) body.provider_config_id = apiConfigId;
if (disabledTools?.length) body.disabled_tools = disabledTools;
let resp = await fetch(BASE + `/api/v1/channels/${channelId}/messages/${messageId}/regenerate`, {
method: 'POST',
headers: this._authHeaders(),
body: JSON.stringify(body),
signal
});
if (resp.status === 401) {
if (await this._handle401()) {
resp = await fetch(BASE + `/api/v1/channels/${channelId}/messages/${messageId}/regenerate`, {
method: 'POST',
headers: this._authHeaders(),
body: JSON.stringify(body),
signal
});
}
}
if (!resp.ok) {
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('text/html')) {
const err = new Error('Proxy blocked the regeneration request');
err.proxyBlocked = true;
throw err;
}
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${resp.status}`);
}
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('text/html')) {
const err = new Error('Proxy intercepted the regeneration stream');
err.proxyBlocked = true;
throw err;
}
return resp;
},
updateCursor(channelId, activeLeafId) {
return this._put(`/api/v1/channels/${channelId}/cursor`, { active_leaf_id: activeLeafId });
},
listSiblings(channelId, messageId) {
return this._get(`/api/v1/channels/${channelId}/messages/${messageId}/siblings`);
},
// ── Summarize & Continue ────────────────
summarizeChannel(channelId) {
return this._post(`/api/v1/channels/${channelId}/summarize`, {});
},
// ── Completions ──────────────────────────
async streamCompletion(channelId, content, model, signal, apiConfigId, personaId, fileIds, disabledTools) {
const body = { channel_id: channelId, content, stream: true };
if (personaId) {
body.persona_id = personaId;
// Persona resolves the model on backend; still pass model for channel metadata
if (model) body.model = model;
} else {
if (model) body.model = model;
}
if (apiConfigId) body.provider_config_id = apiConfigId;
// Only send max_tokens if user explicitly set it (non-zero = override)
if (App.settings.maxTokens > 0) body.max_tokens = App.settings.maxTokens;
if (fileIds?.length) body.file_ids = fileIds;
if (disabledTools?.length) body.disabled_tools = disabledTools;
let resp = await fetch(BASE + '/api/v1/chat/completions', {
method: 'POST',
headers: this._authHeaders(),
body: JSON.stringify(body),
signal
});
if (resp.status === 401) {
if (await this._handle401()) {
resp = await fetch(BASE + '/api/v1/chat/completions', {
method: 'POST',
headers: this._authHeaders(),
body: JSON.stringify(body),
signal
});
}
}
if (!resp.ok) {
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('text/html')) {
const err = new Error('Proxy blocked the completion request');
err.proxyBlocked = true;
throw err;
}
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${resp.status}`);
}
// Also check 200 OK but HTML (proxy returning block page with 200)
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('text/html')) {
const err = new Error('Proxy intercepted the completion stream');
err.proxyBlocked = true;
throw err;
}
return resp;
},
// ── Tools ────────────────────────────────
getTools() { return this._get('/api/v1/tools'); },
// ── Models ───────────────────────────────
listEnabledModels() { return this._get('/api/v1/models/enabled'); },
getModelPreferences() { return this._get('/api/v1/models/preferences'); },
setModelPreference(modelId, providerConfigId, hidden) { return this._put('/api/v1/models/preferences', { model_id: modelId, provider_config_id: providerConfigId, hidden }); },
bulkSetModelPreferences(entries, hidden) { return this._post('/api/v1/models/preferences/bulk', { entries, hidden }); },
// User personas
listUserPersonas() { return this._get('/api/v1/personas'); },
createUserPersona(persona) { return this._post('/api/v1/personas', persona); },
getUserPersonaKBs(personaId) { return this._get(`/api/v1/personas/${personaId}/knowledge-bases`); },
setUserPersonaKBs(personaId, data) { return this._put(`/api/v1/personas/${personaId}/knowledge-bases`, data); },
updateUserPersona(id, updates) { return this._put(`/api/v1/personas/${id}`, updates); },
deleteUserPersona(id) { return this._del(`/api/v1/personas/${id}`); },
listAllModels() { return this._get('/api/v1/models/enabled'); },
// ── API Configs (user providers) ─────────
listConfigs() { return this._get('/api/v1/api-configs'); },
getConfig(id) { return this._get(`/api/v1/api-configs/${id}`); },
createConfig(name, provider, endpoint, apiKey, modelDefault) {
return this._post('/api/v1/api-configs', {
name, provider, endpoint, api_key: apiKey, model_default: modelDefault
});
},
deleteConfig(id) { return this._del(`/api/v1/api-configs/${id}`); },
updateConfig(id, patch) { return this._put(`/api/v1/api-configs/${id}`, patch); },
listProviderModels(id) { return this._get(`/api/v1/api-configs/${id}/models`); },
fetchProviderModels(id) { return this._post(`/api/v1/api-configs/${id}/models/fetch`); },
// ── Profile & Settings ───────────────────
async getProfile() {
const data = await this._get('/api/v1/profile');
// Sync user object with profile data (including avatar)
if (data && this.user) {
this.user.avatar = data.avatar || null;
this.user.display_name = data.display_name;
this.saveTokens();
}
return data;
},
updateProfile(updates) { return this._put('/api/v1/profile', updates); },
uploadAvatar(base64Image) { return this._post('/api/v1/profile/avatar', { image: base64Image }); },
deleteAvatar() { return this._del('/api/v1/profile/avatar'); },
changePassword(current, newPw) {
return this._post('/api/v1/profile/password', { current_password: current, new_password: newPw });
},
getSettings() { return this._get('/api/v1/settings'); },
updateSettings(settings) { return this._put('/api/v1/settings', settings); },
// ── Admin ────────────────────────────────
adminListUsers(p, pp) { return this._get(`/api/v1/admin/users?page=${p||1}&per_page=${pp||50}`); },
adminCreateUser(username, email, password, role) {
return this._post('/api/v1/admin/users', { username, email, password, role });
},
adminResetPassword(id, pw) { return this._post(`/api/v1/admin/users/${id}/reset-password`, { password: pw }); },
adminUpdateRole(id, role) { return this._put(`/api/v1/admin/users/${id}/role`, { role }); },
adminToggleActive(id, active, teamIds, teamRole) {
const body = { is_active: active };
if (teamIds?.length) body.team_ids = teamIds;
if (teamRole) body.team_role = teamRole;
return this._put(`/api/v1/admin/users/${id}/active`, body);
},
adminDeleteUser(id) { return this._del(`/api/v1/admin/users/${id}`); },
adminGetSettings() { return this._get('/api/v1/admin/settings'); },
getPublicSettings() { return this._get('/api/v1/settings/public'); },
adminUpdateSetting(key, value) { return this._put(`/api/v1/admin/settings/${key}`, value); },
adminGetStats() { return this._get('/api/v1/admin/stats'); },
adminGetDashboard() { return this._get('/api/v1/admin/dashboard'); },
adminListGlobalConfigs() { return this._get('/api/v1/admin/configs'); },
adminCreateGlobalConfig(name, provider, endpoint, apiKey, modelDefault, isPrivate) {
return this._post('/api/v1/admin/configs', {
name, provider, endpoint, api_key: apiKey, model_default: modelDefault, is_private: !!isPrivate
});
},
adminDeleteGlobalConfig(id) { return this._del(`/api/v1/admin/configs/${id}`); },
adminUpdateGlobalConfig(id, updates) { return this._put(`/api/v1/admin/configs/${id}`, updates); },
adminListModels() { return this._get('/api/v1/admin/models'); },
adminFetchModels() { return this._post('/api/v1/admin/models/fetch', {}); },
adminUpdateModel(id, updates) { return this._put(`/api/v1/admin/models/${id}`, updates); },
adminBulkUpdateModels(visibility) {
// Send both for backward compat: old BE reads is_enabled, new BE reads visibility
return this._put('/api/v1/admin/models/bulk', {
visibility,
is_enabled: visibility === 'enabled'
});
},
adminDeleteModel(id) { return this._del(`/api/v1/admin/models/${id}`); },
// ── Admin Personas ────────────────────────
adminListPersonas() { return this._get('/api/v1/admin/personas'); },
adminCreatePersona(persona) { return this._post('/api/v1/admin/personas', persona); },
adminUpdatePersona(id, updates) { return this._put(`/api/v1/admin/personas/${id}`, updates); },
adminDeletePersona(id) { return this._del(`/api/v1/admin/personas/${id}`); },
adminUploadPersonaAvatar(id, base64Image) { return this._post(`/api/v1/admin/personas/${id}/avatar`, { image: base64Image }); },
adminDeletePersonaAvatar(id) { return this._del(`/api/v1/admin/personas/${id}/avatar`); },
adminPersonaKBs(personaId) { return this._get(`/api/v1/admin/personas/${personaId}/knowledge-bases`); },
adminSetPersonaKBs(personaId, data) { return this._put(`/api/v1/admin/personas/${personaId}/knowledge-bases`, data); },
// ── Admin Teams ─────────────────────────
adminListTeams() { return this._get('/api/v1/admin/teams'); },
// ── Admin Audit ─────────────────────────
adminListAudit(params) {
const q = new URLSearchParams();
if (params?.page) q.set('page', params.page);
if (params?.per_page) q.set('per_page', params.per_page);
if (params?.action) q.set('action', params.action);
if (params?.actor_id) q.set('actor_id', params.actor_id);
if (params?.resource_type) q.set('resource_type', params.resource_type);
return this._get(`/api/v1/admin/audit?${q}`);
},
adminListAuditActions() { return this._get('/api/v1/admin/audit/actions'); },
adminCreateTeam(name, description) { return this._post('/api/v1/admin/teams', { name, description }); },
adminGetTeam(id) { return this._get(`/api/v1/admin/teams/${id}`); },
adminUpdateTeam(id, updates) { return this._put(`/api/v1/admin/teams/${id}`, updates); },
adminDeleteTeam(id) { return this._del(`/api/v1/admin/teams/${id}`); },
adminListMembers(teamId) { return this._get(`/api/v1/admin/teams/${teamId}/members`); },
adminAddMember(teamId, userId, role) { return this._post(`/api/v1/admin/teams/${teamId}/members`, { user_id: userId, role }); },
adminUpdateMember(teamId, memberId, role) { return this._put(`/api/v1/admin/teams/${teamId}/members/${memberId}`, { role }); },
adminRemoveMember(teamId, memberId) { return this._del(`/api/v1/admin/teams/${teamId}/members/${memberId}`); },
// ── Admin Groups ────────────────────────
adminListGroups() { return this._get('/api/v1/admin/groups'); },
adminCreateGroup(name, description, scope, teamId) {
const body = { name, description, scope };
if (teamId) body.team_id = teamId;
return this._post('/api/v1/admin/groups', body);
},
adminGetGroup(id) { return this._get(`/api/v1/admin/groups/${id}`); },
adminUpdateGroup(id, updates) { return this._put(`/api/v1/admin/groups/${id}`, updates); },
adminDeleteGroup(id) { return this._del(`/api/v1/admin/groups/${id}`); },
adminListGroupMembers(groupId) { return this._get(`/api/v1/admin/groups/${groupId}/members`); },
// Admin — Projects (v0.19.0)
adminListProjects(includeArchived) {
const q = includeArchived ? '?include_archived=true' : '';
return this._get(`/api/v1/admin/projects${q}`);
},
adminDeleteProject(id) { return this._del(`/api/v1/admin/projects/${id}`); },
adminAddGroupMember(groupId, userId) { return this._post(`/api/v1/admin/groups/${groupId}/members`, { user_id: userId }); },
adminRemoveGroupMember(groupId, userId) { return this._del(`/api/v1/admin/groups/${groupId}/members/${userId}`); },
// ── Admin Resource Grants ───────────────
adminGetGrant(type, id) { return this._get(`/api/v1/admin/grants/${type}/${id}`); },
adminSetGrant(type, id, grantScope, grantedGroups) {
return this._put(`/api/v1/admin/grants/${type}/${id}`, { grant_scope: grantScope, granted_groups: grantedGroups || [] });
},
adminDeleteGrant(type, id) { return this._del(`/api/v1/admin/grants/${type}/${id}`); },
// ── Permissions (v0.24.2) ───────────────
adminListPermissions() { return this._get('/api/v1/admin/permissions'); },
adminGetUserPermissions(userId) { return this._get(`/api/v1/admin/users/${userId}/permissions`); },
// ── User Groups ─────────────────────────
listMyGroups() { return this._get('/api/v1/groups/mine'); },
// ── User Teams ──────────────────────────
listMyTeams() { return this._get('/api/v1/teams/mine'); },
// ── Team Admin Self-Service ─────────────
teamListMembers(teamId) { return this._get(`/api/v1/teams/${teamId}/members`); },
teamAddMember(teamId, userId, role) { return this._post(`/api/v1/teams/${teamId}/members`, { user_id: userId, role }); },
teamUpdateMember(teamId, memberId, role) { return this._put(`/api/v1/teams/${teamId}/members/${memberId}`, { role }); },
teamRemoveMember(teamId, memberId) { return this._del(`/api/v1/teams/${teamId}/members/${memberId}`); },
teamListPersonas(teamId) { return this._get(`/api/v1/teams/${teamId}/personas`); },
teamCreatePersona(teamId, persona) { return this._post(`/api/v1/teams/${teamId}/personas`, persona); },
teamGetPersonaKBs(teamId, personaId) { return this._get(`/api/v1/teams/${teamId}/personas/${personaId}/knowledge-bases`); },
teamSetPersonaKBs(teamId, personaId, data) { return this._put(`/api/v1/teams/${teamId}/personas/${personaId}/knowledge-bases`, data); },
teamDeletePersona(teamId, personaId) { return this._del(`/api/v1/teams/${teamId}/personas/${personaId}`); },
teamListAvailableModels(teamId) { return this._get(`/api/v1/teams/${teamId}/models`); },
teamListGroups(teamId) { return this._get(`/api/v1/teams/${teamId}/groups`); },
// ── Team Providers ──────────────────────
teamListProviders(teamId) { return this._get(`/api/v1/teams/${teamId}/providers`); },
teamCreateProvider(teamId, provider) { return this._post(`/api/v1/teams/${teamId}/providers`, provider); },
teamUpdateProvider(teamId, providerId, updates) { return this._put(`/api/v1/teams/${teamId}/providers/${providerId}`, updates); },
teamDeleteProvider(teamId, providerId) { return this._del(`/api/v1/teams/${teamId}/providers/${providerId}`); },
teamListProviderModels(teamId, providerId) { return this._get(`/api/v1/teams/${teamId}/providers/${providerId}/models`); },
teamListAudit(teamId, params) {
const q = new URLSearchParams();
if (params?.page) q.set('page', params.page);
if (params?.per_page) q.set('per_page', params.per_page);
if (params?.action) q.set('action', params.action);
if (params?.actor_id) q.set('actor_id', params.actor_id);
if (params?.resource_type) q.set('resource_type', params.resource_type);
return this._get(`/api/v1/teams/${teamId}/audit?${q}`);
},
teamListAuditActions(teamId) { return this._get(`/api/v1/teams/${teamId}/audit/actions`); },
// ── User Personas ─────────────────────────
listPersonas() { return this._get('/api/v1/personas'); },
createPersona(persona) { return this._post('/api/v1/personas', persona); },
updatePersona(id, updates) { return this._put(`/api/v1/personas/${id}`, updates); },
deletePersona(id) { return this._del(`/api/v1/personas/${id}`); },
// ── Notes ────────────────────────────────
listNotes(limit = 50, offset = 0, folder, tag, sort) {
let url = `/api/v1/notes?limit=${limit}&offset=${offset}`;
if (folder) url += `&folder=${encodeURIComponent(folder)}`;
if (tag) url += `&tag=${encodeURIComponent(tag)}`;
if (sort) url += `&sort=${encodeURIComponent(sort)}`;
return this._get(url);
},
createNote(title, content, folderPath, tags, sourceChannelId, sourceMessageId) {
const body = { title, content };
if (folderPath) body.folder_path = folderPath;
if (tags?.length) body.tags = tags;
if (sourceChannelId) body.source_channel_id = sourceChannelId;
if (sourceMessageId) body.source_message_id = sourceMessageId;
return this._post('/api/v1/notes', body);
},
getNote(id) { return this._get(`/api/v1/notes/${id}`); },
updateNote(id, updates) { return this._put(`/api/v1/notes/${id}`, updates); },
deleteNote(id) { return this._del(`/api/v1/notes/${id}`); },
bulkDeleteNotes(ids) { return this._post('/api/v1/notes/bulk-delete', { ids }); },
searchNotes(query, limit = 20) { return this._get(`/api/v1/notes/search?q=${encodeURIComponent(query)}&limit=${limit}`); },
searchNoteTitles(query, limit = 10) { return this._get(`/api/v1/notes/search-titles?q=${encodeURIComponent(query)}&limit=${limit}`); },
listNoteFolders() { return this._get('/api/v1/notes/folders'); },
getNoteBacklinks(id) { return this._get(`/api/v1/notes/${id}/backlinks`); },
getNoteGraph() { return this._get('/api/v1/notes/graph'); },
// ── Files ────────────────────────────────
/**
* Upload a file to a channel. Uses multipart/form-data (not JSON).
* Returns the created file object with id, metadata, etc.
*/
async uploadFile(channelId, file) {
const form = new FormData();
form.append('file', file, file.name);
const doFetch = () => fetch(BASE + `/api/v1/channels/${channelId}/files`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${this.accessToken}` },
// No Content-Type — browser sets multipart boundary automatically
body: form,
});
let resp = await doFetch();
if (resp.status === 401) {
if (await this._handle401()) resp = await doFetch();
}
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
const err = new Error(data.error || `Upload failed: HTTP ${resp.status}`);
err.status = resp.status;
throw err;
}
return this._parseJSON(resp, `/api/v1/channels/${channelId}/files`);
},
getFile(id) { return this._get(`/api/v1/files/${id}`); },
async downloadFileBlob(id) {
const doFetch = () => fetch(BASE + `/api/v1/files/${id}/download`, {
headers: { 'Authorization': `Bearer ${this.accessToken}` },
});
let resp = await doFetch();
if (resp.status === 401) {
if (await this._handle401()) resp = await doFetch();
}
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `Download failed: HTTP ${resp.status}`);
}
return resp.blob();
},
deleteFile(id) { return this._del(`/api/v1/files/${id}`); },
listChannelFiles(channelId) { return this._get(`/api/v1/channels/${channelId}/files`); },
// ── Admin Storage ───────────────────────
adminGetStorageStatus() { return this._get('/api/v1/admin/storage/status'); },
adminGetOrphanCount() { return this._get('/api/v1/admin/storage/orphans'); },
adminRunStorageCleanup() { return this._post('/api/v1/admin/storage/cleanup', {}); },
adminGetExtractionStatus() { return this._get('/api/v1/admin/storage/extraction'); },
// Vault
adminGetVaultStatus() { return this._get('/api/v1/admin/vault/status'); },
// ── Admin Roles ─────────────────────────
adminListRoles() { return this._get('/api/v1/admin/roles'); },
adminGetRole(role) { return this._get(`/api/v1/admin/roles/${role}`); },
adminUpdateRole(role, config) { return this._put(`/api/v1/admin/roles/${role}`, config); },
adminTestRole(role) { return this._post(`/api/v1/admin/roles/${role}/test`, {}); },
// ── Admin Usage & Pricing ───────────────
adminGetUsage(params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.since) q.set('since', params.since);
if (params?.until) q.set('until', params.until);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/admin/usage?${q}`);
},
adminGetUserUsage(userId, params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/admin/usage/users/${userId}?${q}`);
},
adminGetTeamUsage(teamId, params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/admin/usage/teams/${teamId}?${q}`);
},
adminListPricing() { return this._get('/api/v1/admin/pricing'); },
adminUpsertPricing(entry) { return this._put('/api/v1/admin/pricing', entry); },
adminDeletePricing(providerConfigId, modelId) {
return this._del(`/api/v1/admin/pricing/${providerConfigId}/${encodeURIComponent(modelId)}`);
},
// ── Admin Provider Health (v0.22.3) ─────
adminGetAllProviderHealth() { return this._get('/api/v1/admin/providers/health'); },
adminGetProviderHealth(id) { return this._get(`/api/v1/admin/providers/${id}/health`); },
// ── Admin Routing Policies (v0.22.3) ────
adminListRoutingPolicies() { return this._get('/api/v1/admin/routing/policies'); },
adminGetRoutingPolicy(id) { return this._get(`/api/v1/admin/routing/policies/${id}`); },
adminCreateRoutingPolicy(policy) { return this._post('/api/v1/admin/routing/policies', policy); },
adminUpdateRoutingPolicy(id, policy) { return this._put(`/api/v1/admin/routing/policies/${id}`, policy); },
adminDeleteRoutingPolicy(id) { return this._del(`/api/v1/admin/routing/policies/${id}`); },
adminTestRouting(model, userId, teamIds) {
return this._post('/api/v1/admin/routing/test', { model, user_id: userId, team_ids: teamIds || [] });
},
// ── Admin Capabilities & Provider Types (v0.22.3) ──
adminGetModelCapabilities(modelId) { return this._get(`/api/v1/admin/models/${encodeURIComponent(modelId)}/capabilities`); },
adminSetModelCapability(modelId, overrides) { return this._put(`/api/v1/admin/models/${encodeURIComponent(modelId)}/capabilities`, overrides); },
adminDeleteModelCapability(modelId, overrideId) { return this._del(`/api/v1/admin/models/${encodeURIComponent(modelId)}/capabilities/${overrideId}`); },
adminListCapabilityOverrides() { return this._get('/api/v1/admin/capability-overrides'); },
adminGetProviderTypes() { return this._get('/api/v1/admin/provider-types'); },
// ── Project Files (v0.22.4) ─────────────
async projectUploadFile(projectId, file) {
const form = new FormData();
form.append('file', file, file.name);
const doFetch = () => fetch(BASE + `/api/v1/projects/${projectId}/files`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${this.accessToken}` },
body: form,
});
let resp = await doFetch();
if (resp.status === 401) {
await this._refreshAccessToken();
resp = await doFetch();
}
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${resp.status}`);
}
return resp.json();
},
projectListFiles(projectId) { return this._get(`/api/v1/projects/${projectId}/files`); },
// ── Export (v0.22.4) ────────────────────
exportDocument(content, format, filename) {
return this._post('/api/v1/export', { content, format, filename });
},
// ── User Usage ──────────────────────────
getMyUsage(params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/usage?${q}`);
},
// ── Team Roles ──────────────────────────
teamListRoles(teamId) { return this._get(`/api/v1/teams/${teamId}/roles`); },
teamUpdateRole(teamId, role, config) { return this._put(`/api/v1/teams/${teamId}/roles/${role}`, config); },
teamDeleteRole(teamId, role) { return this._del(`/api/v1/teams/${teamId}/roles/${role}`); },
// ── Team Usage ──────────────────────────
teamGetUsage(teamId, params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/teams/${teamId}/usage?${q}`);
},
// ── Knowledge Bases ──────────────────────
listKnowledgeBases() { return this._get('/api/v1/knowledge-bases'); },
listDiscoverableKBs() { return this._get('/api/v1/knowledge-bases-discoverable'); },
createKnowledgeBase(name, description, scope, teamId) {
const body = { name, description: description || '' };
if (scope) body.scope = scope;
if (teamId) body.team_id = teamId;
return this._post('/api/v1/knowledge-bases', body);
},
getKnowledgeBase(id) { return this._get(`/api/v1/knowledge-bases/${id}`); },
updateKnowledgeBase(id, updates) { return this._put(`/api/v1/knowledge-bases/${id}`, updates); },
deleteKnowledgeBase(id) { return this._del(`/api/v1/knowledge-bases/${id}`); },
searchKnowledgeBase(id, query, limit) {
const body = { query };
if (limit) body.limit = limit;
return this._post(`/api/v1/knowledge-bases/${id}/search`, body);
},
// KB Documents
listKBDocuments(kbId) { return this._get(`/api/v1/knowledge-bases/${kbId}/documents`); },
getKBDocumentStatus(kbId, docId) { return this._get(`/api/v1/knowledge-bases/${kbId}/documents/${docId}/status`); },
deleteKBDocument(kbId, docId) { return this._del(`/api/v1/knowledge-bases/${kbId}/documents/${docId}`); },
async uploadKBDocument(kbId, file) {
const form = new FormData();
form.append('file', file, file.name);
const doFetch = () => fetch(BASE + `/api/v1/knowledge-bases/${kbId}/documents`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${this.accessToken}` },
body: form,
});
let resp = await doFetch();
if (resp.status === 401) {
if (await this._handle401()) resp = await doFetch();
}
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
const err = new Error(data.error || `Upload failed: HTTP ${resp.status}`);
err.status = resp.status;
throw err;
}
return this._parseJSON(resp, `/api/v1/knowledge-bases/${kbId}/documents`);
},
// Channel ↔ KB linking
getChannelKBs(channelId) { return this._get(`/api/v1/channels/${channelId}/knowledge-bases`); },
setChannelKBs(channelId, kbIds) { return this._put(`/api/v1/channels/${channelId}/knowledge-bases`, { kb_ids: kbIds }); },
// ── Memories (v0.18.0) ───────────────────
listMemories(status, query) {
const params = new URLSearchParams();
if (status) params.set('status', status);
if (query) params.set('query', query);
const qs = params.toString();
return this._get('/api/v1/memories' + (qs ? '?' + qs : ''));
},
getMemoryCount() { return this._get('/api/v1/memories/count'); },
updateMemory(id, data) { return this._put(`/api/v1/memories/${id}`, data); },
deleteMemory(id) { return this._del(`/api/v1/memories/${id}`); },
approveMemory(id) { return this._post(`/api/v1/memories/${id}/approve`, {}); },
rejectMemory(id) { return this._post(`/api/v1/memories/${id}/reject`, {}); },
bulkApproveMemories(ids) { return this._post('/api/v1/admin/memories/bulk-approve', { ids }); },
adminListPendingMemories() { return this._get('/api/v1/admin/memories/pending'); },
// ── HTTP Internals ───────────────────────
_esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
},
_authHeaders() {
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.accessToken}`
};
},
async _get(path) { return this._authed(path); },
async _post(path, body, skipAuth) { return skipAuth ? this._raw(path, 'POST', body) : this._authed(path, 'POST', body); },
async _put(path, body) { return this._authed(path, 'PUT', body); },
async _del(path) { return this._authed(path, 'DELETE'); },
async _delete(path) { return this._del(path); }, // alias — `delete` is a JS reserved word
async _patch(path, body) { return this._authed(path, 'PATCH', body); },
async _authed(path, method = 'GET', body) {
try {
return await this._raw(path, method, body, true);
} catch (e) {
if (e.status === 401) {
if (await this._handle401()) {
return this._raw(path, method, body, true);
}
}
throw e;
}
},
async _raw(path, method = 'GET', body, auth = false) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (auth) opts.headers['Authorization'] = `Bearer ${this.accessToken}`;
if (body) opts.body = JSON.stringify(body);
const resp = await fetch(BASE + path, opts);
if (!resp.ok) {
// Check if the error response is also a proxy page
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('text/html')) {
let title = 'unknown';
try {
const html = await resp.text();
const m = html.match(/<title[^>]*>([^<]+)<\/title>/i);
if (m) title = m[1].trim();
} catch (e) { /* */ }
const err = new Error(`Proxy blocked ${method} ${path}: "${title}"`);
err.status = resp.status;
err.proxyBlocked = true;
err.proxyTitle = title;
throw err;
}
const data = await resp.json().catch(() => ({}));
const err = new Error(data.error || `HTTP ${resp.status}`);
err.status = resp.status;
throw err;
}
return this._parseJSON(resp, path);
}
};
sb.ns('API', API);

View File

@@ -1,589 +0,0 @@
// ==========================================
// Chat Switchboard Application (Chat Surface)
// ==========================================
// Boot, auth, init, listener dispatch.
// Shared state (App) and model loading (fetchModels) live in app-state.js
// (loaded by base.html for all surfaces).
// Domain logic lives in chat.js, notes.js, settings-handlers.js,
// admin-handlers.js. UI rendering in ui-*.js files.
//
// Exports: handleLogin, handleRegister, handleLogout, switchAuthTab,
// startApp, initBanners
async function init() {
console.log('🔀 Chat Switchboard initializing...');
initBranding(); // Apply branding before splash is visible
API.loadTokens();
let health = null;
try {
health = await API.health();
console.log('✅ Backend reachable:', health.version);
} catch (e) {
console.error('❌ Backend unreachable:', e.message);
const splashErr = document.getElementById('splashError');
if (e.proxyBlocked) {
splashErr.innerHTML =
`<strong>Network proxy blocked this request</strong><br>` +
`Proxy response: "${API._esc(e.proxyTitle)}"<br>` +
`<span class="splash-error-hint">Ask your network admin to whitelist this domain. ` +
`<a href="#" data-action="openDebugModal" ">Run diagnostics</a></span>`;
} else if (e.name === 'TimeoutError' || e.name === 'AbortError') {
splashErr.innerHTML =
`<strong>Connection timed out</strong><br>` +
`<span class="splash-error-hint">Server may be starting up, or a proxy is blocking the connection. ` +
`<a href="#" data-action="openDebugModal" ">Run diagnostics</a></span>`;
} else {
splashErr.innerHTML =
`<strong>Cannot reach server</strong><br>` +
`<span class="splash-error-hint">${API._esc(e.message)}. ` +
`<a href="#" data-action="openDebugModal" ">Run diagnostics</a></span>`;
}
showSplash(null);
return;
}
if (API.isAuthed) {
try {
await API.getProfile();
console.log('✅ Session valid for', API.user?.username);
} catch (e) {
console.warn('⚠️ Session expired, clearing');
API.clearTokens();
}
}
if (API.isAuthed) {
try {
await startApp();
} catch (e) {
console.error('💥 startApp crashed:', e);
// Surface the error visibly (crash banner from chat.html)
const b = document.getElementById('crashBanner');
const d = document.getElementById('crashDetail');
if (b && d) { b.style.display = ''; d.textContent += 'startApp: ' + e.message + '\n' + (e.stack || '') + '\n'; }
}
} else {
showSplash(health);
}
}
async function startApp() {
hideSplash();
UI.restoreSidebar();
// v0.25.0: Surface-aware initialization.
// Common init runs on all surfaces. Chat-specific init only on chat.
const surface = window.__SURFACE__ || 'chat';
// ── Common init (all surfaces) ──────────
// v0.28.5: Ensure SDK is initialized (idempotent — base.html also calls this).
if (typeof Switchboard !== 'undefined') Switchboard.init();
if (typeof loadSettings === 'function') await loadSettings();
// Guard: if token was invalidated during startup
if (!API.isAuthed) {
console.warn('⚠️ Auth lost during startup, returning to login');
showSplash(null);
return;
}
UI.updateUser();
UI.showAdminButton(sw.isAdmin);
UI.restoreAppearance();
// Load models for all surfaces (editor needs them for model selector)
await fetchModels();
// Connect EventBus WebSocket (non-blocking)
try {
Events.connect((window.__BASE__ || '') + '/ws');
sw.on('ws.connected', () => {
if (typeof ToolsToggle !== 'undefined') ToolsToggle.refresh();
});
} catch (e) {
console.warn('EventBus connect error:', e.message);
}
// Signal that common init is complete — surface-specific JS can react
document.dispatchEvent(new CustomEvent('sb:ready', { detail: { surface } }));
// ── Chat surface init ───────────────────
if (surface === 'chat') {
await _startChatSurface();
}
// Editor, notes, admin, settings — their surface-specific JS handles
// the rest via DOMContentLoaded listeners in their own boot scripts.
}
// Chat-surface-specific initialization (extracted from monolithic startApp)
async function _startChatSurface() {
// v0.23.1: Load folders and channels before rendering sidebar
await loadFolders();
await loadChannels();
// Load extensions BEFORE chats so block renderers (mermaid, katex, csv, diff)
// are registered when messages are first rendered.
try {
await Extensions.loadAll(); // fetch manifests + inject <script> tags
await Extensions.initAll(); // build ctx, call init(), set up tool bridge
} catch (e) {
console.warn('Extension init error:', e.message);
}
await loadChats();
await loadProjects();
await loadProjectChannelPositions(); // v0.19.2: load channel order per project
// Models already loaded in common init; update chat-surface-specific UI
if (typeof UI !== 'undefined') {
UI.updateModelSelector();
UI.updateCapabilityBadges();
}
// v0.23.2: Load user list for @mention autocomplete
await loadUsers();
await initBanners();
initFiles();
// v0.23.2: Create primary ChatPane instance from server-rendered mount points
if (typeof ChatPane !== 'undefined') {
ChatPane.primary = ChatPane.create({
id: 'main',
messagesEl: document.getElementById('chatMessages'),
inputEl: document.getElementById('chatInputBar'),
standalone: false,
});
}
if (typeof ToolsToggle !== 'undefined') ToolsToggle.init();
if (typeof REPL !== 'undefined') REPL.init(); // v0.21.3: admin-gated REPL tab
if (typeof KnowledgeUI !== 'undefined') {
KnowledgeUI.init();
} else {
console.error('[App] KnowledgeUI module not defined — knowledge-ui.js failed to load or parse');
}
UI.renderChatList();
UI.renderChannelsSection();
UI.restoreSidebarSections();
UI.updateModelSelector();
// Restore last-active conversation from sessionStorage
try {
const saved = sessionStorage.getItem('cs-active-conversation');
if (saved) {
const ac = JSON.parse(saved);
if (ac?.id) {
const inChats = App.chats.some(c => c.id === ac.id);
const inChannels = (App.channels || []).some(c => c.id === ac.id);
if (inChats) {
selectChat(ac.id);
} else if (inChannels) {
selectChannel(ac.id);
}
}
}
} catch (_) {}
// If no conversation was restored, show the welcome/empty state
if (!App.activeId) {
UI.showEmptyState();
}
initListeners();
// Admin-only: persistent fallback alert banner (v0.17.0)
sw.on('role.fallback', (payload) => {
if (!sw.isAdmin) return;
const data = typeof payload === 'string' ? JSON.parse(payload) : payload;
showFallbackBanner(data);
});
console.log('✅ Chat Switchboard ready');
}
// ── Role Fallback Banner (v0.17.0) ──────────
function showFallbackBanner(data) {
let banner = document.getElementById('roleFallbackBanner');
if (!banner) {
// Create persistent banner above messages
banner = document.createElement('div');
banner.id = 'roleFallbackBanner';
banner.className = 'role-fallback-banner';
const msgs = document.getElementById('chatMessages');
if (msgs) msgs.parentNode.insertBefore(banner, msgs);
else return;
}
const msg = data.message || `Role "${data.role}" primary failed — using fallback`;
banner.innerHTML = `
<span class="fallback-icon">⚠️</span>
<span class="fallback-msg">${esc(msg)}</span>
<button class="btn-small" onclick="this.parentElement.remove()">Dismiss</button>`;
banner.style.display = '';
}
// ── Modal Helpers ───────────────────────────
// ── Auth Flow ────────────────────────────────
function showSplash(health) {
// v0.22.6: Server-rendered architecture uses /login page.
// Clear stale cookie so the redirect sticks (no loop).
document.cookie = 'sb_token=; path=/; max-age=0';
const base = window.__BASE__ || '';
window.location.href = base + '/login';
}
function hideSplash() {
const splash = document.getElementById('splashGate');
const app = document.getElementById('appContainer');
if (splash) splash.style.display = 'none';
if (app) app.style.display = '';
}
async function handleLogin() {
const login = document.getElementById('authLogin').value.trim();
const password = document.getElementById('authPassword').value;
if (!login || !password) return setAuthError('Fill in all fields');
setAuthLoading(true);
try { await API.login(login, password); await startApp(); }
catch (e) { setAuthError(e.message); }
finally { setAuthLoading(false); }
}
async function handleRegister() {
const username = document.getElementById('authUsername').value.trim();
const email = document.getElementById('authEmail').value.trim();
const password = document.getElementById('authRegPassword').value;
if (!username || !email || !password) return setAuthError('Fill in all fields');
if (password.length < 8) return setAuthError('Password must be at least 8 characters');
setAuthLoading(true);
try {
const resp = await API.register(username, email, password);
if (resp.pending) {
setAuthError('');
const errEl = document.getElementById('authError');
errEl.textContent = 'Account created — pending admin approval. You will be able to sign in once approved.';
errEl.style.color = 'var(--accent)';
setTimeout(() => { errEl.style.color = ''; switchAuthTab('login'); }, 5000);
} else {
await startApp();
}
}
catch (e) { setAuthError(e.message); }
finally { setAuthLoading(false); }
}
async function handleLogout() {
if (!await showConfirm('Sign out?')) return;
Events.disconnect();
Events.clear();
await API.logout();
App.chats = [];
App.setActive(null);
location.reload();
}
function switchAuthTab(tab) {
document.getElementById('authTabLogin').classList.toggle('active', tab === 'login');
document.getElementById('authTabRegister').classList.toggle('active', tab === 'register');
document.getElementById('authLoginForm').style.display = tab === 'login' ? '' : 'none';
document.getElementById('authRegisterForm').style.display = tab === 'register' ? '' : 'none';
document.getElementById('authLoginBtn').style.display = tab === 'login' ? '' : 'none';
document.getElementById('authRegisterBtn').style.display = tab === 'register' ? '' : 'none';
const hdr = document.querySelector('.auth-card-header');
if (hdr) {
hdr.querySelector('h2').textContent = tab === 'login' ? 'Welcome back' : 'Create account';
hdr.querySelector('p').textContent = tab === 'login'
? 'Sign in to continue to your workspace'
: 'Get started with Chat Switchboard';
}
setAuthError('');
}
function setAuthError(msg) { document.getElementById('authError').textContent = msg; }
function setAuthLoading(on) {
document.querySelectorAll('#authLoginBtn, #authRegisterBtn').forEach(btn => {
btn.disabled = on;
btn.textContent = on ? 'Please wait...' : btn.dataset.label;
});
}
// ── Event Listeners ─────────────────────────
// Thin dispatcher — domain listeners extracted to their own files.
let _listenersInit = false;
function initListeners() {
if (_listenersInit) return;
_listenersInit = true;
_initChatListeners(); // from chat.js
// _initSettingsListeners and _initAdminListeners removed in v0.37.7
// _initNotesListeners + _registerNotesPanel removed in v0.37.9 — see sw/components/notes-pane/
_initFileListeners(); // from files.js
_registerPreviewPanel(); // from ui-format.js — register preview with PanelRegistry
_registerProjectPanel(); // from projects-ui.js — register project detail panel
if (typeof Notifications !== 'undefined') Notifications.init(); // v0.20.0
_initGlobalKeyboard(); // local: Escape, Ctrl+K, Ctrl+\, resize
_initWorkspaceResize(); // from panels.js — workspace handle drag
_initPanelSwipe(); // from panels.js — mobile swipe navigation
_initPanelResponsive(); // from panels.js — responsive overlay
_initPanelOverlay(); // from panels.js — mobile tap-to-close overlay
_initSidebarTabs(); // v0.21.6: Chats/Files tab switching
}
function _initSidebarTabs() {
const tabBar = document.getElementById('sidebarTabs');
if (!tabBar) return;
tabBar.addEventListener('click', (e) => {
const btn = e.target.closest('.sidebar-tab');
if (!btn) return;
const tabName = btn.dataset.tab;
// Update active tab
tabBar.querySelectorAll('.sidebar-tab').forEach(t => t.classList.toggle('active', t === btn));
// Show/hide panels
document.querySelectorAll('.sidebar-tab-panel').forEach(p => {
p.style.display = p.dataset.tabPanel === tabName ? '' : 'none';
});
});
}
/** Show or hide the Files tab. Called by EditorMode on register/unregister. */
function showSidebarFilesTab(show) {
const tab = document.getElementById('sidebarFilesTab');
if (tab) tab.style.display = show ? '' : 'none';
// If hiding and Files was active, switch back to Chats
if (!show && tab?.classList.contains('active')) {
const chatsTab = document.querySelector('.sidebar-tab[data-tab="chats"]');
if (chatsTab) chatsTab.click();
}
}
function _initGlobalKeyboard() {
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
// Escape: stop generation → close command palette → close side panel → close topmost modal
if (e.key === 'Escape') {
if (App.isGenerating) { stopGeneration(); return; }
if (document.getElementById('lightbox')?.classList.contains('active')) {
closeLightbox(); return;
}
if (document.getElementById('cmdPalette')?.classList.contains('active')) {
closeCmdPalette(); return;
}
if (PanelRegistry.isContainerOpen()) {
PanelRegistry.closeAll(); return;
}
const open = [...document.querySelectorAll('.modal-overlay.active')];
if (open.length) closeModal(open[open.length - 1].id);
return;
}
// Ctrl/Cmd+K: command palette
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
toggleCmdPalette();
return;
}
// Ctrl/Cmd+\: cycle side panels
if ((e.ctrlKey || e.metaKey) && e.key === '\\') {
e.preventDefault();
PanelRegistry.cycle();
return;
}
// Ctrl/Cmd+Shift+S: focus sidebar search
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'S') {
e.preventDefault();
const sb = document.getElementById('sidebar');
if (sb.classList.contains('collapsed')) UI.toggleSidebar();
document.getElementById('chatSearchInput')?.focus();
return;
}
});
// Recheck tab overflow on resize / zoom changes
window.addEventListener('resize', () => {
document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(checkTabsOverflow);
});
}
// ── Branding ────────────────────────────────
function initBranding() {
const b = window.__BRANDING__;
if (!b || typeof b !== 'object') return;
const brandBase = (window.__BASE__ || '') + '/branding/';
// Accent color → CSS custom property
if (b.accent_color) {
document.documentElement.style.setProperty('--accent-color', b.accent_color);
}
// Page title
if (b.org_name) {
document.title = b.org_name;
}
// Sidebar brand text
const sidebarText = document.getElementById('brandSidebarText');
if (sidebarText && b.org_name) sidebarText.textContent = b.org_name;
// Splash hero wordmark
const wordmark = document.getElementById('brandWordmark');
if (wordmark && b.org_name) wordmark.textContent = b.org_name;
// Splash headline — replace if branding provides it
if (b.headline) {
const headline = document.getElementById('brandHeadline');
if (headline) headline.textContent = b.headline;
}
// Splash tagline
const tagline = document.getElementById('brandTagline');
if (tagline && b.tagline) tagline.textContent = b.tagline;
// Auth card text
if (b.org_name) {
const authHeader = document.querySelector('.auth-card-header h2');
if (authHeader) authHeader.textContent = 'Welcome to ' + b.org_name;
const authSub = document.querySelector('.auth-card-header p');
if (authSub) authSub.textContent = 'Sign in to continue';
}
const authFooter = document.getElementById('brandAuthFooter');
if (authFooter && b.tagline) authFooter.textContent = b.tagline;
// Logo — replace SVG with <img> in splash hero
if (b.logo) {
const logoUrl = brandBase + b.logo;
const logoEl = document.getElementById('brandLogo');
if (logoEl) {
const img = document.createElement('img');
img.src = logoUrl;
img.alt = b.org_name || 'Logo';
img.className = 'hero-logo-img';
img.onerror = function() {
this.style.display = 'none';
const svg = logoEl.querySelector('svg');
if (svg) svg.style.display = '';
};
const svg = logoEl.querySelector('svg');
if (svg) svg.style.display = 'none';
logoEl.appendChild(img);
}
// Sidebar logo — replace emoji with small img
const sidebarLogo = document.getElementById('brandSidebarLogo');
if (sidebarLogo) {
const originalEmoji = sidebarLogo.textContent;
const img = document.createElement('img');
img.src = logoUrl;
img.alt = '';
img.className = 'brand-logo-img';
img.onerror = function() {
this.remove();
sidebarLogo.textContent = originalEmoji;
};
sidebarLogo.textContent = '';
sidebarLogo.appendChild(img);
}
}
// Favicon
if (b.favicon) {
const favUrl = brandBase + b.favicon;
document.querySelectorAll('link[rel="icon"], link[rel="apple-touch-icon"]').forEach(link => {
link.href = favUrl;
});
}
// Feature pills — custom array replaces defaults, empty array hides them
if (b.pills !== undefined) {
const pillsEl = document.getElementById('brandPills');
if (pillsEl) {
if (!Array.isArray(b.pills) || b.pills.length === 0) {
pillsEl.style.display = 'none';
} else {
pillsEl.innerHTML = b.pills.map(p => {
const style = p.style === 'accent' ? ' accent' : p.style === 'purple' ? ' purple' : '';
return `<div class="hero-pill${style}"><span class="pill-icon">${p.icon || ''}</span> ${p.text}</div>`;
}).join('');
}
}
}
console.log('🎨 Branding applied:', b.org_name || '(defaults)');
}
// ── Banners ──────────────────────────────────
async function initBanners() {
try {
const data = await API.getPublicSettings?.() || {};
// Store policies for user-facing checks (allow_user_byok, etc.)
App.policies = data.policies || {};
// Storage capabilities (file upload, vision)
App.storageConfigured = !!data.storage_configured;
// Paste-to-file threshold (synced from backend, default 2000)
App.pasteToFileChars = data.paste_to_file_chars || 2000;
// Also flatten into serverSettings for backward compat
App.serverSettings = {};
if (data.banner) App.serverSettings.banner = data.banner;
if (data.branding) App.serverSettings.branding = data.branding;
const root = document.documentElement;
const banner = data.banner;
// Clear previous banner state
['bannerTop', 'bannerBottom'].forEach(id => {
const el = document.getElementById(id);
if (el) { el.classList.remove('active'); el.textContent = ''; }
});
root.style.setProperty('--banner-top-height', '0px');
root.style.setProperty('--banner-bottom-height', '0px');
if (!banner || !banner.enabled) return;
root.style.setProperty('--banner-bg', banner.bg || '#007a33');
root.style.setProperty('--banner-fg', banner.fg || '#ffffff');
const text = banner.text || '';
const top = document.getElementById('bannerTop');
top.textContent = text;
top.classList.add('active');
root.style.setProperty('--banner-top-height', '22px');
const bot = document.getElementById('bannerBottom');
bot.textContent = text;
bot.classList.add('active');
root.style.setProperty('--banner-bottom-height', '22px');
} catch (e) {
// Banners are optional — non-admin users may not have access to settings
console.debug('Banner init skipped:', e.message);
}
}
// ── Boot ─────────────────────────────────────
document.addEventListener('DOMContentLoaded', init);
// ── Exports ─────────────────────────────────
sb.register('handleLogin', handleLogin);
sb.register('handleRegister', handleRegister);
sb.register('handleLogout', handleLogout);
sb.register('switchAuthTab', switchAuthTab);
sb.register('startApp', startApp);
sb.register('initBanners', initBanners);

View File

@@ -1,437 +0,0 @@
// ==========================================
// Chat Switchboard Channel Models (v0.20.0)
// ==========================================
// Multi-model management per channel.
// - Model pills in chat header (add/remove/set default)
// - @mention autocomplete in chat input
// - Model attribution on assistant messages
// ==========================================
//
// Exports: window.ChannelModels
const ChannelModels = {
_roster: [], // current channel's model roster
_channelId: null, // current channel ID
_acVisible: false, // autocomplete dropdown visible
// ── Init / Lifecycle ────────────────────
init() {
// Close autocomplete on click-outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.mention-ac')) {
this.hideAutocomplete();
}
});
},
// ── Roster Management ───────────────────
async load(channelId) {
this._channelId = channelId;
if (!channelId) {
this._roster = [];
this.renderPills();
return;
}
try {
const roster = await API.listChannelModels(channelId);
const models = roster.models || roster;
this._roster = Array.isArray(models) ? models : [];
} catch (e) {
console.debug('Channel models not loaded:', e.message);
this._roster = [];
}
this.renderPills();
},
getRoster() { return this._roster; },
getDefault() {
return this._roster.find(m => m.is_default) || this._roster[0] || null;
},
// ── Model Pills UI ──────────────────────
renderPills() {
const container = document.getElementById('channelModelPills');
if (!container) return;
if (this._roster.length <= 1) {
container.style.display = 'none';
container.innerHTML = '';
return;
}
container.style.display = '';
const html = this._roster.map(m => `
<span class="ch-model-pill${m.is_default ? ' ch-model-default' : ''}"
data-model-id="${esc(m.id)}" title="${esc(m.model_id)}">
<span class="ch-model-pill-name" data-action="ChannelModels.setDefault" data-args='${JSON.stringify([esc(m.id)])}'">${esc(m.display_name)}</span>
<button class="ch-model-pill-remove" data-action="ChannelModels.remove" data-args='${JSON.stringify([esc(m.id)])}'" title="Remove model">✕</button>
</span>
`).join('') + `
<button class="ch-model-add-btn" data-action="ChannelModels.showAddDialog" " title="Add model to channel">+ Add</button>
`;
container.innerHTML = html;
},
async remove(recordId) {
if (!this._channelId) return;
try {
const resp = await API.deleteChannelModel(this._channelId, recordId);
this._roster = resp.models || [];
this.renderPills();
} catch (e) {
UI.toast('Failed to remove model: ' + e.message, 'error');
}
},
async setDefault(recordId) {
if (!this._channelId) return;
try {
const resp = await API.updateChannelModel(this._channelId, recordId, { is_default: true });
this._roster = resp.models || [];
this.renderPills();
} catch (e) {
UI.toast('Failed to set default: ' + e.message, 'error');
}
},
// ── Add Model Dialog ────────────────────
showAddDialog() {
if (!App.models || App.models.length === 0) {
UI.toast('No models available — configure a provider first', 'warning');
return;
}
// Filter out models already in the roster
const rosterModelIds = new Set(this._roster.map(m => m.model_id));
const available = App.models.filter(m => !m.isPersona && !m.hidden && !rosterModelIds.has(m.baseModelId || m.id));
if (available.length === 0) {
UI.toast('All available models are already added to this channel', 'info');
return;
}
const html = `
<div class="ch-model-add-dialog" id="chModelAddDialog">
<div class="ch-model-add-inner">
<h3>Add Model to Channel</h3>
<label>Model
<select id="chModelAddSelect">
${available.map(m => `<option value="${esc(m.baseModelId || m.id)}" data-config="${esc(m.configId || '')}" data-name="${esc(m.name)}">${esc(m.name)}${m.provider ? ` (${esc(m.provider)})` : ''}</option>`).join('')}
</select>
</label>
<label>Display Name (used for @mentions)
<input type="text" id="chModelAddName" placeholder="e.g. Claude-3-Opus" maxlength="50">
</label>
<div class="ch-model-add-actions">
<button class="btn-secondary" data-action="ChannelModels.closeAddDialog" ">Cancel</button>
<button class="btn-primary" data-action="ChannelModels.submitAdd" ">Add</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML('beforeend', html);
// Auto-fill display name from selected model
const select = document.getElementById('chModelAddSelect');
const nameInput = document.getElementById('chModelAddName');
nameInput.value = _shortName(select.options[0]?.dataset.name || '');
select.addEventListener('change', () => {
nameInput.value = _shortName(select.options[select.selectedIndex]?.dataset.name || '');
});
// Focus and select
nameInput.focus();
nameInput.select();
},
closeAddDialog() {
document.getElementById('chModelAddDialog')?.remove();
},
async submitAdd() {
const select = document.getElementById('chModelAddSelect');
const nameInput = document.getElementById('chModelAddName');
if (!select || !nameInput) return;
const modelId = select.value;
const configId = select.options[select.selectedIndex]?.dataset.config || '';
const displayName = nameInput.value.trim();
if (!displayName) {
UI.toast('Display name is required', 'warning');
return;
}
try {
const resp = await API.addChannelModel(this._channelId, {
model_id: modelId,
provider_config_id: configId,
display_name: displayName,
is_default: this._roster.length === 0
});
this._roster = resp.models || [];
this.renderPills();
this.closeAddDialog();
} catch (e) {
UI.toast('Failed to add model: ' + e.message, 'error');
}
},
// ── @mention Autocomplete ───────────────
/**
* Called from the chat input's keyup/input handler.
* Shows a dropdown of channel models when user types @.
*/
onInput(inputEl) {
const text = typeof inputEl.getValue === 'function' ? inputEl.getValue() : inputEl.value;
const cursorPos = typeof inputEl.getCursorPos === 'function' ? inputEl.getCursorPos() : inputEl.selectionStart;
const before = text.slice(0, cursorPos);
const atIdx = before.lastIndexOf('@');
if (atIdx < 0) { this.hideAutocomplete(); return; }
if (atIdx > 0 && before[atIdx - 1] !== ' ' && before[atIdx - 1] !== '\n') {
this.hideAutocomplete();
return;
}
const partial = before.slice(atIdx + 1).toLowerCase().replace(/-/g, ' ');
if (partial.length === 0) {
// Just typed @ — show everything
}
// Build match list from ALL enabled models + personas (not just roster)
const allModels = (App.models || []).filter(m => !m.hidden);
const modelMatches = allModels.filter(m => {
const handle = (m.personaHandle || '').toLowerCase().replace(/-/g, ' ');
const modelId = (m.baseModelId || m.id || '').toLowerCase().replace(/-/g, ' ');
const name = (m.name || '').toLowerCase().replace(/-/g, ' ');
const firstName = name.split(' ')[0] || '';
if (partial.length === 0) return true;
return handle.startsWith(partial)
|| modelId.startsWith(partial)
|| name.startsWith(partial)
|| firstName.startsWith(partial);
});
// v0.24.0: Context-aware user autocomplete.
// direct / dm : no users (1:1 AI or already-defined human pair)
// group : participants only (defined roster)
// channel : full user list (open, ad-hoc)
const activeType = App.activeType || 'direct';
let userPool = [];
if (activeType === 'channel') {
userPool = App.users || [];
} else if (activeType === 'group') {
const pids = App.activeParticipants || [];
userPool = (App.users || []).filter(u => pids.includes(u.id));
}
// direct and dm: userPool stays empty
const userMatches = userPool.filter(u => {
const uhandle = (u.handle || '').toLowerCase();
const uname = u.username.toLowerCase();
const dname = (u.displayName || '').toLowerCase();
if (partial.length === 0) return true;
return uhandle.startsWith(partial) || uname.startsWith(partial) || dname.startsWith(partial);
}).map(u => ({
id: u.handle || u.username,
name: u.displayName || u.username,
baseModelId: u.handle || u.username,
isPersona: false,
isUser: true,
personaHandle: null,
personaAvatar: null,
provider: '',
}));
// Resolution order: personas first, then users, then models
const personas = modelMatches.filter(m => m.isPersona);
const models = modelMatches.filter(m => !m.isPersona);
const matches = [...personas, ...userMatches, ...models];
if (matches.length === 0) { this.hideAutocomplete(); return; }
// Cap at 10 to keep dropdown manageable
this.showAutocomplete(inputEl, atIdx, cursorPos, matches.slice(0, 10));
},
showAutocomplete(inputEl, atIdx, cursorPos, matches) {
this.hideAutocomplete();
const wrap = document.createElement('div');
wrap.className = 'mention-ac';
wrap.id = 'mentionAc';
const inputRect = (inputEl.el || inputEl).getBoundingClientRect();
wrap.style.bottom = (window.innerHeight - inputRect.top + 4) + 'px';
wrap.style.left = inputRect.left + 'px';
wrap.style.minWidth = '280px';
matches.forEach((m, i) => {
const item = document.createElement('div');
item.className = 'mention-ac-item' + (i === 0 ? ' mention-ac-active' : '');
// Determine the @handle to insert
const mentionToken = m.isPersona
? (m.personaHandle || m.name?.replace(/\s+/g, '-').toLowerCase() || m.id)
: (m.baseModelId || m.id);
item.dataset.handle = mentionToken;
const avatar = m.personaAvatar
? `<img src="${esc(m.personaAvatar)}" class="mention-ac-avatar" alt="">`
: m.isPersona
? `<span class="mention-ac-avatar mention-ac-avatar-fallback">⚡</span>`
: m.isUser
? `<span class="mention-ac-avatar mention-ac-avatar-fallback mention-ac-user">👤</span>`
: `<span class="mention-ac-avatar mention-ac-avatar-fallback">🤖</span>`;
const handleText = m.isPersona && m.personaHandle
? `@${esc(m.personaHandle)}`
: m.isUser
? `@${esc(m.baseModelId)}`
: `@${esc(m.baseModelId || m.id)}`;
const providerHint = m.isUser ? 'user' : (m.provider ? esc(m.provider) : '');
item.innerHTML = `${avatar}<div class="mention-ac-info"><span class="mention-ac-name">${esc(m.name || m.id)}</span><span class="mention-ac-handle">${handleText}</span></div><span class="mention-ac-model">${providerHint}</span>`;
item.addEventListener('click', () => {
this._insertMention(inputEl, atIdx, cursorPos, mentionToken);
});
wrap.appendChild(item);
});
document.body.appendChild(wrap);
this._acVisible = true;
},
hideAutocomplete() {
document.getElementById('mentionAc')?.remove();
this._acVisible = false;
},
isAutocompleteVisible() { return this._acVisible; },
/**
* Handle arrow keys and Enter when autocomplete is visible.
* Returns true if the event was consumed.
*/
handleKey(e) {
if (!this._acVisible) return false;
const ac = document.getElementById('mentionAc');
if (!ac) return false;
const items = ac.querySelectorAll('.mention-ac-item');
let activeIdx = Array.from(items).findIndex(el => el.classList.contains('mention-ac-active'));
if (e.key === 'ArrowDown') {
e.preventDefault();
items[activeIdx]?.classList.remove('mention-ac-active');
activeIdx = (activeIdx + 1) % items.length;
items[activeIdx]?.classList.add('mention-ac-active');
return true;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
items[activeIdx]?.classList.remove('mention-ac-active');
activeIdx = (activeIdx - 1 + items.length) % items.length;
items[activeIdx]?.classList.add('mention-ac-active');
return true;
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
const active = items[activeIdx];
if (active) active.click();
return true;
}
if (e.key === 'Escape') {
this.hideAutocomplete();
return true;
}
return false;
},
_insertMention(inputEl, atIdx, cursorPos, displayName) {
const text = typeof inputEl.getValue === 'function' ? inputEl.getValue() : inputEl.value;
const before = text.slice(0, atIdx);
const after = text.slice(cursorPos);
const newText = before + '@' + displayName + ' ' + after;
if (typeof inputEl.setValue === 'function') {
inputEl.setValue(newText);
} else {
inputEl.value = newText;
}
// Position cursor after the inserted mention
const newPos = atIdx + 1 + displayName.length + 1;
if (typeof inputEl.setCursorPos === 'function') {
inputEl.setCursorPos(newPos);
} else {
inputEl.selectionStart = inputEl.selectionEnd = newPos;
}
this.hideAutocomplete();
inputEl.focus?.();
},
// ── Model Display Name Resolution ───────
/**
* Resolve a model_id to a display name using the current roster.
* Falls back to App.models lookup, then the raw model_id.
*/
resolveDisplayName(modelId) {
if (!modelId) return 'Assistant';
const cm = this._roster.find(m => m.model_id === modelId);
if (cm?.display_name) return cm.display_name;
const m = App.models?.find(x => x.id === modelId || x.baseModelId === modelId);
return m?.name || modelId;
},
/**
* Resolve persona avatar for a channel model roster entry.
* Looks up via persona_id in App.models.
*/
_resolveAvatar(rosterEntry) {
if (!rosterEntry.persona_id) return null;
const persona = App.models?.find(m => m.personaId === rosterEntry.persona_id);
return persona?.personaAvatar || null;
},
/**
* Get persona info for a given model_id from the roster + App.models.
* Returns { displayName, avatar, personaId } or null.
*/
resolvePersonaInfo(modelId) {
const cm = this._roster.find(m => m.model_id === modelId);
if (!cm) return null;
const avatar = this._resolveAvatar(cm);
return {
displayName: cm.display_name || modelId,
avatar: avatar,
personaId: cm.persona_id || null,
};
}
};
// ── Helpers ──────────────────────────────────
function _shortName(fullName) {
// "Claude 3 Opus (Anthropic)" → "Claude-3-Opus"
return fullName
.replace(/\s*\([^)]*\)\s*$/, '') // strip "(provider)"
.trim()
.replace(/\s+/g, '-'); // spaces → hyphens
}
// ── Exports ─────────────────────────────────
sb.ns('ChannelModels', ChannelModels);

View File

@@ -1,160 +0,0 @@
// ==========================================
// Chat Switchboard - ChatPane Component (DOM-binding mode)
// ==========================================
// v0.37.8: DOM-binding mode only. Standalone mode (mount + streaming)
// moved to Preact ChatPane kit (src/js/sw/components/chat-pane/).
// This file is retained for app.js ChatPane.primary on the legacy
// chat surface. Will be deleted in v0.37.10.
//
// Exports: window.ChatPane
const ChatPane = {
...createComponentRegistry('ChatPane'),
create(opts) {
const id = opts.id || 'pane-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
const instance = componentMixin({
id,
messagesEl: opts.messagesEl,
inputEl: opts.inputEl,
sendBtnEl: opts.sendBtnEl || null,
modelSelEl: opts.modelSelEl || null,
toolbarEl: opts.toolbarEl || null,
channelId: opts.channelId || null,
standalone: opts.standalone || false,
_cmView: null,
_typing: null,
renderMessages(messages) {
if (!this.messagesEl) return;
if (typeof UI !== 'undefined' && UI._renderMessages) {
UI._renderMessages(messages, this);
} else {
this.messagesEl.innerHTML = '';
(messages || []).forEach(m => {
const div = document.createElement('div');
div.className = 'chat-msg chat-msg--' + m.role;
div.innerHTML = typeof formatMessage === 'function'
? formatMessage(m) : esc(m.content || '');
this.messagesEl.appendChild(div);
});
}
this.scrollToBottom();
},
appendChunk(html) {
if (!this.messagesEl) return;
const last = this.messagesEl.querySelector('.chat-msg--streaming');
if (last) {
const content = last.querySelector('.chat-msg__content');
if (content) content.innerHTML = html;
} else {
const div = document.createElement('div');
div.className = 'chat-msg chat-msg--assistant chat-msg--streaming';
div.innerHTML = '<div class="chat-msg__content">' + html + '</div>';
this.messagesEl.appendChild(div);
}
this.scrollToBottom();
},
finalizeStream() {
if (!this.messagesEl) return;
const el = this.messagesEl.querySelector('.chat-msg--streaming');
if (el) el.classList.remove('chat-msg--streaming');
},
appendTyping() {
if (!this.messagesEl || this._typing) return;
this._typing = document.createElement('div');
this._typing.className = 'chat-msg chat-msg--typing';
this._typing.innerHTML = '<div class="typing-dots"><span></span><span></span><span></span></div>';
this.messagesEl.appendChild(this._typing);
this.scrollToBottom();
},
removeTyping() {
if (this._typing) { this._typing.remove(); this._typing = null; }
},
scrollToBottom(smooth) {
if (!this.messagesEl) return;
const el = this.messagesEl;
if (smooth) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
else el.scrollTop = el.scrollHeight;
},
isScrolledToBottom() {
if (!this.messagesEl) return true;
const el = this.messagesEl;
return (el.scrollHeight - el.scrollTop - el.clientHeight) < 60;
},
setChannel(channelId) { this.channelId = channelId; },
getChannel() { return this.channelId; },
showWelcome(html) {
if (!this.messagesEl) return;
this.messagesEl.innerHTML = html || '<div class="chat-welcome">' +
'<p class="chat-welcome__title">Start a conversation</p>' +
'<p class="chat-welcome__hint">Type a message below to get started.</p></div>';
},
clear() {
if (this.messagesEl) this.messagesEl.innerHTML = '';
this.removeTyping();
},
getInputValue() {
if (this._cmView) return this._cmView.state.doc.toString();
const ta = this.inputEl && this.inputEl.querySelector('textarea');
return ta ? ta.value : '';
},
setInputValue(val) {
if (this._cmView) {
this._cmView.dispatch({
changes: { from: 0, to: this._cmView.state.doc.length, insert: val }
});
return;
}
const ta = this.inputEl && this.inputEl.querySelector('textarea');
if (ta) ta.value = val;
},
clearInput() { this.setInputValue(''); },
focusInput() {
if (this._cmView) this._cmView.focus();
else { const ta = this.inputEl && this.inputEl.querySelector('textarea'); if (ta) ta.focus(); }
},
_cleanup() {
if (this._cmView) { this._cmView.destroy(); this._cmView = null; }
this.removeTyping();
},
}, ChatPane);
ChatPane._register(id, instance);
return instance;
},
forChannel(channelId) {
for (const [, inst] of this._instances) {
if (inst.channelId === channelId) return inst;
}
return null;
},
active() { return this.primary; },
};
// v0.37.8: Standalone mode (mount + streaming + model selector + history)
// moved to Preact ChatPane kit at src/js/sw/components/chat-pane/.
// sw.chat() now prefers the new Preact path; this file retains only
// DOM-binding mode for the legacy chat surface (app.js ChatPane.primary).
// ── Exports ─────────────────────────────────
sb.ns('ChatPane', ChatPane);

File diff suppressed because it is too large Load Diff

View File

@@ -1,506 +0,0 @@
// ==========================================
// Chat Switchboard Extension System
// ==========================================
// Loader, registry, scoped context, and renderer pipeline.
// Tier 0 (browser) extensions register here. The ctx object
// enforces permissions declared in the manifest.
//
// Usage:
// Extensions.register({
// id: 'my-ext',
// init(ctx) { ctx.renderers.register(...); },
// destroy() { /* cleanup */ }
// });
//
// Load order: events.js → extensions.js → [ext scripts] → api.js → app.js
// ==========================================
//
// Exports: window.Extensions
const Extensions = {
// ── State ────────────────────────────────
_registry: new Map(), // extId → { def, ctx, instance }
_renderers: [], // sorted by priority
_toolHandlers: new Map(), // toolName → { extId, handler }
_loaded: false,
_manifests: [], // loaded from server
// ── Script Loading ────────────────────────
/**
* Fetch enabled browser extensions from the server and inject their scripts.
* Called before app init so extensions can register before initAll().
*/
async loadAll() {
try {
const resp = await API._get('/api/v1/extensions?tier=browser');
const exts = resp.data || [];
this._manifests = exts;
for (const ext of exts) {
// Parse manifest to check for _script (inline) or entry (file)
const manifest = ext.manifest || {};
const extId = ext.id;
if (manifest._script) {
// Inject script tag pointing at the asset endpoint
await this._injectScript(extId, `${window.__BASE__ || ''}/api/v1/extensions/${extId}/assets/main.js`);
} else if (manifest.entry) {
await this._injectScript(extId, `${window.__BASE__ || ''}/api/v1/extensions/${extId}/assets/${manifest.entry}`);
}
}
console.log(`[Extensions] Loaded ${exts.length} browser extension(s)`);
} catch (e) {
console.warn('[Extensions] Failed to load extensions:', e.message || e);
}
},
/**
* Inject a script tag and wait for it to load.
*/
_injectScript(extId, src) {
return new Promise((resolve) => {
const script = document.createElement('script');
script.src = src;
script.dataset.extension = extId;
script.onload = () => { resolve(); };
script.onerror = () => {
console.error(`[Extensions] Failed to load script for ${extId}: ${src}`);
resolve(); // Don't block other extensions
};
document.head.appendChild(script);
});
},
// ── Registration ─────────────────────────
/**
* Register a browser extension. Called by extension scripts.
* @param {object} def — { id, init(ctx), destroy() }
*/
register(def) {
if (!def || !def.id) {
console.error('[Extensions] register() requires an id');
return;
}
if (this._registry.has(def.id)) {
console.warn(`[Extensions] ${def.id} already registered, skipping`);
return;
}
const entry = { def, ctx: null, instance: null, active: false };
this._registry.set(def.id, entry);
console.log(`[Extensions] Registered: ${def.id}`);
},
// ── Lifecycle ────────────────────────────
/**
* Initialize all registered extensions. Called once after app startup.
* Builds scoped ctx for each and calls init().
*/
async initAll() {
// Set up the tool bridge: listen for tool.call.* events from server
this._setupToolBridge();
for (const [id, entry] of this._registry) {
if (entry.active) continue;
try {
entry.ctx = this._buildContext(id, entry.def);
if (typeof entry.def.init === 'function') {
await entry.def.init.call(entry.def, entry.ctx);
}
entry.active = true;
console.log(`[Extensions] Initialized: ${id}`);
} catch (e) {
console.error(`[Extensions] Failed to init ${id}:`, e);
}
}
this._loaded = true;
Events.emit('extension.loaded', { count: this._registry.size }, { localOnly: true });
},
/**
* Destroy all extensions (logout/cleanup).
*/
destroyAll() {
for (const [id, entry] of this._registry) {
if (!entry.active) continue;
try {
if (typeof entry.def.destroy === 'function') {
entry.def.destroy.call(entry.def);
}
} catch (e) {
console.error(`[Extensions] Failed to destroy ${id}:`, e);
}
entry.active = false;
entry.ctx = null;
}
this._renderers = [];
this._loaded = false;
},
// ── Context Builder ──────────────────────
/**
* Build a scoped context object for an extension.
* Each extension gets its own ctx with permission-aware proxies.
*/
_buildContext(extId, def) {
const manifest = def.manifest || {};
const permissions = new Set(manifest.permissions || []);
return {
// Extension identity
id: extId,
// Event bus (scoped — could filter by permissions later)
events: {
on: (label, fn) => Events.on(label, fn),
once: (label, fn) => Events.once(label, fn),
off: (label, fn) => Events.off(label, fn),
emit: (label, payload) => Events.emit(label, payload, { localOnly: true }),
},
// Scoped localStorage namespace
storage: {
get: (key) => {
try {
return JSON.parse(localStorage.getItem(`ext::${extId}::${key}`));
} catch { return null; }
},
set: (key, value) => {
localStorage.setItem(`ext::${extId}::${key}`, JSON.stringify(value));
},
remove: (key) => {
localStorage.removeItem(`ext::${extId}::${key}`);
},
},
// Extension settings (read-only, from manifest defaults + user overrides)
settings: Object.freeze(Object.assign(
{},
Extensions._extractDefaults(manifest.settings || {}),
Extensions._getUserSettings(extId)
)),
// Renderer registration
renderers: {
register: (name, opts) => Extensions._registerRenderer(extId, name, opts),
},
// Tool registration (browser tool bridge)
tools: {
handle: (name, fn) => {
Extensions._toolHandlers.set(name, { extId, handler: fn });
console.log(`[Extensions] Tool registered: ${extId}:${name}`);
},
},
// UI primitives — safe wrappers around primary UI components.
// Extensions use these instead of reaching into globals directly.
ui: {
/** Show a toast notification. type: 'success' | 'error' | 'warning' | 'info' */
toast(msg, type = 'success') {
if (typeof UI !== 'undefined' && UI.toast) UI.toast(msg, type);
},
/** Open the side-panel preview with arbitrary HTML content. */
openPreview(html) {
const frame = document.getElementById('previewFrame');
const empty = document.getElementById('previewEmpty');
if (frame) {
frame.srcdoc = html;
frame.style.display = '';
}
if (empty) empty.style.display = 'none';
if (typeof PanelRegistry !== 'undefined') {
PanelRegistry.open('preview');
PanelRegistry.refreshActions();
}
},
/** Is the current theme dark? */
isDark() {
return document.body.classList.contains('dark-theme') ||
window.matchMedia('(prefers-color-scheme: dark)').matches;
},
/** Is the viewport at mobile width (≤ 768px)? */
isMobile() {
return window.innerWidth <= 768;
},
/** Is the side panel container currently open? */
isPanelOpen() {
if (typeof PanelRegistry !== 'undefined') return PanelRegistry.isContainerOpen();
return false;
},
/** Show a confirm dialog. Returns Promise<boolean>. */
confirm(msg, opts) {
if (typeof showConfirm === 'function') return showConfirm(msg, opts);
return Promise.resolve(window.confirm(msg));
},
/**
* Replace a surface region's content with a new element.
* Current children are preserved in memory (not destroyed)
* and can be restored later. Critical for CM6 state preservation.
*/
replace(regionId, element) {
console.warn(`[Extensions] ui.replace() requires surface system (removed in v0.22.6)`);
},
/**
* Restore a surface region's previously saved content.
*/
restore(regionId) {
console.warn(`[Extensions] ui.restore() requires surface system (removed in v0.22.6)`);
},
/** Inject an element into a named UI region (stub for future use). */
inject(region, el) {
console.warn(`[Extensions] ui.inject() not yet implemented (${extId}:${region})`);
},
/** Create a popup menu anchored to an element. */
createMenu(anchor, opts) {
if (typeof createPopupMenu === 'function') return createPopupMenu(anchor, opts);
return null;
},
},
// Surface registration (removed v0.22.6 — server-rendered surfaces)
surfaces: {
register: (id, opts) => {
console.warn(`[Extensions] surfaces.register() removed in v0.22.6`);
},
unregister: (id) => {
console.warn(`[Extensions] surfaces.unregister() removed in v0.22.6`);
},
activate: (id) => {
console.warn(`[Extensions] surfaces.activate() removed in v0.22.6`);
},
getCurrent: () => {
return window.__SURFACE__ || 'chat';
},
},
// Model info (resolved at call time)
get model() {
return {
id: typeof currentModelId !== 'undefined' ? currentModelId : null,
};
},
// User info
get user() {
return {
id: typeof API !== 'undefined' ? API.user?.id : null,
username: typeof API !== 'undefined' ? API.user?.username : null,
role: typeof API !== 'undefined' ? API.user?.role : null,
};
},
// Proxied API fetch
api: {
fetch: (path, opts) => {
if (typeof API !== 'undefined' && typeof API._fetch === 'function') {
return API._fetch(path, opts);
}
return fetch(path, opts);
},
},
};
},
// ── Renderer Pipeline ────────────────────
/**
* Register a custom renderer.
* @param {string} extId — owning extension
* @param {string} name — renderer name (unique within extension)
* @param {object} opts — { pattern, render, priority, type }
*
* Types:
* 'block' — operates on code blocks (receives lang, code, container)
* 'inline' — operates on the full message HTML (receives html string, returns html string)
* 'post' — operates on the rendered DOM (receives container element)
*/
_registerRenderer(extId, name, opts) {
if (!opts || !opts.render) {
console.error(`[Extensions] Renderer ${extId}:${name} requires a render function`);
return;
}
const renderer = {
extId,
name,
type: opts.type || 'block',
priority: opts.priority || 50,
pattern: opts.pattern || null,
render: opts.render,
match: opts.match || null, // function(lang, code) → bool
};
this._renderers.push(renderer);
this._renderers.sort((a, b) => a.priority - b.priority);
// v0.28.5: Shim 'post' renderers into the SDK render pipe.
// This makes old ctx.renderers.register() calls visible in
// sw.pipe.list() and ordered alongside new sw.pipe.render() filters.
// Block renderers stay in the old path (they run during formatMessage,
// not after DOM insertion).
if (renderer.type === 'post' && typeof sw !== 'undefined' && sw.pipe) {
sw.pipe.render(renderer.priority, (renderCtx) => {
try {
renderer.render(renderCtx.element);
} catch (e) {
console.error(`[Extensions] Shimmed post-renderer ${extId}:${name} error:`, e);
}
return renderCtx;
}, { source: `${extId}:${name}` });
}
console.log(`[Extensions] Renderer registered: ${extId}:${name} (type=${renderer.type}, priority=${renderer.priority})`);
},
/**
* Run block renderers on a code block element.
* Called by ui-format.js after creating the code block DOM.
* Returns true if a renderer handled the block (caller should skip default).
*
* @param {string} lang — language tag
* @param {string} code — raw code content
* @param {HTMLElement} container — the code block wrapper element
*/
runBlockRenderers(lang, code, container) {
for (const r of this._renderers) {
if (r.type !== 'block') continue;
let matched = false;
if (r.match && typeof r.match === 'function') {
matched = r.match(lang, code);
} else if (r.pattern instanceof RegExp) {
matched = r.pattern.test(lang);
} else if (typeof r.pattern === 'string') {
matched = lang === r.pattern;
}
if (matched) {
try {
r.render(lang, code, container);
return true;
} catch (e) {
console.error(`[Extensions] Renderer ${r.extId}:${r.name} error:`, e);
}
}
}
return false;
},
/**
* Run post-render DOM processors on a message container.
* Called after the full message HTML is inserted into the DOM.
*
* @param {HTMLElement} container — the message content element
*/
runPostRenderers(container) {
for (const r of this._renderers) {
if (r.type !== 'post') continue;
try {
r.render(container);
} catch (e) {
console.error(`[Extensions] Post-renderer ${r.extId}:${r.name} error:`, e);
}
}
},
// ── Helpers ──────────────────────────────
_extractDefaults(settingsSchema) {
const defaults = {};
for (const [key, def] of Object.entries(settingsSchema)) {
if (def && 'default' in def) defaults[key] = def.default;
}
return defaults;
},
_getUserSettings(extId) {
// Find user settings from the manifest data loaded from API
const manifest = this._manifests.find(m => m.id === extId);
if (manifest?.user_settings) {
try {
return typeof manifest.user_settings === 'string'
? JSON.parse(manifest.user_settings)
: manifest.user_settings;
} catch { return {}; }
}
return {};
},
/**
* Set up the WebSocket bridge for browser tool execution.
* Listens for tool.call.* events from the server, routes to
* the registered handler, and sends tool.result.* back.
*/
_setupToolBridge() {
Events.on('tool.call.*', async (payload, meta) => {
const { call_id, tool, arguments: args } = payload || {};
if (!call_id || !tool) return;
const registration = this._toolHandlers.get(tool);
if (!registration) {
// No handler registered — send error back
Events.emit(`tool.result.${call_id}`, {
call_id,
error: JSON.stringify({ error: `no handler for tool: ${tool}` }),
});
return;
}
try {
const parsedArgs = typeof args === 'string' ? JSON.parse(args) : args;
const result = await registration.handler(parsedArgs);
const resultStr = typeof result === 'string' ? result : JSON.stringify(result);
Events.emit(`tool.result.${call_id}`, {
call_id,
result: resultStr,
});
} catch (e) {
console.error(`[Extensions] Tool ${tool} error:`, e);
Events.emit(`tool.result.${call_id}`, {
call_id,
error: JSON.stringify({ error: e.message || 'tool execution failed' }),
});
}
});
},
/**
* Check if any renderers are registered for a given type.
*/
hasRenderers(type) {
return this._renderers.some(r => r.type === type);
},
/**
* Get info about all registered extensions (for debug/admin).
*/
debug() {
const exts = {};
for (const [id, entry] of this._registry) {
exts[id] = {
active: entry.active,
renderers: this._renderers.filter(r => r.extId === id).map(r => r.name),
};
}
return { extensions: exts, rendererCount: this._renderers.length };
},
};
// ── Exports ─────────────────────────────────
sb.ns('Extensions', Extensions);

View File

@@ -1,857 +0,0 @@
// ==========================================
// Chat Switchboard Files
// ==========================================
// File upload, smart paste, drag-and-drop, extraction
// polling, staged lifecycle, message rendering, lightbox,
// and admin storage panel.
//
// Depends on: API, App, UI (toast, getModelValue, renderChatList)
// Consumed by: chat.js (send flow), ui-core.js (message rendering),
// ui-admin.js (storage tab)
// ── Constants ───────────────────────────────
const FILE_POLL_INTERVAL = 2000; // ms between extraction status checks
const FILE_MAX_PER_MSG = 5; // mirrors backend defaultMaxFilesPerMsg
// MIME categories for capability-aware filtering
const IMAGE_TYPES = new Set([
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
]);
// ── State ───────────────────────────────────
// Staged files waiting to be sent with the next message.
// Each entry:
// {
// localId: string — crypto.randomUUID(), stable key for DOM
// serverId: string? — file UUID from backend (null while uploading)
// filename: string
// contentType: string
// sizeBytes: number
// previewUrl: string? — object URL for image preview (revoked on remove)
// status: 'uploading' | 'queued' | 'processing' | 'complete' | 'failed' | 'error'
// uploadProgress: number — 0100 (only meaningful during 'uploading')
// extractionStatus: string? — mirrors backend metadata.extraction_status
// queuePosition: number? — extraction queue position (0 = processing)
// error: string? — error message if status is 'error' or 'failed'
// }
// App.stagedFiles is initialized in app.js.
// Polling timers are tracked here for cleanup.
const _pollTimers = new Map(); // localId → intervalId
// ── Upload ──────────────────────────────────
/**
* Stage a file for upload. Creates the staged entry, uploads to backend,
* and starts extraction polling.
*
* If no current chat exists, creates one first (same pattern as sendMessage).
*
* @param {File} file — File object from input, paste, or drop
* @returns {Promise<void>}
*/
async function stageFile(file) {
if (!App.storageConfigured) return;
// Enforce per-message limit
if (App.stagedFiles.length >= FILE_MAX_PER_MSG) {
UI.toast(`Maximum ${FILE_MAX_PER_MSG} files per message`, 'warning');
return;
}
// Build staged entry
const staged = {
localId: crypto.randomUUID(),
serverId: null,
filename: file.name,
contentType: file.type || 'application/octet-stream',
sizeBytes: file.size,
previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : null,
status: 'uploading',
uploadProgress: 0,
extractionStatus: null,
queuePosition: null,
error: null,
};
App.stagedFiles.push(staged);
_renderStrip();
_updateSendBlock();
// Ensure a channel exists (files are channel-scoped)
if (!App.activeId) {
try {
const title = file.name.slice(0, 50);
const selectedId = UI.getModelValue();
const modelInfo = App.findModel(selectedId);
const model = modelInfo?.baseModelId || selectedId;
const resp = await API.createChannel(title, model, App.settings.systemPrompt, 'direct');
const chat = {
id: resp.id, title: resp.title, type: 'direct',
model, messages: [], messageCount: 0, updatedAt: resp.updated_at,
};
App.chats.unshift(chat);
App.setActive(chat.id, 'direct');
UI.renderChatList();
} catch (e) {
staged.status = 'error';
staged.error = 'Failed to create chat: ' + e.message;
_renderStrip();
_updateSendBlock();
return;
}
}
// Upload
try {
const result = await API.uploadFile(App.activeId, file);
staged.serverId = result.id;
staged.status = _mapExtractionStatus(result.metadata?.extraction_status);
staged.extractionStatus = result.metadata?.extraction_status || null;
staged.queuePosition = result.metadata?.queue_position ?? null;
// Start polling if extraction is pending/processing
if (staged.status === 'queued' || staged.status === 'processing') {
_startPolling(staged);
}
} catch (e) {
staged.status = 'error';
staged.error = e.message;
console.error('File upload failed:', e);
}
_renderStrip();
_updateSendBlock();
}
/**
* Remove a staged file by localId.
* Revokes object URL, stops polling, and optionally deletes from backend.
*/
async function unstageFile(localId) {
const idx = App.stagedFiles.findIndex(a => a.localId === localId);
if (idx === -1) return;
const staged = App.stagedFiles[idx];
// Clean up preview blob
if (staged.previewUrl) {
URL.revokeObjectURL(staged.previewUrl);
}
// Stop polling
_stopPolling(localId);
// Delete from backend if it was uploaded
if (staged.serverId) {
try {
await API.deleteFile(staged.serverId);
} catch (e) {
console.warn('Failed to delete staged file:', e.message);
}
}
App.stagedFiles.splice(idx, 1);
_renderStrip();
_updateSendBlock();
}
/**
* Clear all staged files (chat switch, cancel, etc.)
* Does NOT delete from backend — orphan cleanup handles abandoned uploads.
*/
function clearStaged() {
for (const staged of App.stagedFiles) {
if (staged.previewUrl) URL.revokeObjectURL(staged.previewUrl);
_stopPolling(staged.localId);
}
App.stagedFiles = [];
_renderStrip();
_updateSendBlock();
}
// ── Send Integration ────────────────────────
/**
* Returns file IDs (server-side UUIDs) for the current staged set.
* Only includes successfully uploaded files.
*/
function getStagedFileIds() {
return App.stagedFiles
.filter(a => a.serverId)
.map(a => a.serverId);
}
/**
* Whether the send button should be blocked due to in-flight uploads.
* Per design: blocked ONLY while any file is actively uploading (bytes in flight).
* Queued/processing/failed extraction does NOT block send.
*/
function isSendBlocked() {
return App.stagedFiles.some(a => a.status === 'uploading');
}
/**
* Whether there are any staged files (for UI visibility).
*/
function hasStagedFiles() {
return App.stagedFiles.length > 0;
}
/**
* Post-send cleanup: detach staged list without deleting from backend
* (files are now linked to the sent message).
*/
function consumeStaged() {
for (const staged of App.stagedFiles) {
if (staged.previewUrl) URL.revokeObjectURL(staged.previewUrl);
_stopPolling(staged.localId);
}
App.stagedFiles = [];
_renderStrip();
_updateSendBlock();
}
// ── Extraction Polling ──────────────────────
function _startPolling(staged) {
if (_pollTimers.has(staged.localId)) return;
const timer = setInterval(async () => {
if (!staged.serverId) return;
try {
const data = await API.getFile(staged.serverId);
const meta = data.metadata || {};
staged.extractionStatus = meta.extraction_status || null;
staged.queuePosition = meta.queue_position ?? null;
staged.status = _mapExtractionStatus(meta.extraction_status);
if (meta.extraction_error) {
staged.error = meta.extraction_error;
}
_renderStrip();
// Stop polling on terminal state
if (staged.status === 'complete' || staged.status === 'failed') {
_stopPolling(staged.localId);
}
} catch (e) {
console.warn('Extraction poll failed for', staged.localId, e.message);
// Don't stop polling on transient errors — backend might be restarting
}
}, FILE_POLL_INTERVAL);
_pollTimers.set(staged.localId, timer);
}
function _stopPolling(localId) {
const timer = _pollTimers.get(localId);
if (timer) {
clearInterval(timer);
_pollTimers.delete(localId);
}
}
function _stopAllPolling() {
for (const [id, timer] of _pollTimers) {
clearInterval(timer);
}
_pollTimers.clear();
}
// ── Status Helpers ──────────────────────────
/**
* Map backend extraction_status to frontend staged status.
* Images have no extraction — they go straight to 'complete'.
*/
function _mapExtractionStatus(backendStatus) {
switch (backendStatus) {
case 'pending': return 'queued';
case 'processing': return 'processing';
case 'complete': return 'complete';
case 'failed': return 'failed';
case 'unavailable': return 'complete'; // no extraction needed (e.g., images)
default: return 'queued';
}
}
/**
* Human-readable status label for a staged file.
*/
function fileStatusLabel(staged) {
switch (staged.status) {
case 'uploading':
return staged.uploadProgress > 0 ? `Uploading ${staged.uploadProgress}%` : 'Uploading…';
case 'queued':
if (staged.queuePosition != null && staged.queuePosition > 0) {
return `Queued (${staged.queuePosition} ahead)`;
}
return 'Queued';
case 'processing': return 'Extracting…';
case 'complete': return '✓ Ready';
case 'failed': return '⚠ Extraction failed';
case 'error': return '✕ ' + (staged.error || 'Upload failed');
default: return '';
}
}
/**
* Whether the given content type is an image (for vision gating).
*/
function isImageFile(contentType) {
return IMAGE_TYPES.has(contentType);
}
/**
* Format file size for display.
*/
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
// ── Strip Rendering ─────────────────────────
function _renderStrip() {
const strip = document.getElementById('fileStrip');
if (!strip) return;
if (App.stagedFiles.length === 0) {
strip.style.display = 'none';
strip.innerHTML = '';
return;
}
strip.style.display = 'flex';
strip.innerHTML = App.stagedFiles.map(a => {
const icon = isImageFile(a.contentType) ? '🖼' : '📄';
const name = _truncFilename(a.filename, 20);
const size = formatFileSize(a.sizeBytes);
const label = fileStatusLabel(a);
const statusClass = a.status === 'error' || a.status === 'failed' ? 'att-error' :
a.status === 'complete' ? 'att-ready' : 'att-pending';
return `<div class="att-chip ${statusClass}" data-local-id="${a.localId}">
${a.previewUrl ? `<img class="att-thumb" src="${a.previewUrl}" alt="">` : `<span class="att-icon">${icon}</span>`}
<span class="att-info">
<span class="att-name" title="${esc(a.filename)}">${esc(name)}</span>
<span class="att-meta">${esc(size)}${label ? ' · ' + label : ''}</span>
</span>
<button class="att-remove" data-action="unstageFile" data-args='${JSON.stringify([a.localId])}'" title="Remove">✕</button>
</div>`;
}).join('');
}
function _truncFilename(name, max) {
if (name.length <= max) return name;
const ext = name.lastIndexOf('.');
if (ext > 0 && name.length - ext <= 6) {
// Preserve extension: "very-long-name...pdf"
const suffix = name.slice(ext);
return name.slice(0, max - suffix.length - 1) + '…' + suffix;
}
return name.slice(0, max - 1) + '…';
}
// ── Channel File Loading ──────────────
/**
* Fetch all files for a channel, build messageId → [att] map.
* Called after loading messages in selectChat / reloadActivePath.
* Non-blocking: failures just leave the map empty (files are optional UX).
*/
async function loadChannelFiles(channelId) {
App.channelFiles = {};
if (!channelId || !App.storageConfigured) return;
try {
const data = await API.listChannelFiles(channelId);
const list = data.files || data || [];
for (const att of list) {
if (!att.message_id) continue;
if (!App.channelFiles[att.message_id]) {
App.channelFiles[att.message_id] = [];
}
App.channelFiles[att.message_id].push(att);
}
} catch (e) {
console.debug('File load skipped:', e.message);
}
}
// ── Message File Rendering ────────────
/**
* Returns HTML string for files associated with a message.
* Called from ui-core.js _messageHTML(). Returns '' if none.
*
* Images use data-att-id; actual src is loaded post-render with auth
* via loadAuthImages(). Doc downloads use onclick handlers.
*/
function renderMessageFiles(msgId) {
if (!msgId) return '';
const atts = App.channelFiles[msgId];
if (!atts || atts.length === 0) return '';
// Check current model vision capability for hint
const selectedId = UI.getModelValue();
const modelInfo = App.findModel(selectedId);
const hasVision = !!modelInfo?.capabilities?.vision;
const items = atts.map(att => {
const isImage = isImageFile(att.content_type);
if (isImage) {
const dims = att.metadata?.thumb_dimensions || att.metadata?.dimensions;
const w = Math.min(dims?.width || 280, 320);
const visionHint = !hasVision
? '<span class="att-vision-hint" title="Current model does not support images">👁️‍🗨️</span>'
: '';
return `<div class="msg-att msg-att-image" data-action="openLightbox" data-args='${JSON.stringify([esc(att.id)])}'">
<img class="msg-att-img" data-att-id="${esc(att.id)}" alt="${esc(att.filename)}"
style="max-width:${w}px" loading="lazy">
${visionHint}
</div>`;
}
// Document chip
const icon = _docIcon(att.content_type);
const size = formatFileSize(att.size_bytes);
return `<a class="msg-att msg-att-doc" href="#" data-action="downloadFile" data-args='${JSON.stringify([esc(att.id), esc(att.filename)])}' data-prevent-default">
<span class="att-doc-icon">${icon}</span>
<span class="att-doc-info">
<span class="att-doc-name">${esc(att.filename)}</span>
<span class="att-doc-size">${size}</span>
</span>
<span class="att-doc-dl">⬇</span>
</a>`;
});
return `<div class="msg-files">${items.join('')}</div>`;
}
function _docIcon(contentType) {
if (contentType === 'application/pdf') return '📕';
if (contentType?.includes('spreadsheet') || contentType?.includes('excel')) return '📊';
if (contentType?.includes('presentation') || contentType?.includes('powerpoint')) return '📙';
if (contentType?.includes('word') || contentType?.includes('document')) return '📄';
if (contentType?.startsWith('text/')) return '📝';
return '📎';
}
// ── Image Lightbox ──────────────────────────
function openLightbox(fileId) {
const overlay = document.getElementById('lightbox');
const img = document.getElementById('lightboxImg');
if (!overlay || !img) return;
img.src = '';
img.alt = 'Loading…';
overlay.classList.add('active');
document.body.style.overflow = 'hidden';
// Fetch full-size image with auth
API.downloadFileBlob(fileId)
.then(blob => { img.src = URL.createObjectURL(blob); })
.catch(() => { img.alt = 'Failed to load image'; });
}
function closeLightbox() {
const overlay = document.getElementById('lightbox');
const img = document.getElementById('lightboxImg');
if (!overlay) return;
overlay.classList.remove('active');
document.body.style.overflow = '';
// Revoke previous blob URL to free memory
if (img?.src?.startsWith('blob:')) URL.revokeObjectURL(img.src);
if (img) { img.src = ''; img.alt = ''; }
}
// ── Auth Image Loading ──────────────────────
/**
* Post-render pass: finds all <img data-att-id="..."> in the given
* container and fetches them with auth headers, setting blob URLs.
* Called after renderMessages and reloadActivePath.
*/
function loadAuthImages(container) {
if (!container) container = document.getElementById('chatMessages');
if (!container) return;
const imgs = container.querySelectorAll('img[data-att-id]:not([data-loaded])');
for (const img of imgs) {
const attId = img.dataset.attId;
if (!attId) continue;
img.dataset.loaded = '1';
API.downloadFileBlob(attId)
.then(blob => { img.src = URL.createObjectURL(blob); })
.catch(() => {
img.alt = '⚠ Load failed';
img.classList.add('att-load-error');
});
}
}
// ── Document Download ───────────────────────
/**
* Download a file via auth-aware fetch.
* Creates a temporary <a> element to trigger the browser download dialog.
*/
async function downloadFile(fileId, filename) {
try {
const blob = await API.downloadFileBlob(fileId);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || 'download';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) {
UI.toast('Download failed: ' + e.message, 'error');
}
}
// ── Admin Storage Panel ─────────────────────
async function loadAdminStorage() {
const el = document.getElementById('adminStorageContent');
if (!el) return;
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const [status, orphans] = await Promise.all([
API.adminGetStorageStatus().catch(() => null),
API.adminGetOrphanCount().catch(() => null),
]);
let html = '';
// Status card
if (status) {
const healthClass = status.healthy ? 'badge-success' : 'badge-danger';
const healthLabel = status.healthy ? 'Healthy' : 'Unhealthy';
html += `<div class="admin-storage-card">
<h4>Storage Backend</h4>
<div class="admin-storage-grid">
<div class="admin-storage-item">
<span class="admin-storage-label">Backend</span>
<span class="admin-storage-value">${esc(status.backend || 'none')}</span>
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Status</span>
<span class="badge ${healthClass}">${healthLabel}</span>
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Total Files</span>
<span class="admin-storage-value">${status.total_files ?? '—'}</span>
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Total Size</span>
<span class="admin-storage-value">${status.total_bytes ? formatFileSize(status.total_bytes) : '—'}</span>
</div>
${status.path ? `<div class="admin-storage-item">
<span class="admin-storage-label">Path</span>
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${esc(status.path)}</span>
</div>` : ''}
${status.endpoint ? `<div class="admin-storage-item">
<span class="admin-storage-label">Endpoint</span>
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${esc(status.endpoint)}</span>
</div>` : ''}
${status.bucket ? `<div class="admin-storage-item">
<span class="admin-storage-label">Bucket</span>
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${esc(status.bucket)}</span>
</div>` : ''}
</div>
</div>`;
} else {
html += `<div class="admin-storage-card">
<h4>Storage Backend</h4>
<p class="empty-hint">Storage not configured. Set STORAGE_BACKEND=pvc or STORAGE_BACKEND=s3 to enable file uploads.</p>
</div>`;
}
// Orphan cleanup card
if (orphans) {
const count = orphans.count ?? 0;
const bytes = orphans.total_bytes ?? 0;
html += `<div class="admin-storage-card">
<h4>Orphan Files</h4>
<p class="admin-storage-desc">Files uploaded but never linked to a message for &gt;24 hours.</p>
<div class="admin-storage-grid">
<div class="admin-storage-item">
<span class="admin-storage-label">Orphaned</span>
<span class="admin-storage-value">${count} file${count !== 1 ? 's' : ''}</span>
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Reclaimable</span>
<span class="admin-storage-value">${formatFileSize(bytes)}</span>
</div>
</div>
${count > 0 ? `<button class="btn-small" id="adminRunCleanup" style="margin-top:8px">Run Cleanup Now</button>` : ''}
</div>`;
}
el.innerHTML = html;
// Wire cleanup button
document.getElementById('adminRunCleanup')?.addEventListener('click', async (e) => {
const btn = e.target;
btn.disabled = true;
btn.textContent = 'Cleaning…';
try {
const result = await API.adminRunStorageCleanup();
UI.toast(`Cleaned ${result.deleted || 0} orphan${result.deleted !== 1 ? 's' : ''}, freed ${formatFileSize(result.freed_bytes || 0)}`, 'success');
await loadAdminStorage(); // refresh counts
} catch (err) {
UI.toast('Cleanup failed: ' + err.message, 'error');
btn.disabled = false;
btn.textContent = 'Run Cleanup Now';
}
});
} catch (e) {
el.innerHTML = `<div class="empty-hint">Failed to load storage status: ${esc(e.message)}</div>`;
}
}
// ── Constants (Paste) ───────────────────────
// Threshold for auto-attaching pasted text as a file.
// Synced from backend via PublicSettings (storage.paste_to_file_chars).
// Falls back to 2000 if not yet loaded.
function _pasteToFileChars() {
return App.pasteToFileChars || 2000;
}
// Accept string for file picker — mirrors backend allowedMIMETypes.
// Backend is authoritative (rejects disallowed types server-side),
// this just gives the OS file dialog a better filter.
const FILE_ACCEPT = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
'application/pdf',
'text/plain', 'text/markdown', 'text/csv',
'.docx', '.xlsx', '.pptx', '.doc', '.xls', '.odt', '.ods', '.odp', '.rtf',
].join(',');
// ── MIME → Extension ────────────────────────
const _mimeToExt = {
'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif',
'image/webp': 'webp', 'image/svg+xml': 'svg',
'application/pdf': 'pdf', 'text/plain': 'txt', 'text/markdown': 'md',
'text/csv': 'csv', 'text/html': 'html',
};
function _extFromMime(mime) {
return _mimeToExt[mime] || mime.split('/').pop() || 'bin';
}
// ── Send Button Blocking ────────────────────
function _updateSendBlock() {
const sendBtn = document.getElementById('sendBtn');
if (!sendBtn) return;
// Merge with existing generation check — don't override if generating
if (App.isGenerating) return;
if (isSendBlocked()) {
sendBtn.disabled = true;
sendBtn.title = 'Upload in progress…';
} else {
sendBtn.disabled = false;
sendBtn.title = 'Send';
}
}
// ── Init + Listeners ────────────────────────
/**
* Called from app.js startApp(). Reads storage_configured from boot
* payload and sets up file UI visibility.
*/
function initFiles() {
const attachBtn = document.getElementById('attachBtn');
const fileInput = document.getElementById('fileInput');
if (attachBtn) {
attachBtn.style.display = App.storageConfigured ? '' : 'none';
}
if (fileInput) {
fileInput.accept = FILE_ACCEPT;
}
console.log(`📎 Files: storage ${App.storageConfigured ? 'configured' : 'not configured'}`);
}
/**
* Called from app.js initListeners(). Wires up all file input
* handlers: button, file picker, paste, and drag-and-drop.
*/
function _initFileListeners() {
const attachBtn = document.getElementById('attachBtn');
const fileInput = document.getElementById('fileInput');
const chatArea = document.getElementById('chatMessages');
const lightbox = document.getElementById('lightbox');
// ── 📎 Button → File Picker ─────────────
if (attachBtn && fileInput) {
attachBtn.addEventListener('click', () => {
if (!App.storageConfigured) return;
fileInput.click();
});
fileInput.addEventListener('change', () => {
const files = fileInput.files;
if (!files?.length) return;
for (const file of files) stageFile(file);
fileInput.value = ''; // reset so same file can be re-selected
});
}
// ── Smart Paste ─────────────────────────
// Attach to the active input element (CM6 contentDOM or textarea)
const pasteTarget = (typeof ChatInput !== 'undefined' && ChatInput.getDom())
? ChatInput.getDom()
: document.getElementById('messageInput');
if (pasteTarget) {
pasteTarget.addEventListener('paste', (e) => {
if (!App.storageConfigured) return; // fall through to normal paste
const items = [...(e.clipboardData?.items || [])];
// Binary detection: images/files from clipboard
const binaryItems = items.filter(i => i.kind === 'file');
if (binaryItems.length > 0) {
e.preventDefault();
for (const item of binaryItems) {
const file = item.getAsFile();
if (!file) continue;
// Generate UUID filename — original clipboard items have no useful name
const ext = _extFromMime(file.type);
const renamed = new File([file], `${crypto.randomUUID()}.${ext}`, { type: file.type });
stageFile(renamed);
}
return;
}
// Large text detection — auto-attach as .txt file
const _ptfChars = _pasteToFileChars();
if (_ptfChars > 0) {
const text = e.clipboardData?.getData('text/plain');
if (text && text.length > _ptfChars) {
e.preventDefault();
const blob = new Blob([text], { type: 'text/plain' });
const file = new File([blob], `${crypto.randomUUID()}.txt`, { type: 'text/plain' });
stageFile(file);
return;
}
}
// Below threshold or no text: normal paste (no preventDefault)
});
}
// ── Drag-and-Drop ───────────────────────
if (chatArea) {
// Track drag enter/leave depth to avoid flicker from child elements
let dragDepth = 0;
chatArea.addEventListener('dragenter', (e) => {
if (!App.storageConfigured) return;
if (!_hasDragFiles(e)) return;
e.preventDefault();
dragDepth++;
if (dragDepth === 1) chatArea.classList.add('drag-over');
});
chatArea.addEventListener('dragover', (e) => {
if (!App.storageConfigured) return;
if (!_hasDragFiles(e)) return;
e.preventDefault(); // required to allow drop
e.dataTransfer.dropEffect = 'copy';
});
chatArea.addEventListener('dragleave', (e) => {
if (!App.storageConfigured) return;
dragDepth--;
if (dragDepth <= 0) {
dragDepth = 0;
chatArea.classList.remove('drag-over');
}
});
chatArea.addEventListener('drop', (e) => {
if (!App.storageConfigured) return;
e.preventDefault();
dragDepth = 0;
chatArea.classList.remove('drag-over');
const files = e.dataTransfer?.files;
if (!files?.length) return;
for (const file of files) stageFile(file);
});
}
// ── Lightbox ────────────────────────────
if (lightbox) {
// Click overlay (not the image) to close
lightbox.addEventListener('click', (e) => {
if (e.target === lightbox || e.target.classList.contains('lightbox-close')) {
closeLightbox();
}
});
}
}
/**
* Check if a drag event contains files (not just text selection drag).
*/
function _hasDragFiles(e) {
if (!e.dataTransfer?.types) return false;
return e.dataTransfer.types.includes('Files');
}
// ── Exports ─────────────────────────────────
sb.register('_initFileListeners', _initFileListeners);
sb.register('_pollTimers', _pollTimers);
sb.register('_startPolling', _startPolling);
sb.register('_stopPolling', _stopPolling);
sb.register('clearStaged', clearStaged);
sb.register('closeLightbox', closeLightbox);
sb.register('consumeStaged', consumeStaged);
sb.register('getStagedFileIds', getStagedFileIds);
sb.register('hasStagedFiles', hasStagedFiles);
sb.register('initFiles', initFiles);
sb.register('isSendBlocked', isSendBlocked);
sb.register('loadAdminStorage', loadAdminStorage);
sb.register('loadAuthImages', loadAuthImages);
sb.register('loadChannelFiles', loadChannelFiles);
sb.register('renderMessageFiles', renderMessageFiles);
sb.register('unstageFile', unstageFile);
sb.register('openLightbox', openLightbox);
sb.register('downloadFile', downloadFile);

View File

@@ -1,556 +0,0 @@
// ── knowledge-ui.js ─────────────────────────
// Knowledge base management UI.
//
// Three contexts:
// 1. User Settings → personal KBs (scope: personal)
// 2. Admin Panel → global KBs (scope: global)
// 3. Team Admin → team-scoped KBs (scope: team)
//
// Plus the channel KB toggle popup in the chat input bar.
//
// Depends on: API (api.js), App (app.js)
const KnowledgeUI = (() => {
console.debug('[KnowledgeUI] module loaded');
let _allKBs = []; // [{id, name, scope, document_count, ...}]
let _channelKBs = []; // [{kb_id, kb_name, enabled, document_count}]
let _pollTimers = {}; // docId → timer
let _currentKBId = null; // for document panel
let _panelCtx = null; // { container, scope, teamId } — current panel context
// ═════════════════════════════════════════
// Channel KB Toggle Popup (chat input bar)
// ═════════════════════════════════════════
async function openChannelPopup() {
const popup = document.getElementById('kbPopup');
if (!popup) return;
if (!App.activeId) {
popup.innerHTML = '<div class="empty-hint">Start a conversation first</div>';
popup.classList.add('open');
return;
}
popup.innerHTML = '<div class="loading">Loading…</div>';
popup.classList.add('open');
try {
const [allResp, chResp] = await Promise.all([
API.listKnowledgeBases(),
API.getChannelKBs(App.activeId),
]);
_allKBs = allResp.data || [];
_channelKBs = chResp.data || [];
_renderChannelPopup(popup);
} catch (e) {
popup.innerHTML = '<div class="empty-hint">Failed to load knowledge bases</div>';
console.warn('KB popup load failed:', e);
}
}
function _renderChannelPopup(popup) {
if (_allKBs.length === 0) {
popup.innerHTML = `
<div class="empty-hint">
No knowledge bases yet.
<br><small>Create one in Settings → Knowledge Bases</small>
</div>`;
return;
}
const linkedSet = new Set(_channelKBs.map(kb => kb.kb_id));
let html = '<div class="kb-popup-header">Knowledge Bases</div>';
for (const kb of _allKBs) {
const linked = linkedSet.has(kb.id);
const scope = kb.scope === 'personal' ? '' : ` · ${kb.scope}`;
const docs = kb.document_count === 1 ? '1 doc' : `${kb.document_count} docs`;
const status = kb.chunk_count === 0 ? ' · no content' : '';
html += `
<div class="kb-popup-item ${linked ? 'enabled' : ''}" data-kb-id="${kb.id}">
<span class="kb-popup-check"></span>
<span class="kb-popup-label">
<span class="kb-popup-name">${esc(kb.name)}</span>
<span class="kb-popup-meta">${docs}${scope}${status}</span>
</span>
</div>`;
}
popup.innerHTML = html;
}
async function _toggleChannelKB(kbId) {
if (!App.activeId) return;
const linkedSet = new Set(_channelKBs.map(kb => kb.kb_id));
if (linkedSet.has(kbId)) {
linkedSet.delete(kbId);
} else {
linkedSet.add(kbId);
}
try {
const resp = await API.setChannelKBs(App.activeId, [...linkedSet]);
_channelKBs = resp.data || [];
const popup = document.getElementById('kbPopup');
if (popup) _renderChannelPopup(popup);
_updateKBBtnState();
} catch (e) {
console.warn('Failed to update channel KBs:', e);
}
}
function _updateKBBtnState() {
const btn = document.getElementById('kbToggleBtn');
if (!btn) return;
const count = _channelKBs.filter(kb => kb.enabled !== false).length;
btn.classList.toggle('has-active', count > 0);
btn.title = count > 0 ? `Knowledge Bases (${count} active)` : 'Knowledge Bases';
}
// ═════════════════════════════════════════
// Shared KB Management Panel
// ═════════════════════════════════════════
// Rendered into any container with { scope, teamId } context.
/**
* Open KB management panel.
* @param {string} containerId — DOM id of the container to render into
* @param {string} scope — 'personal', 'global', or 'team'
* @param {string} [teamId] — required when scope='team'
*/
async function openPanel(containerId, scope, teamId) {
console.debug(`[KB] openPanel: container=${containerId}, scope=${scope}, teamId=${teamId}`);
const panel = document.getElementById(containerId);
if (!panel) {
console.warn(`[KB] openPanel: container '${containerId}' not found`);
return;
}
_panelCtx = { container: containerId, scope, teamId };
panel.innerHTML = '<div class="loading">Loading…</div>';
panel.style.display = '';
try {
const resp = await API.listKnowledgeBases();
// Filter by scope
const all = resp.data || [];
_allKBs = all.filter(kb => {
if (scope === 'personal') return kb.scope === 'personal';
if (scope === 'global') return kb.scope === 'global';
if (scope === 'team') return kb.scope === 'team' && kb.team_id === teamId;
return true;
});
_renderManagePanel(panel);
} catch (e) {
console.warn('[KB] openPanel error:', e);
panel.innerHTML = '<div class="empty-hint">Failed to load knowledge bases</div>';
}
}
// Convenience wrappers
function openManagePanel() { openPanel('kbManagePanel', 'personal'); }
function openAdminPanel() { openPanel('adminKBContent', 'global'); }
function openTeamPanel(teamId) { openPanel('teamKBContent', 'team', teamId); }
function _renderManagePanel(panel) {
const ctx = _panelCtx || { scope: 'personal' };
const scopeLabel = ctx.scope === 'global' ? 'Global' : ctx.scope === 'team' ? 'Team' : 'Personal';
let html = `
<div class="kb-manage-header">
<h3>${scopeLabel} Knowledge Bases</h3>
<button class="btn-small btn-primary" id="kbCreateBtn">+ New</button>
</div>`;
if (_allKBs.length === 0) {
html += '<div class="empty-hint">No knowledge bases yet. Create one to get started.</div>';
} else {
// Summary stats
const totalDocs = _allKBs.reduce((s, kb) => s + (kb.document_count || 0), 0);
const totalChunks = _allKBs.reduce((s, kb) => s + (kb.chunk_count || 0), 0);
const totalBytes = _allKBs.reduce((s, kb) => s + (kb.total_bytes || 0), 0);
html += `<div class="kb-manage-summary">${_allKBs.length} KB${_allKBs.length !== 1 ? 's' : ''} · ${totalDocs} documents · ${totalChunks} chunks · ${_formatSize(totalBytes)}</div>`;
html += '<div class="kb-manage-list">';
for (const kb of _allKBs) {
const scope = kb.scope === 'personal' ? '🔒' : kb.scope === 'team' ? '👥' : '🌐';
const status = kb.status === 'processing' ? ' · ⏳ processing' : '';
const docs = kb.document_count === 1 ? '1 document' : `${kb.document_count} documents`;
const chunks = kb.chunk_count === 1 ? '1 chunk' : `${kb.chunk_count} chunks`;
const size = kb.total_bytes > 0 ? ` · ${_formatSize(kb.total_bytes)}` : '';
html += `
<div class="kb-manage-item" data-kb-id="${kb.id}" data-grant-kb="${kb.id}">
<div class="kb-manage-info">
<span class="kb-manage-name">${scope} ${esc(kb.name)}</span>
<span class="kb-manage-desc">${esc(kb.description || '')}</span>
<span class="kb-manage-stats">${docs} · ${chunks}${size}${status}</span>
</div>
<div class="kb-manage-actions">
${kb.scope !== 'personal' ? `<button class="btn-small" data-kb-grant="${kb.id}" data-kb-name="${esc(kb.name)}" title="Access">🔒</button>` : ''}
<button class="btn-small" data-kb-docs="${kb.id}" title="Documents">📄</button>
<button class="btn-small btn-danger" data-kb-del="${kb.id}" title="Delete">✕</button>
</div>
</div>`;
}
html += '</div>';
}
panel.innerHTML = html;
// Wire create button
document.getElementById('kbCreateBtn')?.addEventListener('click', () => {
_showCreateDialog(ctx.scope, ctx.teamId);
});
// Wire item actions
panel.querySelectorAll('[data-kb-docs]').forEach(btn => {
btn.addEventListener('click', () => _openDocumentsPanel(btn.dataset.kbDocs));
});
panel.querySelectorAll('[data-kb-del]').forEach(btn => {
btn.addEventListener('click', () => _deleteKB(btn.dataset.kbDel));
});
// Wire grant access buttons (admin only, non-personal KBs)
panel.querySelectorAll('[data-kb-grant]').forEach(btn => {
btn.addEventListener('click', () => {
if (typeof UI !== 'undefined' && UI.openGrantPicker) {
UI.openGrantPicker('knowledge_base', btn.dataset.kbGrant, btn.dataset.kbName, `[data-grant-kb="${btn.dataset.kbGrant}"]`);
}
});
});
}
// ── Create KB Dialog ─────────────────────
function _showCreateDialog(scope, teamId) {
const existing = document.getElementById('kbCreateDialog');
if (existing) existing.remove();
const scopeLabel = scope === 'global' ? 'Global' : scope === 'team' ? 'Team' : 'Personal';
const dialog = document.createElement('div');
dialog.id = 'kbCreateDialog';
dialog.className = 'modal-overlay active';
dialog.innerHTML = `
<div class="modal" style="max-width: 420px">
<div class="modal-header">
<h2>New ${scopeLabel} Knowledge Base</h2>
<button class="modal-close" id="kbCreateClose">✕</button>
</div>
<div class="modal-body">
<div style="margin-bottom:12px">
<label style="font-size:12px;font-weight:600;color:var(--text-2);display:block;margin-bottom:4px">Name</label>
<input type="text" id="kbCreateName" placeholder="e.g. Project Docs" maxlength="200"
style="width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-size:13px">
</div>
<div>
<label style="font-size:12px;font-weight:600;color:var(--text-2);display:block;margin-bottom:4px">Description (optional)</label>
<textarea id="kbCreateDesc" rows="2" maxlength="2000" placeholder="What documents will this contain?"
style="width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-size:13px;resize:vertical"></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn-small" id="kbCreateCancel">Cancel</button>
<button class="btn-small btn-primary" id="kbCreateSave">Create</button>
</div>
</div>`;
document.body.appendChild(dialog);
const close = () => dialog.remove();
document.getElementById('kbCreateClose').addEventListener('click', close);
document.getElementById('kbCreateCancel').addEventListener('click', close);
document.getElementById('kbCreateSave').addEventListener('click', async () => {
const name = document.getElementById('kbCreateName').value.trim();
if (!name) {
document.getElementById('kbCreateName').focus();
return;
}
const desc = document.getElementById('kbCreateDesc').value.trim();
try {
await API.createKnowledgeBase(name, desc, scope, teamId);
close();
_refreshCurrentPanel();
} catch (e) {
UI.toast('Failed to create: ' + (e.message || e), 'error');
}
});
// Enter key submits
document.getElementById('kbCreateName').addEventListener('keydown', (e) => {
if (e.key === 'Enter') document.getElementById('kbCreateSave').click();
});
dialog.addEventListener('click', (e) => {
if (e.target === dialog) close();
});
// Focus name field after render
requestAnimationFrame(() => document.getElementById('kbCreateName')?.focus());
}
async function _deleteKB(kbId) {
const kb = _allKBs.find(k => k.id === kbId);
if (!await showConfirm(`Delete "${kb?.name || kbId}" and all its documents?`)) return;
try {
await API.deleteKnowledgeBase(kbId);
_refreshCurrentPanel();
} catch (e) {
UI.toast('Delete failed: ' + (e.message || e), 'error');
}
}
/** Refresh whatever panel is currently active. */
function _refreshCurrentPanel() {
if (!_panelCtx) return;
openPanel(_panelCtx.container, _panelCtx.scope, _panelCtx.teamId);
}
// ═════════════════════════════════════════
// Documents Panel
// ═════════════════════════════════════════
async function _openDocumentsPanel(kbId) {
// Use the same alias for backward compat
return openDocumentsPanel(kbId);
}
async function openDocumentsPanel(kbId) {
_currentKBId = kbId;
const containerId = _panelCtx?.container || 'kbManagePanel';
const panel = document.getElementById(containerId);
if (!panel) return;
panel.innerHTML = '<div class="loading">Loading documents…</div>';
try {
const [kbResp, docsResp] = await Promise.all([
API.getKnowledgeBase(kbId),
API.listKBDocuments(kbId),
]);
const kb = kbResp;
const docs = docsResp.data || docsResp || [];
_renderDocumentsPanel(panel, kb, Array.isArray(docs) ? docs : []);
} catch (e) {
panel.innerHTML = `<div class="empty-hint">Failed to load documents: ${esc(e.message || '')}</div>`;
}
}
function _renderDocumentsPanel(panel, kb, docs) {
let html = `
<div class="kb-manage-header">
<button class="btn-small" id="kbDocsBack">← Back</button>
<h3>${esc(kb.name)}</h3>
<label class="btn-small btn-primary" id="kbUploadLabel" title="Upload document">
+ Upload
<input type="file" id="kbFileInput" accept=".txt,.md,.csv,.html,.htm" style="display:none" multiple>
</label>
</div>`;
if (kb.description) {
html += `<div class="kb-manage-desc-block">${esc(kb.description)}</div>`;
}
// Stats line
const totalBytes = docs.reduce((s, d) => s + (d.size_bytes || 0), 0);
const totalChunks = docs.reduce((s, d) => s + (d.chunk_count || 0), 0);
if (docs.length > 0) {
html += `<div class="kb-manage-summary">${docs.length} file${docs.length !== 1 ? 's' : ''} · ${totalChunks} chunks · ${_formatSize(totalBytes)}</div>`;
}
if (docs.length === 0) {
html += '<div class="empty-hint">No documents yet. Upload text, markdown, CSV, or HTML files.</div>';
} else {
html += '<div class="kb-manage-list">';
for (const doc of docs) {
const statusIcon = _docStatusIcon(doc.status);
const size = _formatSize(doc.size_bytes);
const chunks = doc.chunk_count > 0 ? ` · ${doc.chunk_count} chunks` : '';
const errText = doc.error ? ` · ⚠ ${esc(doc.error)}` : '';
html += `
<div class="kb-manage-item kb-doc-item" data-doc-id="${doc.id}" data-doc-status="${doc.status}">
<div class="kb-manage-info">
<span class="kb-manage-name">${statusIcon} ${esc(doc.filename)}</span>
<span class="kb-manage-stats">${size}${chunks}${errText}</span>
</div>
<div class="kb-manage-actions">
<button class="btn-small btn-danger" data-doc-del="${doc.id}" title="Delete">✕</button>
</div>
</div>`;
}
html += '</div>';
}
panel.innerHTML = html;
// Wire back button
document.getElementById('kbDocsBack')?.addEventListener('click', () => _refreshCurrentPanel());
// Wire upload
document.getElementById('kbFileInput')?.addEventListener('change', async (e) => {
const files = e.target.files;
if (!files.length) return;
for (const file of files) {
await _uploadDocument(kb.id, file);
}
e.target.value = '';
openDocumentsPanel(kb.id);
});
// Wire delete buttons
panel.querySelectorAll('[data-doc-del]').forEach(btn => {
btn.addEventListener('click', () => _deleteDocument(kb.id, btn.dataset.docDel));
});
// Start polling for in-progress documents
for (const doc of docs) {
if (['pending', 'extracting', 'chunking', 'embedding'].includes(doc.status)) {
_startPolling(kb.id, doc.id);
}
}
}
async function _uploadDocument(kbId, file) {
try {
const resp = await API.uploadKBDocument(kbId, file);
console.log(`📄 Uploaded ${file.name}, status: ${resp.status}`);
if (resp.id) _startPolling(kbId, resp.id);
} catch (e) {
UI.toast(`Upload failed for ${file.name}: ${e.message || e}`, 'error');
}
}
async function _deleteDocument(kbId, docId) {
if (!await showConfirm('Delete this document and all its chunks?')) return;
try {
_stopPolling(docId);
await API.deleteKBDocument(kbId, docId);
openDocumentsPanel(kbId);
} catch (e) {
UI.toast('Delete failed: ' + (e.message || e), 'error');
}
}
// ═════════════════════════════════════════
// Status Polling
// ═════════════════════════════════════════
function _startPolling(kbId, docId) {
if (_pollTimers[docId]) return;
_pollTimers[docId] = setInterval(async () => {
try {
const status = await API.getKBDocumentStatus(kbId, docId);
_updateDocStatus(docId, status);
if (status.status === 'ready' || status.status === 'error') {
_stopPolling(docId);
if (_currentKBId === kbId) openDocumentsPanel(kbId);
}
} catch {
_stopPolling(docId);
}
}, 2000);
}
function _stopPolling(docId) {
if (_pollTimers[docId]) {
clearInterval(_pollTimers[docId]);
delete _pollTimers[docId];
}
}
function _updateDocStatus(docId, status) {
const item = document.querySelector(`[data-doc-id="${docId}"]`);
if (!item) return;
item.dataset.docStatus = status.status;
const nameEl = item.querySelector('.kb-manage-name');
if (nameEl) {
const icon = _docStatusIcon(status.status);
const filename = nameEl.textContent.replace(/^[^\s]+ /, '');
nameEl.innerHTML = `${icon} ${esc(status.filename || filename)}`;
}
const statsEl = item.querySelector('.kb-manage-stats');
if (statsEl && status.chunk_count > 0) {
statsEl.textContent = `${status.chunk_count} chunks`;
}
}
// ═════════════════════════════════════════
// Event Wiring
// ═════════════════════════════════════════
function init() {
// KB toggle button in chat input bar
const btn = document.getElementById('kbToggleBtn');
const popup = document.getElementById('kbPopup');
if (btn && popup) {
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (popup.classList.contains('open')) {
popup.classList.remove('open');
} else {
openChannelPopup();
}
});
popup.addEventListener('click', (e) => {
e.stopPropagation();
const item = e.target.closest('.kb-popup-item');
if (item) _toggleChannelKB(item.dataset.kbId);
});
document.addEventListener('click', () => popup.classList.remove('open'));
}
}
/** Refresh channel KB state when switching chats. */
async function onChatChanged() {
_channelKBs = [];
if (App.activeId) {
try {
const resp = await API.getChannelKBs(App.activeId);
_channelKBs = resp.data || [];
} catch { /* silent */ }
}
_updateKBBtnState();
}
// ═════════════════════════════════════════
// Helpers
// ═════════════════════════════════════════
function _docStatusIcon(status) {
switch (status) {
case 'ready': return '✅';
case 'error': return '❌';
case 'pending': return '⏳';
case 'extracting': return '📖';
case 'chunking': return '✂️';
case 'embedding': return '🧮';
default: return '📄';
}
}
function _formatSize(bytes) {
if (!bytes || bytes === 0) return '0 B';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
// ═════════════════════════════════════════
// Public API
// ═════════════════════════════════════════
return {
init,
onChatChanged,
openManagePanel, // User Settings → personal
openAdminPanel, // Admin Panel → global
openTeamPanel, // Team Admin → team-scoped
openDocumentsPanel,
openChannelPopup,
};
})();
sb.ns('KnowledgeUI', KnowledgeUI);

View File

@@ -1,431 +0,0 @@
// ==========================================
// Chat Switchboard Notifications (v0.20.0)
// ==========================================
// In-app notification bell with dropdown and
// side panel. Real-time push via WebSocket.
//
// Dependencies: API, Events, PanelRegistry, UI
// ==========================================
//
// Exports: window.Notifications
const Notifications = {
_items: [], // cached notifications (latest first)
_unreadCount: 0,
_loaded: false, // full list fetched at least once
_dropdownOpen: false,
// ── Init ─────────────────────────────────
async init() {
this._bindEvents();
this._registerPanel();
await this._fetchUnreadCount();
},
// ── Data ─────────────────────────────────
async _fetchUnreadCount() {
try {
const data = await API._get('/api/v1/notifications/unread-count');
this._unreadCount = data.count || 0;
this._renderBadge();
} catch (e) {
// Silently fail — badge just stays at 0
}
},
async _fetchList(opts = {}) {
const limit = opts.limit || 20;
const offset = opts.offset || 0;
const unreadOnly = opts.unreadOnly || false;
try {
const data = await API._get(
`/api/v1/notifications?limit=${limit}&offset=${offset}&unread_only=${unreadOnly}`);
return data;
} catch (e) {
return { data: [], total: 0 };
}
},
async _loadLatest() {
const result = await this._fetchList({ limit: 10 });
this._items = result.data || [];
this._loaded = true;
return result;
},
// ── Badge ────────────────────────────────
_renderBadge() {
const badge = document.getElementById('notifBadge');
if (!badge) return;
if (this._unreadCount > 0) {
badge.textContent = this._unreadCount > 9 ? '9+' : String(this._unreadCount);
badge.style.display = '';
} else {
badge.style.display = 'none';
}
},
// ── Dropdown ─────────────────────────────
async toggleDropdown() {
if (this._dropdownOpen) {
this._closeDropdown();
return;
}
// Lazy-load on first open
if (!this._loaded) {
await this._loadLatest();
}
const dd = document.getElementById('notifDropdown');
if (!dd) return;
dd.innerHTML = this._renderDropdownContent();
dd.style.display = 'block';
this._dropdownOpen = true;
// Click-outside handler
setTimeout(() => {
document.addEventListener('click', this._outsideClickHandler);
}, 0);
},
_closeDropdown() {
const dd = document.getElementById('notifDropdown');
if (dd) dd.style.display = 'none';
this._dropdownOpen = false;
document.removeEventListener('click', this._outsideClickHandler);
},
_outsideClickHandler(e) {
const wrap = document.getElementById('notifWrap');
if (wrap && !wrap.contains(e.target)) {
Notifications._closeDropdown();
}
},
_renderDropdownContent() {
const items = this._items;
if (!items.length) {
return `<div class="notif-empty">No notifications</div>`;
}
let html = `<div class="notif-dd-header">
<span>Notifications</span>
<button class="notif-mark-all" data-action="Notifications.markAllRead">Mark all ✓</button>
</div><div class="notif-dd-list">`;
for (const n of items.slice(0, 10)) {
const unread = !n.is_read;
const dot = unread ? '●' : '○';
const cls = unread ? 'notif-item notif-unread' : 'notif-item';
const ago = _timeAgo(n.created_at);
html += `<div class="${cls}" data-id="${n.id}" data-resource-type="${n.resource_type || ''}" data-resource-id="${n.resource_id || ''}" data-action="Notifications._onItemClick" data-pass-el>
<span class="notif-dot">${dot}</span>
<div class="notif-body">
<div class="notif-title">${esc(n.title)}</div>
${n.body ? `<div class="notif-detail">${esc(n.body)}</div>` : ''}
<div class="notif-time">${ago}</div>
</div>
</div>`;
}
html += `</div>`;
html += `<div class="notif-dd-footer">
<button class="notif-view-all" data-action="Notifications.openPanel">View all →</button>
</div>`;
return html;
},
// ── Item Actions ─────────────────────────
async _onItemClick(el) {
const id = el.dataset.id;
const resType = el.dataset.resourceType;
const resId = el.dataset.resourceId;
// Mark read
if (el.classList.contains('notif-unread')) {
await this._markRead(id);
el.classList.remove('notif-unread');
el.querySelector('.notif-dot').textContent = '○';
}
// Navigate
this._navigate(resType, resId);
this._closeDropdown();
},
_navigate(resourceType, resourceId) {
if (!resourceType || !resourceId) return;
switch (resourceType) {
case 'channel':
if (typeof selectChat === 'function') selectChat(resourceId);
break;
case 'knowledge_base':
// Open admin panel → KB section (if admin)
if (API.isAdmin && typeof showAdmin === 'function') {
showAdmin('ai', 'knowledge-bases');
}
break;
case 'project':
// Open project detail panel
if (typeof openProjectPanel === 'function') openProjectPanel(resourceId);
break;
case 'group':
if (API.isAdmin && typeof showAdmin === 'function') {
showAdmin('people', 'groups');
}
break;
}
},
async _markRead(id) {
try {
await API._authed(`/api/v1/notifications/${id}/read`, 'PATCH');
this._unreadCount = Math.max(0, this._unreadCount - 1);
const item = this._items.find(n => n.id === id);
if (item) item.is_read = true;
this._renderBadge();
} catch (e) { /* best effort */ }
},
async markAllRead() {
try {
await API._post('/api/v1/notifications/mark-all-read');
this._unreadCount = 0;
this._items.forEach(n => n.is_read = true);
this._renderBadge();
// Re-render dropdown if open
if (this._dropdownOpen) {
const dd = document.getElementById('notifDropdown');
if (dd) dd.innerHTML = this._renderDropdownContent();
}
// Re-render panel if open
this._renderPanelContent();
} catch (e) { /* best effort */ }
},
// ── Side Panel ───────────────────────────
_registerPanel() {
const body = document.getElementById('sidePanelBody');
if (!body || typeof PanelRegistry === 'undefined') return;
const el = document.createElement('div');
el.className = 'side-panel-page';
el.id = 'sidePanelNotifications';
el.style.display = 'none';
el.innerHTML = `<div class="notif-panel" id="notifPanelInner">
<div class="notif-panel-toolbar">
<button class="btn-small" data-action="Notifications.markAllRead">Mark all read</button>
<button class="btn-small btn-danger" data-action="Notifications.clearAll">Clear all</button>
</div>
<div class="notif-panel-list" id="notifPanelList"></div>
<div class="notif-panel-more" id="notifPanelMore" style="display:none">
<button class="btn-small" data-action="Notifications.loadMore">Load more</button>
</div>
</div>`;
body.appendChild(el);
PanelRegistry.register('notifications', {
element: el,
label: 'Notifications',
onOpen: () => this._onPanelOpen(),
saveState: () => ({ scrollTop: el.querySelector('.notif-panel-list')?.scrollTop || 0 }),
restoreState: (s) => {
const list = el.querySelector('.notif-panel-list');
if (list && s.scrollTop) list.scrollTop = s.scrollTop;
},
});
},
_panelOffset: 0,
_panelTotal: 0,
async _onPanelOpen() {
this._panelOffset = 0;
const result = await this._fetchList({ limit: 30 });
this._panelTotal = result.total || 0;
this._panelOffset = (result.data || []).length;
this._panelItems = result.data || [];
this._renderPanelContent();
},
_panelItems: [],
_renderPanelContent() {
const list = document.getElementById('notifPanelList');
if (!list) return;
if (!this._panelItems.length) {
list.innerHTML = '<div class="notif-empty">No notifications yet</div>';
return;
}
let html = '';
for (const n of this._panelItems) {
const unread = !n.is_read;
const cls = unread ? 'notif-panel-item notif-unread' : 'notif-panel-item';
const dot = unread ? '●' : '○';
const ago = _timeAgo(n.created_at);
html += `<div class="${cls}" data-id="${n.id}" data-resource-type="${n.resource_type || ''}" data-resource-id="${n.resource_id || ''}" data-action="Notifications._onPanelItemClick" data-pass-el>
<div class="notif-panel-row">
<span class="notif-dot">${dot}</span>
<div class="notif-body">
<div class="notif-title">${esc(n.title)}</div>
${n.body ? `<div class="notif-detail">${esc(n.body)}</div>` : ''}
<div class="notif-time">${ago}</div>
</div>
<button class="notif-delete" data-action="Notifications._deleteItem" data-args='${JSON.stringify([n.id])}'" title="Delete">✕</button>
</div>
</div>`;
}
list.innerHTML = html;
// Show/hide load more
const moreBtn = document.getElementById('notifPanelMore');
if (moreBtn) {
moreBtn.style.display = this._panelOffset < this._panelTotal ? '' : 'none';
}
},
async _onPanelItemClick(el) {
const id = el.dataset.id;
const resType = el.dataset.resourceType;
const resId = el.dataset.resourceId;
if (el.classList.contains('notif-unread')) {
await this._markRead(id);
el.classList.remove('notif-unread');
el.querySelector('.notif-dot').textContent = '○';
const pItem = this._panelItems.find(n => n.id === id);
if (pItem) pItem.is_read = true;
}
this._navigate(resType, resId);
},
async _deleteItem(id) {
try {
await API._del(`/api/v1/notifications/${id}`);
// Check if it was unread before removal
const item = this._panelItems.find(n => n.id === id);
if (item && !item.is_read) {
this._unreadCount = Math.max(0, this._unreadCount - 1);
this._renderBadge();
}
this._panelItems = this._panelItems.filter(n => n.id !== id);
this._items = this._items.filter(n => n.id !== id);
this._panelTotal = Math.max(0, this._panelTotal - 1);
this._renderPanelContent();
} catch (e) { /* best effort */ }
},
async loadMore() {
const result = await this._fetchList({ limit: 30, offset: this._panelOffset });
const newItems = result.data || [];
this._panelItems = this._panelItems.concat(newItems);
this._panelOffset += newItems.length;
this._renderPanelContent();
},
async clearAll() {
if (typeof showConfirm === 'function') {
const ok = await showConfirm('Delete all notifications?', 'This cannot be undone.');
if (!ok) return;
}
// Delete visible items (API doesn't have bulk delete, so mark all read and let retention handle it)
await this.markAllRead();
},
openPanel() {
this._closeDropdown();
if (typeof PanelRegistry !== 'undefined') {
PanelRegistry.open('notifications');
}
},
// ── WebSocket Events ─────────────────────
_bindEvents() {
if (typeof Events === 'undefined') return;
// New notification from server
Events.on('notification.new', (payload) => {
// Prepend to cached list
this._items.unshift(payload);
if (this._items.length > 50) this._items.length = 50;
this._unreadCount++;
this._renderBadge();
// Update dropdown if open
if (this._dropdownOpen) {
const dd = document.getElementById('notifDropdown');
if (dd) dd.innerHTML = this._renderDropdownContent();
}
// Update panel if it has items
if (this._panelItems.length) {
this._panelItems.unshift(payload);
this._panelTotal++;
this._panelOffset++;
this._renderPanelContent();
}
// Toast for high-priority types
const highPriority = ['kb.error', 'role.fallback'];
if (highPriority.includes(payload.type)) {
if (typeof UI !== 'undefined' && UI.toast) {
UI.toast(payload.title, 'warning');
}
}
});
// Badge sync across tabs (from markRead / markAllRead in another tab)
Events.on('notification.read', (payload) => {
if (payload.action === 'mark_all_read') {
this._unreadCount = 0;
this._items.forEach(n => n.is_read = true);
this._panelItems.forEach(n => n.is_read = true);
} else if (payload.id) {
this._unreadCount = Math.max(0, this._unreadCount - 1);
const item = this._items.find(n => n.id === payload.id);
if (item) item.is_read = true;
const pItem = this._panelItems.find(n => n.id === payload.id);
if (pItem) pItem.is_read = true;
}
this._renderBadge();
if (this._dropdownOpen) {
const dd = document.getElementById('notifDropdown');
if (dd) dd.innerHTML = this._renderDropdownContent();
}
this._renderPanelContent();
});
},
};
// ── Helpers ──────────────────────────────────
function _timeAgo(isoStr) {
if (!isoStr) return '';
const d = new Date(isoStr);
const now = Date.now();
const sec = Math.floor((now - d.getTime()) / 1000);
if (sec < 60) return 'just now';
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
if (sec < 604800) return `${Math.floor(sec / 86400)}d ago`;
return d.toLocaleDateString();
}
// ── Exports ─────────────────────────────────
sb.ns('Notifications', Notifications);

View File

@@ -1,349 +0,0 @@
// ==========================================
// Chat Switchboard Pages (v0.22.5)
// ==========================================
// Client-side handlers for Go template-rendered pages.
// Works with server-rendered HTML — no DOM construction,
// just event handling and API calls.
//
// The server pre-populates all dropdowns. This JS handles:
// - Cascading model select (provider change → filter models)
// - Admin save operations (roles, routing, providers, teams, users, settings)
// - Table filtering
//
// Exports: window.Pages, window._val (used by pages-splash.js)
// ==========================================
const Pages = {
// ── Model Select Cascading ───────────────
roleProviderChanged(selectEl) {
const providerID = selectEl.value;
const filterType = selectEl.dataset.filterType || '';
const row = selectEl.closest('.form-row') || selectEl.parentElement;
const modelSelect = row.querySelector('.model-model-select');
if (!modelSelect) return;
const models = _pageData('Models') || [];
const filtered = models.filter(m =>
m.provider_config_id === providerID &&
(!filterType || m.model_type === filterType)
);
modelSelect.innerHTML = '<option value="">— select model —</option>' +
filtered.map(m =>
`<option value="${m.model_id}">${esc(m.display_name || m.model_id)}</option>`
).join('');
},
// ── Role Save ────────────────────────────
async saveRole(roleName) {
const container = document.querySelector(`.role-config[data-role="${roleName}"]`);
if (!container) return;
const getValue = (slot, cls) => {
const sel = container.querySelector(`[data-slot="${slot}"].${cls}`);
return sel ? sel.value : '';
};
const body = {
role: roleName,
primary: getValue('primary','model-provider-select') && getValue('primary','model-model-select')
? { provider_config_id: getValue('primary','model-provider-select'), model_id: getValue('primary','model-model-select') }
: null,
fallback: getValue('fallback','model-provider-select') && getValue('fallback','model-model-select')
? { provider_config_id: getValue('fallback','model-provider-select'), model_id: getValue('fallback','model-model-select') }
: null,
};
const resp = await _api('PUT', '/api/v1/admin/roles/' + roleName, body);
resp ? _toast('Role saved', 'success') : null;
},
// ── Routing Policy ───────────────────────
showRoutingForm() { _show('routingPolicyForm'); },
hideRoutingForm() { _hide('routingPolicyForm'); },
routingScopeChanged(sel) {
const row = document.getElementById('routingTeamRow');
if (row) row.style.display = sel.value === 'team' ? '' : 'none';
},
async saveRoutingPolicy() {
const id = _val('routingPolicyId');
const body = {
name: _val('routingPolicyName'),
priority: parseInt(_val('routingPolicyPriority') || '100'),
type: _val('routingPolicyType'),
scope: _val('routingPolicyScope'),
team_id: _val('routingPolicyScope') === 'team' ? _val('routingPolicyTeamId') : '',
config: _parseJSON(_val('routingPolicyConfig')),
active: document.getElementById('routingPolicyActive')?.checked ?? true,
};
if (body.config === null) return; // parse error
const ok = await _api(id ? 'PUT' : 'POST',
id ? '/api/v1/admin/routing/policies/' + id : '/api/v1/admin/routing/policies', body);
if (ok) { this.hideRoutingForm(); window.location.reload(); }
},
// ── Providers ────────────────────────────
showProviderForm() { _show('providerFormWrap'); _val('providerFormId', ''); _val('providerFormTitle', 'Add Provider'); },
hideProviderForm() { _hide('providerFormWrap'); },
editProvider(id) {
const details = (_pageData('ProviderDetails') || []).find(p => p.id === id);
if (!details) return;
_val('providerFormId', id);
_val('providerFormName', details.name);
_val('providerFormEndpoint', details.endpoint);
document.getElementById('providerFormTitle').textContent = 'Edit Provider';
_show('providerFormWrap');
},
async saveProvider() {
const id = _val('providerFormId');
const body = {
name: _val('providerFormName'),
provider: _val('providerFormType'),
endpoint: _val('providerFormEndpoint'),
scope: _val('providerFormScope'),
};
const key = _val('providerFormKey');
if (key) body.api_key = key;
const teamId = _val('providerFormTeamId');
if (body.scope === 'team' && teamId) body.team_id = teamId;
const ok = await _api(id ? 'PUT' : 'POST',
id ? '/api/v1/admin/providers/' + id : '/api/v1/admin/providers', body);
if (ok) { this.hideProviderForm(); window.location.reload(); }
},
async syncProvider(id) {
_toast('Syncing…', 'info');
const ok = await _api('POST', '/api/v1/admin/providers/' + id + '/sync', {});
if (ok) { _toast('Sync complete', 'success'); window.location.reload(); }
},
// ── Model Filtering ──────────────────────
filterModels(query) {
const q = (query || _val('modelSearch') || '').toLowerCase();
const typeF = _val('modelTypeFilter') || '';
const provF = _val('modelProviderFilter') || '';
document.querySelectorAll('#modelTable tbody tr').forEach(tr => {
const name = (tr.dataset.name || '').toLowerCase();
const type = tr.dataset.type || '';
const prov = tr.dataset.provider || '';
const show = (!q || name.includes(q)) && (!typeF || type === typeF) && (!provF || prov === provF);
tr.style.display = show ? '' : 'none';
});
},
// ── Teams ────────────────────────────────
showTeamForm() { _show('teamFormWrap'); _val('teamFormId', ''); _val('teamFormName', ''); _val('teamFormDescription', ''); },
hideTeamForm() { _hide('teamFormWrap'); },
editTeam(id, name, desc) {
_val('teamFormId', id);
_val('teamFormName', name);
_val('teamFormDescription', desc);
document.getElementById('teamFormTitle').textContent = 'Edit Team';
_show('teamFormWrap');
},
async saveTeam() {
const id = _val('teamFormId');
const body = { name: _val('teamFormName'), description: _val('teamFormDescription') };
const ok = await _api(id ? 'PUT' : 'POST',
id ? '/api/v1/admin/teams/' + id : '/api/v1/admin/teams', body);
if (ok) { this.hideTeamForm(); window.location.reload(); }
},
// ── Users ────────────────────────────────
filterUsers(query) {
const q = (query || '').toLowerCase();
document.querySelectorAll('#userTable tbody tr').forEach(tr => {
tr.style.display = (!q || (tr.dataset.name || '').toLowerCase().includes(q)) ? '' : 'none';
});
},
async editUserRole(id, username, currentRole) {
const role = await showPrompt({ title: `Set role for ${username}`, value: currentRole, ok: 'Save' });
if (!role || role === currentRole) return;
const ok = await _api('PUT', '/api/v1/admin/users/' + id, { role });
if (ok) window.location.reload();
},
async toggleUser(id, active) {
const ok = await _api('PUT', '/api/v1/admin/users/' + id, { is_active: active });
if (ok) window.location.reload();
},
// ── Settings ─────────────────────────────
async saveSettings() {
// Policies
const policies = {
allow_registration: document.getElementById('settRegEnabled')?.checked ? 'true' : 'false',
default_user_active: _val('settRegDefaultState') === 'active' ? 'true' : 'false',
allow_user_byok: document.getElementById('settUserBYOK')?.checked ? 'true' : 'false',
allow_user_personas: document.getElementById('settUserPersonas')?.checked ? 'true' : 'false',
kb_direct_access: document.getElementById('settKBDirect')?.checked ? 'true' : 'false',
};
// Banner
const banner = {
enabled: document.getElementById('settBannerEnabled')?.checked || false,
text: _val('settBannerText'),
bg: _val('settBannerBg'),
fg: _val('settBannerFg'),
};
// System prompt
const system_prompt = { content: _val('settSystemPrompt') };
const ok = await _api('PUT', '/api/v1/admin/settings', { policies, settings: { banner, system_prompt } });
if (ok) _toast('Settings saved', 'success');
},
// ── Login ─────────────────────────────────
async doLogin() {
const username = _val('loginUsername');
const password = _val('loginPassword');
const errEl = document.getElementById('loginError');
const btn = document.getElementById('loginBtn');
if (!username || !password) {
if (errEl) { errEl.textContent = 'Enter username and password'; errEl.style.display = ''; }
return;
}
if (errEl) errEl.style.display = 'none';
if (btn) { btn.disabled = true; btn.textContent = 'Logging in…'; }
const base = window.__BASE__ || '';
try {
const resp = await fetch(base + '/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ login: username, password }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || `Login failed (${resp.status})`);
}
const data = await resp.json();
// Save tokens in same format as API._setAuth / API.saveTokens
const storageKey = base ? `sb_auth_${base.replace(/\//g, '')}` : 'sb_auth';
localStorage.setItem(storageKey, JSON.stringify({
accessToken: data.access_token,
refreshToken: data.refresh_token,
user: data.user,
}));
// Set cookie for server-rendered page auth
document.cookie = `sb_token=${data.access_token}; path=/; max-age=900; SameSite=Strict`;
// Redirect to chat surface
window.location.href = base + '/';
} catch (e) {
if (errEl) { errEl.textContent = e.message; errEl.style.display = ''; }
if (btn) { btn.disabled = false; btn.textContent = 'Log In'; }
}
},
// ── User Settings (settings surface) ─────
async saveProfile() {
const name = _val('profileDisplayName');
if (!name) { _toast('Display name is required', 'error'); return; }
const ok = await _api('PUT', '/api/v1/profile', { display_name: name });
if (ok) _toast('Profile saved', 'success');
},
async changePassword() {
const current = _val('settingsCurrentPw');
const newPw = _val('settingsNewPw');
const confirm = _val('settingsConfirmPw');
if (!current || !newPw) { _toast('All password fields are required', 'error'); return; }
if (newPw !== confirm) { _toast('Passwords do not match', 'error'); return; }
if (newPw.length < 8) { _toast('Password must be at least 8 characters', 'error'); return; }
const ok = await _api('POST', '/api/v1/profile/password', { current_password: current, new_password: newPw });
if (ok) {
_toast('Password changed', 'success');
_val('settingsCurrentPw', '');
_val('settingsNewPw', '');
_val('settingsConfirmPw', '');
}
},
};
// ── Init: banner toggle ──────────────────────
document.addEventListener('DOMContentLoaded', () => {
const cb = document.getElementById('settBannerEnabled');
const fields = document.getElementById('bannerFields');
if (cb && fields) {
cb.addEventListener('change', () => { fields.style.display = cb.checked ? '' : 'none'; });
fields.style.display = cb.checked ? '' : 'none';
}
});
// ── Helpers ──────────────────────────────────
function _pageData(key) {
const d = window.__PAGE_DATA__;
return d ? (d[key] || d[key.toLowerCase()]) : null;
}
function _val(id, setVal) {
const el = document.getElementById(id);
if (!el) return '';
if (setVal !== undefined) { el.value = setVal; return; }
return el.value;
}
function _show(id) { const el = document.getElementById(id); if (el) el.style.display = ''; }
function _hide(id) { const el = document.getElementById(id); if (el) el.style.display = 'none'; }
function _toast(msg, type) {
if (typeof UI !== 'undefined' && UI.toast) {
UI.toast(msg, type);
} else {
console[type === 'error' ? 'error' : 'log']('[Pages]', msg);
}
}
function _parseJSON(text) {
if (!text || !text.trim()) return {};
try { return JSON.parse(text); }
catch (e) { _toast('Invalid JSON', 'error'); return null; }
}
async function _api(method, path, body) {
const base = window.__BASE__ || '';
const storageKey = base ? `sb_auth_${base.replace(/\//g, '')}` : 'sb_auth';
let token = '';
try {
const saved = JSON.parse(localStorage.getItem(storageKey) || '{}');
token = saved.accessToken || '';
} catch (e) { /* ignore */ }
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token;
try {
const resp = await fetch(base + path, { method, headers, body: body ? JSON.stringify(body) : undefined });
if (resp.ok) return true;
const err = await resp.json().catch(() => ({}));
_toast(err.error || `Error ${resp.status}`, 'error');
return false;
} catch (e) {
_toast('Connection error', 'error');
return false;
}
}
// ── Exports ─────────────────────────────────
sb.ns('Pages', Pages);
sb.register('_val', _val); // used by pages-splash.js

View File

@@ -1,589 +0,0 @@
// ==========================================
// Chat Switchboard — PaneContainer
// ==========================================
// v0.25.0: Composable multi-pane layout system.
// Replaces the "one surface fills viewport" model with resizable
// side-by-side panes. Supports leaf panes (single component) and
// tabbed panes (N components, tab bar, one active at a time).
//
// Layout presets define the pane structure. The surface manifest
// declares which preset to use via __MANIFEST__.layout.
//
// Usage:
// const layout = PaneContainer.mount(
// document.getElementById('editorBody'),
// 'editor',
// { workspaceId: 'ws-123' }
// );
// const fileTree = layout.getPane('files');
// layout.resize('files', 250);
// layout.destroy();
//
// Exports: window.PaneContainer
const PaneContainer = {
_presets: {},
_active: null,
// ── Layout Preset Registration ──────────
/**
* Register a named layout preset.
* @param {string} name - Preset name (e.g. 'single', 'editor', 'split')
* @param {object} definition - Layout tree definition
*
* Definition format:
* { type: 'leaf', id: 'main', component: 'chat-pane', opts: {} }
* { type: 'tabbed', id: 'right', tabs: [{id,label,component,opts}], defaultTab: 0 }
* { type: 'split', direction: 'horizontal', children: [def, def, ...], sizes: [220, null, 380] }
*
* sizes: array matching children. Numbers are initial px widths. null = flex:1 (fills remaining).
*/
registerPreset(name, definition) {
this._presets[name] = definition;
},
// ── Mount ───────────────────────────────
/**
* Mount a layout preset into a root element.
* @param {HTMLElement} rootEl - Container element
* @param {string} presetName - Registered preset name
* @param {object} opts - Passed to component factories (e.g. { workspaceId })
* @returns {object} Layout instance
*/
mount(rootEl, presetName, opts) {
if (!rootEl) { console.error('[PaneContainer] No root element'); return null; }
const preset = this._presets[presetName];
if (!preset) { console.error('[PaneContainer] Unknown preset:', presetName); return null; }
opts = opts || {};
const surfaceId = window.__SURFACE__ || 'unknown';
// Build the DOM tree from the preset definition
const panes = new Map(); // id → { el, component, type }
const handles = [];
const buildNode = (def, parentEl) => {
if (def.type === 'leaf') {
return _buildLeafPane(def, parentEl, panes, opts);
}
if (def.type === 'tabbed') {
return _buildTabbedPane(def, parentEl, panes, opts, surfaceId);
}
if (def.type === 'split') {
return _buildSplit(def, parentEl, panes, handles, opts, surfaceId);
}
console.error('[PaneContainer] Unknown node type:', def.type);
return null;
};
rootEl.classList.add('pane-container');
const rootNode = buildNode(preset, rootEl);
// Restore persisted sizes
_restoreSizes(surfaceId, presetName, handles);
const instance = {
rootEl,
presetName,
_panes: panes,
_handles: handles,
_rootNode: rootNode,
/** Get the component instance for a pane by ID. */
getPane(id) {
const p = panes.get(id);
return p ? p.component : null;
},
/** Get the pane element by ID. */
getPaneEl(id) {
const p = panes.get(id);
return p ? p.el : null;
},
/** Programmatic resize of a leaf pane. */
resize(id, sizePx) {
const p = panes.get(id);
if (p?.el) {
p.el.style.flexBasis = sizePx + 'px';
p.el.style.flexGrow = '0';
p.el.style.flexShrink = '0';
}
},
/** Minimize a pane (collapse to 0). */
minimize(id) {
const p = panes.get(id);
if (!p?.el) return;
p._prevBasis = p.el.style.flexBasis;
p._prevGrow = p.el.style.flexGrow;
p.el.style.flexBasis = '0px';
p.el.style.flexGrow = '0';
p.el.style.overflow = 'hidden';
p.el.classList.add('pane-minimized');
},
/** Restore a minimized pane. */
restore(id) {
const p = panes.get(id);
if (!p?.el) return;
p.el.style.flexBasis = p._prevBasis || '';
p.el.style.flexGrow = p._prevGrow || '';
p.el.style.overflow = '';
p.el.classList.remove('pane-minimized');
},
/** Tear down the entire layout. */
destroy() {
// Destroy all component instances
for (const [, p] of panes) {
if (p.component?.destroy) p.component.destroy();
// Tabbed: destroy all tab components
if (p.tabs) {
for (const tab of p.tabs) {
if (tab.component?.destroy) tab.component.destroy();
}
}
}
panes.clear();
handles.length = 0;
rootEl.innerHTML = '';
rootEl.classList.remove('pane-container');
PaneContainer._active = null;
},
};
PaneContainer._active = instance;
return instance;
},
/** Get the currently active layout instance. */
active() { return this._active; },
};
// ── Leaf Pane Builder ───────────────────────
function _buildLeafPane(def, parentEl, panes, opts) {
const el = document.createElement('div');
el.className = 'pane pane-leaf';
el.dataset.paneId = def.id;
if (def.size) {
el.style.flexBasis = def.size + 'px';
el.style.flexGrow = '0';
el.style.flexShrink = '0';
} else {
el.style.flex = '1';
el.style.minWidth = '0';
el.dataset.paneFlex = '1'; // Mark as flexible — drag handles skip this pane
}
if (def.minSize) el.style.minWidth = def.minSize + 'px';
parentEl.appendChild(el);
// Component instantiation is deferred — the surface boot script
// creates components and mounts them into pane elements.
// We just register the pane slot here.
panes.set(def.id, {
el,
component: null, // set by surface boot script
type: 'leaf',
def,
});
return el;
}
// ── Tabbed Pane Builder ─────────────────────
function _buildTabbedPane(def, parentEl, panes, opts, surfaceId) {
const el = document.createElement('div');
el.className = 'pane pane-tabbed';
el.dataset.paneId = def.id;
if (def.size) {
el.style.flexBasis = def.size + 'px';
el.style.flexGrow = '0';
el.style.flexShrink = '0';
} else {
el.style.flex = '1';
el.style.minWidth = '0';
}
if (def.minSize) el.style.minWidth = def.minSize + 'px';
// Tab bar
const tabBar = document.createElement('div');
tabBar.className = 'pane-tab-bar';
el.appendChild(tabBar);
// Content area
const contentArea = document.createElement('div');
contentArea.className = 'pane-tab-content';
el.appendChild(contentArea);
// Restore persisted active tab
const persistKey = 'sb_tabs_' + surfaceId + '_' + def.id;
let savedTab = 0;
try { savedTab = parseInt(localStorage.getItem(persistKey)) || 0; } catch (_) {}
if (savedTab >= (def.tabs || []).length) savedTab = 0;
const tabs = (def.tabs || []).map((tabDef, i) => {
// Tab button
const btn = document.createElement('button');
btn.className = 'pane-tab-btn' + (i === savedTab ? ' pane-tab-btn--active' : '');
btn.textContent = tabDef.label || tabDef.id;
btn.dataset.tabIndex = i;
tabBar.appendChild(btn);
// Tab panel (mount point for component)
const panel = document.createElement('div');
panel.className = 'pane-tab-panel';
panel.dataset.tabId = tabDef.id;
panel.style.display = i === savedTab ? '' : 'none';
contentArea.appendChild(panel);
return {
id: tabDef.id,
label: tabDef.label,
component: tabDef.component,
opts: tabDef.opts || {},
btn,
panel,
instance: null, // lazy-created on first activation
_activated: i === savedTab,
};
});
// Tab click handler
const activateTab = (index) => {
tabs.forEach((tab, i) => {
const isActive = i === index;
tab.btn.classList.toggle('pane-tab-btn--active', isActive);
tab.panel.style.display = isActive ? '' : 'none';
if (isActive) tab._activated = true;
});
// Persist
try { localStorage.setItem(persistKey, String(index)); } catch (_) {}
};
tabs.forEach((tab, i) => {
tab.btn.addEventListener('click', () => activateTab(i));
});
parentEl.appendChild(el);
panes.set(def.id, {
el,
component: null, // tabbed panes don't have a single component
type: 'tabbed',
def,
tabs,
activateTab,
getActiveTab() {
return tabs.find((_, i) => tabs[i].btn.classList.contains('pane-tab-btn--active')) || tabs[0];
},
getTabPanel(tabId) {
const t = tabs.find(t => t.id === tabId);
return t ? t.panel : null;
},
});
return el;
}
// ── Split Builder ───────────────────────────
function _buildSplit(def, parentEl, panes, handles, opts, surfaceId) {
const el = document.createElement('div');
el.className = 'pane-split pane-split--' + (def.direction || 'horizontal');
parentEl.appendChild(el);
const children = def.children || [];
const sizes = def.sizes || [];
children.forEach((childDef, i) => {
// Apply initial size from preset
if (sizes[i] != null && childDef.size === undefined) {
childDef.size = sizes[i];
}
// Build child
if (childDef.type === 'leaf') {
_buildLeafPane(childDef, el, panes, opts);
} else if (childDef.type === 'tabbed') {
_buildTabbedPane(childDef, el, panes, opts, surfaceId);
} else if (childDef.type === 'split') {
_buildSplit(childDef, el, panes, handles, opts, surfaceId);
}
// Add drag handle between children (not after last)
if (i < children.length - 1) {
const handle = _createHandle(def.direction || 'horizontal', el, i, surfaceId, def.id || 'root');
handles.push(handle);
}
});
return el;
}
// ── Drag Handle ─────────────────────────────
function _createHandle(direction, splitEl, index, surfaceId, splitId) {
const handle = document.createElement('div');
handle.className = 'pane-handle pane-handle--' + direction;
handle.dataset.handleIndex = index;
// Insert after the Nth child (child, handle, child, handle, child)
// Children are at positions 0, 2, 4, ... and handles at 1, 3, ...
// But since we add sequentially, handle goes after the (index)th pane
const childEls = splitEl.querySelectorAll(':scope > .pane, :scope > .pane-split');
const afterEl = childEls[index];
if (afterEl?.nextSibling) {
splitEl.insertBefore(handle, afterEl.nextSibling);
} else {
splitEl.appendChild(handle);
}
// Track adjacent panes for dblclick reset
let lastLeftEl = null, lastRightEl = null;
initDragResize(handle, {
direction,
onStart(_clientPos) {
// Find the two panes adjacent to this handle
const handleIdx = Array.from(splitEl.children).indexOf(handle);
const leftEl = splitEl.children[handleIdx - 1];
const rightEl = splitEl.children[handleIdx + 1];
if (!leftEl || !rightEl) return false;
lastLeftEl = leftEl;
lastRightEl = rightEl;
const isHoriz = direction === 'horizontal';
const dim = isHoriz ? 'width' : 'height';
const startLeftBasis = leftEl.getBoundingClientRect()[dim];
const startRightBasis = rightEl.getBoundingClientRect()[dim];
// Lock non-flex panes to current rendered size so the first
// move delta doesn't cause a visual snap
if (leftEl.dataset.paneFlex !== '1') {
leftEl.style.flexBasis = startLeftBasis + 'px';
leftEl.style.flexGrow = '0';
leftEl.style.flexShrink = '0';
}
if (rightEl.dataset.paneFlex !== '1') {
rightEl.style.flexBasis = startRightBasis + 'px';
rightEl.style.flexGrow = '0';
rightEl.style.flexShrink = '0';
}
handle.classList.add('pane-handle--active');
return { leftEl, rightEl, startLeftBasis, startRightBasis };
},
onMove(delta, ctx) {
const { leftEl, rightEl, startLeftBasis, startRightBasis } = ctx;
const leftIsFlex = leftEl.dataset.paneFlex === '1';
const rightIsFlex = rightEl.dataset.paneFlex === '1';
const isHoriz = direction === 'horizontal';
const dim = isHoriz ? 'width' : 'height';
const maxSize = splitEl.getBoundingClientRect()[dim] * 0.6;
if (leftIsFlex) {
const newRight = Math.min(maxSize, Math.max(50, startRightBasis - delta));
rightEl.style.flexBasis = newRight + 'px';
rightEl.style.flexGrow = '0';
rightEl.style.flexShrink = '0';
} else if (rightIsFlex) {
const newLeft = Math.min(maxSize, Math.max(50, startLeftBasis + delta));
leftEl.style.flexBasis = newLeft + 'px';
leftEl.style.flexGrow = '0';
leftEl.style.flexShrink = '0';
} else {
const newLeft = Math.max(50, startLeftBasis + delta);
const newRight = Math.max(50, startRightBasis - delta);
leftEl.style.flexBasis = newLeft + 'px';
leftEl.style.flexGrow = '0';
leftEl.style.flexShrink = '0';
rightEl.style.flexBasis = newRight + 'px';
rightEl.style.flexGrow = '0';
rightEl.style.flexShrink = '0';
}
},
onEnd(_ctx) {
handle.classList.remove('pane-handle--active');
_persistSizes(surfaceId, splitEl);
},
});
// Double-click resets to default
handle.addEventListener('dblclick', () => {
_resetPaneSize(lastLeftEl);
_resetPaneSize(lastRightEl);
_persistSizes(surfaceId, splitEl);
});
return { handle, splitEl, index, direction };
}
/** Reset a pane element to its default flex sizing. */
function _resetPaneSize(el) {
if (!el) return;
if (el.dataset.paneFlex === '1') {
el.style.flex = '1';
}
el.style.flexBasis = '';
el.style.flexGrow = '';
el.style.flexShrink = '';
}
// ── Persistence ─────────────────────────────
// v0.27.0: Debounced server sync for cross-device pane layout persistence.
var _paneSyncTimer = null;
function _syncPaneLayoutsToServer() {
if (_paneSyncTimer) clearTimeout(_paneSyncTimer);
_paneSyncTimer = setTimeout(function() {
try {
// Collect all sb_layout_* keys
var layouts = {};
for (var i = 0; i < localStorage.length; i++) {
var k = localStorage.key(i);
if (k && k.indexOf('sb_layout_') === 0) {
try { layouts[k] = JSON.parse(localStorage.getItem(k)); } catch (_) {}
}
}
if (typeof API !== 'undefined' && API._put) {
API._put('/api/v1/settings', { pane_layouts: layouts }).catch(function() {});
}
} catch (_) {}
}, 2000); // 2s debounce — avoid hammering during resize drags
}
// v0.27.0: On page load, restore pane layouts from server if available.
// Called once from PaneContainer init (or surface boot).
function _loadPaneLayoutsFromServer() {
if (typeof API === 'undefined' || !API._get) return;
API._get('/api/v1/settings').then(function(settings) {
if (!settings || !settings.pane_layouts) return;
var layouts = settings.pane_layouts;
for (var key in layouts) {
if (key.indexOf('sb_layout_') === 0 && layouts[key]) {
// Only overwrite if localStorage is empty for this key
if (!localStorage.getItem(key)) {
localStorage.setItem(key, JSON.stringify(layouts[key]));
}
}
}
}).catch(function() {});
}
// Kick off server layout load on script execution
if (typeof API !== 'undefined') _loadPaneLayoutsFromServer();
function _persistSizes(surfaceId, splitEl) {
const key = 'sb_layout_' + surfaceId;
try {
// Collect sizes for fixed panes only (skip flex panes)
const sizes = {};
splitEl.querySelectorAll(':scope > .pane, :scope > .pane-split').forEach(child => {
const id = child.dataset?.paneId;
if (id && child.dataset.paneFlex !== '1') {
sizes[id] = child.getBoundingClientRect().width;
}
});
// Merge with existing persisted data
let existing = {};
try { existing = JSON.parse(localStorage.getItem(key) || '{}'); } catch (_) {}
Object.assign(existing, sizes);
localStorage.setItem(key, JSON.stringify(existing));
// v0.27.0: Debounced sync to server for cross-device persistence
_syncPaneLayoutsToServer();
} catch (_) {}
}
function _restoreSizes(surfaceId, presetName, handles) {
const key = 'sb_layout_' + surfaceId;
try {
const saved = JSON.parse(localStorage.getItem(key) || '{}');
if (Object.keys(saved).length === 0) return;
// Find the split container to measure available width
const splitEl = document.querySelector('.pane-split--horizontal');
const containerWidth = splitEl ? splitEl.getBoundingClientRect().width : window.innerWidth;
// Validate: sum of all fixed pane sizes must leave at least 150px for flex pane
const fixedPanes = document.querySelectorAll('.pane[data-pane-id]');
let totalFixed = 0;
fixedPanes.forEach(el => {
const id = el.dataset.paneId;
if (saved[id] && el.dataset.paneFlex !== '1') {
totalFixed += saved[id];
}
});
// If saved sizes overflow, clear corrupt data and use defaults
if (totalFixed > containerWidth - 150) {
console.warn('[PaneContainer] Saved layout overflows viewport (' + totalFixed + 'px > ' + containerWidth + 'px), resetting');
localStorage.removeItem(key);
return;
}
// Apply saved sizes to pane elements (skip flex panes)
fixedPanes.forEach(el => {
const id = el.dataset.paneId;
if (saved[id] && el.dataset.paneFlex !== '1') {
el.style.flexBasis = saved[id] + 'px';
el.style.flexGrow = '0';
el.style.flexShrink = '0';
}
});
} catch (_) {}
}
// ── Built-in Presets ────────────────────────
// single: One leaf pane (default chat UX)
PaneContainer.registerPreset('single', {
type: 'leaf',
id: 'main',
component: 'chat-pane',
});
// editor: files | code-editor | <chat, notes>
PaneContainer.registerPreset('editor', {
type: 'split',
id: 'root',
direction: 'horizontal',
sizes: [220, null, 380],
children: [
{ type: 'leaf', id: 'files', component: 'file-tree', size: 220, minSize: 140 },
{ type: 'leaf', id: 'editor', component: 'code-editor' },
{
type: 'tabbed', id: 'assist', size: 380, minSize: 200,
tabs: [
{ id: 'chat', label: 'Chat', component: 'chat-pane' },
{ id: 'notes', label: 'Notes', component: 'note-editor' },
],
},
],
});
// split: generic two-pane layout
PaneContainer.registerPreset('split', {
type: 'split',
id: 'root',
direction: 'horizontal',
children: [
{ type: 'leaf', id: 'primary', component: null },
{ type: 'leaf', id: 'secondary', component: null },
],
});
// ── Exports ─────────────────────────────────
sb.ns('PaneContainer', PaneContainer);

View File

@@ -1,454 +0,0 @@
// ==========================================
// Chat Switchboard Panel Registry (v0.22.0)
// ==========================================
// Workspace-aware panel system. Panels are shown inside the
// workspace secondary pane (.workspace-secondary). The workspace
// owns the resize handle between primary and secondary.
//
// Each panel registers with name, DOM element, and lifecycle hooks.
// The registry owns all open/close/focus logic — individual panels
// never touch the secondary container directly.
//
// Layout model (v0.22.0):
// .workspace
// .workspace-primary ← main content (chat / notes / editor)
// .workspace-handle ← drag-resize between panes
// .workspace-secondary ← panel pages (preview / notes / project)
//
// Exports: window.PanelRegistry,window.toggleSidePanelFullscreen,window._initWorkspaceResize,window._initPanelSwipe,window._initPanelResponsive,window._initPanelOverlay
// ── Panel Registry ──────────────────────────
const PanelRegistry = {
_panels: {}, // { name: { element, label, onOpen, onClose, saveState, restoreState, actions, state } }
_active: null, // currently visible panel name
_order: [], // registration order (for tab rendering + cycle)
/**
* Register a panel.
* @param {string} name — unique identifier (e.g. 'preview', 'notes')
* @param {object} opts
* @param {HTMLElement} opts.element — the .side-panel-page div
* @param {string} opts.label — display name
* @param {Function} [opts.onOpen] — called when panel becomes visible
* @param {Function} [opts.onClose] — called when panel is hidden
* @param {Function} [opts.saveState] — returns state snapshot before hiding
* @param {Function} [opts.restoreState]— receives state snapshot on re-show
* @param {Array} [opts.actions] — per-panel action button configs
* Each action: { id, icon, title, onClick, visible? }
*/
register(name, opts) {
if (this._panels[name]) {
console.warn(`[panels] panel "${name}" already registered, replacing`);
}
this._panels[name] = {
element: opts.element,
label: opts.label || name,
onOpen: opts.onOpen || null,
onClose: opts.onClose || null,
saveState: opts.saveState || null,
restoreState: opts.restoreState || null,
actions: opts.actions || [],
state: {},
};
if (!this._order.includes(name)) {
this._order.push(name);
}
this._renderLabel();
},
/**
* Open a panel (and the secondary pane if collapsed).
* Hides current active panel, shows the requested one.
*/
open(name) {
const panel = this._panels[name];
if (!panel) {
console.warn(`[panels] unknown panel: "${name}"`);
return;
}
const container = this._container();
if (!container) return;
// Already active — just refresh label/actions
if (this._active === name) {
this._renderLabel();
this._renderActions();
return;
}
// Hide current active (saves state + fires onClose)
if (this._active && this._active !== name) {
this._hide(this._active);
}
// Hide ALL panel pages in the body, including unregistered ones
// (e.g. sidePanelPreview which is a static placeholder)
const body = this._body();
if (body) {
body.querySelectorAll('.side-panel-page').forEach(el => {
if (el !== panel.element) el.style.display = 'none';
});
}
// Show the secondary pane
container.classList.add('open');
this._showHandle(true);
this._show(name);
this._active = name;
this._showOverlay();
this._renderLabel();
this._renderActions();
},
/**
* Close a specific panel. If it's active, close the secondary pane.
*/
close(name) {
const panel = this._panels[name];
if (!panel) return;
if (this._active === name) {
this._hide(name);
this._active = null;
this._closeContainer();
}
},
/**
* Toggle: if this panel is visible, close it. Otherwise, open it.
*/
toggle(name) {
if (this.isOpen(name)) {
this.close(name);
} else {
this.open(name);
}
},
/**
* Close all panels and the secondary pane.
*/
closeAll() {
if (this._active) {
this._hide(this._active);
this._active = null;
}
this._closeContainer();
},
/** Is a specific panel currently visible? */
isOpen(name) {
return this._active === name;
},
/** Is the secondary pane open at all? */
isContainerOpen() {
return this._container()?.classList.contains('open') || false;
},
/** Get the active panel name (or null). */
active() {
return this._active;
},
/**
* Cycle to the next registered panel. If container is closed, open
* the first panel. Wraps around.
*/
cycle() {
if (this._order.length === 0) return;
if (!this._active) {
this.open(this._order[0]);
return;
}
const idx = this._order.indexOf(this._active);
const next = this._order[(idx + 1) % this._order.length];
this.open(next);
},
// ── Internal ────────────────────────────
/** The secondary pane element. */
_container() {
return document.getElementById('workspaceSecondary');
},
_body() {
return document.getElementById('sidePanelBody');
},
/** Show a panel: set display, restore state, fire onOpen. */
_show(name) {
const panel = this._panels[name];
if (!panel) return;
panel.element.style.display = '';
// Restore state if previously saved
if (panel.restoreState && Object.keys(panel.state).length > 0) {
panel.restoreState(panel.state);
}
if (panel.onOpen) panel.onOpen();
},
/** Hide a panel: save state, call onClose, set display:none. */
_hide(name) {
const panel = this._panels[name];
if (!panel) return;
if (panel.saveState) {
panel.state = panel.saveState() || {};
}
if (panel.onClose) panel.onClose();
panel.element.style.display = 'none';
},
/** Close the secondary pane. */
_closeContainer() {
const container = this._container();
if (!container) return;
container.classList.remove('open', 'fullscreen');
container.style.width = '';
container.style.minWidth = '';
this._showHandle(false);
this._hideOverlay();
},
/** Show/hide the workspace resize handle. */
_showHandle(show) {
const handle = document.getElementById('workspaceHandle');
if (handle) handle.classList.toggle('active', show);
},
// ── Mobile overlay ──────────────────────
_isMobile() {
return window.innerWidth <= 768;
},
/** Show overlay behind secondary on mobile (tap-to-close). */
_showOverlay() {
const ov = document.getElementById('sidePanelOverlay');
if (ov && this._isMobile()) ov.style.display = 'block';
},
/** Hide the mobile overlay. */
_hideOverlay() {
const ov = document.getElementById('sidePanelOverlay');
if (ov) ov.style.display = 'none';
},
// ── Label and action rendering ──────────
/** Update the header label to show the active panel name. */
_renderLabel() {
const el = document.getElementById('sidePanelLabel');
if (!el) return;
if (this._active) {
const p = this._panels[this._active];
el.textContent = p ? p.label : '';
} else {
el.textContent = '';
}
},
/** Render per-panel action buttons for the active panel. */
_renderActions() {
const slot = document.getElementById('sidePanelPanelActions');
if (!slot) return;
if (!this._active) {
slot.innerHTML = '';
return;
}
const panel = this._panels[this._active];
slot.innerHTML = (panel.actions || [])
.filter(a => !a.visible || a.visible())
.map(a => `<button class="side-panel-action-btn" id="${a.id || ''}" onclick="${a.onClickName || ''}" title="${esc(a.title || '')}">${a.icon || ''}</button>`)
.join('');
},
/** Refresh action button visibility (call after state changes). */
refreshActions() {
this._renderActions();
},
};
/** Minimal HTML escaping for panel labels/titles. */
// ── Secondary Pane Fullscreen ──────────────
function toggleSidePanelFullscreen() {
const panel = document.getElementById('workspaceSecondary');
if (!panel) return;
panel.classList.toggle('fullscreen');
const btn = document.getElementById('sidePanelFullscreenBtn');
if (btn) {
const isFS = panel.classList.contains('fullscreen');
btn.title = isFS ? 'Exit fullscreen' : 'Toggle fullscreen';
btn.innerHTML = isFS
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>'
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
}
}
// ── Workspace Resize Handle ────────────────
function _initWorkspaceResize() {
const secondary = document.getElementById('workspaceSecondary');
const handle = document.getElementById('workspaceHandle');
if (!handle || !secondary) return;
initDragResize(handle, {
direction: 'horizontal',
canDrag() {
return handle.classList.contains('active') &&
!secondary.classList.contains('fullscreen');
},
onStart(_clientPos) {
// Kill transition first, force reflow, then measure.
// Pin inline width immediately to prevent snap-back if
// the open-transition is still animating.
secondary.style.transition = 'none';
void secondary.offsetWidth;
// #surfaceInner has transform:scale(z) from applyAppearance.
// getBoundingClientRect on descendants returns post-transform
// visual px. Divide by scale to recover CSS px for style.width.
const inner = document.getElementById('surfaceInner');
const m = inner?.style.transform?.match(/scale\(([^)]+)\)/);
const scale = m ? parseFloat(m[1]) : 1;
const startW = secondary.getBoundingClientRect().width / scale;
secondary.style.width = startW + 'px';
secondary.style.minWidth = startW + 'px';
return { startW, scale };
},
onMove(delta, ctx) {
// Handle is left of secondary — dragging left (negative delta) grows it.
// Mouse delta is in viewport px; convert to CSS px in scaled space.
const cssDelta = delta / ctx.scale;
const newW = Math.max(280, Math.min(window.innerWidth * 0.7, ctx.startW - cssDelta));
secondary.style.width = newW + 'px';
secondary.style.minWidth = newW + 'px';
},
onEnd(_ctx) {
secondary.style.transition = '';
},
});
}
// ── Mobile Swipe Navigation ─────────────────
function _initPanelSwipe() {
const body = document.getElementById('sidePanelBody');
if (!body) return;
let startX = 0;
let startY = 0;
let tracking = false;
body.addEventListener('touchstart', (e) => {
if (!PanelRegistry.isContainerOpen()) return;
if (PanelRegistry._order.length < 2) return;
if (e.touches.length !== 1) return;
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
tracking = true;
}, { passive: true });
body.addEventListener('touchend', (e) => {
if (!tracking) return;
tracking = false;
const touch = e.changedTouches[0];
if (!touch) return;
const dx = touch.clientX - startX;
const dy = touch.clientY - startY;
// Require horizontal movement > 80px and more horizontal than vertical
if (Math.abs(dx) < 80 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
if (dx > 0) {
_cyclePanelDirection(-1);
} else {
_cyclePanelDirection(1);
}
}, { passive: true });
}
/**
* Cycle panels in a specific direction.
* direction: +1 = next, -1 = previous.
*/
function _cyclePanelDirection(direction) {
const order = PanelRegistry._order;
if (order.length < 2 || !PanelRegistry._active) return;
const idx = order.indexOf(PanelRegistry._active);
const next = order[(idx + direction + order.length) % order.length];
PanelRegistry.open(next);
}
// ── Responsive Resize Handler ───────────────
function _initPanelResponsive() {
let wasWide = window.innerWidth > 768;
window.addEventListener('resize', () => {
const isWide = window.innerWidth > 768;
// Update overlay visibility: show on mobile if panel open, hide on desktop
if (PanelRegistry.isContainerOpen()) {
const ov = document.getElementById('sidePanelOverlay');
if (ov) {
ov.style.display = isWide ? 'none' : 'block';
}
}
wasWide = isWide;
});
}
// ── Panel Overlay (mobile tap-to-close) ─────
function _initPanelOverlay() {
const ov = document.getElementById('sidePanelOverlay');
if (!ov) return;
ov.addEventListener('click', () => {
PanelRegistry.closeAll();
});
}
// ── Legacy Compat (thin wrappers) ───────────
function openSidePanel(tab) {
PanelRegistry.open(tab);
}
function closeSidePanel() {
PanelRegistry.closeAll();
}
function switchSidePanelTab(tab) {
PanelRegistry.open(tab);
}
// ── Exports ─────────────────────────────────
sb.ns('PanelRegistry', PanelRegistry);
sb.register('toggleSidePanelFullscreen', toggleSidePanelFullscreen);
sb.register('_initWorkspaceResize', _initWorkspaceResize);
sb.register('_initPanelSwipe', _initPanelSwipe);
sb.register('_initPanelResponsive', _initPanelResponsive);
sb.register('_initPanelOverlay', _initPanelOverlay);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,122 @@
// ==========================================
// ChatPane Kit — CodeBlock Component
// ==========================================
// Enhanced code block: language badge, copy button.
// Independently importable.
//
// Usage (from markdown post-processing):
// Enhances <pre><code> blocks after markdown rendering.
const html = window.html;
const { useState, useCallback } = window.hooks;
/**
* SVG icons for copy/check.
*/
const COPY_SVG = html`
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>`;
const CHECK_SVG = html`
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>`;
/**
* CodeBlock — wraps a <pre><code> block with language badge + copy button.
*
* @param {{ code: string, language?: string }} props
*/
export function CodeBlock({ code, language }) {
const [copied, setCopied] = useState(false);
const _copy = useCallback(async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (_) {
// Fallback
const ta = document.createElement('textarea');
ta.value = code;
ta.style.cssText = 'position:fixed;left:-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}, [code]);
const langLabel = language && language !== 'text' ? language : null;
return html`
<div class="sw-code-block">
<div class="sw-code-block__header">
${langLabel && html`<span class="sw-code-block__lang">${langLabel}</span>`}
<button class="sw-code-block__copy"
title=${copied ? 'Copied!' : 'Copy code'}
onClick=${_copy}
aria-label="Copy code to clipboard">
${copied ? CHECK_SVG : COPY_SVG}
<span class="sw-code-block__copy-text">${copied ? 'Copied' : 'Copy'}</span>
</button>
</div>
<pre class="sw-code-block__pre"><code class=${langLabel ? 'language-' + langLabel : ''}
dangerouslySetInnerHTML=${{ __html: _escapeHtml(code) }} /></pre>
</div>`;
}
const _escMap = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
function _escapeHtml(text) {
return text.replace(/[&<>"']/g, c => _escMap[c]);
}
/**
* Post-process rendered markdown HTML to enhance code blocks.
* Extracts language from class="language-xxx" and returns
* an array of { type, code, language } blocks for Preact rendering.
*
* @param {string} html — rendered markdown HTML
* @returns {{ segments: Array<{type: 'html'|'code', html?: string, code?: string, language?: string}> }}
*/
export function extractCodeBlocks(htmlStr) {
// Match <pre><code class="language-xxx">...</code></pre> blocks
const pattern = /<pre><code(?:\s+class="language-(\w+)")?>([\s\S]*?)<\/code><\/pre>/g;
const segments = [];
let lastIndex = 0;
let match;
while ((match = pattern.exec(htmlStr)) !== null) {
// Push preceding HTML
if (match.index > lastIndex) {
segments.push({ type: 'html', html: htmlStr.slice(lastIndex, match.index) });
}
// Push code block
const language = match[1] || '';
const code = _unescapeHtml(match[2]);
segments.push({ type: 'code', code, language });
lastIndex = match.index + match[0].length;
}
// Push trailing HTML
if (lastIndex < htmlStr.length) {
segments.push({ type: 'html', html: htmlStr.slice(lastIndex) });
}
return segments;
}
function _unescapeHtml(text) {
return text
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}

View File

@@ -0,0 +1,205 @@
// ==========================================
// ChatPane Kit — EmojiPicker Component
// ==========================================
// Categorized emoji selector popup with search and recents.
// Independently importable.
//
// Usage:
// html`<${EmojiPicker} open=${true} onSelect=${fn} onClose=${fn} />`
const html = window.html;
const { useState, useRef, useEffect, useCallback, useMemo } = window.hooks;
// ── Emoji data (compact) ─────────────────
// Categories with popular emoji — not a full Unicode dump.
// Keeps bundle small. Each entry is just the emoji character.
const CATEGORIES = [
{ key: 'recent', icon: '🕐', label: 'Recent', emoji: [] },
{ key: 'smileys', icon: '😀', label: 'Smileys & People', emoji: [
'😀','😃','😄','😁','😆','😅','🤣','😂','🙂','😊',
'😇','🥰','😍','🤩','😘','😋','😛','😜','🤪','😝',
'🤗','🤭','🤫','🤔','😐','😑','😶','😏','😒','🙄',
'😬','😮‍💨','🤥','😌','😔','😪','🤤','😴','😷','🤒',
'🤕','🤢','🤮','🥵','🥶','🥴','😵','🤯','🤠','🥳',
'🥸','😎','🤓','🧐','😕','😟','🙁','😮','😯','😲',
'😳','🥺','😦','😧','😨','😰','😥','😢','😭','😱',
'😖','😣','😞','😓','😩','😫','🥱','😤','😡','😠',
'🤬','😈','👿','💀','☠️','💩','🤡','👹','👺','👻',
'👽','👾','🤖','👋','🤚','🖐️','✋','🖖','👌','🤌',
'🤏','✌️','🤞','🤟','🤘','🤙','👈','👉','👆','🖕',
'👇','☝️','👍','👎','✊','👊','🤛','🤜','👏','🙌',
'👐','🤲','🤝','🙏',
]},
{ key: 'nature', icon: '🌿', label: 'Nature', emoji: [
'🐶','🐱','🐭','🐹','🐰','🦊','🐻','🐼','🐨','🐯',
'🦁','🐮','🐷','🐸','🐵','🐔','🐧','🐦','🐤','🦆',
'🦅','🦉','🦇','🐺','🐗','🐴','🦄','🐝','🐛','🦋',
'🐌','🐞','🐜','🪲','🪳','🦟','🦗','🕷️','🌸','💐',
'🌷','🌹','🥀','🌺','🌻','🌼','🌱','🌲','🌳','🌴',
'🌵','🍀','🍁','🍂','🍃','🍄',
]},
{ key: 'food', icon: '🍔', label: 'Food & Drink', emoji: [
'🍇','🍈','🍉','🍊','🍋','🍌','🍍','🥭','🍎','🍐',
'🍑','🍒','🍓','🫐','🥝','🍅','🫒','🥥','🥑','🍆',
'🥔','🥕','🌽','🌶️','🫑','🥒','🥬','🧄','🧅','🍄',
'🥜','🫘','🌰','🍞','🥐','🥖','🫓','🥨','🥯','🥞',
'🧇','🧀','🍖','🍗','🥩','🥓','🍔','🍟','🍕','🌭',
'🥪','🌮','🌯','🫔','🥙','🧆','🥚','🍳','🥘','🍲',
'🫕','🥣','🥗','🍿','🧈','🍱','🍘','🍙','🍚','🍛',
'🍜','🍝','🍠','🍢','🍣','🍤','🍥','🥮','🍡','🥟',
'🥠','🥡','☕','🍵','🧃','🥤','🧋','🍺','🍻','🥂',
'🍷','🥃','🍸','🍹','🧉','🍾','🫗',
]},
{ key: 'activity', icon: '⚽', label: 'Activities', emoji: [
'⚽','🏀','🏈','⚾','🥎','🎾','🏐','🏉','🥏','🎱',
'🏓','🏸','🏒','🥅','⛳','🏹','🎣','🤿','🥊','🥋',
'🎿','⛷️','🏂','🪂','🏋️','🤸','🤺','⛹️','🤾','🏌️',
'🏇','🧘','🏄','🏊','🤽','🚣','🧗','🚴','🚵','🎪',
'🎭','🎨','🎬','🎤','🎧','🎼','🎹','🥁','🎷','🎺',
'🎸','🪕','🎲','🎯','🎳','🎮','🕹️','🧩',
]},
{ key: 'objects', icon: '💡', label: 'Objects', emoji: [
'⌚','📱','💻','⌨️','🖥️','🖨️','🖱️','🖲️','💾','💿',
'📷','📹','🎥','📽️','🎞️','📞','📟','📠','📺','📻',
'🎙️','⏱️','⏲️','⏰','🕰️','⌛','📡','🔋','🔌','💡',
'🔦','🕯️','🪔','🧯','🛢️','💸','💵','💴','💶','💷',
'🪙','💰','💳','💎','⚖️','🪜','🧰','🪛','🔧','🔨',
'⛏️','🪚','🔩','⚙️','🪤','🧱','⛓️','🧲','🔫','💣',
'🪓','🔪','🗡️','⚔️','🛡️','🚬','⚰️','🏺','🔮','📿',
'🧿','💈','⚗️','🔭','🔬','🕳️','🩹','🩺','💊','💉',
'🩸','🧬','🦠','🧫','🧪','🌡️','🧹','🪠','🧺','🧻',
]},
{ key: 'symbols', icon: '❤️', label: 'Symbols', emoji: [
'❤️','🧡','💛','💚','💙','💜','🖤','🤍','🤎','💔',
'❣️','💕','💞','💓','💗','💖','💘','💝','💟','☮️',
'✝️','☪️','🕉️','☸️','✡️','🔯','🕎','☯️','☦️','🛐',
'⛎','♈','♉','♊','♋','♌','♍','♎','♏','♐',
'♑','♒','♓','🆔','⚛️','🉑','☢️','☣️','📴','📳',
'🈶','🈚','🈸','🈺','🈷️','✴️','🆚','💮','🉐','㊙️',
'㊗️','🈴','🈵','🈹','🈲','🅰️','🅱️','🆎','🆑','🅾️',
'🆘','❌','⭕','🛑','⛔','📛','🚫','💯','💢','♨️',
'🚷','🚯','🚳','🚱','🔞','📵','🚭','❗','❓','‼️',
'⁉️','🔅','🔆','〽️','⚠️','🚸','🔱','⚜️','🔰','♻️',
'✅','🈯','💹','❎','🌐','💠','Ⓜ️','🌀','💤','🏧',
'🎦','🈁','🔣','','🔤','🔡','🔠','🆖','🆗','🆙',
'🆒','🆕','🆓','0⃣','1⃣','2⃣','3⃣','4⃣','5⃣','6⃣',
'7⃣','8⃣','9⃣','🔟','🔢','#️⃣','*️⃣','⏏️','▶️','⏸️',
'⏹️','⏺️','⏭️','⏮️','⏩','⏪','🔀','🔁','🔂','◀️',
'🔼','🔽','➡️','⬅️','⬆️','⬇️','↗️','↘️','↙️','↖️',
'↕️','↔️','↩️','↪️','⤴️','⤵️','🔃','🔄','🔙','🔚',
'🔛','🔜','🔝',
]},
];
const RECENTS_KEY = 'sw-emoji-recent';
const MAX_RECENTS = 24;
function _loadRecents() {
try {
return JSON.parse(localStorage.getItem(RECENTS_KEY)) || [];
} catch (_) { return []; }
}
function _saveRecent(emoji) {
const recents = _loadRecents().filter(e => e !== emoji);
recents.unshift(emoji);
if (recents.length > MAX_RECENTS) recents.length = MAX_RECENTS;
try { localStorage.setItem(RECENTS_KEY, JSON.stringify(recents)); } catch (_) {}
return recents;
}
/**
* @param {{ open: boolean, onSelect: (emoji: string) => void, onClose: () => void }} props
*/
export function EmojiPicker({ open, onSelect, onClose }) {
const [activeCategory, setActiveCategory] = useState('smileys');
const [search, setSearch] = useState('');
const [recents, setRecents] = useState(_loadRecents);
const searchRef = useRef(null);
const panelRef = useRef(null);
// Focus search on open
useEffect(() => {
if (open && searchRef.current) {
setTimeout(() => searchRef.current?.focus(), 50);
}
}, [open]);
// Close on Escape
useEffect(() => {
if (!open) return;
function _onKey(e) {
if (e.key === 'Escape') { e.stopPropagation(); onClose(); }
}
document.addEventListener('keydown', _onKey, true);
return () => document.removeEventListener('keydown', _onKey, true);
}, [open, onClose]);
// Close on outside click
useEffect(() => {
if (!open) return;
function _onClick(e) {
if (panelRef.current && !panelRef.current.contains(e.target)) {
onClose();
}
}
setTimeout(() => document.addEventListener('click', _onClick), 0);
return () => document.removeEventListener('click', _onClick);
}, [open, onClose]);
const _pick = useCallback((emoji) => {
setRecents(_saveRecent(emoji));
onSelect(emoji);
}, [onSelect]);
// Build active emoji list
const activeEmoji = useMemo(() => {
if (search) {
// Flat search across all categories (emoji chars don't have searchable names,
// so this just filters for exact substring — limited but functional)
const all = CATEGORIES.flatMap(c => c.emoji);
return all.filter(e => e.includes(search));
}
if (activeCategory === 'recent') return recents;
const cat = CATEGORIES.find(c => c.key === activeCategory);
return cat ? cat.emoji : [];
}, [activeCategory, search, recents]);
if (!open) return null;
return html`
<div class="sw-emoji-picker" ref=${panelRef} role="dialog" aria-label="Emoji picker">
<div class="sw-emoji-picker__tabs">
${CATEGORIES.map(c => html`
<button key=${c.key}
class=${'sw-emoji-picker__tab' + (activeCategory === c.key ? ' sw-emoji-picker__tab--active' : '')}
title=${c.label}
onClick=${() => { setActiveCategory(c.key); setSearch(''); }}
type="button">
${c.icon}
</button>`)}
</div>
<input ref=${searchRef}
class="sw-emoji-picker__search"
type="text"
placeholder="Search emoji..."
value=${search}
onInput=${e => setSearch(e.target.value)}
aria-label="Search emoji" />
<div class="sw-emoji-picker__grid" role="listbox">
${activeEmoji.length === 0 && html`
<div class="sw-emoji-picker__empty">
${activeCategory === 'recent' ? 'No recent emoji' : 'No results'}
</div>`}
${activeEmoji.map(e => html`
<button key=${e}
class="sw-emoji-picker__emoji"
onClick=${() => _pick(e)}
role="option"
type="button"
title=${e}>
${e}
</button>`)}
</div>
</div>`;
}

View File

@@ -5,6 +5,9 @@
// 1-on-1 chat experience. Surfaces that need a different
// layout import individual pieces instead.
//
// v0.37.10: Error banner, stop button, regenerate, pagination,
// markdown toolbar, emoji picker, code block copy.
//
// Usage:
// import { ChatPane } from './components/chat-pane/index.js';
// html`<${ChatPane} handleRef=${ref} standalone=${true} getContext=${fn} />`
@@ -20,8 +23,12 @@ import { MessageInput } from './message-input.js';
import { ModelSelector } from './model-selector.js';
import { ChatHistory } from './chat-history.js';
import { renderMarkdown } from './markdown.js';
import { CodeBlock, extractCodeBlocks } from './code-block.js';
import { MessageActions } from './message-actions.js';
import { MarkdownToolbar, handleFormatShortcut } from './markdown-toolbar.js';
import { EmojiPicker } from './emoji-picker.js';
const { useRef, useEffect } = window.hooks;
const { useRef, useEffect, useCallback } = window.hooks;
const html = window.html;
/**
@@ -70,6 +77,8 @@ export function ChatPane(props) {
}
});
const _dismissError = useCallback(() => chat.clearError(), [chat.clearError]);
return html`
<div class=${'sw-chat-pane' + (className ? ' ' + className : '')}>
${standalone && html`
@@ -84,14 +93,28 @@ export function ChatPane(props) {
onChange=${chat.setModel} />
</div>
</div>`}
${chat.error && html`
<div class="sw-chat-pane__error" role="alert">
<span class="sw-chat-pane__error-text">${chat.error}</span>
<button class="sw-chat-pane__error-dismiss"
onClick=${_dismissError}
aria-label="Dismiss error">\u00d7</button>
</div>`}
<${MessageList}
messages=${chat.messages}
streamContent=${chat.streamContent}
streaming=${chat.streaming} />
streaming=${chat.streaming}
hasMore=${chat.hasMore}
loadingMore=${chat.loadingMore}
onLoadMore=${chat.loadMore}
onRegenerate=${chat.regenerate}
onDelete=${chat.deleteMessage} />
<${MessageInput}
handleRef=${inputHandle}
onSend=${chat.sendMessage}
disabled=${chat.sending} />
onStop=${chat.stop}
disabled=${chat.sending}
streaming=${chat.streaming} />
</div>`;
}
@@ -104,3 +127,7 @@ export { MessageInput } from './message-input.js';
export { ModelSelector } from './model-selector.js';
export { ChatHistory } from './chat-history.js';
export { renderMarkdown } from './markdown.js';
export { CodeBlock, extractCodeBlocks } from './code-block.js';
export { MessageActions } from './message-actions.js';
export { MarkdownToolbar, handleFormatShortcut } from './markdown-toolbar.js';
export { EmojiPicker } from './emoji-picker.js';

View File

@@ -0,0 +1,110 @@
// ==========================================
// ChatPane Kit — MarkdownToolbar Component
// ==========================================
// Formatting toolbar for the message input.
// Wraps selection with markdown syntax. Independently importable.
//
// Usage:
// html`<${MarkdownToolbar} textareaRef=${ref} onInput=${fn} />`
const html = window.html;
/**
* Insert markdown wrapper around the textarea's selection.
*
* @param {HTMLTextAreaElement} ta
* @param {string} before — prefix (e.g. '**')
* @param {string} after — suffix (e.g. '**')
* @param {string} placeholder — text if nothing selected
* @param {Function} onInput — trigger resize after edit
*/
function _wrap(ta, before, after, placeholder, onInput) {
if (!ta) return;
ta.focus();
const start = ta.selectionStart;
const end = ta.selectionEnd;
const selected = ta.value.slice(start, end) || placeholder;
const replacement = before + selected + after;
// Use execCommand for undo support, fallback to direct edit
const hasExecCommand = document.queryCommandSupported?.('insertText');
if (hasExecCommand) {
document.execCommand('insertText', false, replacement);
} else {
ta.value = ta.value.slice(0, start) + replacement + ta.value.slice(end);
}
// Select the inserted text (without wrappers) for quick overtype
const selStart = start + before.length;
const selEnd = selStart + selected.length;
ta.setSelectionRange(selStart, selEnd);
if (onInput) onInput();
}
/**
* Insert text at cursor (no wrapping).
*/
function _insert(ta, text, onInput) {
if (!ta) return;
ta.focus();
const start = ta.selectionStart;
const hasExecCommand = document.queryCommandSupported?.('insertText');
if (hasExecCommand) {
document.execCommand('insertText', false, text);
} else {
ta.value = ta.value.slice(0, start) + text + ta.value.slice(ta.selectionEnd);
}
const pos = start + text.length;
ta.setSelectionRange(pos, pos);
if (onInput) onInput();
}
const ACTIONS = [
{ key: 'bold', label: 'B', title: 'Bold (Ctrl+B)', fn: (ta, cb) => _wrap(ta, '**', '**', 'bold text', cb) },
{ key: 'italic', label: 'I', title: 'Italic (Ctrl+I)', fn: (ta, cb) => _wrap(ta, '_', '_', 'italic text', cb), style: 'font-style:italic' },
{ key: 'code', label: '<>', title: 'Inline code', fn: (ta, cb) => _wrap(ta, '`', '`', 'code', cb), style: 'font-family:var(--mono, monospace);font-size:11px' },
{ key: 'codeblk', label: '```',title: 'Code block', fn: (ta, cb) => _wrap(ta, '```\n', '\n```', 'code', cb), style: 'font-family:var(--mono, monospace);font-size:10px' },
{ key: 'link', label: '🔗', title: 'Link', fn: (ta, cb) => _wrap(ta, '[', '](url)', 'link text', cb) },
{ key: 'list', label: '•', title: 'Bullet list', fn: (ta, cb) => _insert(ta, '\n- ', cb) },
{ key: 'olist', label: '1.', title: 'Numbered list', fn: (ta, cb) => _insert(ta, '\n1. ', cb) },
{ key: 'heading', label: 'H', title: 'Heading', fn: (ta, cb) => _insert(ta, '\n## ', cb), style: 'font-weight:700' },
{ key: 'quote', label: '>', title: 'Blockquote', fn: (ta, cb) => _insert(ta, '\n> ', cb) },
];
/**
* @param {{ textareaRef: { current: HTMLTextAreaElement }, onInput?: () => void }} props
*/
export function MarkdownToolbar({ textareaRef, onInput }) {
return html`
<div class="sw-md-toolbar" role="toolbar" aria-label="Formatting">
${ACTIONS.map(a => html`
<button key=${a.key}
class="sw-md-toolbar__btn"
title=${a.title}
style=${a.style || ''}
onClick=${() => a.fn(textareaRef.current, onInput)}
tabindex="-1"
type="button">
${a.label}
</button>`)}
</div>`;
}
/**
* Handle keyboard shortcuts for formatting.
* Call from textarea onKeyDown handler.
*
* @param {KeyboardEvent} e
* @param {HTMLTextAreaElement} ta
* @param {Function} onInput
* @returns {boolean} true if handled
*/
export function handleFormatShortcut(e, ta, onInput) {
if (!(e.ctrlKey || e.metaKey)) return false;
switch (e.key) {
case 'b': e.preventDefault(); _wrap(ta, '**', '**', 'bold', onInput); return true;
case 'i': e.preventDefault(); _wrap(ta, '_', '_', 'italic', onInput); return true;
case 'e': e.preventDefault(); _wrap(ta, '`', '`', 'code', onInput); return true;
default: return false;
}
}

View File

@@ -4,6 +4,8 @@
// Wraps window.marked + window.DOMPurify with fallback.
// Independently importable utility.
//
// v0.37.10: Task list rendering, code block language extraction.
//
// Usage:
// import { renderMarkdown } from './markdown.js';
// const html = renderMarkdown('**bold** text');
@@ -17,6 +19,37 @@ function _escapeHtml(text) {
return text.replace(/[&<>"']/g, c => _escMap[c]);
}
/**
* Configure marked with task list support if available.
*/
let _configured = false;
function _ensureConfigured() {
if (_configured) return;
_configured = true;
if (typeof marked === 'undefined') return;
marked.setOptions({
breaks: true,
gfm: true,
});
// Custom renderer for task list items
const renderer = new marked.Renderer();
const origListItem = renderer.listitem?.bind(renderer);
renderer.listitem = function(text, task, checked) {
if (task) {
const checkbox = checked
? '<input type="checkbox" checked disabled class="sw-task-checkbox" />'
: '<input type="checkbox" disabled class="sw-task-checkbox" />';
return '<li class="sw-task-item">' + checkbox + ' ' + text + '</li>\n';
}
if (origListItem) return origListItem(text, task, checked);
return '<li>' + text + '</li>\n';
};
marked.use({ renderer });
}
/**
* Render markdown text to sanitized HTML.
* Falls back to escaped plaintext when marked/DOMPurify unavailable.
@@ -30,6 +63,7 @@ export function renderMarkdown(text) {
let result;
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
_ensureConfigured();
result = DOMPurify.sanitize(marked.parse(text));
} else {
result = _escapeHtml(text);

View File

@@ -0,0 +1,91 @@
// ==========================================
// ChatPane Kit — MessageActions Component
// ==========================================
// Per-message action toolbar: regenerate, copy, delete.
// Appears on hover/focus. Independently importable.
//
// Usage:
// html`<${MessageActions} role="assistant" content=${text}
// onRegenerate=${fn} onDelete=${fn} />`
const html = window.html;
const { useState, useCallback } = window.hooks;
const REGEN_SVG = html`
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="1 4 1 10 7 10"/>
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
</svg>`;
const COPY_SVG = html`
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>`;
const CHECK_SVG = html`
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>`;
const DELETE_SVG = html`
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>`;
/**
* @param {{
* role: string,
* content: string,
* onRegenerate?: () => void,
* onDelete?: () => void,
* }} props
*/
export function MessageActions({ role, content, onRegenerate, onDelete }) {
const [copied, setCopied] = useState(false);
const _copy = useCallback(async () => {
try {
await navigator.clipboard.writeText(content || '');
} catch (_) {
const ta = document.createElement('textarea');
ta.value = content || '';
ta.style.cssText = 'position:fixed;left:-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [content]);
return html`
<div class="sw-chat-msg__actions" role="toolbar" aria-label="Message actions">
${role === 'assistant' && onRegenerate && html`
<button class="sw-chat-msg__action-btn"
title="Regenerate"
onClick=${onRegenerate}
aria-label="Regenerate response">
${REGEN_SVG}
</button>`}
<button class="sw-chat-msg__action-btn"
title=${copied ? 'Copied!' : 'Copy'}
onClick=${_copy}
aria-label="Copy message text">
${copied ? CHECK_SVG : COPY_SVG}
</button>
${onDelete && html`
<button class="sw-chat-msg__action-btn sw-chat-msg__action-btn--danger"
title="Delete"
onClick=${onDelete}
aria-label="Delete message">
${DELETE_SVG}
</button>`}
</div>`;
}

View File

@@ -1,35 +1,79 @@
// ==========================================
// ChatPane Kit — MessageBubble Component
// ==========================================
// Single message rendering with role styling and markdown.
// Single message rendering with role styling, markdown,
// action toolbar, and enhanced code blocks.
// Independently importable.
//
// v0.37.10: Added message actions, timestamps, code block copy.
//
// Usage:
// html`<${MessageBubble} role="assistant" content=${text} />`
// html`<${MessageBubble} role="assistant" content=${text}
// onRegenerate=${fn} onDelete=${fn} />`
import { renderMarkdown } from './markdown.js';
import { CodeBlock, extractCodeBlocks } from './code-block.js';
import { MessageActions } from './message-actions.js';
const html = window.html;
const { useMemo } = window.hooks;
/**
* @param {{ role: string, content: string, streaming?: boolean }} props
* Format a relative time string from an ISO date.
*/
export function MessageBubble({ role, content, streaming }) {
function _relTime(isoDate) {
if (!isoDate) return '';
try {
const d = new Date(isoDate);
const now = Date.now();
const sec = Math.floor((now - d.getTime()) / 1000);
if (sec < 60) return 'just now';
if (sec < 3600) return Math.floor(sec / 60) + 'm ago';
if (sec < 86400) return Math.floor(sec / 3600) + 'h ago';
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
} catch (_) { return ''; }
}
/**
* @param {{
* role: string,
* content: string,
* id?: string,
* created_at?: string,
* streaming?: boolean,
* onRegenerate?: () => void,
* onDelete?: () => void,
* }} props
*/
export function MessageBubble({ role, content, id, created_at, streaming, onRegenerate, onDelete }) {
const cls = 'sw-chat-msg sw-chat-msg--' + role
+ (streaming ? ' sw-chat-msg--streaming' : '');
if (role === 'user') {
// User messages use escaped text (no markdown)
return html`
<div class=${cls}>
<div class="sw-chat-msg__content"
dangerouslySetInnerHTML=${{ __html: renderMarkdown(content || '') }} />
</div>`;
}
// Parse markdown and extract code blocks for enhanced rendering
const segments = useMemo(() => {
const rendered = renderMarkdown(content || '');
return extractCodeBlocks(rendered);
}, [content]);
const timeStr = _relTime(created_at);
return html`
<div class=${cls}>
<div class="sw-chat-msg__content"
dangerouslySetInnerHTML=${{ __html: renderMarkdown(content || '') }} />
<div class="sw-chat-msg__content">
${segments.map((seg, i) =>
seg.type === 'code'
? html`<${CodeBlock} key=${i} code=${seg.code} language=${seg.language} />`
: html`<span key=${i} dangerouslySetInnerHTML=${{ __html: seg.html }} />`
)}
</div>
${!streaming && html`
<div class="sw-chat-msg__footer">
${timeStr && html`<span class="sw-chat-msg__time">${timeStr}</span>`}
<${MessageActions}
role=${role}
content=${content}
onRegenerate=${onRegenerate}
onDelete=${onDelete} />
</div>`}
</div>`;
}

View File

@@ -1,15 +1,21 @@
// ==========================================
// ChatPane Kit — MessageInput Component
// ==========================================
// Auto-resize textarea with Enter-to-send.
// Auto-resize textarea with Enter-to-send, markdown toolbar,
// emoji picker, and stop button.
// Independently importable.
//
// v0.37.10: Added MarkdownToolbar, EmojiPicker, stop overlay, aria.
//
// Usage:
// const inputHandle = useRef(null);
// html`<${MessageInput} onSend=${fn} disabled=${false} handleRef=${inputHandle} />`
// inputHandle.current.focus();
// html`<${MessageInput} onSend=${fn} onStop=${fn} disabled=${false}
// streaming=${false} handleRef=${inputHandle} />`
const { useRef, useCallback, useEffect } = window.hooks;
import { MarkdownToolbar, handleFormatShortcut } from './markdown-toolbar.js';
import { EmojiPicker } from './emoji-picker.js';
const { useRef, useState, useCallback, useEffect } = window.hooks;
const html = window.html;
const SEND_SVG = html`
@@ -19,11 +25,33 @@ const SEND_SVG = html`
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>`;
const STOP_SVG = html`
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="4" y="4" width="16" height="16" rx="2"/>
</svg>`;
const EMOJI_SVG = html`
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M8 14s1.5 2 4 2 4-2 4-2"/>
<line x1="9" y1="9" x2="9.01" y2="9"/>
<line x1="15" y1="9" x2="15.01" y2="9"/>
</svg>`;
/**
* @param {{ onSend: (text: string) => void, disabled?: boolean, handleRef?: { current: any } }} props
* @param {{
* onSend: (text: string) => void,
* onStop?: () => void,
* disabled?: boolean,
* streaming?: boolean,
* handleRef?: { current: any },
* }} props
*/
export function MessageInput({ onSend, disabled, handleRef }) {
export function MessageInput({ onSend, onStop, disabled, streaming, handleRef }) {
const taRef = useRef(null);
const [emojiOpen, setEmojiOpen] = useState(false);
function _autoResize() {
const ta = taRef.current;
@@ -40,6 +68,7 @@ export function MessageInput({ onSend, disabled, handleRef }) {
setValue(val) { if (taRef.current) { taRef.current.value = val; _autoResize(); } },
focus() { taRef.current?.focus(); },
clear() { if (taRef.current) { taRef.current.value = ''; _autoResize(); } },
getTextarea() { return taRef.current; },
};
}
return () => { if (handleRef) handleRef.current = null; };
@@ -48,6 +77,9 @@ export function MessageInput({ onSend, disabled, handleRef }) {
const _onInput = useCallback(() => _autoResize(), []);
const _onKeyDown = useCallback((e) => {
// Formatting shortcuts (Ctrl+B, Ctrl+I, Ctrl+E)
if (handleFormatShortcut(e, taRef.current, _autoResize)) return;
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const text = taRef.current?.value?.trim();
@@ -66,8 +98,28 @@ export function MessageInput({ onSend, disabled, handleRef }) {
}
}, [onSend, disabled]);
const _insertEmoji = useCallback((emoji) => {
const ta = taRef.current;
if (!ta) return;
ta.focus();
const start = ta.selectionStart;
const hasExecCommand = document.queryCommandSupported?.('insertText');
if (hasExecCommand) {
document.execCommand('insertText', false, emoji);
} else {
ta.value = ta.value.slice(0, start) + emoji + ta.value.slice(ta.selectionEnd);
}
const pos = start + emoji.length;
ta.setSelectionRange(pos, pos);
_autoResize();
}, []);
const _toggleEmoji = useCallback(() => setEmojiOpen(v => !v), []);
const _closeEmoji = useCallback(() => setEmojiOpen(false), []);
return html`
<div class="sw-chat-pane__input-bar">
<${MarkdownToolbar} textareaRef=${taRef} onInput=${_autoResize} />
<div class="sw-msg-input__wrap">
<textarea
ref=${taRef}
@@ -77,13 +129,38 @@ export function MessageInput({ onSend, disabled, handleRef }) {
disabled=${disabled}
onInput=${_onInput}
onKeyDown=${_onKeyDown}
aria-label="Message input"
/>
<div class="sw-msg-input__buttons">
<div class="sw-msg-input__emoji-wrap">
<button
class="sw-msg-input__emoji-btn"
title="Emoji"
onClick=${_toggleEmoji}
type="button"
aria-label="Open emoji picker"
aria-expanded=${emojiOpen}
>${EMOJI_SVG}</button>
<${EmojiPicker}
open=${emojiOpen}
onSelect=${_insertEmoji}
onClose=${_closeEmoji} />
</div>
${streaming && onStop
? html`<button
class="sw-msg-input__stop"
title="Stop generating"
onClick=${onStop}
aria-label="Stop generating"
>${STOP_SVG}</button>`
: html`<button
class="sw-msg-input__send"
title="Send"
disabled=${disabled}
onClick=${_onClickSend}
>${SEND_SVG}</button>
aria-label="Send message"
>${SEND_SVG}</button>`}
</div>
</div>
</div>`;
}

View File

@@ -1,25 +1,44 @@
// ==========================================
// ChatPane Kit — MessageList Component
// ==========================================
// Scrollable message list with auto-scroll-to-bottom.
// Scrollable message list with auto-scroll-to-bottom
// and "load older" pagination.
// Independently importable.
//
// v0.37.10: Added pagination (load more), regenerate/delete wiring.
//
// Usage:
// html`<${MessageList} messages=${msgs} streamContent=${text} streaming=${true} />`
// html`<${MessageList} messages=${msgs} streamContent=${text}
// streaming=${true} hasMore=${true} loadingMore=${false}
// onLoadMore=${fn} onRegenerate=${fn} onDelete=${fn} />`
import { MessageBubble } from './message-bubble.js';
const { useRef, useEffect } = window.hooks;
const { useRef, useEffect, useCallback } = window.hooks;
const html = window.html;
const SCROLL_THRESHOLD = 60;
/**
* @param {{ messages: Array<{role: string, content: string}>, streamContent?: string, streaming?: boolean, className?: string }} props
* @param {{
* messages: Array<{role: string, content: string, id?: string, created_at?: string}>,
* streamContent?: string,
* streaming?: boolean,
* hasMore?: boolean,
* loadingMore?: boolean,
* onLoadMore?: () => void,
* onRegenerate?: (msgId: string) => void,
* onDelete?: (msgId: string) => void,
* className?: string,
* }} props
*/
export function MessageList({ messages, streamContent, streaming, className }) {
export function MessageList({
messages, streamContent, streaming, hasMore, loadingMore,
onLoadMore, onRegenerate, onDelete, className,
}) {
const scrollRef = useRef(null);
const wasAtBottom = useRef(true);
const prevScrollHeightRef = useRef(0);
// Track scroll position
function _onScroll() {
@@ -36,19 +55,50 @@ export function MessageList({ messages, streamContent, streaming, className }) {
}
}, [messages, streamContent]);
// Preserve scroll position when prepending older messages
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const prevH = prevScrollHeightRef.current;
if (prevH && el.scrollHeight > prevH && !wasAtBottom.current) {
// Older messages were prepended — maintain relative position
el.scrollTop += (el.scrollHeight - prevH);
}
prevScrollHeightRef.current = el.scrollHeight;
}, [messages.length]);
const hasContent = messages.length > 0 || streaming;
return html`
<div class=${'sw-chat-pane__messages' + (className ? ' ' + className : '')}
ref=${scrollRef}
onScroll=${_onScroll}>
onScroll=${_onScroll}
role="log"
aria-label="Chat messages"
aria-live="polite">
${hasMore && html`
<div class="sw-chat-pane__load-more">
<button class="sw-chat-pane__load-more-btn"
onClick=${onLoadMore}
disabled=${loadingMore}
aria-label="Load older messages">
${loadingMore ? 'Loading\u2026' : 'Load older messages'}
</button>
</div>`}
${!hasContent && html`
<div class="sw-chat-welcome">
<p class="sw-chat-welcome__title">Start a conversation</p>
<p class="sw-chat-welcome__hint">Type a message below to get started.</p>
</div>`}
${messages.map((m, i) => html`
<${MessageBubble} key=${m.id || i} role=${m.role} content=${m.content} />`)}
<${MessageBubble}
key=${m.id || i}
role=${m.role}
content=${m.content}
id=${m.id}
created_at=${m.created_at}
onRegenerate=${m.role === 'assistant' && m.id && onRegenerate ? () => onRegenerate(m.id) : undefined}
onDelete=${m.id && onDelete ? () => onDelete(m.id) : undefined} />`)}
${streaming && html`
<${MessageBubble} role="assistant" content=${streamContent} streaming=${true} />`}
</div>`;

View File

@@ -4,6 +4,8 @@
// Core chat state machine: messages, channel CRUD, send/receive,
// model selection. Independently importable.
//
// v0.37.10: Added regenerate(), deleteMessage(), pagination.
//
// Usage:
// const chat = useChat({ onChannelChange });
// chat.sendMessage('Hello');
@@ -12,6 +14,8 @@ import { useStream } from './use-stream.js';
const { useState, useRef, useCallback } = window.hooks;
const PAGE_SIZE = 50;
/**
* @param {{ initialChannelId?: string, getContext?: () => {path: string, content: string}|null, onChannelChange?: (id: string) => void }} opts
*/
@@ -23,21 +27,22 @@ export function useChat(opts = {}) {
const [sending, setSending] = useState(false);
const [error, setError] = useState(null);
const [model, setModel] = useState('');
const [hasMore, setHasMore] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const channelRef = useRef(channelId);
const pageRef = useRef(1);
const { streamContent, streaming, startStream, stopStream } = useStream();
// Keep ref in sync
channelRef.current = channelId;
// ── Init model from first enabled ──────
// Deferred — set on first render if not already set
// ── Init model from session ─────────────
if (!model && window.sw?.auth?.session?.settings?.model) {
// Will be picked up on next render cycle via setModel
setTimeout(() => setModel(window.sw.auth.session.settings.model), 0);
}
// ── Send Message ───────────────────────
// ── Send Message ───────────────────────
const sendMessage = useCallback(async (text) => {
if (!text.trim() || sending) return;
setSending(true);
@@ -78,9 +83,6 @@ export function useChat(opts = {}) {
const data = { channel_id: cid, content, model };
const resp = await window.sw.api.channels.complete(data);
await startStream(resp);
// After stream completes, append assistant message
// (streamContent will have the final text via useStream)
} catch (e) {
if (e.name !== 'AbortError') {
setError('Error: ' + e.message);
@@ -94,15 +96,54 @@ export function useChat(opts = {}) {
const prevStreamingRef = useRef(false);
if (prevStreamingRef.current && !streaming && streamContent) {
prevStreamingRef.current = false;
// Use functional update to avoid stale closure
setMessages(prev => [...prev, { role: 'assistant', content: streamContent }]);
}
prevStreamingRef.current = streaming;
// ── Switch Channel ─────────────────────
// ── Regenerate ──────────────────────────
const regenerate = useCallback(async (msgId) => {
const cid = channelRef.current;
if (!cid || !msgId || sending) return;
setSending(true);
setError(null);
try {
const resp = await window.sw.api.channels.regenerate(cid, msgId, { model });
// Remove the old assistant message being regenerated
setMessages(prev => {
const idx = prev.findIndex(m => m.id === msgId);
return idx >= 0 ? prev.slice(0, idx) : prev;
});
await startStream(resp);
} catch (e) {
if (e.name !== 'AbortError') {
setError('Regenerate failed: ' + e.message);
}
}
setSending(false);
}, [sending, model, startStream]);
// ── Delete Message ──────────────────────
const deleteMessage = useCallback(async (msgId) => {
// Optimistic remove from UI
setMessages(prev => prev.filter(m => m.id !== msgId));
// If API supports delete, fire it (no-op if not available)
if (window.sw?.api?.channels?.deleteMessage) {
try {
await window.sw.api.channels.deleteMessage(channelRef.current, msgId);
} catch (_) { /* swallow — already removed from UI */ }
}
}, []);
// ── Switch Channel ──────────────────────
const switchChannel = useCallback(async (id) => {
setMessages([]);
setError(null);
setHasMore(false);
pageRef.current = 1;
channelRef.current = id;
setChannelId(id);
if (onChannelChange) onChannelChange(id);
@@ -110,30 +151,60 @@ export function useChat(opts = {}) {
if (!id) return;
try {
const resp = await window.sw.api.channels.messages(id, { page: 1, per_page: 100 });
setMessages(resp?.data || resp || []);
const resp = await window.sw.api.channels.messages(id, { page: 1, per_page: PAGE_SIZE });
const data = resp?.data || resp || [];
setMessages(data);
setHasMore(data.length >= PAGE_SIZE);
} catch (e) {
setError('Failed to load messages: ' + e.message);
}
}, [onChannelChange]);
// ── New Chat ───────────────────────────
// ── Load More (pagination) ──────────────
const loadMore = useCallback(async () => {
const cid = channelRef.current;
if (!cid || loadingMore || !hasMore) return;
setLoadingMore(true);
const nextPage = pageRef.current + 1;
try {
const resp = await window.sw.api.channels.messages(cid, { page: nextPage, per_page: PAGE_SIZE });
const data = resp?.data || resp || [];
if (data.length > 0) {
pageRef.current = nextPage;
// Prepend older messages
setMessages(prev => [...data, ...prev]);
setHasMore(data.length >= PAGE_SIZE);
} else {
setHasMore(false);
}
} catch (e) {
setError('Failed to load older messages: ' + e.message);
}
setLoadingMore(false);
}, [loadingMore, hasMore]);
// ── New Chat ────────────────────────────
const newChat = useCallback(() => {
stopStream();
setMessages([]);
setError(null);
setHasMore(false);
pageRef.current = 1;
channelRef.current = null;
setChannelId(null);
if (onChannelChange) onChannelChange(null);
}, [stopStream, onChannelChange]);
// ── Stop ───────────────────────────────
// ── Stop ───────────────────────────────
const stop = useCallback(() => {
stopStream();
setSending(false);
}, [stopStream]);
// ── Clear Error ────────────────────────
// ── Clear Error ────────────────────────
const clearError = useCallback(() => setError(null), []);
return {
@@ -151,5 +222,11 @@ export function useChat(opts = {}) {
// Streaming state (passthrough from useStream)
streamContent,
streaming,
// v0.37.10: new methods
regenerate,
deleteMessage,
loadMore,
hasMore,
loadingMore,
};
}

View File

@@ -4,9 +4,13 @@
* RBAC-gated items based on sw.can() / sw.isAdmin / sw.isTeamAdmin().
* Surfaces mount this wherever they want; the shell does not own placement.
*
* Surface links (Notes, extensions) are auto-included and the CURRENT
* surface is filtered out so every surface gets the same menu minus itself.
*
* Props:
* placement — Menu direction: 'down-right' (default) | 'down-left'
* onAction — Override handler; if omitted, uses default routing
* placement — Menu direction: 'down-right' (default) | 'up-right' etc.
* onAction — Override handler; if omitted, navigates via location.href
* extraItems — Additional menu items inserted after surface links
*/
const { html } = window;
const { useState, useRef, useMemo } = hooks;
@@ -14,7 +18,14 @@ const { useState, useRef, useMemo } = hooks;
import { Avatar } from '../primitives/avatar.js';
import { Menu } from '../primitives/menu.js';
export function UserMenu({ placement = 'down-right', onAction }) {
/**
* Detect current surface from body[data-surface].
*/
function _currentSurface() {
return document.body?.dataset?.surface || '';
}
export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
const [open, setOpen] = useState(false);
const btnRef = useRef(null);
@@ -23,18 +34,51 @@ export function UserMenu({ placement = 'down-right', onAction }) {
const authenticated = sw?.auth?.isAuthenticated;
const items = useMemo(() => {
const list = [
{ label: 'Settings', action: 'settings', icon: '\u2699\ufe0f' },
const current = _currentSurface();
const list = [];
// ── Surface links (auto-filtered) ──────
// Each surface that exists gets a menu item, except the current one.
const surfaces = [
{ key: 'chat', label: 'Chat', icon: '\ud83d\udcac' },
{ key: 'notes', label: 'Notes', icon: '\ud83d\udcdd' },
];
// Add extension surfaces from page data if available
const extSurfaces = window.__EXTENSION_SURFACES__ || [];
for (const ext of extSurfaces) {
surfaces.push({ key: ext.id || ext.route, label: ext.title, icon: '\ud83e\udde9' });
}
const surfaceItems = surfaces
.filter(s => s.key !== current)
.map(s => ({ label: s.label, action: s.key, icon: s.icon }));
if (surfaceItems.length) {
list.push(...surfaceItems);
list.push({ divider: true });
}
// ── Extra items from caller ────────────
if (extraItems && extraItems.length) {
list.push(...extraItems);
list.push({ divider: true });
}
// ── Standard items ─────────────────────
// Settings — skip if current surface IS settings
if (current !== 'settings') {
list.push({ label: 'Settings', action: 'settings', icon: '\u2699\ufe0f' });
}
// Admin — requires admin role
if (!authenticated || sw?.isAdmin) {
if ((!authenticated || sw?.isAdmin) && current !== 'admin') {
list.push({ label: 'Admin', action: 'admin', icon: '\ud83d\udee1\ufe0f' });
}
// Team Admin — requires admin role on at least one team
const hasTeamAdmin = sw?.auth?.teams?.some(t => t.my_role === 'admin');
if (!authenticated || hasTeamAdmin) {
if ((!authenticated || hasTeamAdmin) && current !== 'team-admin') {
list.push({ label: 'Team Admin', action: 'team-admin', icon: '\ud83d\udc65' });
}
@@ -47,15 +91,27 @@ export function UserMenu({ placement = 'down-right', onAction }) {
list.push({ label: 'Sign Out', action: 'sign-out' });
return list;
}, [user, authenticated]);
}, [user, authenticated, extraItems]);
function handleSelect(action) {
setOpen(false);
if (onAction) return onAction(action);
if (action === 'sign-out') {
sw?.auth?.logout();
} else {
sw?.emit('shell.navigate', { target: action });
// Default routing
const BASE = window.__BASE__ || '';
switch (action) {
case 'sign-out':
window.sw?.auth?.logout();
break;
case 'debug':
const dbg = document.getElementById('debugModal');
if (dbg) dbg.classList.toggle('active');
break;
case 'chat':
location.href = BASE + '/';
break;
default:
location.href = BASE + '/' + action;
}
}

View File

@@ -0,0 +1,66 @@
// ==========================================
// Chat Surface — ChatWorkspace Component
// ==========================================
// Header bar (model selector, tools, sidebar toggle) + ChatPane.
// ChatPane runs standalone=false — the surface manages navigation.
import { ChatPane, ModelSelector, useChat } from '../../components/chat-pane/index.js';
const html = window.html;
const { useRef, useEffect } = window.hooks;
const PANEL_SVG = html`
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<line x1="9" y1="3" x2="9" y2="21"/>
</svg>`;
/**
* @param {{
* activeId: string|null,
* activeType: 'chat'|'channel'|null,
* onChannelChange: (id: string) => void,
* onNewChat: () => void,
* sidebarOpen: boolean,
* onToggleSidebar: () => void,
* toolsButton?: any,
* }} props
*/
export function ChatWorkspace({
activeId, activeType, onChannelChange, onNewChat,
sidebarOpen, onToggleSidebar, toolsButton,
}) {
const chatRef = useRef(null);
// When activeId changes, tell ChatPane to switch channel
useEffect(() => {
if (chatRef.current) {
chatRef.current.setChannel(activeId);
}
}, [activeId]);
return html`
<div class="sw-chat-surface__workspace">
<div class="sw-chat-surface__workspace-header">
<div class="sw-chat-surface__workspace-header-left">
${!sidebarOpen && html`
<button class="sw-chat-surface__sidebar-toggle"
onClick=${onToggleSidebar}
title="Show sidebar"
aria-label="Show sidebar">
${PANEL_SVG}
</button>`}
</div>
<div class="sw-chat-surface__workspace-header-right">
${toolsButton}
</div>
</div>
<${ChatPane}
channelId=${activeId}
standalone=${false}
handleRef=${chatRef}
onChannelChange=${onChannelChange}
className="sw-chat-surface__chat-pane" />
</div>`;
}

View File

@@ -0,0 +1,115 @@
// ==========================================
// Chat Surface — Root Component
// ==========================================
// Main chat surface: sidebar + workspace.
// Follows the settings/admin mount pattern.
//
// v0.37.10: New Preact surface replacing the legacy SPA.
import { ToastContainer } from '../../primitives/toast.js';
import { DialogStack } from '../../shell/dialog-stack.js';
import { useWorkspace } from './use-workspace.js';
import { useSidebar } from './use-sidebar.js';
import { Sidebar } from './sidebar.js';
import { ChatWorkspace } from './chat-workspace.js';
import { useTools, ToolsPopup } from './tools-popup.js';
const { render } = window.preact;
const html = window.html;
const { useState, useCallback } = window.hooks;
const WRENCH_SVG = html`
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0
0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1
7.94-7.94l-3.76 3.76z"/>
</svg>`;
function ChatSurface() {
const workspace = useWorkspace();
const sidebar = useSidebar({ activeId: workspace.activeId });
const tools = useTools();
const [toolsOpen, setToolsOpen] = useState(false);
const _onNewChat = useCallback(() => {
workspace.clearSelection();
}, [workspace.clearSelection]);
const _onChannelChange = useCallback((id) => {
if (id) workspace.selectChat(id);
}, [workspace.selectChat]);
const _toggleSidebar = useCallback(() => {
workspace.setSidebarOpen(!workspace.sidebarOpen);
}, [workspace.sidebarOpen, workspace.setSidebarOpen]);
const _toggleTools = useCallback(() => {
setToolsOpen(v => !v);
}, []);
const _closeTools = useCallback(() => {
setToolsOpen(false);
}, []);
// Tools button — shared between mobile bar and workspace header
const toolsButton = html`
<button class="sw-chat-surface__tools-btn"
onClick=${_toggleTools}
title="Tools"
aria-label="Toggle tools"
aria-expanded=${toolsOpen}>
${WRENCH_SVG}
${tools.disabled.size > 0 && html`
<span class="sw-chat-surface__tools-badge">${tools.disabled.size}</span>`}
</button>`;
return html`
<div class="sw-chat-surface">
${/* Mobile sidebar overlay */''}
${workspace.sidebarOpen && html`
<div class="sw-chat-surface__sidebar-overlay"
onClick=${_toggleSidebar} />`}
${/* Sidebar */''}
${workspace.sidebarOpen && html`
<${Sidebar}
sidebar=${sidebar}
activeId=${workspace.activeId}
onSelectChat=${workspace.selectChat}
onSelectChannel=${workspace.selectChannel}
onNewChat=${_onNewChat}
onCollapse=${_toggleSidebar} />`}
${/* Main workspace */''}
<div class="sw-chat-surface__main">
<${ChatWorkspace}
activeId=${workspace.activeId}
activeType=${workspace.activeType}
onChannelChange=${_onChannelChange}
onNewChat=${_onNewChat}
sidebarOpen=${workspace.sidebarOpen}
onToggleSidebar=${_toggleSidebar}
toolsButton=${toolsButton} />
<${ToolsPopup}
open=${toolsOpen}
onClose=${_closeTools}
categories=${tools.categories}
disabled=${tools.disabled}
onToggle=${tools.toggle}
onToggleCategory=${tools.toggleCategory} />
</div>
</div>
<${ToastContainer} />
<${DialogStack} />
`;
}
// ── Mount ────────────────────────────────────
const mount = document.getElementById('chat-mount');
if (mount) {
render(html`<${ChatSurface} />`, mount);
console.log('[chat] Surface mounted (v0.37.10)');
}
export { ChatSurface };

View File

@@ -0,0 +1,65 @@
// ==========================================
// Chat Surface — SidebarChannels Component
// ==========================================
// Collapsible channels section: DMs and named channels.
const html = window.html;
const CHEVRON_SVG = html`
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"/>
</svg>`;
const HASH_SVG = html`
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" y1="9" x2="20" y2="9"/>
<line x1="4" y1="15" x2="20" y2="15"/>
<line x1="10" y1="3" x2="8" y2="21"/>
<line x1="16" y1="3" x2="14" y2="21"/>
</svg>`;
/**
* @param {{
* channels: Array<{id: string, title: string, unread_count?: number}>,
* activeId: string|null,
* collapsed: boolean,
* onToggle: () => void,
* onSelect: (id: string) => void,
* }} props
*/
export function SidebarChannels({ channels, activeId, collapsed, onToggle, onSelect }) {
if (!channels.length && collapsed) return null;
return html`
<div class="sw-chat-surface__section">
<button class="sw-chat-surface__section-header"
onClick=${onToggle}
aria-expanded=${!collapsed}>
<span class=${'sw-chat-surface__chevron' + (collapsed ? '' : ' sw-chat-surface__chevron--open')}>
${CHEVRON_SVG}
</span>
<span class="sw-chat-surface__section-label">Channels</span>
${channels.length > 0 && html`
<span class="sw-chat-surface__section-count">${channels.length}</span>`}
</button>
${!collapsed && html`
<div class="sw-chat-surface__section-body" role="listbox">
${channels.length === 0 && html`
<div class="sw-chat-surface__empty">No channels</div>`}
${channels.map(ch => html`
<button key=${ch.id}
class=${'sw-chat-surface__item' + (ch.id === activeId ? ' sw-chat-surface__item--active' : '')}
onClick=${() => onSelect(ch.id)}
role="option"
aria-selected=${ch.id === activeId}
title=${ch.title || 'Untitled'}>
<span class="sw-chat-surface__item-icon">${HASH_SVG}</span>
<span class="sw-chat-surface__item-title">${ch.title || 'Untitled'}</span>
${(ch.unread_count || 0) > 0 && html`
<span class="sw-chat-surface__unread">${ch.unread_count}</span>`}
</button>`)}
</div>`}
</div>`;
}

View File

@@ -0,0 +1,199 @@
// ==========================================
// Chat Surface — SidebarChats Component
// ==========================================
// Personal AI chats with folder grouping and context menus.
const html = window.html;
const { useState, useCallback, useRef, useEffect } = window.hooks;
const CHEVRON_SVG = html`
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"/>
</svg>`;
const CHAT_SVG = html`
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>`;
const FOLDER_SVG = html`
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>`;
const DOTS_SVG = html`
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/>
</svg>`;
/**
* @param {{
* chatsByFolder: Record<string, Array>,
* folders: Array<{id: string, name: string}>,
* activeId: string|null,
* collapsed: boolean,
* onToggle: () => void,
* onSelect: (id: string) => void,
* onRename: (id: string, title: string) => void,
* onDelete: (id: string) => void,
* onMoveToFolder: (chatId: string, folderId: string|null) => void,
* onCreateFolder: (name: string) => void,
* onRenameFolder: (id: string, name: string) => void,
* onDeleteFolder: (id: string) => void,
* }} props
*/
export function SidebarChats({
chatsByFolder, folders, activeId, collapsed, onToggle, onSelect,
onRename, onDelete, onMoveToFolder, onCreateFolder,
onRenameFolder, onDeleteFolder,
}) {
const [contextMenu, setContextMenu] = useState(null);
const menuRef = useRef(null);
// Close context menu on outside click or Escape
useEffect(() => {
if (!contextMenu) return;
function _close(e) {
if (e.type === 'keydown' && e.key !== 'Escape') return;
if (e.type === 'click' && menuRef.current?.contains(e.target)) return;
setContextMenu(null);
}
document.addEventListener('click', _close);
document.addEventListener('keydown', _close);
return () => {
document.removeEventListener('click', _close);
document.removeEventListener('keydown', _close);
};
}, [contextMenu]);
const _openMenu = useCallback((e, type, item) => {
e.preventDefault();
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setContextMenu({ type, item, x: rect.right, y: rect.bottom });
}, []);
const _action = useCallback(async (action, item) => {
setContextMenu(null);
if (action === 'rename') {
const newTitle = prompt('Rename:', item.title || item.name || '');
if (newTitle && newTitle.trim()) {
if (item.name !== undefined) await onRenameFolder(item.id, newTitle.trim());
else await onRename(item.id, newTitle.trim());
}
} else if (action === 'delete') {
const label = item.name !== undefined ? 'folder' : 'chat';
if (confirm('Delete this ' + label + '?')) {
if (item.name !== undefined) await onDeleteFolder(item.id);
else await onDelete(item.id);
}
} else if (action === 'move-out') {
await onMoveToFolder(item.id, null);
} else if (action === 'new-folder') {
const name = prompt('Folder name:');
if (name && name.trim()) await onCreateFolder(name.trim());
} else if (action.startsWith('move-to:')) {
const fid = action.slice(8);
await onMoveToFolder(item.id, fid);
}
}, [onRename, onDelete, onMoveToFolder, onCreateFolder, onRenameFolder, onDeleteFolder]);
// Count total chats
const totalChats = Object.values(chatsByFolder).reduce((sum, arr) => sum + arr.length, 0);
const unfolderedChats = chatsByFolder[''] || [];
return html`
<div class="sw-chat-surface__section">
<button class="sw-chat-surface__section-header"
onClick=${onToggle}
aria-expanded=${!collapsed}>
<span class=${'sw-chat-surface__chevron' + (collapsed ? '' : ' sw-chat-surface__chevron--open')}>
${CHEVRON_SVG}
</span>
<span class="sw-chat-surface__section-label">Chats</span>
${totalChats > 0 && html`
<span class="sw-chat-surface__section-count">${totalChats}</span>`}
</button>
${!collapsed && html`
<div class="sw-chat-surface__section-body" role="listbox">
${/* Folders first */''}
${folders.map(folder => {
const folderChats = chatsByFolder[folder.id] || [];
return html`
<div key=${'f-' + folder.id} class="sw-chat-surface__folder">
<div class="sw-chat-surface__folder-header">
<span class="sw-chat-surface__item-icon">${FOLDER_SVG}</span>
<span class="sw-chat-surface__folder-name">${folder.name}</span>
<span class="sw-chat-surface__folder-count">${folderChats.length}</span>
<button class="sw-chat-surface__item-menu"
onClick=${(e) => _openMenu(e, 'folder', folder)}
title="Folder actions"
aria-label="Folder actions">
${DOTS_SVG}
</button>
</div>
${folderChats.map(ch => html`
<${ChatItem} key=${ch.id} chat=${ch} activeId=${activeId}
onSelect=${onSelect} onOpenMenu=${_openMenu}
indent=${true} />`)}
</div>`;
})}
${/* Unfoldered chats */''}
${unfolderedChats.map(ch => html`
<${ChatItem} key=${ch.id} chat=${ch} activeId=${activeId}
onSelect=${onSelect} onOpenMenu=${_openMenu} />`)}
${totalChats === 0 && html`
<div class="sw-chat-surface__empty">No chats yet</div>`}
</div>`}
${/* Context Menu */''}
${contextMenu && html`
<div class="sw-chat-surface__context-menu" ref=${menuRef}
style=${'left:' + contextMenu.x + 'px;top:' + contextMenu.y + 'px;'}>
${contextMenu.type === 'chat' && html`
<button onClick=${() => _action('rename', contextMenu.item)}>Rename</button>
${folders.length > 0 && folders.map(f => html`
<button key=${f.id} onClick=${() => _action('move-to:' + f.id, contextMenu.item)}>
Move to ${f.name}
</button>`)}
${contextMenu.item.folder_id && html`
<button onClick=${() => _action('move-out', contextMenu.item)}>Remove from folder</button>`}
<button class="sw-chat-surface__ctx-danger"
onClick=${() => _action('delete', contextMenu.item)}>Delete</button>
`}
${contextMenu.type === 'folder' && html`
<button onClick=${() => _action('rename', contextMenu.item)}>Rename</button>
<button class="sw-chat-surface__ctx-danger"
onClick=${() => _action('delete', contextMenu.item)}>Delete</button>
`}
</div>`}
</div>`;
}
function ChatItem({ chat, activeId, onSelect, onOpenMenu, indent }) {
return html`
<button class=${'sw-chat-surface__item' + (chat.id === activeId ? ' sw-chat-surface__item--active' : '') + (indent ? ' sw-chat-surface__item--indent' : '')}
onClick=${() => onSelect(chat.id)}
onContextMenu=${(e) => { e.preventDefault(); onOpenMenu(e, 'chat', chat); }}
role="option"
aria-selected=${chat.id === activeId}
title=${chat.title || 'Untitled'}>
<span class="sw-chat-surface__item-icon">${CHAT_SVG}</span>
<span class="sw-chat-surface__item-title">${chat.title || 'Untitled'}</span>
<button class="sw-chat-surface__item-menu"
onClick=${(e) => _stopAndOpen(e, onOpenMenu, 'chat', chat)}
title="Chat actions"
aria-label="Chat actions">
${DOTS_SVG}
</button>
</button>`;
}
function _stopAndOpen(e, onOpenMenu, type, item) {
e.stopPropagation();
onOpenMenu(e, type, item);
}

View File

@@ -0,0 +1,109 @@
// ==========================================
// Chat Surface — Sidebar Component
// ==========================================
// Search bar, new chat, channels section, chats section with folders.
// Footer has UserMenu (avatar) — all navigation lives in the menu flyout.
import { SidebarChannels } from './sidebar-channels.js';
import { SidebarChats } from './sidebar-chats.js';
import { UserMenu } from '../../shell/user-menu.js';
const html = window.html;
const { useCallback } = window.hooks;
const PLUS_SVG = html`
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>`;
const SEARCH_SVG = html`
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>`;
const COLLAPSE_SVG = html`
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<line x1="9" y1="3" x2="9" y2="21"/>
</svg>`;
/**
* @param {{
* sidebar: ReturnType<import('./use-sidebar.js').useSidebar>,
* activeId: string|null,
* onSelectChat: (id: string) => void,
* onSelectChannel: (id: string) => void,
* onNewChat: () => void,
* onCollapse: () => void,
* }} props
*/
export function Sidebar({ sidebar, activeId, onSelectChat, onSelectChannel, onNewChat, onCollapse }) {
const _onSearch = useCallback((e) => {
sidebar.setSearch(e.target.value);
}, [sidebar.setSearch]);
return html`
<div class="sw-chat-surface__sidebar" role="navigation" aria-label="Chat sidebar">
${/* Header: new chat + collapse */''}
<div class="sw-chat-surface__sidebar-header">
<button class="sw-chat-surface__new-chat-btn"
onClick=${onNewChat}
title="New chat"
aria-label="Start new chat">
${PLUS_SVG}
<span>New Chat</span>
</button>
<button class="sw-chat-surface__collapse-btn"
onClick=${onCollapse}
title="Collapse sidebar"
aria-label="Collapse sidebar">
${COLLAPSE_SVG}
</button>
</div>
${/* Search */''}
<div class="sw-chat-surface__search-wrap">
<span class="sw-chat-surface__search-icon">${SEARCH_SVG}</span>
<input class="sw-chat-surface__search"
type="text"
placeholder="Search\u2026"
value=${sidebar.search}
onInput=${_onSearch}
aria-label="Search chats and channels" />
</div>
${/* Scrollable content */''}
<div class="sw-chat-surface__sidebar-body">
<${SidebarChannels}
channels=${sidebar.channels}
activeId=${activeId}
collapsed=${sidebar.collapsed.channels}
onToggle=${() => sidebar.toggleCollapsed('channels')}
onSelect=${onSelectChannel} />
<${SidebarChats}
chatsByFolder=${sidebar.chatsByFolder}
folders=${sidebar.folders}
activeId=${activeId}
collapsed=${sidebar.collapsed.chats}
onToggle=${() => sidebar.toggleCollapsed('chats')}
onSelect=${onSelectChat}
onRename=${sidebar.renameChat}
onDelete=${sidebar.deleteChat}
onMoveToFolder=${sidebar.moveToFolder}
onCreateFolder=${sidebar.createFolder}
onRenameFolder=${sidebar.renameFolder}
onDeleteFolder=${sidebar.deleteFolder} />
</div>
${/* Footer: UserMenu only */''}
<div class="sw-chat-surface__sidebar-footer">
<${UserMenu} placement="up-right" />
</div>
</div>`;
}

View File

@@ -0,0 +1,171 @@
// ==========================================
// Chat Surface — ToolsPopup Component
// ==========================================
// Scrollable popup anchored to a toolbar icon.
// Category-based tool toggles with persistence to localStorage.
const html = window.html;
const { useState, useEffect, useRef, useCallback, useMemo } = window.hooks;
const STORAGE_KEY = 'sw-disabled-tools';
/**
* Load tools list from API and organize by category.
* @returns {{ categories: Array, disabled: Set, toggle: Function, getDisabled: Function }}
*/
export function useTools() {
const [tools, setTools] = useState([]);
const [disabled, setDisabled] = useState(_loadDisabled);
useEffect(() => {
// Load available tools from API
if (window.sw?.api?.admin?.tools?.list) {
window.sw.api.admin.tools.list().then(resp => {
setTools(resp?.data || resp || []);
}).catch(() => {});
} else if (window.sw?.api?.channels?.tools) {
// Fallback — try the channels tools endpoint
window.sw.api.channels.tools?.().then(resp => {
setTools(resp?.data || resp || []);
}).catch(() => {});
}
}, []);
const categories = useMemo(() => {
const map = {};
for (const t of tools) {
const cat = t.category || 'Other';
if (!map[cat]) map[cat] = [];
map[cat].push(t);
}
return Object.entries(map).map(([name, items]) => ({ name, items }));
}, [tools]);
const toggle = useCallback((toolName) => {
setDisabled(prev => {
const next = new Set(prev);
if (next.has(toolName)) next.delete(toolName);
else next.add(toolName);
_saveDisabled(next);
return next;
});
}, []);
const toggleCategory = useCallback((catName) => {
setDisabled(prev => {
const cat = categories.find(c => c.name === catName);
if (!cat) return prev;
const next = new Set(prev);
const allDisabled = cat.items.every(t => next.has(t.name));
for (const t of cat.items) {
if (allDisabled) next.delete(t.name);
else next.add(t.name);
}
_saveDisabled(next);
return next;
});
}, [categories]);
const getDisabled = useCallback(() => [...disabled], [disabled]);
return { categories, disabled, toggle, toggleCategory, getDisabled };
}
/**
* @param {{
* open: boolean,
* onClose: () => void,
* categories: Array<{name: string, items: Array<{name: string, description?: string}>}>,
* disabled: Set<string>,
* onToggle: (name: string) => void,
* onToggleCategory: (catName: string) => void,
* }} props
*/
export function ToolsPopup({ open, onClose, categories, disabled, onToggle, onToggleCategory }) {
const panelRef = useRef(null);
const [expandedCats, setExpandedCats] = useState({});
// Close on Escape or outside click
useEffect(() => {
if (!open) return;
function _onKey(e) {
if (e.key === 'Escape') { e.stopPropagation(); onClose(); }
}
function _onClick(e) {
if (panelRef.current && !panelRef.current.contains(e.target)) onClose();
}
document.addEventListener('keydown', _onKey, true);
setTimeout(() => document.addEventListener('click', _onClick), 0);
return () => {
document.removeEventListener('keydown', _onKey, true);
document.removeEventListener('click', _onClick);
};
}, [open, onClose]);
const _toggleCat = useCallback((name) => {
setExpandedCats(prev => ({ ...prev, [name]: !prev[name] }));
}, []);
if (!open) return null;
const disabledCount = disabled.size;
return html`
<div class="sw-tools-popup" ref=${panelRef} role="dialog" aria-label="Tools toggle">
<div class="sw-tools-popup__header">
<span class="sw-tools-popup__title">Tools</span>
${disabledCount > 0 && html`
<span class="sw-tools-popup__badge">${disabledCount} disabled</span>`}
</div>
<div class="sw-tools-popup__body">
${categories.length === 0 && html`
<div class="sw-tools-popup__empty">No tools available</div>`}
${categories.map(cat => {
const allDisabled = cat.items.every(t => disabled.has(t.name));
const someDisabled = cat.items.some(t => disabled.has(t.name));
const isExpanded = expandedCats[cat.name] !== false; // default open
return html`
<div key=${cat.name} class="sw-tools-popup__category">
<div class="sw-tools-popup__cat-header">
<button class="sw-tools-popup__cat-toggle"
onClick=${() => _toggleCat(cat.name)}
aria-expanded=${isExpanded}>
<span class=${'sw-tools-popup__chevron' + (isExpanded ? ' sw-tools-popup__chevron--open' : '')}>
\u25B6
</span>
<span>${cat.name}</span>
<span class="sw-tools-popup__cat-count">${cat.items.length}</span>
</button>
<label class="sw-tools-popup__cat-switch">
<input type="checkbox"
checked=${!allDisabled}
indeterminate=${someDisabled && !allDisabled}
onChange=${() => onToggleCategory(cat.name)} />
</label>
</div>
${isExpanded && cat.items.map(tool => html`
<label key=${tool.name} class="sw-tools-popup__tool">
<input type="checkbox"
checked=${!disabled.has(tool.name)}
onChange=${() => onToggle(tool.name)} />
<span class="sw-tools-popup__tool-name">${tool.name}</span>
${tool.description && html`
<span class="sw-tools-popup__tool-desc">${tool.description}</span>`}
</label>`)}
</div>`;
})}
</div>
</div>`;
}
function _loadDisabled() {
try {
const arr = JSON.parse(localStorage.getItem(STORAGE_KEY));
return new Set(arr || []);
} catch (_) { return new Set(); }
}
function _saveDisabled(set) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify([...set])); } catch (_) {}
}

View File

@@ -0,0 +1,175 @@
// ==========================================
// Chat Surface — useSidebar Hook
// ==========================================
// Loads and manages sidebar data: channels (DMs + named),
// personal chats, and folders. Subscribes to WS events
// for live updates.
const { useState, useEffect, useCallback, useMemo } = window.hooks;
const COLLAPSE_KEY = 'sw-chat-sidebar-collapsed';
/**
* @param {{ activeId: string|null }} opts
*/
export function useSidebar(opts = {}) {
const { activeId } = opts;
const [channels, setChannels] = useState([]); // named channels + DMs
const [chats, setChats] = useState([]); // personal AI chats (direct, no participants)
const [folders, setFolders] = useState([]);
const [search, setSearch] = useState('');
const [collapsed, setCollapsed] = useState(_loadCollapsed);
const [loading, setLoading] = useState(true);
// ── Load data ───────────────────────────
const reload = useCallback(async () => {
if (!window.sw?.api?.channels?.list) return;
try {
const foldersPromise = window.sw.api.folders?.list
? window.sw.api.folders.list().catch(() => [])
: Promise.resolve([]);
const [chResp, dmResp, fResp] = await Promise.all([
// Named channels (group chats, DMs with participants)
window.sw.api.channels.list({ page: 1, per_page: 100, channel_type: 'channel' }).catch(() => []),
// Personal AI chats
window.sw.api.channels.list({ page: 1, per_page: 100, channel_type: 'direct' }).catch(() => []),
// Folders
foldersPromise,
]);
setChannels(_extract(chResp));
setChats(_extract(dmResp));
setFolders(_extract(fResp));
} catch (_) {}
setLoading(false);
}, []);
useEffect(() => { reload(); }, [reload]);
// ── WebSocket live updates ──────────────
useEffect(() => {
if (!window.sw?.on) return;
const handlers = {
'channel.created': reload,
'channel.updated': reload,
'channel.deleted': reload,
'folder.created': reload,
'folder.updated': reload,
'folder.deleted': reload,
};
Object.entries(handlers).forEach(([ev, fn]) => window.sw.on(ev, fn));
return () => {
Object.entries(handlers).forEach(([ev, fn]) => window.sw.off?.(ev, fn));
};
}, [reload]);
// ── Search filter ───────────────────────
const filteredChannels = useMemo(() => {
if (!search) return channels;
const q = search.toLowerCase();
return channels.filter(c => (c.title || '').toLowerCase().includes(q));
}, [channels, search]);
const filteredChats = useMemo(() => {
if (!search) return chats;
const q = search.toLowerCase();
return chats.filter(c => (c.title || '').toLowerCase().includes(q));
}, [chats, search]);
// Group chats by folder
const chatsByFolder = useMemo(() => {
const map = { '': [] };
for (const f of (folders || [])) map[f.id] = [];
for (const c of filteredChats) {
const fid = c.folder_id || '';
if (!map[fid]) map[fid] = [];
map[fid].push(c);
}
return map;
}, [filteredChats, folders]);
// ── Collapse sections ───────────────────
const toggleCollapsed = useCallback((key) => {
setCollapsed(prev => {
const next = { ...prev, [key]: !prev[key] };
try { localStorage.setItem(COLLAPSE_KEY, JSON.stringify(next)); } catch (_) {}
return next;
});
}, []);
// ── CRUD helpers ────────────────────────
const createFolder = useCallback(async (name) => {
if (!window.sw?.api?.folders?.create) return;
await window.sw.api.folders.create(name);
reload();
}, [reload]);
const renameChat = useCallback(async (id, title) => {
await window.sw.api.channels.update(id, { title });
reload();
}, [reload]);
const deleteChat = useCallback(async (id) => {
await window.sw.api.channels.del(id);
reload();
}, [reload]);
const moveToFolder = useCallback(async (chatId, folderId) => {
await window.sw.api.channels.update(chatId, { folder_id: folderId || null });
reload();
}, [reload]);
const renameFolder = useCallback(async (id, name) => {
await window.sw.api.folders.update(id, { name });
reload();
}, [reload]);
const deleteFolder = useCallback(async (id) => {
await window.sw.api.folders.del(id);
reload();
}, [reload]);
return {
channels: filteredChannels,
chats: filteredChats,
chatsByFolder,
folders,
search,
setSearch,
collapsed,
toggleCollapsed,
loading,
reload,
// CRUD
createFolder,
renameChat,
deleteChat,
moveToFolder,
renameFolder,
deleteFolder,
};
}
function _extract(resp) {
if (!resp) return [];
if (Array.isArray(resp)) return resp;
if (Array.isArray(resp.data)) return resp.data;
// Handle wrapped responses like { folders: [], channels: [] }
const vals = Object.values(resp);
for (const v of vals) {
if (Array.isArray(v)) return v;
}
return [];
}
function _loadCollapsed() {
try {
return JSON.parse(localStorage.getItem(COLLAPSE_KEY)) || {};
} catch (_) { return {}; }
}

View File

@@ -0,0 +1,78 @@
// ==========================================
// Chat Surface — useWorkspace Hook
// ==========================================
// Coordinator hook: manages active conversation, sidebar state.
// Persists active selection to sessionStorage for reload restore.
const { useState, useCallback, useEffect } = window.hooks;
const STORAGE_KEY = 'sw-chat-active';
/**
* @returns {{
* activeId: string|null,
* activeType: 'chat'|'channel'|null,
* sidebarOpen: boolean,
* setSidebarOpen: (v: boolean) => void,
* selectChat: (id: string) => void,
* selectChannel: (id: string) => void,
* clearSelection: () => void,
* }}
*/
export function useWorkspace() {
// Restore from sessionStorage
const stored = _loadStored();
const [activeId, setActiveId] = useState(stored.id);
const [activeType, setActiveType] = useState(stored.type);
const [sidebarOpen, setSidebarOpen] = useState(true);
// Persist on change
useEffect(() => {
if (activeId) {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ id: activeId, type: activeType }));
} catch (_) {}
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
}, [activeId, activeType]);
const selectChat = useCallback((id) => {
setActiveId(id);
setActiveType('chat');
// Auto-close sidebar on mobile
if (window.innerWidth < 768) setSidebarOpen(false);
}, []);
const selectChannel = useCallback((id) => {
setActiveId(id);
setActiveType('channel');
if (window.innerWidth < 768) setSidebarOpen(false);
}, []);
const clearSelection = useCallback(() => {
setActiveId(null);
setActiveType(null);
}, []);
return {
activeId,
activeType,
sidebarOpen,
setSidebarOpen,
selectChat,
selectChannel,
clearSelection,
};
}
function _loadStored() {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (raw) {
const obj = JSON.parse(raw);
return { id: obj.id || null, type: obj.type || null };
}
} catch (_) {}
return { id: null, type: null };
}

View File

@@ -1,167 +0,0 @@
// ==========================================
// Chat Switchboard Token Estimation
// ==========================================
// Context tracking, token estimation, and context warning.
//
// Exports: window.Tokens,window.updateInputTokens,window.updateContextWarning
// ── Token Estimation + Context Tracking ─────
const Tokens = {
// Rough heuristic: ~4 chars per token for English (GPT/Claude average).
// Not exact, but good enough for a UI indicator.
estimate(text) {
if (!text) return 0;
return Math.ceil(text.length / 4);
},
// Estimate tokens for the full conversation context sent to the model
estimateConversation(messages, systemPrompt) {
let total = 0;
// System prompt
if (systemPrompt) total += this.estimate(systemPrompt) + 4; // +4 for role/delimiters
// Messages
for (const m of messages) {
total += this.estimate(m.content) + 4; // +4 per message overhead (role, delimiters)
}
return total;
},
// Estimate tokens for a set of files (staged or sent).
// Images: ~765 tokens (base tile estimate, reasonable cross-provider).
// Documents with extracted text: text length / 4.
// Other/unknown: raw size / 4 (rough fallback).
estimateFiles(files) {
if (!files?.length) return 0;
let total = 0;
for (const att of files) {
if (att.content_type?.startsWith('image/')) {
total += 765; // base tile estimate
} else if (att.extracted_text) {
total += this.estimate(att.extracted_text);
} else if (att.size_bytes) {
total += Math.ceil(att.size_bytes / 4);
}
}
return total;
},
// Get context budget for current model
getContextBudget() {
const caps = UI.getSelectedModelCaps();
return {
maxContext: caps.max_context || 0,
maxOutput: caps.max_output_tokens || 0,
};
},
// Format token count for display
format(n) {
if (n >= 100000) return (n / 1000).toFixed(0) + 'K';
if (n >= 10000) return (n / 1000).toFixed(1) + 'K';
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
return String(n);
},
_warningDismissed: false,
};
// Update the token counter below the input
function updateInputTokens() {
const el = document.getElementById('inputTokenCount');
if (!el) return;
const inputText = (typeof ChatInput !== 'undefined') ? ChatInput.getValue() : '';
const inputTokens = Tokens.estimate(inputText);
if (!inputText.trim()) {
el.textContent = '';
el.className = 'input-token-count';
return;
}
const budget = Tokens.getContextBudget();
if (budget.maxContext > 0) {
// Show relative to available context
const chat = App.chats.find(c => c.id === App.activeId);
const convTokens = Tokens.estimateConversation(chat?.messages || [], App.settings.systemPrompt);
// Include staged file estimates
const fileTokens = Tokens.estimateFiles(
(App.stagedFiles || []).filter(a => a.status !== 'error').map(a => ({
content_type: a.contentType,
size_bytes: a.sizeBytes,
}))
);
const totalWithInput = convTokens + inputTokens + fileTokens;
const pct = totalWithInput / budget.maxContext;
const attLabel = fileTokens > 0 ? ` +${Tokens.format(fileTokens)} files` : '';
el.textContent = `~${Tokens.format(inputTokens)} tokens${attLabel} · ${Tokens.format(totalWithInput)} / ${Tokens.format(budget.maxContext)} context`;
el.className = 'input-token-count' + (pct > 0.9 ? ' danger' : pct > 0.75 ? ' warning' : '');
} else {
el.textContent = `~${Tokens.format(inputTokens)} tokens`;
el.className = 'input-token-count';
}
}
// Check conversation length and show/hide warning
function updateContextWarning() {
const warning = document.getElementById('contextWarning');
const text = document.getElementById('contextWarningText');
const summarizeBtn = document.getElementById('summarizeBtn');
if (!warning || !text) return;
const budget = Tokens.getContextBudget();
if (budget.maxContext <= 0) {
warning.style.display = 'none';
return;
}
const chat = App.chats.find(c => c.id === App.activeId);
if (!chat || !chat.messages?.length) {
warning.style.display = 'none';
return;
}
const convTokens = Tokens.estimateConversation(chat.messages, App.settings.systemPrompt);
const pct = convTokens / budget.maxContext;
// Show summarize button at ≥75% context usage (if enough messages)
const showSummarize = pct >= 0.75 && chat.messages.length >= 4;
if (summarizeBtn) {
summarizeBtn.style.display = showSummarize ? 'inline-block' : 'none';
}
// Show auto-compact toggle next to summarize button
const autoToggle = document.getElementById('autoCompactToggle');
const autoCheck = document.getElementById('autoCompactCheck');
if (autoToggle && autoCheck) {
autoToggle.style.display = showSummarize ? 'inline-flex' : 'none';
// Reflect current channel setting
const chatSettings = chat?.settings || {};
autoCheck.checked = chatSettings.auto_compaction !== false; // default: on
}
if (pct >= 0.9 && !Tokens._warningDismissed) {
warning.style.display = 'flex';
warning.className = 'context-warning danger';
text.textContent = `This conversation is using ~${Math.round(pct * 100)}% of the model's context window (~${Tokens.format(convTokens)} / ${Tokens.format(budget.maxContext)} tokens). Responses may lose earlier context.`;
} else if (pct >= 0.75 && !Tokens._warningDismissed) {
warning.style.display = 'flex';
warning.className = 'context-warning';
text.textContent = `Conversation is getting long (~${Math.round(pct * 100)}% of context window). The model may start losing track of earlier messages.`;
} else {
warning.style.display = 'none';
}
}
function dismissContextWarning() {
Tokens._warningDismissed = true;
const el = document.getElementById('contextWarning');
if (el) el.style.display = 'none';
}
// ── Exports ─────────────────────────────────
sb.ns('Tokens', Tokens);
sb.register('updateInputTokens', updateInputTokens);
sb.register('updateContextWarning', updateContextWarning);

View File

@@ -1,245 +0,0 @@
// ── tools-toggle.js ─────────────────────────
// Tools toggle popup: category-based enable/disable of server-side tools.
// Categories expand to show individual tool toggles.
// State persisted in localStorage, sent as disabled_tools[] in completion requests.
const ToolsToggle = (() => {
const STORAGE_KEY = 'cs-disabled-tools';
// Category display config
const CATEGORY_META = {
search: { label: 'Web Search', icon: '🔍' },
knowledge: { label: 'Knowledge Bases', icon: '📚' },
notes: { label: 'Notes', icon: '📝' },
utilities: { label: 'Utilities', icon: '🧮' },
workspace: { label: 'Workspace', icon: '🗂️' },
context: { label: 'Context', icon: '📎' },
memory: { label: 'Memory', icon: '🧠' },
browser: { label: 'Extensions', icon: '🧩' },
};
// Cached tool manifest from GET /api/v1/tools
let _tools = []; // [{name, display_name, description, category}]
let _categories = {}; // {category: [{name, displayName}]}
let _disabled = new Set();
let _expanded = new Set(); // which categories are expanded
// ── Public API ──────────────────────────
/** Initialize: load disabled set from storage, fetch tool manifest. */
async function init() {
_loadDisabled();
try {
const resp = await API.getTools();
_tools = resp.tools || [];
_buildCategories();
_render();
_show();
} catch (e) {
console.warn('Tools toggle: failed to load tools', e);
}
_wireEvents();
}
/** Returns array of disabled tool names for the completion request. */
function getDisabledTools() {
return [..._disabled];
}
/** Refresh tool list (e.g. after browser extension connects). */
async function refresh() {
try {
const resp = await API.getTools();
_tools = resp.tools || [];
_buildCategories();
_render();
} catch (e) { /* silent */ }
}
// ── Internal ────────────────────────────
function _loadDisabled() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
_disabled = raw ? new Set(JSON.parse(raw)) : new Set();
} catch { _disabled = new Set(); }
}
function _saveDisabled() {
localStorage.setItem(STORAGE_KEY, JSON.stringify([..._disabled]));
_updateBtnState();
}
function _buildCategories() {
_categories = {};
for (const t of _tools) {
const cat = t.category || 'other';
if (!_categories[cat]) _categories[cat] = [];
_categories[cat].push({
name: t.name,
displayName: t.display_name || t.name,
});
}
}
/** Returns 'all' | 'none' | 'partial' for a category's enabled state. */
function _categoryState(cat) {
const tools = _categories[cat] || [];
if (tools.length === 0) return 'none';
const enabledCount = tools.filter(t => !_disabled.has(t.name)).length;
if (enabledCount === tools.length) return 'all';
if (enabledCount === 0) return 'none';
return 'partial';
}
function _toggleCategory(cat) {
const tools = _categories[cat] || [];
const state = _categoryState(cat);
if (state === 'all') {
// All on → all off
tools.forEach(t => _disabled.add(t.name));
} else {
// Partial or none → all on
tools.forEach(t => _disabled.delete(t.name));
}
_saveDisabled();
_render();
}
function _toggleTool(name) {
if (_disabled.has(name)) {
_disabled.delete(name);
} else {
_disabled.add(name);
}
_saveDisabled();
_render();
}
function _toggleExpand(cat) {
if (_expanded.has(cat)) {
_expanded.delete(cat);
} else {
_expanded.add(cat);
}
_render();
}
function _show() {
const wrap = document.querySelector('.tools-toggle-wrap');
if (wrap && _tools.length > 0) {
wrap.style.display = '';
_updateBtnState();
}
}
function _updateBtnState() {
const btn = document.getElementById('toolsToggleBtn');
if (!btn) return;
btn.classList.toggle('has-disabled', _disabled.size > 0);
const count = _disabled.size;
btn.title = count > 0 ? `Tools (${count} disabled)` : 'Tools';
}
function _render() {
const popup = document.getElementById('toolsPopup');
if (!popup) return;
const cats = Object.keys(_categories);
const order = Object.keys(CATEGORY_META);
cats.sort((a, b) => {
const ai = order.indexOf(a), bi = order.indexOf(b);
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
});
let html = '';
for (const cat of cats) {
const meta = CATEGORY_META[cat] || { label: cat, icon: '🔧' };
const state = _categoryState(cat);
const tools = _categories[cat];
const expanded = _expanded.has(cat);
const switchClass = state === 'all' ? 'on' : state === 'partial' ? 'on partial' : '';
const chevron = tools.length > 1
? `<span class="tools-popup-expand ${expanded ? 'open' : ''}" data-expand="${cat}"></span>`
: '';
const labelExpand = tools.length > 1 ? ` data-expand="${cat}"` : '';
html += `<div class="tools-popup-category" data-category="${cat}">
<span class="tools-popup-switch ${switchClass}" data-switch="${cat}"><span class="tools-popup-switch-knob"></span></span>
<span class="tools-popup-label"${labelExpand}>${meta.icon} ${meta.label}</span>
${chevron}
</div>`;
// Expanded children
if (expanded && tools.length > 1) {
for (const t of tools) {
const on = !_disabled.has(t.name);
html += `<div class="tools-popup-tool" data-tool="${t.name}">
<span class="tools-popup-switch ${on ? 'on' : ''}" data-tool-switch="${t.name}"><span class="tools-popup-switch-knob"></span></span>
<span class="tools-popup-label">${t.displayName}</span>
</div>`;
}
}
}
popup.innerHTML = html;
}
function _wireEvents() {
const btn = document.getElementById('toolsToggleBtn');
const popup = document.getElementById('toolsPopup');
if (!btn || !popup) return;
btn.addEventListener('click', (e) => {
e.stopPropagation();
popup.classList.toggle('open');
});
popup.addEventListener('click', (e) => {
e.stopPropagation();
// Toggle switch on category row
const catSwitch = e.target.closest('[data-switch]');
if (catSwitch) {
_toggleCategory(catSwitch.dataset.switch);
return;
}
// Toggle switch on individual tool row
const toolSwitch = e.target.closest('[data-tool-switch]');
if (toolSwitch) {
_toggleTool(toolSwitch.dataset.toolSwitch);
return;
}
// Expand/collapse: chevron or label click on multi-tool categories
const expander = e.target.closest('[data-expand]');
if (expander) {
_toggleExpand(expander.dataset.expand);
return;
}
// Individual tool label (no expand, just toggle)
const toolRow = e.target.closest('.tools-popup-tool');
if (toolRow) {
_toggleTool(toolRow.dataset.tool);
return;
}
// Single-tool category label click (no chevron) — toggle
const catRow = e.target.closest('.tools-popup-category');
if (catRow) {
_toggleCategory(catRow.dataset.category);
return;
}
});
// Close on outside click
document.addEventListener('click', () => popup.classList.remove('open'));
}
return { init, getDisabledTools, refresh };
})();
sb.ns('ToolsToggle', ToolsToggle);

File diff suppressed because it is too large Load Diff