Changeset 0.22.6 (#148)
This commit is contained in:
@@ -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"
|
||||
|
||||
23
CHANGELOG.md
23
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ==========================================
|
||||
|
||||
|
||||
@@ -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;'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
96
nginx.conf
96
nginx.conf
@@ -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;
|
||||
}
|
||||
@@ -119,4 +83,4 @@ server {
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,8 +56,9 @@ type PageData struct {
|
||||
Surface string // active surface ID
|
||||
Section string // sub-section (for admin pages)
|
||||
CSPNonce string
|
||||
BasePath string
|
||||
Version 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()
|
||||
|
||||
|
||||
@@ -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}};
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
1307
src/index.html
1307
src/index.html
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
try {
|
||||
const savedChat = sessionStorage.getItem('cs-active-chat');
|
||||
if (savedChat && App.chats.some(c => c.id === savedChat)) {
|
||||
selectChat(savedChat);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
// 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() {
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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';
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
322
src/js/router.js
322
src/js/router.js
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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'];
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user