Changeset 0.22.6 (#148)

This commit is contained in:
2026-03-03 13:12:13 +00:00
parent 45fe965c32
commit d8e0664fa3
24 changed files with 983 additions and 2473 deletions

View File

@@ -261,7 +261,7 @@ jobs:
echo "${UNIT_PKGS}" | sed 's/^/ /'
echo ""
go test -v -race -count=1 -p 1 ${UNIT_PKGS}
go test -v -race -count=1 -p 1 -timeout 8m ${UNIT_PKGS}
echo "✓ Unit tests complete"
- name: Run SQLite handler integration tests
@@ -270,7 +270,7 @@ jobs:
DB_DRIVER: sqlite
run: |
echo "━━━ SQLite Integration Tests (handlers + stores) ━━━"
go test -v -race -count=1 -p 1 \
go test -v -race -count=1 -p 1 -timeout 8m \
./handlers/... \
./store/sqlite/...
echo "✓ SQLite integration tests complete"
@@ -351,7 +351,7 @@ jobs:
PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
run: |
echo "━━━ Postgres Integration Tests ━━━"
go test -v -race -count=1 -p 1 \
go test -v -race -count=1 -p 1 -timeout 8m \
./store/postgres/... \
./handlers/...
echo "✓ Postgres integration tests complete"

View File

@@ -2,6 +2,29 @@
All notable changes to Chat Switchboard.
## [0.22.6] — 2026-03-03
### Fixed
- **Split FE/BE deployment: page route proxying.** Frontend container now supports `BACKEND_URL` env var. When set, the entrypoint generates nginx `proxy_pass` blocks for page routes (`/`, `/login`, `/chat/:id`, `/admin/*`, `/editor/*`, `/notes/*`, `/settings/*`) so Go template rendering works in k8s split-image deployments. When unset, behavior is unchanged (SPA fallback). Both `BASE_PATH=""` and `BASE_PATH="/prefix"` variants supported.
- **CI test timeout.** All `go test` commands now include `-timeout 8m` to prevent indefinite hangs from runner resource contention or race-detector compilation stalls. Previously used Go's 10-minute default with no goroutine dump on timeout.
### Removed
- **`router.js` (322 lines).** Client-side hash router fully replaced by server-side page routes. All `Router.*` call sites in `chat.js` replaced with `history.replaceState()` for URL updates. All `typeof Router` guards in `app.js` removed.
- **`surfaces.js` (368 lines).** Client-side surface registry replaced by Go template surface architecture. Extension API stubs (`surfaces.register`, `surfaces.activate`, etc.) now log deprecation warnings. `surfaces.getCurrent()` returns `window.__SURFACE__` from server-rendered page data.
- **`index.html` SPA shell (1,296 lines → 16 lines).** Original monolithic SPA entry point replaced with a minimal redirect page. All real routes now served by Go templates; `index.html` only serves as nginx `try_files` fallback for unknown paths.
- **`editor-mode.js` old Surfaces paths (~260 lines).** Removed `_register()`, `openDirect()`, `_unregister()`, `_activate()`, `_deactivate()`, `_embedChat()`, `_returnChat()`, and full `check()`/`checkStartup()` implementations. Only `mountServerRendered()` remains as the entry point. `check()` and `checkStartup()` kept as no-ops for backward compat with `projects-ui.js` callers.
- **SPA-only entrypoint code path.** `docker-entrypoint-fe.sh` now requires `BACKEND_URL` (fails fast if unset). Removed dead `%%BASE_HREF%%`, `%%ENVIRONMENT%%`, `%%BRANDING_JSON%%` injection into index.html. Removed branding config file loading (branding now handled by Go templates).
### Changed
- `docker-entrypoint-fe.sh`: Simplified from 227 → 184 lines. Requires `BACKEND_URL`. Removed SPA-only fallback path.
- `nginx.conf` (unified container): Consolidated repeated `proxy_set_header` blocks. Uses `$backend` variable. Page routes proxy to Go backend for template rendering.
- `base.html`: Added `__ENV__` and `__BRANDING__` globals for JS compatibility (previously injected by index.html sed).
- `server/pages/pages.go`: Added `Environment` field to `PageData`, populated from config.
- `extensions.js`: `ui.replace()`, `ui.restore()`, and `surfaces.*` API methods now log deprecation warnings instead of calling removed Surfaces system.
- `app.js`: Removed `Surfaces.init()`, `EditorMode.checkStartup()`, and `Router.init()` calls. Simplified to direct `sessionStorage` chat restore.
- `chat.js`: Replaced `Router.update()` calls with `history.replaceState()` for URL-bar sync on chat selection/creation.
- `repl.js`: Removed `surfaces.js` import reference.
## [0.22.5] — 2026-03-03
### Added

View File

@@ -1,15 +1,20 @@
# ==========================================
# Chat Switchboard - Frontend Dockerfile
# ==========================================
# Static SPA served by nginx. No API proxy —
# in k8s, the Ingress routes /api/ and /ws to
# the backend service directly.
# Static SPA served by nginx. In k8s, the
# Ingress routes /api/ and /ws to the backend
# service directly.
#
# Supports path-based deployment via BASE_PATH
# env var (e.g. /dev, /test, or empty for root).
# The entrypoint generates the nginx config and
# injects BASE_PATH into index.html at startup.
#
# v0.22.6+: Set BACKEND_URL to proxy page routes
# (/, /login, /admin, /editor, /notes, /settings)
# to the Go backend for server-rendered templates.
# When unset, page routes fall through to SPA.
#
# Build context: repo root
# ==========================================

View File

@@ -1 +1 @@
0.22.5
0.22.6

View File

@@ -2,16 +2,23 @@
# ============================================
# Chat Switchboard - Frontend Entrypoint
# ============================================
# Injects BASE_PATH into index.html and nginx
# config at container startup. Supports path-
# based multi-env deployment on a single domain.
# nginx serves static assets and proxies page
# routes to the Go backend for template rendering.
# API/WS routes are handled by Ingress directly.
#
# Environment:
# BASE_PATH - URL prefix (e.g. "/dev", "/test", or "")
# Environment (required):
# BACKEND_URL - Backend service URL
# (e.g. "http://switchboard-be:8080")
#
# Environment (optional):
# BASE_PATH - URL prefix (e.g. "/dev", "" for root)
# ENVIRONMENT - Environment name for logging
# ============================================
set -e
BASE_PATH="${BASE_PATH:-}"
BACKEND_URL="${BACKEND_URL:?BACKEND_URL is required (e.g. http://switchboard-be:8080)}"
ENVIRONMENT="${ENVIRONMENT:-production}"
# Read version from VERSION file (baked in at build time)
APP_VERSION="dev"
@@ -19,39 +26,16 @@ if [ -f /VERSION ]; then
APP_VERSION=$(cat /VERSION | tr -d '[:space:]')
fi
# Compute base href (needs trailing slash for <base> tag)
if [ -z "${BASE_PATH}" ]; then
BASE_HREF="/"
else
BASE_HREF="${BASE_PATH}/"
fi
# ── Read branding config ────────────────────
BRANDING_JSON="{}"
if [ -f /branding/branding.json ]; then
# Compact to single line for safe sed injection
BRANDING_JSON=$(tr -d '\n' < /branding/branding.json | sed 's/ */ /g')
echo "✅ Branding config loaded from /branding/branding.json"
else
echo " No branding mount — using defaults"
fi
# ── Read environment name ─────────────────
ENVIRONMENT="${ENVIRONMENT:-production}"
# Compute build hash from frontend file contents (changes on every deploy)
# Compute build hash from JS file contents (unique per deploy)
BUILD_HASH=$(find /usr/share/nginx/html/js -name '*.js' -exec md5sum {} + 2>/dev/null | sort | md5sum | cut -c1-8)
if [ -z "${BUILD_HASH}" ]; then
BUILD_HASH=$(date +%s | md5sum | cut -c1-8)
fi
# ── Inject into index.html ──────────────────
# ── Inject into index.html (redirect page) ──
sed -i \
-e "s|%%BASE_PATH%%|${BASE_PATH}|g" \
-e "s|%%BASE_HREF%%|${BASE_HREF}|g" \
-e "s|%%APP_VERSION%%|${APP_VERSION}|g" \
-e "s|%%ENVIRONMENT%%|${ENVIRONMENT}|g" \
-e "s|%%BRANDING_JSON%%|${BRANDING_JSON}|g" \
/usr/share/nginx/html/index.html
# Inject version and build hash into service worker
@@ -60,13 +44,45 @@ sed -i \
-e "s|%%BUILD_HASH%%|${BUILD_HASH}|g" \
/usr/share/nginx/html/sw.js
echo "✅ Frontend configured: BASE_PATH=${BASE_PATH:-/} VERSION=${APP_VERSION} BUILD=${BUILD_HASH} ENV=${ENVIRONMENT}"
echo "✅ Frontend: BASE_PATH=${BASE_PATH:-/} VERSION=${APP_VERSION} BUILD=${BUILD_HASH} BACKEND=${BACKEND_URL}"
# ── Page route proxy blocks ─────────────────
page_proxy_block() {
local path="$1"
cat <<PROXY
location ${path} {
proxy_pass ${BACKEND_URL};
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
PROXY
}
PAGE_ROUTES=$(cat <<ROUTES
# ── Page routes → backend ────────────────
$(page_proxy_block "= ${BASE_PATH}/")
$(page_proxy_block "= ${BASE_PATH}/login")
location ~ ^${BASE_PATH}/chat/[^/]+\$ {
proxy_pass ${BACKEND_URL};
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
$(page_proxy_block "${BASE_PATH}/admin")
$(page_proxy_block "${BASE_PATH}/editor")
$(page_proxy_block "${BASE_PATH}/notes")
$(page_proxy_block "${BASE_PATH}/settings")
ROUTES
)
# ── Generate nginx config ───────────────────
# If BASE_PATH is set, serve under that prefix with alias.
# If empty, serve from root (default behavior).
if [ -z "${BASE_PATH}" ]; then
cat > /etc/nginx/conf.d/default.conf <<'NGINX'
cat > /etc/nginx/conf.d/default.conf <<NGINX
server {
listen 80;
server_name _;
@@ -84,25 +100,24 @@ server {
add_header Cache-Control "public, immutable";
}
# Service worker must never be cached by the browser
location = /sw.js {
expires off;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Branding assets — served from volume mount, 404s gracefully
location /branding/ {
alias /branding/;
expires 1h;
add_header Cache-Control "public";
try_files $uri =404;
try_files \$uri =404;
}
${PAGE_ROUTES}
location / {
try_files $uri $uri/ /index.html;
try_files \$uri \$uri/ /index.html;
}
# Health check for k8s probes
location = /healthz {
return 200 'ok';
add_header Content-Type text/plain;
@@ -125,6 +140,8 @@ server {
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript;
${PAGE_ROUTES}
location ${BASE_PATH}/ {
alias /usr/share/nginx/html/;
try_files \$uri \$uri/ ${BASE_PATH}/index.html;
@@ -134,7 +151,6 @@ server {
add_header Cache-Control "public, immutable";
}
# Service worker must never be cached by the browser
location = ${BASE_PATH}/sw.js {
alias /usr/share/nginx/html/sw.js;
expires off;
@@ -142,7 +158,6 @@ server {
}
}
# Branding assets — under BASE_PATH so Traefik routes them here
location ${BASE_PATH}/branding/ {
alias /branding/;
expires 1h;
@@ -150,12 +165,10 @@ server {
try_files \$uri =404;
}
# Redirect bare path to trailing slash
location = ${BASE_PATH} {
return 301 ${BASE_PATH}/;
}
# Health check for k8s probes (always at root)
location = /healthz {
return 200 'ok';
add_header Content-Type text/plain;
@@ -168,5 +181,4 @@ server {
NGINX
fi
# ── Start nginx ─────────────────────────────
exec nginx -g 'daemon off;'

View File

@@ -107,6 +107,8 @@ v0.22.4 Provider Health UX + Project Files + Export ✅
v0.22.5 Surfaces + Go Templates + Admin Bug Fixes ✅
v0.22.6 Split Deployment Fix + CI Timeout + Code Pruning ✅
v0.23.0 Multi-Participant Channels + Presence
v0.24.0 Auth Strategy (mTLS/OIDC) + Full RBAC + Group permissions
@@ -1080,6 +1082,26 @@ population bugs by construction.
---
## v0.22.6 — Split Deployment Fix + CI Timeout + Code Pruning ✅
Hotfix for FE/BE split k8s deployment broken by v0.22.5 surfaces
architecture, plus aggressive pruning of OBE client-side code now
replaced by server-rendered surfaces.
- [x] `BACKEND_URL` env var on FE container for page route proxying
- [x] Entrypoint generates `proxy_pass` blocks for all surface routes
- [x] Works with both `BASE_PATH=""` and `BASE_PATH="/prefix"`
- [x] CI: `-timeout 8m` on all `go test` invocations
- [x] Removed `router.js` (322 lines) — hash routing replaced by server routes
- [x] Removed `surfaces.js` (368 lines) — client registry replaced by Go templates
- [x] Replaced `index.html` SPA shell (1,296 → 16 lines) with redirect stub
- [x] Stripped old Surfaces paths from `editor-mode.js` (~260 lines)
- [x] Simplified `docker-entrypoint-fe.sh` — requires `BACKEND_URL`, removed SPA-only path
- [x] Consolidated `nginx.conf` proxy blocks for unified container
- [x] **Net: ~2,430 lines removed**
---
## v0.23.0 — Multi-Participant Channels + Presence
The channel foundation for workflows and real-time collaboration. Today

View File

@@ -2,8 +2,11 @@
# ============================================
# Chat Switchboard - Frontend Deployment
# ============================================
# Static nginx serving the SPA. No /api/ proxy here —
# the Ingress routes /api/ directly to the backend service.
# nginx serving the SPA + proxying page routes
# to the backend for Go template rendering.
#
# v0.22.6+: BACKEND_URL enables proxy_pass for
# /, /login, /admin, /editor, /notes, /settings.
# ============================================
apiVersion: apps/v1
kind: Deployment
@@ -40,6 +43,8 @@ spec:
value: "${BASE_PATH}"
- name: ENVIRONMENT
value: "${ENVIRONMENT}"
- name: BACKEND_URL
value: "http://switchboard-be${DEPLOY_SUFFIX}:8080"
volumeMounts:
- name: branding
mountPath: /branding

View File

@@ -4,9 +4,25 @@ server {
root /usr/share/nginx/html;
index index.html;
# Backend API proxy
# Backend upstream (unified container: localhost:8080)
set $backend http://localhost:8080;
# Gzip
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript;
# Static assets — long cache
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# ── API + WebSocket → backend ────────────
location /api/ {
proxy_pass http://localhost:8080;
proxy_pass $backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -14,14 +30,12 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
# Health check passthrough
location = /health {
proxy_pass http://localhost:8080;
proxy_pass $backend;
}
# WebSocket proxy
location = /ws {
proxy_pass http://localhost:8080;
proxy_pass $backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
@@ -32,85 +46,35 @@ server {
proxy_send_timeout 86400s;
}
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript;
# Cache static assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# ── Page routes (v0.22.5) ────────────────
# Go templates serve these via the pages engine.
# Static assets (.js, .css, images) matched above stay with nginx.
# Root page → backend
# ── Page routes → backend (Go templates) ─
location = / {
proxy_pass http://localhost:8080;
proxy_pass $backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Login page → backend
location = /login {
proxy_pass http://localhost:8080;
proxy_pass $backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Chat with specific ID → backend
location ~ ^/chat/[^/]+$ {
proxy_pass http://localhost:8080;
proxy_pass $backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Admin pages → backend
location /admin {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /admin { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
location /editor { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
location /notes { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
location /settings { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
# Editor surface → backend
location /editor {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Notes surface → backend
location /notes {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Settings surface → backend
location /settings {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SPA fallback
# Fallback: redirect unknown paths to root
location / {
try_files $uri $uri/ /index.html;
}

View File

@@ -58,6 +58,7 @@ type PageData struct {
CSPNonce string
BasePath string
Version string
Environment string
User *UserContext
Data any // surface-specific data from loader
}
@@ -133,6 +134,7 @@ func (e *Engine) RegisterLoader(name string, fn DataLoaderFunc) {
func (e *Engine) Render(c *gin.Context, name string, data PageData) {
data.BasePath = e.cfg.BasePath
data.Version = Version
data.Environment = e.cfg.Environment
data.CSPNonce = generateNonce()
data.Banner = e.loadBanner()

View File

@@ -52,6 +52,8 @@
<script nonce="{{.CSPNonce}}">
window.__BASE__ = '{{.BasePath}}';
window.__VERSION__ = '{{.Version}}';
window.__ENV__ = '{{.Environment}}';
window.__BRANDING__ = {};
window.__SURFACE__ = '{{.Surface}}';
window.__PAGE_DATA__ = {{.Data | toJSON}};
window.__USER__ = {{.User | toJSON}};

View File

@@ -1,14 +1,631 @@
{{/*
Chat surface (Phase 1 bridge).
Server renders the page shell. Existing SPA JS takes over the DOM.
All scripts from index.html are loaded here — minus the three
already in base.html (api.js, events.js, ui-primitives.js).
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.
*/}}
{{define "surface-chat"}}
<div id="appContainer" class="app" style="display:none;height:100%;">
{{/* SPA builds its DOM here — same as current index.html */}}
{{/* ── 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="switchAuthTab('login')">Sign In</button>
<button id="authTabRegister" class="auth-tab" onclick="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="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="handleRegister()">Create Account</button>
</div>
<div id="authError" class="auth-error"></div>
<div id="brandAuthFooter" class="auth-footer"></div>
</div>
</div>
{{/* ── 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 */}}
<div class="sb-brand">
<div id="brandSidebarLogo" class="sb-logo">
<img src="{{.BasePath}}/favicon.svg" alt="" style="width:28px;height:28px;">
</div>
<span id="brandSidebarText" class="brand-text">Chat Switchboard</span>
<span id="brandWordmark" class="brand-text" style="display:none;"></span>
</div>
{{/* New Chat (split button) */}}
<div class="split-btn">
<button id="newChatBtn" class="split-btn-main sb-btn" title="New chat">
<svg width="16" height="16" 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>
<button id="newChatDropBtn" class="split-btn-drop" title="More options">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div id="newChatDropdown" class="split-btn-dropdown">
{{/* Populated by projects-ui.js */}}
</div>
</div>
{{/* Search */}}
<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="search-clear" title="Clear search"></button>
</div>
{{/* Sidebar toggle (hamburger) */}}
<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>
</div>
{{/* Sidebar Tabs (Chats / Files) */}}
<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>
{{/* Chat list */}}
<div id="chatHistory" class="sidebar-chats sidebar-tab-panel" data-tab-panel="chats">
{{/* Populated by UI.renderChatList() */}}
</div>
{{/* Files panel (editor mode) */}}
<div class="sidebar-tab-panel" data-tab-panel="files" style="display:none;"></div>
{{/* User / bottom */}}
<div class="sidebar-bottom">
<button id="userMenuBtn" class="user-btn">
<div id="userAvatar" class="user-avatar">
<span id="avatarLetter">?</span>
</div>
<span id="userName" class="sb-label">User</span>
</button>
<div id="userFlyout" class="user-flyout">
<button id="menuSettings" class="flyout-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.32 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
<span>Settings</span>
</button>
<button id="menuAdmin" class="flyout-item" style="display:none;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
<span>Admin</span>
</button>
<button id="menuTeamAdmin" class="flyout-item" style="display:none;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span>Team Admin</span>
</button>
<button id="menuDebug" class="flyout-item" style="display:none;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"/><path d="M12 6V12L16 14"/></svg>
<span>Debug Console</span>
</button>
<hr class="flyout-divider">
<button id="menuSignout" class="flyout-item flyout-danger">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
<span>Sign Out</span>
</button>
</div>
</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 + token count */}}
<div class="chat-header">
<div id="modelDropdown" class="model-dropdown">
<button id="modelDropdownBtn" class="model-dropdown-btn">
<span id="modelDropdownLabel" class="model-dropdown-label">Select model…</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div id="modelDropdownMenu" class="model-dropdown-menu">
{{/* Populated by UI.updateModelSelector() */}}
</div>
</div>
<span id="modelCaps" class="model-caps"></span>
<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>
</div>
</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>
{{/* Attachment strip */}}
<div id="attachmentStrip" class="attachment-strip"></div>
{{/* Streaming tools display */}}
<div id="streamTools" class="stream-tools" style="display:none;"></div>
{{/* 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.js adds toggle buttons here */}}
</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>
{{/* 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="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="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 */}}
</div>
</div>
</div>
</div>
</div>
{{/* Side panel overlay (mobile) */}}
<div id="sidePanelOverlay" class="side-panel-overlay" style="display:none;"></div>
{{/* ── Toast Container ─────────────────────── */}}
<div id="toastContainer" class="toast-container"></div>
{{/* ── Lightbox ────────────────────────────── */}}
<div id="lightbox" class="lightbox" onclick="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 (attachments) ─────── */}}
<input type="file" id="fileInput" multiple style="display:none;">
{{/* ── Settings Modal ──────────────────────── */}}
<div id="settingsModal" class="modal-overlay">
<div class="modal-content modal-lg">
<div class="modal-header">
<h2>Settings</h2>
<button class="modal-close" onclick="closeModal('settingsModal')"></button>
</div>
<div class="modal-tabs">
<button class="settings-tab active" data-stab="general" onclick="UI.switchSettingsTab('general')">General</button>
<button class="settings-tab" data-stab="appearance" onclick="UI.switchSettingsTab('appearance')">Appearance</button>
<button id="settingsProvidersTabBtn" class="settings-tab" data-stab="providers" onclick="UI.switchSettingsTab('providers')">My Providers</button>
<button id="settingsPersonasTabBtn" class="settings-tab" data-stab="personas" onclick="UI.switchSettingsTab('personas')">My Presets</button>
<button id="settingsUsageTabBtn" class="settings-tab" data-stab="usage" onclick="UI.switchSettingsTab('usage')">Usage</button>
<button class="settings-tab" data-stab="profile" onclick="UI.switchSettingsTab('profile')">Profile</button>
<button id="settingsRolesTabBtn" class="settings-tab" data-stab="roles" onclick="UI.switchSettingsTab('roles')" style="display:none;">Roles</button>
<button id="settingsKBTabBtn" class="settings-tab" data-stab="kb" onclick="UI.switchSettingsTab('kb')" style="display:none;">Knowledge</button>
<button id="settingsTeamsSection" class="settings-tab" data-stab="teams" onclick="UI.switchSettingsTab('teams')">Teams</button>
</div>
<div class="modal-body">
{{/* General tab */}}
<div id="settingsGeneralTab" class="settings-tab-content" style="display:block;">
<div class="form-group">
<label>Model</label>
<select id="settingsModel"></select>
</div>
<div class="form-group">
<label>System Prompt</label>
<textarea id="settingsSystemPrompt" rows="4"></textarea>
</div>
<div class="form-group">
<label>Max Tokens <span id="settingsMaxHint" class="form-hint"></span></label>
<input type="number" id="settingsMaxTokens" placeholder="default">
</div>
<div class="form-group">
<label>Temperature</label>
<input type="range" id="settingsTemp" min="0" max="2" step="0.1" value="0.7">
</div>
<div class="form-group">
<label><input type="checkbox" id="settingsThinking"> Show thinking blocks</label>
</div>
</div>
{{/* Appearance tab */}}
<div id="settingsAppearanceTab" class="settings-tab-content" style="display:none;">
<div class="form-group">
<label>Message Font Size</label>
<div style="display:flex;align-items:center;gap:8px;">
<input type="range" id="settingsMsgFont" min="12" max="20" step="1" value="14">
<span id="msgFontValue">14px</span>
</div>
</div>
<div class="form-group">
<label>UI Scale</label>
<div style="display:flex;align-items:center;gap:8px;">
<input type="range" id="settingsScale" min="0.8" max="1.2" step="0.05" value="1.0">
<span id="scaleValue">100%</span>
</div>
</div>
</div>
{{/* Providers tab */}}
<div id="settingsProvidersTab" class="settings-tab-content" style="display:none;">
<div id="userProvidersDisabled" class="notice" style="display:none;">Personal API keys are not enabled by your administrator.</div>
<button id="providerShowAddBtn" class="btn-small" style="display:none;">+ Add Provider</button>
<div id="providerAddForm" style="display:none;"></div>
<div id="providerList"></div>
</div>
{{/* Presets tab */}}
<div id="settingsPersonasTab" class="settings-tab-content" style="display:none;">
<button id="userAddPresetBtn" class="btn-small" style="display:none;">+ New Preset</button>
<div id="userAddPresetForm" style="display:none;"></div>
<div id="userPresetList"></div>
</div>
{{/* Usage tab */}}
<div id="settingsUsageTab" class="settings-tab-content" style="display:none;">
<div id="myUsageTotals"></div>
</div>
{{/* Profile tab */}}
<div id="settingsProfileTab" class="settings-tab-content" style="display:none;">
<div class="form-group">
<label>Display Name</label>
<input type="text" id="profileDisplayName">
</div>
<div class="form-group">
<label>Email</label>
<input type="email" id="profileEmail" disabled>
</div>
<div class="form-group">
<button class="btn-small" onclick="document.getElementById('profileChangePwForm').style.display=''">Change Password</button>
<div id="profileChangePwForm" style="display:none;margin-top:8px;">
<input type="password" id="settingsCurrentPw" placeholder="Current password" autocomplete="current-password" style="margin-bottom:6px;">
<input type="password" id="settingsNewPw" placeholder="New password" autocomplete="new-password" style="margin-bottom:6px;">
<input type="password" id="settingsConfirmPw" placeholder="Confirm new password" autocomplete="new-password" style="margin-bottom:6px;">
<button class="btn-small" onclick="Pages.changePassword()">Update Password</button>
</div>
</div>
</div>
{{/* Roles tab (admin) */}}
<div id="settingsRolesTab" class="settings-tab-content" style="display:none;">
<div id="userRolesDisabled" class="notice" style="display:none;"></div>
<div id="userRolesConfig"></div>
<div id="userModelList"></div>
</div>
{{/* KB tab */}}
<div id="settingsKBTab" class="settings-tab-content" style="display:none;">
<div id="kbManagePanel"></div>
</div>
{{/* Teams tab */}}
<div id="settingsTeamsTab" class="settings-tab-content" style="display:none;">
<div id="settingsTeamsList"></div>
<div id="settingsTeamMembers" style="display:none;"></div>
<div id="settingsTeamPresets" style="display:none;">
<div id="adminPresetList"></div>
</div>
<div id="settingsTeamProviders" style="display:none;">
<button id="settingsTeamAddProviderBtn" class="btn-small" style="display:none;">+ Add Provider</button>
<div id="settingsTeamProviderForm" style="display:none;"></div>
</div>
<div id="settingsTeamGroups" style="display:none;"></div>
<div id="settingsTeamAudit" style="display:none;"></div>
<div id="settingsTeamUsageTotals" style="display:none;"></div>
</div>
{{/* Memory tab */}}
<div id="settingsMemoryTab" class="settings-tab-content" style="display:none;">
<div id="settingsMemoryContent"></div>
</div>
</div>
<div class="modal-footer">
<button class="btn-primary" onclick="UI.saveSettingsAndClose()">Save</button>
<button class="btn-secondary" onclick="closeModal('settingsModal')">Cancel</button>
</div>
</div>
</div>
{{/* ── Admin Panel (SPA modal — fallback, admin surface is preferred) */}}
<div id="adminPanel" class="modal-overlay">
<div class="modal-content modal-xl">
<div class="modal-header">
<h2>Admin</h2>
<button class="modal-close" onclick="closeModal('adminPanel')"></button>
</div>
<div class="modal-body" style="display:flex;min-height:400px;">
<div id="adminSidebar" class="admin-sidebar"></div>
<div class="admin-content" style="flex:1;overflow-y:auto;padding:16px;">
{{/* Admin sections — dynamically shown/hidden by ui-admin.js */}}
<div id="adminStats" class="admin-section"></div>
<div id="adminModelList" class="admin-section" style="display:none;"></div>
<div id="adminProviderList" class="admin-section" style="display:none;"></div>
<div id="adminUserList" class="admin-section" style="display:none;"></div>
<div id="adminTeamList" class="admin-section" style="display:none;"></div>
<div id="adminHealthContent" class="admin-section" style="display:none;">
<button id="adminHealthRefreshBtn" class="btn-small">Refresh</button>
</div>
<div id="adminRolesContent" class="admin-section" style="display:none;"></div>
<div id="adminCapabilityList" class="admin-section" style="display:none;"></div>
<div id="adminExtensionsList" class="admin-section" style="display:none;"></div>
<div id="adminPricingTable" class="admin-section" style="display:none;"></div>
<div id="adminAuditList" class="admin-section" style="display:none;">
<div style="display:flex;gap:8px;margin-bottom:8px;">
<select id="auditFilterAction"><option value="">All actions</option></select>
<select id="auditFilterResource"><option value="">All resources</option></select>
</div>
<div id="auditPagination" style="display:flex;gap:8px;align-items:center;">
<button id="auditPrevBtn" class="btn-small" disabled>← Prev</button>
<span id="auditPageInfo"></span>
<button id="auditNextBtn" class="btn-small">Next →</button>
</div>
</div>
<div id="adminStorageContent" class="admin-section" style="display:none;"></div>
<div id="adminRoutingPolicyList" class="admin-section" style="display:none;">
<button id="adminAddRoutingPolicyBtn" class="btn-small">+ Add Policy</button>
<div id="adminRoutingForm" style="display:none;"></div>
<div style="margin-top:16px;border-top:1px solid var(--border);padding-top:16px;">
<h4>Test Routing</h4>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<input type="text" id="routingTestModel" placeholder="Model ID">
<input type="text" id="routingTestUser" placeholder="User ID (optional)">
<button id="routingTestBtn" class="btn-small">Test</button>
</div>
<pre id="routingTestResult" style="margin-top:8px;font-size:12px;"></pre>
</div>
</div>
<div id="adminUsageResults" class="admin-section" style="display:none;">
<div id="adminUsageTotals"></div>
<div id="_adminUsageContainer"></div>
</div>
{{/* Settings admin section */}}
<div class="admin-section" style="display:none;">
<div class="form-group"><label><input type="checkbox" id="adminRegToggle"> Registration Enabled</label></div>
<div class="form-group"><label>Default User State</label><select id="adminRegDefaultState"><option value="active">Active</option><option value="pending">Pending Approval</option></select></div>
<div class="form-group"><label><input type="checkbox" id="adminUserProvidersToggle"> Allow User BYOK</label></div>
<div class="form-group"><label><input type="checkbox" id="adminUserPresetsToggle"> Allow User Presets</label></div>
<div class="form-group"><label><input type="checkbox" id="adminKBDirectAccessToggle"> KB Direct Access</label></div>
<div class="form-group"><label>Default Model</label><select id="adminDefaultModel"></select></div>
<div class="form-group"><label>System Prompt</label><textarea id="adminSystemPrompt" rows="4"></textarea></div>
<div class="form-group"><label><input type="checkbox" id="adminBannerEnabled"> Banner</label></div>
<div id="bannerConfigFields" style="display:none;">
<input type="text" id="adminBannerText" placeholder="Banner text">
<div style="display:flex;gap:8px;margin-top:6px;">
<select id="adminBannerPreset"><option value="">Custom colors</option></select>
<select id="adminBannerPosition"><option value="both">Both</option><option value="top">Top</option><option value="bottom">Bottom</option></select>
</div>
<div style="display:flex;gap:8px;margin-top:6px;">
<input type="color" id="adminBannerBg" value="#007a33"><input type="text" id="adminBannerBgHex" value="#007a33" style="width:80px;">
<input type="color" id="adminBannerFg" value="#ffffff"><input type="text" id="adminBannerFgHex" value="#ffffff" style="width:80px;">
</div>
<div id="bannerPreview" class="banner-preview" style="margin-top:8px;"></div>
</div>
<div class="form-group"><label><input type="checkbox" id="adminMemoryExtractionEnabled"> Memory Extraction</label></div>
<div id="memoryExtractionConfigFields" style="display:none;">
<label><input type="checkbox" id="adminMemoryAutoApprove"> Auto-approve</label>
</div>
<div class="form-group"><label><input type="checkbox" id="adminCompactionEnabled"> Auto-Compaction</label></div>
<div id="compactionConfigFields" style="display:none;">
<label>Threshold <input type="number" id="adminCompactionThreshold" value="100000"></label>
<label>Cooldown <input type="number" id="adminCompactionCooldown" value="3600"></label>
</div>
<div class="form-group">
<label>Web Search</label>
<select id="adminSearchProvider"><option value="">Disabled</option><option value="searxng">SearXNG</option></select>
<div id="searxngConfigFields" style="display:none;">
<input type="text" id="adminSearchEndpoint" placeholder="SearXNG URL">
<input type="text" id="adminSearchApiKey" placeholder="API key (optional)">
<input type="number" id="adminSearchMaxResults" placeholder="Max results" value="5">
</div>
</div>
<button id="adminRunCleanup" class="btn-small btn-danger" style="margin-top:12px;">Run Cleanup</button>
<div id="adminVaultStatus" style="margin-top:12px;"></div>
</div>
</div>
</div>
</div>
</div>
{{/* ── Team Admin Modal ────────────────────── */}}
<div id="teamAdminModal" class="modal-overlay">
<div class="modal-content modal-lg">
<div class="modal-header">
<h2 id="teamAdminTitle">Team Admin</h2>
<button id="teamAdminCloseBtn" class="modal-close"></button>
</div>
<div class="modal-body" style="display:flex;min-height:300px;">
<div id="teamAdminPicker" class="admin-sidebar">
<div id="teamAdminPickerList"></div>
</div>
<div id="teamAdminContent" style="flex:1;overflow-y:auto;padding:16px;">
<div id="adminCurrentUser" style="display:none;"></div>
<div id="adminTeamDetail" style="display:none;">
<h3 id="adminTeamDetailName"></h3>
<div id="adminMemberList"></div>
<div id="adminAddMemberForm" style="display:none;">
<select id="adminMemberUser"></select>
</div>
<div id="adminTeamAllowProviders" style="display:none;"></div>
<div id="adminTeamPrivatePolicy" style="display:none;"></div>
</div>
<div id="adminGroupDetail" style="display:none;">
<div id="adminGroupDetailMeta"></div>
<h3 id="adminGroupDetailName"></h3>
<div id="adminGroupList" style="display:none;"></div>
<div id="adminGroupMemberList"></div>
<div id="adminAddGroupMemberForm" style="display:none;">
<select id="adminGroupMemberUser"></select>
</div>
</div>
<div id="adminAddTeamForm" style="display:none;"></div>
<div id="adminAddGroupForm" style="display:none;"></div>
<div id="adminAddProviderForm" style="display:none;"></div>
{{/* Team audit (inside team admin modal) */}}
<div id="teamKBContent" style="display:none;"></div>
<div style="display:none;">
<select id="teamAuditFilterAction"><option value="">All</option></select>
<div id="teamAuditPagination" style="display:flex;gap:8px;align-items:center;">
<button id="teamAuditPrevBtn" class="btn-small" disabled></button>
<span id="teamAuditPageInfo"></span>
<button id="teamAuditNextBtn" class="btn-small"></button>
</div>
</div>
{{/* Team preset model select */}}
<div style="display:none;"><select id="teamPreset_model"></select></div>
</div>
</div>
</div>
</div>
{{/* ── Debug Modal ─────────────────────────── */}}
<div id="debugModal" class="modal-overlay">
<div class="modal-content modal-lg">
<div class="modal-header">
<h2>Debug Console</h2>
<button class="modal-close" onclick="closeModal('debugModal')"></button>
</div>
<div class="modal-tabs">
<button class="debug-tab active" data-dtab="console" onclick="switchDebugTab('console')">Console</button>
<button class="debug-tab" data-dtab="network" onclick="switchDebugTab('network')">Network</button>
<button class="debug-tab" data-dtab="state" onclick="switchDebugTab('state')">State</button>
<button class="debug-tab" data-dtab="repl" onclick="switchDebugTab('repl')">REPL</button>
</div>
<div class="modal-body" style="padding:0;height:400px;overflow:hidden;">
<div id="debugConsoleTab" class="debug-tab-content" style="display:block;height:100%;overflow-y:auto;">
<div style="padding:8px;display:flex;gap:8px;align-items:center;">
<label><input type="checkbox" id="debugAutoScroll" checked> Auto-scroll</label>
<label><input type="checkbox" id="debugFilterErrors"> Errors only</label>
<span id="debugConsoleCount" class="badge">0</span>
</div>
<div id="debugConsoleContent" class="debug-content" style="font-family:monospace;font-size:12px;padding:8px;"></div>
</div>
<div id="debugNetworkTab" class="debug-tab-content" style="display:none;height:100%;overflow-y:auto;">
<div style="padding:8px;"><span id="debugNetworkCount" class="badge">0</span> requests</div>
<div id="debugNetworkContent" class="debug-content" style="font-family:monospace;font-size:12px;padding:8px;"></div>
</div>
<div id="debugStateTab" class="debug-tab-content" style="display:none;height:100%;overflow-y:auto;">
<div id="debugStateContent" class="debug-content" style="font-family:monospace;font-size:12px;padding:8px;white-space:pre-wrap;"></div>
</div>
<div id="debugReplTab" class="debug-tab-content" style="display:none;height:100%;display:flex;flex-direction:column;">
<div id="replOutput" style="flex:1;overflow-y:auto;font-family:monospace;font-size:12px;padding:8px;"></div>
<div style="border-top:1px solid var(--border);padding:8px;">
<input type="text" id="replInput" placeholder="Type expression…" style="width:100%;font-family:monospace;font-size:12px;">
</div>
</div>
</div>
</div>
</div>
{{/* ── Notification Bell ───────────────────── */}}
<div id="notifWrap" class="notif-wrap" style="position:fixed;top:8px;right:12px;z-index:100;">
<button class="notif-btn" onclick="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>
{{/* ── 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="Pages.saveRoutingPolicy()">Save</button>
<button id="rpCancelBtn" class="btn-secondary" onclick="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"}}
@@ -29,7 +646,6 @@
{{/* App JS — order matches index.html (minus api/events/ui-primitives already in base) */}}
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/debug.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/repl.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/surfaces.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/extensions.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/panels.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/ui-format.js?v={{$.Version}}"></script>
@@ -52,6 +668,5 @@
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/settings-handlers.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/admin-handlers.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/editor-mode.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/router.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/app.js?v={{$.Version}}"></script>
{{end}}

View File

@@ -71,7 +71,6 @@
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/memory-ui.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/notifications.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/panels.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/surfaces.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/debug.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/editor-mode.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/projects-ui.js?v={{.Version}}"></script>

View File

@@ -72,7 +72,6 @@
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/memory-ui.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/notifications.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/panels.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/surfaces.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/debug.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/projects-ui.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/app.js?v={{.Version}}"></script>

View File

@@ -27,6 +27,8 @@
<div class="settings-content-inner" id="settingsSection" data-section="{{.Section}}">
{{if eq .Section "general"}}{{template "settings-general" .}}
{{else if eq .Section "appearance"}}{{template "settings-appearance" .}}
{{else if eq .Section "providers"}}{{template "settings-providers" .}}
{{else if eq .Section "personas"}}{{template "settings-personas" .}}
{{else}}<div class="settings-placeholder" id="settingsDynamic">Loading…</div>
{{end}}
</div>
@@ -96,6 +98,25 @@
</div>
{{end}}
{{define "settings-providers"}}
<div id="userProvidersDisabled" class="settings-section" style="display:none;">
<p style="color:var(--text-secondary);font-size:13px;">Your admin has disabled user-managed API keys (BYOK). Models are available through team presets.</p>
</div>
<div style="margin-bottom:12px;">
<button class="btn-small btn-primary" id="providerShowAddBtn">+ Add Provider</button>
</div>
<div id="providerAddForm" style="display:none;"></div>
<div id="providerList"><div class="settings-placeholder">Loading providers…</div></div>
{{end}}
{{define "settings-personas"}}
<div style="margin-bottom:12px;">
<button class="btn-small btn-primary" id="userAddPresetBtn">+ New Preset</button>
</div>
<div id="userAddPresetForm" style="display:none;"></div>
<div id="userPresetList"><div class="settings-placeholder">Loading presets…</div></div>
{{end}}
{{define "css-settings"}}
<style>
.surface-settings {

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,10 @@
// 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.
//
// Run: node --test src/js/__tests__/policy-gating.test.js
// ==========================================
@@ -16,6 +20,21 @@ const fs = require('fs');
const path = require('path');
const SRC = path.join(__dirname, '..');
const TEMPLATES = path.join(__dirname, '..', '..', '..', 'server', 'pages', 'templates');
// ── Helper: read all server template HTML ────
function readAllTemplates() {
const files = [];
function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (entry.name.endsWith('.html')) files.push(fs.readFileSync(full, 'utf-8'));
}
}
walk(TEMPLATES);
return files.join('\n');
}
// ── Source code audits ───────────────────────
// These tests read the actual source files and verify that required
@@ -28,7 +47,9 @@ describe('Policy wiring audit — source code', () => {
// 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');
const indexSrc = fs.readFileSync(path.join(SRC, '..', 'index.html'), 'utf-8');
// 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 ──
@@ -54,18 +75,18 @@ describe('Policy wiring audit — source code', () => {
'MISSING: allow_user_personas check in UI');
});
it('admin settings UI has adminUserPresetsToggle', () => {
assert.ok(indexSrc.includes('adminUserPresetsToggle'),
'MISSING: preset toggle in admin settings HTML');
it('admin settings template has user-personas toggle', () => {
assert.ok(templateSrc.includes('settUserPersonas'),
'MISSING: settUserPersonas toggle in admin settings template');
});
it('admin settings load reads allow_user_personas', () => {
assert.ok(uiSrc.includes("adminUserPresetsToggle"),
'MISSING: loadAdminSettings must read allow_user_personas into toggle');
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', () => {
assert.ok(appSrc.includes("allow_user_personas"),
it('admin settings save writes allow_user_personas (SPA bridge)', () => {
assert.ok(appSrc.includes('allow_user_personas'),
'MISSING: handleSaveAdminSettings must write allow_user_personas');
});
@@ -199,55 +220,101 @@ 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.
describe('Admin settings field mapping', () => {
// Maps what the frontend sends to what the backend expects
describe('Admin settings field mapping (server templates)', () => {
// New element IDs used by server-rendered admin/settings.html + pages.js
const settingsFieldMap = {
'adminRegToggle': 'allow_registration',
'adminRegDefaultState': 'default_user_active',
'adminUserProvidersToggle': 'allow_user_byok',
'adminUserPresetsToggle': 'allow_user_personas',
'adminBannerEnabled': 'banner',
'settRegEnabled': 'allow_registration',
'settRegDefaultState': 'default_user_active',
'settUserBYOK': 'allow_user_byok',
'settUserPersonas': 'allow_user_personas',
'settBannerEnabled': 'banner',
};
// Read all app-side files (handleSaveAdminSettings is in settings-handlers.js)
const appSrc = ['app.js', 'settings-handlers.js', 'admin-handlers.js']
.map(f => fs.readFileSync(path.join(SRC, f), 'utf-8')).join('\n');
const indexSrc = fs.readFileSync(path.join(SRC, '..', 'index.html'), 'utf-8');
const pagesSrc = fs.readFileSync(path.join(SRC, 'pages.js'), 'utf-8');
const templateSrc = readAllTemplates();
for (const [elementId, settingKey] of Object.entries(settingsFieldMap)) {
it(`HTML has element #${elementId}`, () => {
assert.ok(indexSrc.includes(`id="${elementId}"`),
`MISSING: #${elementId} in index.html — admin settings incomplete`);
it(`template has element #${elementId}`, () => {
assert.ok(templateSrc.includes(`id="${elementId}"`),
`MISSING: #${elementId} in server templates — admin settings incomplete`);
});
it(`frontend writes setting "${settingKey}"`, () => {
assert.ok(appSrc.includes(settingKey),
`MISSING: "${settingKey}" in handleSaveAdminSettings`);
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',
];
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`);
});
}
});
// ── HTML element existence checks ────────────
// v0.22.5: Elements now live in server templates, not index.html.
// Settings surface dynamic sections (providers, personas) have scaffold
// containers rendered by Go templates that JS then populates.
describe('Critical HTML elements exist', () => {
const indexSrc = fs.readFileSync(path.join(SRC, '..', 'index.html'), 'utf-8');
describe('Critical HTML elements exist in server templates', () => {
const templateSrc = readAllTemplates();
const requiredElements = [
'adminMemberUser', // Team member user dropdown
// Settings surface — provider section scaffold
'userPresetList', // User preset list container
'userAddPresetBtn', // New preset button (policy-gated)
'userAddPresetForm', // Preset form container
'userProvidersDisabled', // BYOK disabled notice
'providerShowAddBtn', // Add provider button (policy-gated)
'adminUserProvidersToggle', // Admin toggle for BYOK
'adminUserPresetsToggle', // Admin toggle for presets
// Admin settings — policy toggles
'settUserBYOK', // Admin toggle for BYOK (was adminUserProvidersToggle)
'settUserPersonas', // Admin toggle for presets (was adminUserPresetsToggle)
];
for (const id of requiredElements) {
it(`#${id} exists in index.html`, () => {
assert.ok(indexSrc.includes(`id="${id}"`),
it(`#${id} exists in server templates`, () => {
assert.ok(templateSrc.includes(`id="${id}"`),
`MISSING element: #${id} — UI feature will break`);
});
}
});
// ── 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.
describe('SPA bridge — dynamic element creators', () => {
const uiAdminSrc = fs.readFileSync(path.join(SRC, 'ui-admin.js'), 'utf-8');
it('ui-admin.js has loadMemberUserDropdown', () => {
assert.ok(uiAdminSrc.includes('loadMemberUserDropdown'),
'MISSING: loadMemberUserDropdown — team member add will break');
});
it('loadMemberUserDropdown references adminMemberUser', () => {
assert.ok(uiAdminSrc.includes('adminMemberUser'),
'MISSING: adminMemberUser reference in loadMemberUserDropdown');
});
});

View File

@@ -173,10 +173,6 @@ async function startApp() {
UI.restoreSidebar();
await loadSettings();
// Initialize surface system (v0.21.3) — must happen before extensions
// so that extensions can register surfaces during init().
if (typeof Surfaces !== 'undefined') Surfaces.init();
// Load extensions BEFORE chats so block renderers (mermaid, katex, csv, diff)
// are registered when messages are first rendered.
try {
@@ -211,25 +207,13 @@ async function startApp() {
UI.renderChatList();
UI.updateModelSelector();
// Check for workspaces on startup — register editor surface early (v0.21.6)
// This ensures the Editor button + Files tab appear without needing to browse first.
if (typeof EditorMode !== 'undefined') {
try { await EditorMode.checkStartup(); } catch (_) {}
}
// Initialize hash router (v0.21.6) — replaces sessionStorage chat restore.
// Reads current URL hash and routes to the right surface/chat/workspace.
if (typeof Router !== 'undefined') {
Router.init();
} else {
// Fallback: restore last-active chat from sessionStorage
// Restore last-active chat from sessionStorage
try {
const savedChat = sessionStorage.getItem('cs-active-chat');
if (savedChat && App.chats.some(c => c.id === savedChat)) {
selectChat(savedChat);
}
} catch (_) {}
}
UI.updateUser();
UI.showAdminButton(API.isAdmin);
@@ -352,16 +336,17 @@ function updateTabArrows(tabs) {
// ── Auth Flow ────────────────────────────────
function showSplash(health) {
document.getElementById('splashGate').style.display = 'flex';
document.getElementById('appContainer').style.display = 'none';
if (health && health.registration_enabled === false) {
document.getElementById('authTabRegister').style.display = 'none';
}
// v0.22.6: Server-rendered architecture uses /login page.
// Redirect instead of showing the SPA splash gate.
const base = window.__BASE__ || '';
window.location.href = base + '/login';
}
function hideSplash() {
document.getElementById('splashGate').style.display = 'none';
document.getElementById('appContainer').style.display = '';
const splash = document.getElementById('splashGate');
const app = document.getElementById('appContainer');
if (splash) splash.style.display = 'none';
if (app) app.style.display = '';
}
async function handleLogin() {

View File

@@ -262,10 +262,8 @@ async function selectChat(chatId) {
// Notify surfaces and extensions about channel switch (v0.21.6)
Events.emit('chat.switched', { chatId, projectId: chat.projectId || null }, { localOnly: true });
// Sync URL hash (v0.21.6)
if (typeof Router !== 'undefined' && Router._initialized) {
Router.update('chat', { chatId });
}
// Update browser URL to reflect selected chat
window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chatId}`);
}
// ── Chat Header Token Count ──────────────────
@@ -397,10 +395,7 @@ async function newChat() {
if (typeof KnowledgeUI !== 'undefined') KnowledgeUI.onChatChanged();
// Notify surfaces — no channel selected (v0.21.6)
Events.emit('chat.switched', { chatId: null, projectId: null }, { localOnly: true });
// Sync URL hash
if (typeof Router !== 'undefined' && Router._initialized) {
Router.update('chat');
}
window.history.replaceState(null, '', `${window.__BASE__ || ''}/`);
}
async function deleteChat(chatId) {
@@ -539,10 +534,7 @@ async function sendMessage() {
UI.renderChatList();
// Notify surfaces about new chat creation (v0.21.6)
Events.emit('chat.created', { chatId: chat.id, projectId: chat.projectId || null }, { localOnly: true });
// Sync URL hash
if (typeof Router !== 'undefined' && Router._initialized) {
Router.update('chat', { chatId: chat.id });
}
window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chat.id}`);
} catch (e) { UI.toast('Failed to create chat: ' + e.message, 'error'); return; }
}

View File

@@ -1,11 +1,9 @@
// ==========================================
// Chat Switchboard Editor Surface (v0.21.5)
// Chat Switchboard Editor Surface (v0.22.6)
// ==========================================
// IDE-like experience built as a surface consuming workspace
// primitives (v0.21.0) and surface infrastructure (v0.21.3).
//
// Layout: file tree (sidebar) | code editor (CM6) + chat panel (split)
// Load order: surfaces.js → editor-mode.js (after app init)
// IDE-like experience for workspace file editing with CM6.
// Server-rendered via Go template (editor.html).
// Entry point: EditorMode.mountServerRendered(wsId, wsName)
// ==========================================
const EditorMode = {
@@ -14,7 +12,6 @@ const EditorMode = {
_wsId: null, // workspace ID
_wsName: '', // workspace display name
_gitBranch: null, // current git branch (nullable)
_registered: false, // surface registered?
_active: false, // currently active?
// DOM (built once, reused across activations)
@@ -40,145 +37,15 @@ const EditorMode = {
// ── Initialization ───────────────────────
/**
* Check if the current channel/project has a workspace.
* Called on channel switch and app init.
* No-op — kept for backward compat with projects-ui.js callers.
* Editor is now only reachable via server route (/editor).
*/
async check() {
const channelId = App.currentChatId;
if (!channelId) {
// Don't unregister on empty state — startup check may have registered
if (!this._registered) return;
// Only unregister if we were registered via a channel (not startup)
return;
}
try {
// Check local data first to avoid unnecessary API calls
const localChat = App.chats?.find(c => c.id === channelId);
let wsId = localChat?.workspace_id || null;
let projectId = localChat?.projectId || null;
// If no local workspace, fetch from server (has workspace_id column)
if (!wsId) {
try {
const ch = await API.getChannel(channelId);
wsId = ch.workspace_id || null;
projectId = projectId || ch.project_id || null;
} catch (_) {}
}
// Check project workspace if channel doesn't have one
if (!wsId && projectId) {
try {
const proj = await API.getProject(projectId);
if (proj.workspace_id) {
this._register(proj.workspace_id, proj.name || 'Workspace');
return;
}
} catch (_) { /* no project workspace */ }
}
if (wsId) {
try {
const ws = await API.getWorkspace(wsId);
this._register(wsId, ws.name || 'Workspace');
} catch (_) {
this._register(wsId, 'Workspace');
}
}
// Don't unregister just because this channel has no workspace
} catch (e) {
console.warn('[EditorMode] check failed:', e);
}
},
/**
* Startup check — register editor if any workspace exists.
* Called once from startApp(), independent of channel selection.
*/
async checkStartup() {
if (this._registered) return;
try {
// First: check active project for workspace
if (App.activeProjectId) {
const proj = App.projects?.find(p => p.id === App.activeProjectId);
if (proj?.workspace_id) {
this._register(proj.workspace_id, proj.name || 'Workspace');
return;
}
}
// Then: check all projects for workspaces
for (const p of (App.projects || [])) {
if (p.workspace_id) {
this._register(p.workspace_id, p.name || 'Workspace');
return;
}
}
// Finally: check if any workspaces exist at all
const resp = await API.listWorkspaces();
const workspaces = Array.isArray(resp) ? resp : (resp?.data || []);
if (workspaces.length > 0) {
this._register(workspaces[0].id, workspaces[0].name || 'Workspace');
}
} catch (e) {
console.warn('[EditorMode] startup check failed:', e);
}
},
_register(wsId, name) {
this._wsId = wsId;
this._wsName = name;
if (this._registered) return;
this._registered = true;
// Build DOM early so file tree is available for sidebar Files tab
if (!this._built) this._build();
Surfaces.register('editor', {
label: 'Editor',
icon: 'code',
regions: ['surface-header', 'surface-main'],
activate: () => this._activate(),
deactivate: () => this._deactivate(),
// Layout: editor is primary, chat in secondary (handled internally)
primary: 'editor',
secondary: 'chat',
secondaryOpts: ['chat'],
});
// Show the Files tab in sidebar and populate it
if (typeof showSidebarFilesTab === 'function') showSidebarFilesTab(true);
const filesPanel = document.getElementById('sidebarFilesPanel');
if (filesPanel && this._els?.fileTree) {
filesPanel.innerHTML = '';
filesPanel.appendChild(this._els.fileTree);
}
this._refreshFileTree();
console.log(`[EditorMode] Registered for workspace ${wsId}`);
},
/**
* Direct open — bypass check(), register with a specific workspace.
* Used by Router for hash-based navigation (e.g. #/editor/ws_abc123).
*/
openDirect(wsId) {
if (this._wsId === wsId && this._registered) return;
if (this._registered && this._wsId !== wsId) this._unregister();
this._register(wsId, 'Workspace');
// Async: fetch real name
API.getWorkspace(wsId).then(ws => {
this._wsName = ws.name || 'Workspace';
}).catch(() => {});
},
async check() {},
async checkStartup() {},
/**
* Mount into server-rendered editor surface template containers.
* Called by the <script> in editor.html when __SURFACE__ === 'editor'.
* Bypasses Surfaces registry — the Go template owns the layout.
*/
mountServerRendered(wsId, wsName) {
this._wsId = wsId;
@@ -197,7 +64,6 @@ const EditorMode = {
}
this._active = true;
this._registered = true;
// Populate file tree and git info
this._refreshFileTree();
@@ -212,131 +78,6 @@ const EditorMode = {
console.log(`[EditorMode] Mounted server-rendered for workspace ${wsId}`);
},
_unregister() {
if (!this._registered) return;
if (this._active) {
Surfaces.activate('chat');
}
Surfaces.unregister('editor');
this._registered = false;
this._wsId = null;
this._built = false;
this._openFiles.clear();
this._activeFile = null;
// Clean up sidebar Files tab
if (typeof showSidebarFilesTab === 'function') showSidebarFilesTab(false);
const filesPanel = document.getElementById('sidebarFilesPanel');
if (filesPanel) filesPanel.innerHTML = '';
this._els = null;
console.log('[EditorMode] Unregistered');
},
// ── Surface Callbacks ────────────────────
_activate() {
this._active = true;
if (!this._built) {
this._build();
}
// Populate regions
const regions = Surfaces._regionEls;
// Header
const headerEl = regions.get('surface-header');
if (headerEl) headerEl.appendChild(this._els.header);
// Main → split pane (editor + chat). Footer lives inside left pane.
const mainEl = regions.get('surface-main');
if (mainEl) mainEl.appendChild(this._els.main);
// Hide the global footer region — editor has its own status bar in the left pane
const footerRegion = regions.get('surface-footer');
if (footerRegion) footerRegion.style.display = 'none';
// Embed saved chat DOM into our chat pane
this._embedChat();
// Auto-switch sidebar to Files tab
const filesTab = document.getElementById('sidebarFilesTab');
if (filesTab && !filesTab.classList.contains('active')) filesTab.click();
// Refresh file tree
this._refreshFileTree();
this._refreshGitBranch();
// Focus active editor
if (this._activeFile) {
const f = this._openFiles.get(this._activeFile);
if (f?.editor?.focus) setTimeout(() => f.editor.focus(), 50);
}
},
_deactivate() {
this._active = false;
// Auto-save all modified files before switching surfaces (v0.21.6)
for (const [path, f] of this._openFiles) {
if (f.modified) this._saveFile(path);
}
// Switch sidebar back to Chats tab
const chatsTab = document.querySelector('.sidebar-tab[data-tab="chats"]');
if (chatsTab && !chatsTab.classList.contains('active')) chatsTab.click();
// Restore global footer region visibility
const footerRegion = Surfaces._regionEls?.get('surface-footer');
if (footerRegion) footerRegion.style.display = '';
// Return chat DOM to Surfaces saved store before regions are saved
this._returnChat();
},
// ── Chat Panel Embedding ─────────────────
// Borrow chat DOM from the saved fragments so the chat panel
// shows real messages and the user can interact with AI.
_chatPane: null,
_chatMessagesSlot: null,
_chatInputSlot: null,
_embedChat() {
if (!this._chatPane) return;
// Grab saved chat fragments
const msgFrag = Surfaces.getSavedFragment('chat', 'surface-main');
const inputFrag = Surfaces.getSavedFragment('chat', 'surface-footer');
if (msgFrag) {
this._chatMessagesSlot.appendChild(msgFrag);
// Scroll to bottom
this._chatMessagesSlot.scrollTop = this._chatMessagesSlot.scrollHeight;
}
if (inputFrag) {
this._chatInputSlot.appendChild(inputFrag);
}
},
_returnChat() {
if (!this._chatPane) return;
// Collect chat DOM back into fragments and return to Surfaces store
const msgFrag = document.createDocumentFragment();
while (this._chatMessagesSlot.firstChild) {
msgFrag.appendChild(this._chatMessagesSlot.firstChild);
}
Surfaces.putSavedFragment('chat', 'surface-main', msgFrag);
const inputFrag = document.createDocumentFragment();
while (this._chatInputSlot.firstChild) {
inputFrag.appendChild(this._chatInputSlot.firstChild);
}
Surfaces.putSavedFragment('chat', 'surface-footer', inputFrag);
},
// ── DOM Construction ─────────────────────
_build() {

View File

@@ -248,14 +248,14 @@ const Extensions = {
* and can be restored later. Critical for CM6 state preservation.
*/
replace(regionId, element) {
if (typeof Surfaces !== 'undefined') Surfaces.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) {
if (typeof Surfaces !== 'undefined') Surfaces.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). */
@@ -270,20 +270,19 @@ const Extensions = {
},
},
// Surface registration (v0.21.3)
// Surface registration (removed v0.22.6 — server-rendered surfaces)
surfaces: {
register: (id, opts) => {
if (typeof Surfaces !== 'undefined') Surfaces.register(id, opts);
console.warn(`[Extensions] surfaces.register() removed in v0.22.6`);
},
unregister: (id) => {
if (typeof Surfaces !== 'undefined') Surfaces.unregister(id);
console.warn(`[Extensions] surfaces.unregister() removed in v0.22.6`);
},
activate: (id) => {
if (typeof Surfaces !== 'undefined') Surfaces.activate(id);
console.warn(`[Extensions] surfaces.activate() removed in v0.22.6`);
},
getCurrent: () => {
if (typeof Surfaces !== 'undefined') return Surfaces.getCurrent();
return 'chat';
return window.__SURFACE__ || 'chat';
},
},

View File

@@ -205,6 +205,51 @@ const Pages = {
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() {

View File

@@ -145,7 +145,6 @@ const REPL = {
if (typeof API !== 'undefined') globals.API = API;
if (typeof Events !== 'undefined') globals.Events = Events;
if (typeof Extensions !== 'undefined') globals.Extensions = Extensions;
if (typeof Surfaces !== 'undefined') globals.Surfaces = Surfaces;
if (typeof DebugLog !== 'undefined') globals.DebugLog = DebugLog;
if (typeof PanelRegistry !== 'undefined') globals.Panels = PanelRegistry;
if (typeof UI !== 'undefined') globals.UI = UI;

View File

@@ -1,322 +0,0 @@
// ==========================================
// Chat Switchboard Hash Router (v0.21.6)
// ==========================================
// URL-driven navigation. The hash IS the entry point.
//
// #/chat → default chat view
// #/chat/ch_abc123 → open specific chat
// #/editor → editor surface (workspace picker if none)
// #/editor/ws_abc123 → editor with specific workspace
//
// Bookmarkable. Browser back/forward works.
// ==========================================
const Router = {
_navigating: false, // prevents circular hash updates
_pending: null, // queued route when surface isn't registered yet
_initialized: false,
// ── Public API ───────────────────────────
/**
* Initialize router. Call once after Surfaces.init(), loadChats(), etc.
* Reads the current hash and routes to it.
*/
init() {
window.addEventListener('hashchange', () => this._onHashChange());
// Listen for surface registration to resolve pending routes
Events.on('surface.registered', () => this._tryPending());
// Keep hash in sync when surfaces or chats change externally
Events.on('surface.activated', (ev) => {
if (!this._navigating) this._syncHash();
});
this._initialized = true;
this.resolve();
console.log('[Router] Initialized');
},
/**
* Navigate programmatically.
* @param {string} path — e.g. '/editor/ws_abc123'
*/
navigate(path) {
const hash = '#' + (path.startsWith('/') ? path : '/' + path);
if (location.hash === hash) {
this.resolve(); // re-resolve even if same hash
} else {
location.hash = hash;
// hashchange event will fire → _onHashChange → resolve
}
},
/**
* Parse current hash and execute the route.
*/
resolve() {
const route = this._parse();
console.log('[Router] Resolving:', route);
this._navigating = true;
switch (route.surface) {
case 'chat':
this._routeChat(route);
break;
case 'editor':
this._routeEditor(route);
break;
default:
// Unknown route → default to chat
this._routeChat({ surface: 'chat', id: null });
}
this._navigating = false;
},
/**
* Update hash to reflect current app state.
* Called after external navigation (e.g. sidebar click).
*/
update(surface, params = {}) {
if (this._navigating) return;
let hash = '#/' + (surface || 'chat');
if (params.chatId) hash += '/' + params.chatId;
if (params.wsId) hash += '/' + params.wsId;
if (params.path) hash += '/' + params.path;
if (location.hash !== hash) {
this._navigating = true;
history.replaceState(null, '', hash);
this._navigating = false;
}
},
// ── Route Handlers ───────────────────────
_routeChat(route) {
// Ensure we're on chat surface
if (Surfaces.getCurrent() !== 'chat') {
Surfaces.activate('chat');
}
if (route.id) {
const chat = App.chats?.find(c => c.id === route.id);
if (chat) {
selectChat(route.id);
} else {
console.warn(`[Router] Chat ${route.id} not found`);
}
}
// No id → just show empty state (newChat was already showing)
},
_routeEditor(route) {
const wsId = route.id || null;
// If EditorMode is already registered, activate directly
if (Surfaces.get('editor')) {
// Update workspace if specified and different
if (wsId && typeof EditorMode !== 'undefined' && EditorMode._wsId !== wsId) {
EditorMode.openDirect(wsId);
}
Surfaces.activate('editor');
return;
}
// Surface not registered yet — try to register it
if (wsId && typeof EditorMode !== 'undefined') {
EditorMode.openDirect(wsId);
// After registration, activate
if (Surfaces.get('editor')) {
Surfaces.activate('editor');
return;
}
}
// No workspace specified — try active project's workspace
if (!wsId && typeof EditorMode !== 'undefined') {
const projWsId = this._activeProjectWorkspace();
if (projWsId) {
EditorMode.openDirect(projWsId);
if (Surfaces.get('editor')) {
Surfaces.activate('editor');
return;
}
}
}
// Still not registered — show workspace picker
if (!Surfaces.get('editor')) {
this._pending = route;
this._showWorkspacePicker('editor');
}
},
// ── Workspace Picker (inline) ────────────
async _showWorkspacePicker(targetSurface) {
let workspaces = [];
try {
const resp = await API.listWorkspaces();
workspaces = Array.isArray(resp) ? resp : (resp?.data || []);
} catch (_) {}
if (workspaces.length === 1) {
// Auto-select single workspace
this._pickWorkspace(targetSurface, workspaces[0].id);
return;
}
// Build overlay
const overlay = document.createElement('div');
overlay.className = 'router-picker-overlay';
const label = 'Editor';
let inner = `
<div class="router-picker">
<h3>Open ${label}</h3>
<p>Choose a workspace to get started:</p>
<div class="router-picker-list">`;
if (workspaces.length === 0) {
inner += '<div class="router-picker-empty">No workspaces yet</div>';
}
for (const ws of workspaces) {
inner += `<button class="router-picker-item" data-ws="${ws.id}">
<span class="router-picker-icon">📁</span>
<span>${this._esc(ws.name || ws.id.slice(0, 8))}</span>
</button>`;
}
inner += `</div>
<div class="router-picker-actions">
<button class="btn-small btn-primary" id="routerPickerNew">+ New workspace</button>
<button class="btn-small" id="routerPickerCancel">Cancel</button>
</div>
</div>`;
overlay.innerHTML = inner;
// Wire clicks
overlay.querySelectorAll('.router-picker-item').forEach(btn => {
btn.addEventListener('click', () => {
overlay.remove();
this._pickWorkspace(targetSurface, btn.dataset.ws);
});
});
overlay.querySelector('#routerPickerNew')?.addEventListener('click', async () => {
const name = prompt('Workspace name:');
if (!name?.trim()) return;
try {
const ws = await API.createWorkspace({
name: name.trim(),
owner_type: 'user',
owner_id: API.user?.id || '',
});
overlay.remove();
this._pickWorkspace(targetSurface, ws.id);
} catch (e) {
if (typeof UI !== 'undefined') UI.toast('Failed: ' + (e.message || e), 'error');
}
});
overlay.querySelector('#routerPickerCancel')?.addEventListener('click', () => {
overlay.remove();
this._pending = null;
this.navigate('/chat');
});
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
overlay.remove();
this._pending = null;
this.navigate('/chat');
}
});
document.body.appendChild(overlay);
},
_pickWorkspace(targetSurface, wsId) {
if (typeof EditorMode !== 'undefined') {
EditorMode.openDirect(wsId);
if (Surfaces.get('editor')) Surfaces.activate('editor');
}
// Update hash
this.navigate('/editor/' + wsId);
},
// ── Hash Parsing ─────────────────────────
_parse() {
const hash = (location.hash || '').replace(/^#\/?/, '');
const parts = hash.split('/').filter(Boolean);
if (!parts.length) return { surface: 'chat', id: null };
const surface = parts[0];
const id = parts[1] || null;
const extra = parts.length > 2 ? parts.slice(2).join('/') : null;
return { surface, id, extra };
},
// ── Event Handlers ───────────────────────
_onHashChange() {
if (this._navigating) return;
this.resolve();
},
_tryPending() {
if (!this._pending) return;
const route = this._pending;
// Check if the target surface is now registered
if (Surfaces.get(route.surface)) {
this._pending = null;
this._navigating = true;
Surfaces.activate(route.surface);
this._navigating = false;
}
},
/**
* Sync hash to current app state (called when navigation happens
* outside the router, e.g. clicking a chat in the sidebar).
*/
_syncHash() {
const surface = Surfaces.getCurrent();
const params = {};
if (surface === 'chat' && App.currentChatId) {
params.chatId = App.currentChatId;
} else if (surface === 'editor' && typeof EditorMode !== 'undefined') {
params.wsId = EditorMode._wsId;
}
this.update(surface, params);
},
// ── Helpers ──────────────────────────────
_activeProjectWorkspace() {
if (!App.activeProjectId) return null;
const proj = App.projects?.find(p => p.id === App.activeProjectId);
return proj?.workspace_id || null;
},
_esc(s) {
const d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
},
};

View File

@@ -1,368 +0,0 @@
// ==========================================
// Chat Switchboard Surface Registry
// ==========================================
// Manages "modes" (surfaces) — chat, editor, etc.
// Each surface can take over named regions of the UI without
// destroying the DOM nodes of the previous surface.
//
// Load order: events.js → surfaces.js → extensions.js
//
// Key design: replace() detaches children (kept in memory),
// restore() re-attaches them. CM6 editor instances survive
// mode switches because their DOM isn't destroyed.
// ==========================================
const Surfaces = {
// ── State ────────────────────────────────
_registry: new Map(), // surfaceId → { label, icon, regions, activate, deactivate }
_current: 'chat', // active surface id
_saved: new Map(), // regionId → DocumentFragment (saved DOM children)
_regionEls: new Map(), // regionId → DOM element (cached lookups)
// ── Initialization ───────────────────────
/**
* Cache region elements and register chat as the implicit default surface.
* Called from app.js init after DOM is ready.
*/
init() {
// Cache all region containers
document.querySelectorAll('[data-surface-region]').forEach(el => {
const id = el.dataset.surfaceRegion;
this._regionEls.set(id, el);
});
// Chat is always registered as the default surface
this._registry.set('chat', {
label: 'Chat',
icon: 'message-square',
regions: ['surface-header', 'surface-main', 'surface-footer'],
activate: null, // chat activation is implicit (restore regions)
deactivate: null,
_isDefault: true,
// Layout declarations (v0.22.0)
primary: 'chat',
secondary: 'preview',
secondaryOpts: ['preview', 'notes', 'project'],
});
console.log(`[Surfaces] Initialized with ${this._regionEls.size} region(s)`);
},
// ── Registration ─────────────────────────
/**
* Register a new surface (mode).
* @param {string} id — unique surface identifier
* @param {object} opts — { label, icon, regions[], activate(), deactivate(),
* primary?, secondary?, secondaryOpts? }
*
* Layout declarations (v0.22.0):
* primary — pane id that owns the workspace-primary slot ('chat', 'notes', 'editor')
* secondary — default pane id for workspace-secondary (null = hidden)
* secondaryOpts — array of pane ids allowed in secondary for this surface
*/
register(id, opts = {}) {
if (this._registry.has(id)) {
console.warn(`[Surfaces] ${id} already registered`);
return;
}
this._registry.set(id, {
label: opts.label || id,
icon: opts.icon || 'layout',
regions: opts.regions || [],
activate: opts.activate || null,
deactivate: opts.deactivate || null,
primary: opts.primary || id,
secondary: opts.secondary || null,
secondaryOpts: opts.secondaryOpts || [],
});
console.log(`[Surfaces] Registered: ${id} (${opts.label || id})`);
// Show mode selector if we now have >1 surface
this._updateModeSelector();
Events.emit('surface.registered', { surface: id }, { localOnly: true });
},
/**
* Unregister a surface. If it's currently active, switch back to chat.
*/
unregister(id) {
if (id === 'chat') return; // can't unregister chat
if (!this._registry.has(id)) return;
if (this._current === id) {
this.activate('chat');
}
this._registry.delete(id);
this._updateModeSelector();
Events.emit('surface.unregistered', { surface: id }, { localOnly: true });
},
// ── Activation ───────────────────────────
/**
* Switch to a different surface.
* Deactivates the current surface, saves its region DOM, and activates the new one.
*/
activate(id) {
if (!this._registry.has(id)) {
console.error(`[Surfaces] Unknown surface: ${id}`);
return;
}
if (this._current === id) return;
const previous = this._current;
const prevDef = this._registry.get(previous);
const nextDef = this._registry.get(id);
// Deactivate current surface
if (prevDef) {
// Save current region contents
for (const regionId of (prevDef.regions || [])) {
this._saveRegion(regionId);
}
// Call surface-specific deactivation
if (typeof prevDef.deactivate === 'function') {
try { prevDef.deactivate(); } catch (e) {
console.error(`[Surfaces] deactivate ${previous}:`, e);
}
}
}
Events.emit('surface.deactivated', { surface: previous }, { localOnly: true });
this._current = id;
// Activate new surface
if (typeof nextDef.activate === 'function') {
try { nextDef.activate(); } catch (e) {
console.error(`[Surfaces] activate ${id}:`, e);
}
} else {
// Default behavior: restore saved DOM for this surface's regions
for (const regionId of (nextDef.regions || [])) {
this._restoreRegion(regionId);
}
}
// Update mode selector active state
this._updateModeSelectorActive();
Events.emit('surface.activated', { surface: id, previous }, { localOnly: true });
console.log(`[Surfaces] Activated: ${id} (was: ${previous})`);
},
// ── Query ────────────────────────────────
/** Get the currently active surface id. */
getCurrent() {
return this._current;
},
/** Get surface definition by id. */
get(id) {
return this._registry.get(id) || null;
},
/** Get all registered surface ids. */
list() {
return Array.from(this._registry.keys());
},
/** True when more than just chat is registered. */
hasMultiple() {
return this._registry.size > 1;
},
/**
* Get the layout declarations for the current surface.
* Returns { primary, secondary, secondaryOpts } or defaults.
*/
getLayout(id) {
const def = this._registry.get(id || this._current);
if (!def) return { primary: 'chat', secondary: null, secondaryOpts: [] };
return {
primary: def.primary || id || 'chat',
secondary: def.secondary || null,
secondaryOpts: def.secondaryOpts || [],
};
},
/**
* Get a saved DocumentFragment for a specific surface + region.
* Used by surfaces like editor-mode that want to embed chat DOM
* inside their own layout.
* @param {string} surfaceId — the surface that owns the saved DOM
* @param {string} regionId — the region name
* @returns {DocumentFragment|null}
*/
getSavedFragment(surfaceId, regionId) {
const key = `${surfaceId}::${regionId}`;
return this._saved.get(key) || null;
},
/**
* Put a DocumentFragment back into the saved store.
* Used during deactivation to return borrowed DOM.
*/
putSavedFragment(surfaceId, regionId, frag) {
const key = `${surfaceId}::${regionId}`;
this._saved.set(key, frag);
},
// ── Region Management ────────────────────
/**
* Replace a region's content with a new element.
* The current children are saved (detached, not destroyed) and can be
* restored later with restore(). This is critical for CM6 state preservation.
*
* @param {string} regionId — data-surface-region value
* @param {Element} element — new content to insert
*/
replace(regionId, element) {
const container = this._regionEls.get(regionId);
if (!container) {
console.warn(`[Surfaces] Unknown region: ${regionId}`);
return;
}
// Save current children to a DocumentFragment (preserves DOM state)
const key = `${this._current}::${regionId}`;
const frag = document.createDocumentFragment();
while (container.firstChild) {
frag.appendChild(container.firstChild);
}
this._saved.set(key, frag);
// Insert new content
if (element) {
container.appendChild(element);
}
},
/**
* Restore a region's previously saved content.
* @param {string} regionId — data-surface-region value
*/
restore(regionId) {
const container = this._regionEls.get(regionId);
if (!container) return;
const key = `${this._current}::${regionId}`;
const frag = this._saved.get(key);
// Clear current contents
while (container.firstChild) {
container.removeChild(container.firstChild);
}
// Re-attach saved DOM
if (frag) {
container.appendChild(frag);
this._saved.delete(key);
}
},
// ── Internal: Save/Restore Regions ───────
/**
* Save the current DOM children of a region for the active surface.
* Called during deactivation.
*/
_saveRegion(regionId) {
const container = this._regionEls.get(regionId);
if (!container) return;
const key = `${this._current}::${regionId}`;
const frag = document.createDocumentFragment();
while (container.firstChild) {
frag.appendChild(container.firstChild);
}
this._saved.set(key, frag);
},
/**
* Restore the saved DOM children of a region for the active surface.
* Called during activation.
*/
_restoreRegion(regionId) {
const container = this._regionEls.get(regionId);
if (!container) return;
const key = `${this._current}::${regionId}`;
const frag = this._saved.get(key);
// Clear container
while (container.firstChild) {
container.removeChild(container.firstChild);
}
// Restore saved content
if (frag) {
container.appendChild(frag);
this._saved.delete(key);
}
},
// ── Mode Selector UI ────────────────────
/**
* Rebuild the mode selector in the sidebar.
* Shown only when ≥1 extension surface is registered (i.e. more than just chat).
*/
_updateModeSelector() {
const wrap = document.getElementById('modeSelectorWrap');
if (!wrap) return;
if (this._registry.size <= 1) {
wrap.style.display = 'none';
wrap.innerHTML = '';
return;
}
wrap.style.display = '';
wrap.innerHTML = '';
for (const [id, def] of this._registry) {
const btn = document.createElement('button');
btn.className = 'mode-btn' + (id === this._current ? ' active' : '');
btn.dataset.surface = id;
btn.title = def.label;
btn.innerHTML = `${this._iconSvg(def.icon)}<span class="mode-btn-label">${def.label}</span>`;
btn.addEventListener('click', () => this.activate(id));
wrap.appendChild(btn);
}
},
/** Update the active class on mode selector buttons. */
_updateModeSelectorActive() {
const wrap = document.getElementById('modeSelectorWrap');
if (!wrap) return;
wrap.querySelectorAll('.mode-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.surface === this._current);
});
},
/**
* Return an SVG string for a lucide-style icon name.
* Only the icons we actually need are included here.
*/
_iconSvg(name) {
const icons = {
'message-square': '<svg width="16" height="16" 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>',
'code': '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>',
'file-text': '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/><line x1="10" y1="9" x2="8" y2="9"/></svg>',
'layout': '<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="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>',
'terminal': '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>',
'globe': '<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"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>',
};
return icons[name] || icons['layout'];
},
};