Changeset 0.25.0 (#160)

This commit is contained in:
2026-03-08 16:54:17 +00:00
parent 937be26578
commit 2b01d540d6
63 changed files with 6942 additions and 2773 deletions

View File

@@ -1 +1 @@
0.24.3
0.25.0

File diff suppressed because it is too large Load Diff

350
docs/SURFACES.md Normal file
View File

@@ -0,0 +1,350 @@
# Surface Development Guide
**Version:** 0.25.0
**Audience:** Anyone with admin access to a Chat Switchboard instance
---
## Overview
A surface is a full-screen application mode in Chat Switchboard. The platform ships with core surfaces (Chat, Notes, Settings, Admin, Workflow) that cannot be uninstalled. Extension surfaces are uploaded as `.surface` archives via the Admin panel and can be enabled, disabled, and uninstalled at runtime.
This guide covers everything needed to build, package, install, and manage surfaces using only a running instance — no access to the Go codebase required.
---
## Quick Start
Create a minimal surface in under 5 minutes:
### 1. Create the files
**manifest.json:**
```json
{
"id": "hello",
"version": "1.0.0",
"title": "Hello World",
"description": "Minimal surface example",
"route": "/hello",
"auth": "authenticated",
"scripts": ["js/hello.js"],
"styles": ["css/hello.css"],
"source": "extension"
}
```
**js/hello.js:**
```javascript
(function() {
document.addEventListener('DOMContentLoaded', function() {
if (window.__SURFACE__ !== 'hello') return;
var root = document.getElementById('surfaceRoot');
if (!root) return;
root.innerHTML =
'<div style="display:flex;flex-direction:column;height:100%;background:var(--bg);color:var(--text);font-family:inherit;">' +
'<div style="display:flex;align-items:center;gap:8px;padding:0 12px;height:40px;background:var(--bg-secondary);border-bottom:1px solid var(--border);position:relative;z-index:20;">' +
'<a href="' + (window.__BASE__ || '') + '/" style="color:var(--text-3);text-decoration:none;font-size:12px;">← Back</a>' +
'<span style="font-size:13px;font-weight:600;">Hello World</span>' +
'</div>' +
'<div style="flex:1;display:flex;align-items:center;justify-content:center;">' +
'<div style="text-align:center;">' +
'<div style="font-size:48px;margin-bottom:16px;">👋</div>' +
'<h1 style="font-size:24px;font-weight:600;margin:0 0 8px;">Hello from a Surface</h1>' +
'<p style="color:var(--text-3);margin:0;" id="helloStatus">Waiting for app init…</p>' +
'</div>' +
'</div>' +
'</div>';
document.addEventListener('sb:ready', function() {
var el = document.getElementById('helloStatus');
if (el) {
el.textContent = 'Logged in as ' + (API.user?.username || 'unknown') +
' | ' + (App.models?.length || 0) + ' models loaded';
}
}, { once: true });
});
})();
```
**css/hello.css:**
```css
/* Empty for minimal example. Real surfaces use CSS classes with var(--*) tokens. */
```
### 2. Package
```bash
zip -r hello.surface manifest.json js/ css/
```
### 3. Install
**Admin → System → Surfaces → + Install Surface**. Upload `hello.surface`.
### 4. Visit
Navigate to `https://your-instance/hello`.
---
## How Surface Loading Works
```
Browser requests /hello
→ Router matches route from surface_registry
→ Auth middleware runs (based on manifest "auth" field)
→ base.html renders with:
- Common CSS (variables, layout, components)
- Common JS (api.js, events.js, ui-core.js, all component factories)
- Your surface CSS and JS from manifest
- app.js (boot script)
→ DOMContentLoaded fires:
1. Your surface JS runs — build DOM, create components
2. app.js init() runs — auth, settings, models, WebSocket
3. 'sb:ready' CustomEvent fires when init is complete
```
### Critical: Boot Timing
Your JS runs BEFORE app init completes.
| Available at DOMContentLoaded | Available at sb:ready |
|---|---|
| `window.__SURFACE__`, `window.__BASE__` | `App.models`, `App.settings` |
| All component factories | `API.user`, `API.isAdmin` |
| DOM ready | `Events` WebSocket connected |
**Rule:** Anything needing models, user info, or settings goes in `sb:ready`:
```javascript
document.addEventListener('sb:ready', function() {
// Safe to use App.models, API.user, App.settings
}, { once: true });
```
---
## manifest.json Reference
| Field | Required | Description |
|---|---|---|
| `id` | Yes | Unique identifier. Lowercase, no spaces. |
| `title` | Yes | Display name in admin panel and nav. |
| `version` | No | Semver for display. |
| `description` | No | Short description for admin. |
| `route` | No | URL pattern. Gin params: `/thing/:id`. Defaults to `/{id}`. |
| `alt_routes` | No | Additional URL patterns. |
| `auth` | No | `"authenticated"` (default), `"admin"`, `"session"`, `"public"`. |
| `scripts` | No | JS files relative to archive root. |
| `styles` | No | CSS files relative to archive root. |
| `components` | No | Component IDs used (documentation only). |
| `source` | No | Always `"extension"` for uploaded surfaces. |
---
## .surface Archive Format
```
my-surface.surface (ZIP file)
├── manifest.json ← Required
├── js/
│ └── my-surface.js ← Boot script(s)
├── css/
│ └── my-surface.css ← Styles
└── assets/ ← Optional: images, fonts
```
Standard ZIP. `.surface` extension is conventional; `.zip` also accepted.
---
## CSS Theme Tokens
Use these instead of hardcoded colors:
```css
/* Backgrounds */
var(--bg) var(--bg-secondary) var(--bg-tertiary) var(--bg-raised) var(--bg-hover)
/* Text */
var(--text) var(--text-2) var(--text-3)
/* Accent */
var(--accent) var(--accent-dim) var(--accent-hover)
/* Borders */
var(--border) var(--border-light)
/* Semantic */
var(--success) var(--warning) var(--danger) var(--purple)
var(--success-dim) var(--warning-dim) var(--danger-dim) var(--purple-dim)
/* Type & Layout */
var(--mono) var(--msg-font) var(--radius) var(--radius-lg) var(--transition)
```
---
## Reusable Components
All loaded by `base.html` on every surface. No imports needed.
### UserMenu
```javascript
// Build DOM into a container
container.innerHTML =
'<div class="user-menu-wrap" id="userMenuWrap">' +
'<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">Settings</button>' +
'<button id="menuAdmin" class="flyout-item" style="display:none">Admin</button>' +
'<button id="menuDebug" class="flyout-item" style="display:none">Debug</button>' +
'<hr class="flyout-divider">' +
'<button id="menuSignout" class="flyout-item flyout-danger">Sign Out</button>' +
'</div>' +
'</div>';
// Wire after auth (sb:ready)
var menu = UserMenu.create({ id: '' });
menu.setUser(API.user);
menu.bind({
onSettings: function() { location.href = (window.__BASE__ || '') + '/settings'; },
onAdmin: function() { location.href = (window.__BASE__ || '') + '/admin'; },
onSignout: function() { if (typeof handleLogout === 'function') handleLogout(); },
});
menu.showAdmin(API.isAdmin);
```
### ChatPane
```javascript
// Build DOM
slot.innerHTML =
'<div class="chat-pane" id="xChatPane">' +
'<div class="chat-pane-messages" id="xChatMessages"></div>' +
'<div class="chat-pane-input-bar">' +
'<div class="chat-pane-input-wrap">' +
'<div class="chat-pane-input" id="xChatInput"></div>' +
'<button class="chat-pane-send" id="xSendBtn">Send</button>' +
'</div>' +
'</div>' +
'</div>';
// Create instance
var pane = ChatPane.create({
id: 'x',
messagesEl: document.getElementById('xChatMessages'),
inputEl: document.getElementById('xChatInput'),
sendBtnEl: document.getElementById('xSendBtn'),
standalone: true,
});
pane.showWelcome();
```
### PaneContainer
```javascript
PaneContainer.registerPreset('my-layout', {
type: 'split', direction: 'horizontal',
sizes: [250, null, 350],
children: [
{ type: 'leaf', id: 'nav', size: 250, minSize: 150 },
{ type: 'leaf', id: 'main' }, // flex pane (no size)
{ type: 'tabbed', id: 'assist', size: 350, minSize: 200,
tabs: [{ id: 'chat', label: 'Chat' }, { id: 'tools', label: 'Tools' }]
},
],
});
var layout = PaneContainer.mount(document.getElementById('body'), 'my-layout');
var mainEl = layout._panes.get('main').el;
var chatTab = layout._panes.get('assist').getTabPanel('chat');
```
**Rules:** One child per split must omit `size` (flex pane). Drag handles cap at 60%. Sizes persist in localStorage.
---
## Making API Calls
```javascript
// CRUD
var data = await API._get('/api/v1/channels');
var resp = await API._post('/api/v1/channels', { title: 'New', type: 'direct' });
await API._put('/api/v1/channels/' + id, { title: 'Updated' });
await API._del('/api/v1/channels/' + id);
// Streaming (SSE)
var resp = await API.streamCompletion(channelId, content, model, abortSignal);
var reader = resp.body.getReader();
```
Auth tokens and base path are handled automatically.
---
## Admin: Surface Management
**Location:** Admin → System → Surfaces
| Action | Description |
|---|---|
| **+ Install Surface** | Upload `.surface` or `.zip` archive |
| **Toggle** | Enable/disable. Disabled surfaces redirect to Chat. |
| **Uninstall** | Remove extension surface (files + DB entry). |
**Protected:** Chat and Admin toggles are locked — cannot be disabled.
### API
```bash
# List
curl -H "$AUTH" "$BASE/api/v1/admin/surfaces"
# Install
curl -H "$AUTH" -F "file=@hello.surface" "$BASE/api/v1/admin/surfaces/install"
# Disable / Enable
curl -X PUT -H "$AUTH" "$BASE/api/v1/admin/surfaces/editor/disable"
curl -X PUT -H "$AUTH" "$BASE/api/v1/admin/surfaces/editor/enable"
# Uninstall
curl -X DELETE -H "$AUTH" "$BASE/api/v1/admin/surfaces/hello"
# Public: list enabled (for nav)
curl -H "$AUTH" "$BASE/api/v1/surfaces"
```
---
## Checklist
- [ ] `manifest.json` has `id` and `title`
- [ ] Boot script checks `window.__SURFACE__` guard
- [ ] DOM built in JS (no Go template dependency)
- [ ] Deferred init via `sb:ready`
- [ ] Topbar: `position: relative; z-index: 20`
- [ ] All colors use `var(--*)` tokens
- [ ] Back link: `window.__BASE__ + '/'`
- [ ] Graceful with 0 models
- [ ] Archive: only `manifest.json`, `js/`, `css/`, `assets/`
---
## Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Surface doesn't appear after install | Routes registered at startup | Restart server or toggle disable/enable |
| "Cannot overwrite core surface" | ID conflicts with chat/notes/etc. | Choose different ID |
| `API is undefined` | Code runs before app init | Move to `sb:ready` listener |
| No styles applied | CSS path mismatch | Check manifest `styles` matches archive structure |
| Flyout hidden behind content | Missing stacking context | Add `z-index: 20` to topbar |
| Drag handle snaps | v0.25.0 known issue | Sub-pixel rounding; partially mitigated |

View File

@@ -0,0 +1,17 @@
-- 022_surfaces.sql
-- Surface registry for dynamic surface lifecycle (v0.25.0)
-- Core surfaces are seeded at startup. Extension surfaces are uploaded via admin.
CREATE TABLE IF NOT EXISTS surface_registry (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
manifest JSONB NOT NULL DEFAULT '{}',
enabled BOOLEAN NOT NULL DEFAULT true,
source TEXT NOT NULL DEFAULT 'core',
installed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE surface_registry IS 'Registry of all surfaces (core + extension). Controls enable/disable and stores manifest metadata.';
COMMENT ON COLUMN surface_registry.source IS 'core = built-in, extension = uploaded via admin';
COMMENT ON COLUMN surface_registry.enabled IS 'Admin toggle — disabled surfaces redirect to / and hide from nav';

View File

@@ -287,12 +287,21 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
var personaID string // tracks active persona for KB scoping
var personaThinkingBudget *int // persona-level thinking budget for hook injection (v0.22.1)
// v0.25.0: Query channel metadata once — used by group leader check, tool context,
// and execution context. Avoids redundant SELECTs on channels.
var channelType string
var channelTeamID *string
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT COALESCE(type, 'direct'), team_id FROM channels WHERE id = $1
`), channelID).Scan(&channelType, &channelTeamID)
teamID := ""
if channelTeamID != nil {
teamID = *channelTeamID
}
// v0.23.2: Group leader default — when type=group and no @mention, use the leader persona
if req.PersonaID == "" && extractFirstMention(req.Content) == "" {
var channelType string
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT COALESCE(type, 'direct') FROM channels WHERE id = $1
`), channelID).Scan(&channelType)
if channelType == "group" {
// Find the leader persona via channel_participants → persona_group_members
var leaderPersonaID string
@@ -493,6 +502,16 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
// Resolve effective workspace: channel.workspace_id > project.workspace_id
workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID)
// ── Tool context (v0.25.0) ─────────────────
// Uses channel metadata queried above (channelType, teamID).
tctx := tools.ToolContext{
ChannelType: channelType,
WorkspaceID: workspaceID,
TeamID: teamID,
IsVisitor: isSessionAuth(c),
// PersonaID set below after persona resolution
}
// ── @mention routing (v0.23.0 / v0.23.1) ─────────────────────────────────
// Resolve @persona-handle, @model-id, or @username.
// - User mention → skip completion, notify recipient (v0.23.1)
@@ -637,9 +656,10 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}
// Attach tool definitions if model supports tool calling and tools are available
tctx.PersonaID = personaID // finalize after all persona resolution (@mention, group leader, etc.)
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, workspaceID)
provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, tctx, personaID)
}
// ── Permission pre-flight ─────────────────────────────────────────────
@@ -787,7 +807,12 @@ func (h *CompletionHandler) multiModelStream(
}
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, workspaceID)
tctx := tools.ToolContext{
WorkspaceID: workspaceID,
PersonaID: personaID,
IsVisitor: isSessionAuth(c),
}
provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, tctx, personaID)
}
// Apply provider-specific request hooks (v0.22.1)
@@ -823,25 +848,21 @@ func (h *CompletionHandler) multiModelStream(
// buildToolDefs converts registered tools to provider-format tool definitions.
// If includeBrowser is true, also fetches tool schemas from enabled browser extensions.
// Tools whose names appear in disabledTools are excluded.
// Workspace tools are excluded when workspaceID is empty (no workspace bound).
func (h *CompletionHandler) buildToolDefs(ctx context.Context, userID string, includeBrowser bool, disabledTools []string, workspaceID string) []providers.ToolDef {
// v0.25.0: Tools self-declare availability via predicates on ToolContext.
// v0.25.0: Persona tool grants apply as a second-pass allowlist when personaID
// has explicit grants. Empty grants = inherit all (backward compat).
func (h *CompletionHandler) buildToolDefs(ctx context.Context, userID string, includeBrowser bool, disabledTools []string, tctx tools.ToolContext, personaID string) []providers.ToolDef {
// Build disabled set for O(1) lookup
disabled := make(map[string]bool, len(disabledTools))
for _, name := range disabledTools {
disabled[name] = true
}
// If no workspace is bound, disable workspace + git tools
if workspaceID == "" {
for _, name := range tools.WorkspaceToolNames() {
disabled[name] = true
}
for _, name := range tools.GitToolNames() {
disabled[name] = true
}
}
allTools := tools.AllDefinitionsFiltered(disabled)
// v0.25.0: Tools self-declare availability via predicates.
// AvailableFor evaluates each tool's Availability() against tctx,
// then applies the disabled set. Replaces manual WorkspaceToolNames()
// and GitToolNames() suppression.
allTools := tools.AvailableFor(tctx, disabled)
defs := make([]providers.ToolDef, 0, len(allTools))
for _, t := range allTools {
defs = append(defs, providers.ToolDef{
@@ -892,6 +913,28 @@ func (h *CompletionHandler) buildToolDefs(ctx context.Context, userID string, in
}
}
// v0.25.0: Persona tool grants — second-pass allowlist.
// If the active persona has explicit tool grants, restrict to only those tools.
// Empty grants = persona inherits all context-available tools (backward compat).
if personaID != "" && h.stores.Personas != nil {
grants, err := h.stores.Personas.GetToolGrants(ctx, personaID)
if err != nil {
log.Printf("⚠️ Failed to load tool grants for persona %s: %v", personaID, err)
} else if len(grants) > 0 {
allowed := make(map[string]bool, len(grants))
for _, g := range grants {
allowed[g] = true
}
filtered := defs[:0]
for _, d := range defs {
if allowed[d.Function.Name] {
filtered = append(filtered, d)
}
}
defs = filtered
}
}
return defs
}

View File

@@ -547,7 +547,24 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID)
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
provReq.Tools = comp.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, workspaceID)
// v0.25.0: Build ToolContext with channel metadata
var chType string
var chTeamID *string
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT COALESCE(type, 'direct'), team_id FROM channels WHERE id = $1
`), channelID).Scan(&chType, &chTeamID)
msgTeamID := ""
if chTeamID != nil {
msgTeamID = *chTeamID
}
tctx := tools.ToolContext{
ChannelType: chType,
WorkspaceID: workspaceID,
TeamID: msgTeamID,
PersonaID: personaID,
IsVisitor: isSessionAuth(c),
}
provReq.Tools = comp.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, tctx, personaID)
}
// ── Stream the response (shared loop handles tools, reasoning, SSE) ──

View File

@@ -306,3 +306,48 @@ func (h *PersonaHandler) SetPersonaKBs(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// ── Persona Tool Grants (v0.25.0) ───────────
// GetPersonaToolGrants returns the tool names granted to a persona.
// Empty list = persona inherits all context-available tools.
func (h *PersonaHandler) GetPersonaToolGrants(c *gin.Context) {
personaID := c.Param("id")
grants, err := h.stores.Personas.GetToolGrants(c.Request.Context(), personaID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load tool grants"})
return
}
c.JSON(http.StatusOK, gin.H{"data": grants})
}
// SetPersonaToolGrants replaces the tool grants for a persona.
// Sending an empty tool_names array clears all grants (persona inherits all tools).
func (h *PersonaHandler) SetPersonaToolGrants(c *gin.Context) {
personaID := c.Param("id")
var req struct {
ToolNames []string `json:"tool_names"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Convert tool names to Grant objects
grants := make([]models.Grant, 0, len(req.ToolNames))
for _, name := range req.ToolNames {
grants = append(grants, models.Grant{
PersonaID: personaID,
GrantType: models.GrantTypeTool,
GrantRef: name,
})
}
if err := h.stores.Personas.SetGrants(c.Request.Context(), personaID, grants); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update tool grants"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}

297
server/handlers/surfaces.go Normal file
View File

@@ -0,0 +1,297 @@
package handlers
import (
"archive/zip"
"encoding/json"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// SurfaceHandler manages surface lifecycle (admin-only).
type SurfaceHandler struct {
stores store.Stores
surfacesDir string // e.g. /data/surfaces — where archives are extracted
}
func NewSurfaceHandler(s store.Stores, surfacesDir ...string) *SurfaceHandler {
dir := ""
if len(surfacesDir) > 0 {
dir = surfacesDir[0]
}
return &SurfaceHandler{stores: s, surfacesDir: dir}
}
// ListSurfaces returns all registered surfaces with their enabled state.
// GET /api/v1/admin/surfaces
func (h *SurfaceHandler) ListSurfaces(c *gin.Context) {
surfaces, err := h.stores.Surfaces.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list surfaces"})
return
}
if surfaces == nil {
surfaces = []store.SurfaceRegistration{}
}
c.JSON(http.StatusOK, gin.H{"surfaces": surfaces})
}
// GetSurface returns a single surface by ID.
// GET /api/v1/admin/surfaces/:id
func (h *SurfaceHandler) GetSurface(c *gin.Context) {
id := c.Param("id")
surface, err := h.stores.Surfaces.Get(c.Request.Context(), id)
if err != nil || surface == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "surface not found"})
return
}
c.JSON(http.StatusOK, surface)
}
// EnableSurface enables a surface.
// PUT /api/v1/admin/surfaces/:id/enable
func (h *SurfaceHandler) EnableSurface(c *gin.Context) {
id := c.Param("id")
if err := h.stores.Surfaces.SetEnabled(c.Request.Context(), id, true); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "surface not found"})
return
}
c.JSON(http.StatusOK, gin.H{"id": id, "enabled": true})
}
// DisableSurface disables a surface. Chat cannot be disabled.
// PUT /api/v1/admin/surfaces/:id/disable
func (h *SurfaceHandler) DisableSurface(c *gin.Context) {
id := c.Param("id")
// Chat and Admin are required — cannot be disabled
if id == "chat" || id == "admin" {
c.JSON(http.StatusBadRequest, gin.H{"error": id + " surface cannot be disabled"})
return
}
if err := h.stores.Surfaces.SetEnabled(c.Request.Context(), id, false); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "surface not found"})
return
}
c.JSON(http.StatusOK, gin.H{"id": id, "enabled": false})
}
// DeleteSurface uninstalls an extension surface. Core surfaces cannot be deleted.
// DELETE /api/v1/admin/surfaces/:id
func (h *SurfaceHandler) DeleteSurface(c *gin.Context) {
id := c.Param("id")
// Check it's not a core surface
surface, err := h.stores.Surfaces.Get(c.Request.Context(), id)
if err != nil || surface == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "surface not found"})
return
}
if surface.Source == "core" {
c.JSON(http.StatusBadRequest, gin.H{"error": "core surfaces cannot be uninstalled"})
return
}
if err := h.stores.Surfaces.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete surface"})
return
}
// TODO: Delete static assets from SURFACE_ASSET_DIR/{id}/
// TODO: Delete templates from SURFACE_TEMPLATE_DIR/{id}/
c.JSON(http.StatusOK, gin.H{"id": id, "deleted": true})
}
// InstallSurface uploads and installs a .surface archive.
// POST /api/v1/admin/surfaces/install (multipart: file)
func (h *SurfaceHandler) InstallSurface(c *gin.Context) {
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
return
}
defer file.Close()
// Validate extension
if !strings.HasSuffix(header.Filename, ".surface") && !strings.HasSuffix(header.Filename, ".zip") {
c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .surface or .zip archive"})
return
}
// Size limit: 50MB
if header.Size > 50*1024*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"})
return
}
// Read into temp file for zip processing
tmpFile, err := os.CreateTemp("", "surface-*.zip")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
return
}
tmpPath := tmpFile.Name()
defer os.Remove(tmpPath)
if _, err := io.Copy(tmpFile, file); err != nil {
tmpFile.Close()
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"})
return
}
tmpFile.Close()
// Open as zip
zr, err := zip.OpenReader(tmpPath)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"})
return
}
defer zr.Close()
// Find and parse manifest.json
var manifest map[string]any
for _, f := range zr.File {
name := filepath.Base(f.Name)
if name == "manifest.json" && !f.FileInfo().IsDir() {
rc, err := f.Open()
if err != nil {
continue
}
data, err := io.ReadAll(rc)
rc.Close()
if err != nil {
continue
}
if err := json.Unmarshal(data, &manifest); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manifest.json: " + err.Error()})
return
}
break
}
}
if manifest == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "archive missing manifest.json"})
return
}
// Validate required fields
surfaceID, _ := manifest["id"].(string)
title, _ := manifest["title"].(string)
if surfaceID == "" || title == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest must have 'id' and 'title'"})
return
}
// Check for conflicts with core surfaces
existing, _ := h.stores.Surfaces.Get(c.Request.Context(), surfaceID)
if existing != nil && existing.Source == "core" {
c.JSON(http.StatusConflict, gin.H{"error": "cannot overwrite core surface: " + surfaceID})
return
}
// Extract static assets (js/, css/, assets/) to surfacesDir/{id}/
if h.surfacesDir != "" {
destDir := filepath.Join(h.surfacesDir, surfaceID)
os.MkdirAll(destDir, 0755)
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
}
// Only extract js/, css/, assets/ directories
name := f.Name
// Strip leading directory if archive has one (e.g., "my-surface/js/foo.js" → "js/foo.js")
if idx := strings.Index(name, "/"); idx > 0 {
rest := name[idx+1:]
if strings.HasPrefix(rest, "js/") || strings.HasPrefix(rest, "css/") || strings.HasPrefix(rest, "assets/") {
name = rest
} else if strings.HasPrefix(name, "js/") || strings.HasPrefix(name, "css/") || strings.HasPrefix(name, "assets/") {
// Already relative
} else {
continue // Skip non-static files (templates, manifest)
}
}
destPath := filepath.Join(destDir, name)
// Security: prevent path traversal
if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(destDir)) {
continue
}
os.MkdirAll(filepath.Dir(destPath), 0755)
rc, err := f.Open()
if err != nil {
continue
}
out, err := os.Create(destPath)
if err != nil {
rc.Close()
continue
}
io.Copy(out, rc)
out.Close()
rc.Close()
}
log.Printf("[surfaces] Extracted %s to %s", surfaceID, destDir)
}
// Register in database
if err := h.stores.Surfaces.Seed(c.Request.Context(), surfaceID, title, "extension", manifest); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register surface"})
return
}
c.JSON(http.StatusOK, gin.H{
"id": surfaceID,
"title": title,
"source": "extension",
"enabled": true,
})
}
// ListEnabledSurfaces returns enabled surface IDs (non-admin, for nav).
// GET /api/v1/surfaces
func (h *SurfaceHandler) ListEnabledSurfaces(c *gin.Context) {
surfaces, err := h.stores.Surfaces.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list surfaces"})
return
}
// Return only enabled surfaces with minimal info for nav
type navSurface struct {
ID string `json:"id"`
Title string `json:"title"`
Route string `json:"route"`
}
var enabled []navSurface
for _, s := range surfaces {
if !s.Enabled {
continue
}
route, _ := s.Manifest["route"].(string)
enabled = append(enabled, navSurface{
ID: s.ID,
Title: s.Title,
Route: route,
})
}
if enabled == nil {
enabled = []navSurface{}
}
c.JSON(http.StatusOK, gin.H{"surfaces": enabled})
}

View File

@@ -524,6 +524,10 @@ func main() {
protected.POST("/chat/completions", comp.Complete)
protected.GET("/tools", comp.ListTools)
// Surface discovery (v0.25.0)
surfaceH := handlers.NewSurfaceHandler(stores)
protected.GET("/surfaces", surfaceH.ListEnabledSurfaces)
// Summarize & Continue (backed by compaction service)
compactionSvc := compaction.NewService(stores, roleResolver)
summarize := handlers.NewSummarizeHandler(compactionSvc)
@@ -580,6 +584,8 @@ func main() {
protected.DELETE("/personas/:id/avatar", handlers.DeletePersonaAvatar)
protected.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
protected.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0
protected.GET("/personas/:id/tool-grants", personas.GetPersonaToolGrants) // v0.25.0
protected.PUT("/personas/:id/tool-grants", personas.SetPersonaToolGrants) // v0.25.0
// Notes
notes := handlers.NewNoteHandler(stores)
@@ -826,6 +832,8 @@ func main() {
admin.DELETE("/personas/:id/avatar", handlers.DeletePersonaAvatar)
admin.GET("/personas/:id/knowledge-bases", personaAdm.GetPersonaKBs) // v0.17.0
admin.PUT("/personas/:id/knowledge-bases", personaAdm.SetPersonaKBs) // v0.17.0
admin.GET("/personas/:id/tool-grants", personaAdm.GetPersonaToolGrants) // v0.25.0
admin.PUT("/personas/:id/tool-grants", personaAdm.SetPersonaToolGrants) // v0.25.0
// Admin memory review (v0.18.0)
adminMemH := handlers.NewMemoryHandler(stores)
@@ -941,6 +949,19 @@ func main() {
admin.PUT("/routing/policies/:id", routingAdm.UpdatePolicy)
admin.DELETE("/routing/policies/:id", routingAdm.DeletePolicy)
admin.POST("/routing/test", routingAdm.TestRouting)
// Surface lifecycle management (v0.25.0)
surfacesDir := ""
if cfg.StoragePath != "" {
surfacesDir = cfg.StoragePath + "/surfaces"
}
surfaceAdm := handlers.NewSurfaceHandler(stores, surfacesDir)
admin.GET("/surfaces", surfaceAdm.ListSurfaces)
admin.GET("/surfaces/:id", surfaceAdm.GetSurface)
admin.POST("/surfaces/install", surfaceAdm.InstallSurface)
admin.PUT("/surfaces/:id/enable", surfaceAdm.EnableSurface)
admin.PUT("/surfaces/:id/disable", surfaceAdm.DisableSurface)
admin.DELETE("/surfaces/:id", surfaceAdm.DeleteSurface)
}
}
@@ -954,48 +975,16 @@ func main() {
// Login page — no auth required
base.GET("/login", pageEngine.RenderLogin())
// Authenticated page routes
pageRoutes := base.Group("")
pageRoutes.Use(middleware.AuthOrRedirect(cfg))
{
// Chat surface (default) — bridge to existing SPA
pageRoutes.GET("/", pageEngine.RenderSurface("chat"))
pageRoutes.GET("/chat/:chatID", pageEngine.RenderSurface("chat"))
// Editor surface — server renders layout, JS builds CodeMirror inside
pageRoutes.GET("/editor", pageEngine.RenderSurface("editor"))
pageRoutes.GET("/editor/:wsId", pageEngine.RenderSurface("editor"))
// Notes surface
pageRoutes.GET("/notes", pageEngine.RenderSurface("notes"))
pageRoutes.GET("/notes/:noteId", pageEngine.RenderSurface("notes"))
}
// Admin pages — auth + admin role required
adminPages := base.Group("/admin")
adminPages.Use(middleware.AuthOrRedirect(cfg))
adminPages.Use(middleware.RequireAdminPage())
{
adminPages.GET("", pageEngine.RenderSurface("admin"))
adminPages.GET("/:section", pageEngine.RenderSurface("admin"))
}
// Settings pages — auth required
settingsPages := base.Group("/settings")
settingsPages.Use(middleware.AuthOrRedirect(cfg))
{
settingsPages.GET("", pageEngine.RenderSurface("settings"))
settingsPages.GET("/:section", pageEngine.RenderSurface("settings"))
}
// ── Workflow routes (v0.24.3) ────────────
// Anonymous session entry point for workflow channels.
// AuthOrSession tries JWT first, falls back to session cookie.
wfPages := base.Group("/w")
wfPages.Use(middleware.AuthOrSession(cfg, stores))
{
wfPages.GET("/:id", pageEngine.RenderWorkflow())
}
// v0.25.0: Surface routes generated from manifest registry.
// Replaces manual per-surface route blocks.
pageEngine.RegisterPageRoutes(base, pages.PageRouteMiddleware{
Authenticated: middleware.AuthOrRedirect(cfg),
Admin: []gin.HandlerFunc{
middleware.AuthOrRedirect(cfg),
middleware.RequireAdminPage(),
},
Session: middleware.AuthOrSession(cfg, stores),
})
// Workflow API — session participants can send messages + trigger completions
wfAPI := base.Group("/api/v1/w")

View File

@@ -77,6 +77,11 @@ type RoleSelection struct {
}
// ── Page data structs ────────────────────────
//
// v0.25.0: Each loader function is a "data provider" keyed by surface ID.
// The surface manifest's DataRequires field references these keys.
// Currently 1:1 (one loader per surface). Future: composite loaders
// that assemble data from multiple providers for dashboard-style surfaces.
// AdminPageData is what the admin surface templates receive.
type AdminPageData struct {
@@ -129,6 +134,16 @@ func (e *Engine) registerLoaders() {
e.RegisterLoader("settings", e.settingsLoader)
}
// ListDataProviders returns the keys of all registered data providers.
// Used for manifest validation — DataRequires entries must match a key here.
func (e *Engine) ListDataProviders() []string {
keys := make([]string, 0, len(e.loaders))
for k := range e.loaders {
keys = append(keys, k)
}
return keys
}
// ── Admin loader ─────────────────────────────
// Pre-loads ALL dropdown data. Fixes bugs #1 (model roles) and #2 (team scope).
@@ -299,7 +314,7 @@ func sectionCategory(section string) string {
return "ai"
case "health", "routing", "capabilities":
return "routing"
case "settings", "storage", "extensions":
case "settings", "storage", "extensions", "channels", "surfaces":
return "system"
case "usage", "audit", "stats":
return "monitoring"

View File

@@ -39,9 +39,28 @@ type Engine struct {
cfg *config.Config
stores store.Stores
loaders map[string]DataLoaderFunc
surfaces []SurfaceManifest // v0.25.0: registered surface definitions
devMode bool
}
// SurfaceManifest describes a surface's registration. Core surfaces are
// registered in Go at startup. Extension surfaces will be registered from
// manifest files (future).
type SurfaceManifest struct {
ID string `json:"id"` // unique identifier: "chat", "editor", "my-dashboard"
Route string `json:"route"` // primary URL pattern: "/", "/editor/:wsId"
AltRoutes []string `json:"alt_routes"` // additional URL patterns: ["/chat/:chatID"]
Title string `json:"title"` // human-readable: "Chat", "Editor"
Template string `json:"template"` // Go template name: "surface-chat", "surface-editor"
Components []string `json:"components"` // component IDs used: ["chat-pane", "file-tree"]
DataRequires []string `json:"data_requires"` // data loader keys: ["workspace", "models"]
Scripts []string `json:"scripts"` // JS files (surface-specific, beyond base.html common)
Styles []string `json:"styles"` // CSS files (surface-specific)
Auth string `json:"auth"` // "authenticated", "public", "session", "admin"
Layout string `json:"layout"` // default pane layout preset: "single", "editor"
Source string `json:"source"` // "core" or "extension"
}
// BannerConfig holds environment banner settings.
type BannerConfig struct {
Text string `json:"text"`
@@ -66,6 +85,12 @@ type PageData struct {
Theme string // "dark", "light", or "" (= use default)
SurfaceSettings any // JSON-serialized to window.__SETTINGS__
// v0.25.0: Surface manifest — layout preset, components, etc.
Manifest *SurfaceManifest `json:"manifest,omitempty"`
// v0.25.0: Enabled surface IDs for conditional nav rendering.
EnabledSurfaces []string `json:"-"`
// v0.22.7: Login/splash page fields
InstanceName string // branding: instance display name
LogoURL string // branding: custom logo URL
@@ -74,6 +99,17 @@ type PageData struct {
AuthMode string // v0.24.1: "builtin", "mtls", "oidc"
}
// SurfaceEnabled returns true if the given surface ID is in the EnabledSurfaces list.
// Used by templates: {{if .SurfaceEnabled "editor"}}
func (pd PageData) SurfaceEnabled(id string) bool {
for _, s := range pd.EnabledSurfaces {
if s == id {
return true
}
}
return false
}
// UserContext is the authenticated user's info available to templates.
type UserContext struct {
ID string `json:"id"`
@@ -96,9 +132,74 @@ func New(cfg *config.Config, stores store.Stores) *Engine {
}
e.parseTemplates()
e.registerLoaders()
e.registerCoreSurfaces()
e.SeedSurfaces() // v0.25.0: Write core manifests to DB (preserves admin toggle state)
return e
}
// registerCoreSurfaces populates the surface manifest list with the five
// core surfaces. Extension surfaces will be appended at startup from
// extension manifests (future).
func (e *Engine) registerCoreSurfaces() {
e.surfaces = []SurfaceManifest{
{
ID: "chat", Route: "/", AltRoutes: []string{"/chat/:chatID"},
Title: "Chat", Template: "surface-chat", Auth: "authenticated",
Components: []string{"chat-pane", "model-selector", "user-menu"},
DataRequires: []string{"chat"},
Layout: "single", Source: "core",
},
{
ID: "editor", Route: "/editor/:wsId", AltRoutes: []string{"/editor"},
Title: "Editor", Template: "surface-editor", Auth: "authenticated",
Components: []string{"file-tree", "code-editor", "chat-pane", "note-editor", "model-selector"},
DataRequires: []string{"editor"},
Layout: "editor", Source: "extension", // Dogfood: editor is the first non-core surface
},
{
ID: "notes", Route: "/notes", AltRoutes: []string{"/notes/:noteId"},
Title: "Notes", Template: "surface-notes", Auth: "authenticated",
Components: []string{"note-editor", "chat-pane"},
DataRequires: []string{"notes"},
Layout: "single", Source: "core",
},
{
ID: "admin", Route: "/admin/:section", AltRoutes: []string{"/admin"},
Title: "Admin", Template: "surface-admin", Auth: "admin",
DataRequires: []string{"admin"},
Layout: "single", Source: "core",
},
{
ID: "settings", Route: "/settings/:section", AltRoutes: []string{"/settings"},
Title: "Settings", Template: "surface-settings", Auth: "authenticated",
DataRequires: []string{"settings"},
Layout: "single", Source: "core",
},
{
ID: "workflow", Route: "/w/:id",
Title: "Workflow", Template: "workflow", Auth: "session",
Components: []string{"chat-pane"},
DataRequires: []string{"workflow"},
Layout: "single", Source: "core",
},
}
}
// GetSurface returns the manifest for a surface by ID, or nil if not found.
func (e *Engine) GetSurface(id string) *SurfaceManifest {
for i := range e.surfaces {
if e.surfaces[i].ID == id {
return &e.surfaces[i]
}
}
return nil
}
// Surfaces returns all registered surface manifests.
func (e *Engine) Surfaces() []SurfaceManifest {
return e.surfaces
}
// parseTemplates compiles all embedded templates with the custom FuncMap.
func (e *Engine) parseTemplates() {
funcMap := template.FuncMap{
@@ -195,10 +296,152 @@ func (e *Engine) RenderSurface(surfaceID string) gin.HandlerFunc {
Section: section,
User: user,
Data: data,
Manifest: e.GetSurface(surfaceID),
EnabledSurfaces: e.EnabledSurfaceIDs(),
})
}
}
// PageRouteMiddleware holds middleware handlers for each auth level.
// Passed to RegisterPageRoutes by main.go.
type PageRouteMiddleware struct {
Authenticated gin.HandlerFunc // AuthOrRedirect — for chat, editor, notes, settings
Admin []gin.HandlerFunc // AuthOrRedirect + RequireAdminPage
Session gin.HandlerFunc // AuthOrSession — for workflow
}
// RegisterPageRoutes generates Gin routes from the registered surface manifests.
// Replaces manual per-surface route blocks in main.go.
//
// Groups surfaces by auth level to avoid redundant middleware chains.
// Workflow surfaces use RenderWorkflow() (separate template + data model).
// All other surfaces use RenderSurface().
func (e *Engine) RegisterPageRoutes(base *gin.RouterGroup, mw PageRouteMiddleware) {
// Group surfaces by auth type
byAuth := map[string][]SurfaceManifest{}
for _, s := range e.surfaces {
byAuth[s.Auth] = append(byAuth[s.Auth], s)
}
// Authenticated surfaces — single middleware group
if surfaces, ok := byAuth["authenticated"]; ok && mw.Authenticated != nil {
group := base.Group("")
group.Use(mw.Authenticated)
for _, s := range surfaces {
if !e.IsSurfaceEnabled(s.ID) {
// Disabled surface → redirect to chat
registerRoutes(group, s, e.disabledRedirect())
continue
}
handler := e.RenderSurface(s.ID)
registerRoutes(group, s, handler)
}
}
// Admin surfaces — auth + admin role middleware
if surfaces, ok := byAuth["admin"]; ok && len(mw.Admin) > 0 {
for _, s := range surfaces {
prefix := routePrefix(s.Route)
group := base.Group(prefix)
for _, m := range mw.Admin {
group.Use(m)
}
// Admin surfaces are always enabled (needed to manage other surfaces)
handler := e.RenderSurface(s.ID)
registerRoutesRelative(group, prefix, s, handler)
}
}
// Session surfaces (workflow) — session middleware
if surfaces, ok := byAuth["session"]; ok && mw.Session != nil {
for _, s := range surfaces {
if !e.IsSurfaceEnabled(s.ID) {
prefix := routePrefix(s.Route)
group := base.Group(prefix)
group.Use(mw.Session)
registerRoutesRelative(group, prefix, s, e.disabledRedirect())
continue
}
prefix := routePrefix(s.Route)
group := base.Group(prefix)
group.Use(mw.Session)
handler := e.surfaceHandler(s)
registerRoutesRelative(group, prefix, s, handler)
}
}
// Public surfaces — no middleware
if surfaces, ok := byAuth["public"]; ok {
for _, s := range surfaces {
if !e.IsSurfaceEnabled(s.ID) {
registerRoutes(base, s, e.disabledRedirect())
continue
}
handler := e.RenderSurface(s.ID)
registerRoutes(base, s, handler)
}
}
log.Printf("[pages] Registered routes for %d surfaces", len(e.surfaces))
}
// surfaceHandler returns the appropriate Gin handler for a surface.
func (e *Engine) surfaceHandler(s SurfaceManifest) gin.HandlerFunc {
if s.ID == "workflow" {
return e.RenderWorkflow()
}
return e.RenderSurface(s.ID)
}
// registerRoutes adds the primary route and alt routes for a surface.
// Used for surfaces in the root group (authenticated).
func registerRoutes(group *gin.RouterGroup, s SurfaceManifest, handler gin.HandlerFunc) {
group.GET(s.Route, handler)
for _, alt := range s.AltRoutes {
if alt != s.Route {
group.GET(alt, handler)
}
}
}
// registerRoutesRelative adds routes relative to a group prefix.
// Used for admin/session surfaces with their own prefix group.
// "/admin/:section" with prefix "/admin" → "/:section"
func registerRoutesRelative(group *gin.RouterGroup, prefix string, s SurfaceManifest, handler gin.HandlerFunc) {
rel := stripPrefix(s.Route, prefix)
group.GET(rel, handler)
for _, alt := range s.AltRoutes {
r := stripPrefix(alt, prefix)
if r != rel {
group.GET(r, handler)
}
}
}
// routePrefix extracts the first path segment as a group prefix.
// "/admin/:section" → "/admin", "/w/:id" → "/w", "/" → ""
func routePrefix(route string) string {
trimmed := strings.TrimPrefix(route, "/")
parts := strings.SplitN(trimmed, "/", 2)
if len(parts) == 0 || parts[0] == "" || strings.HasPrefix(parts[0], ":") {
return ""
}
return "/" + parts[0]
}
// stripPrefix removes a prefix from a route path. Returns "" if the
// entire path IS the prefix (which Gin interprets as the group root).
func stripPrefix(route, prefix string) string {
if prefix == "" {
return route
}
rel := strings.TrimPrefix(route, prefix)
if rel == "" {
rel = ""
}
return rel
}
// RenderLogin serves the standalone login page.
func (e *Engine) RenderLogin() gin.HandlerFunc {
return func(c *gin.Context) {

View File

@@ -0,0 +1,70 @@
package pages
import (
"context"
"log"
"net/http"
"github.com/gin-gonic/gin"
)
// SeedSurfaces writes core surface manifests to the registry table.
// Called once at startup. Does NOT overwrite the enabled flag — admin
// toggles survive restarts.
func (e *Engine) SeedSurfaces() {
ctx := context.Background()
for _, s := range e.surfaces {
manifest := map[string]any{
"route": s.Route,
"alt_routes": s.AltRoutes,
"template": s.Template,
"components": s.Components,
"data_requires": s.DataRequires,
"auth": s.Auth,
"layout": s.Layout,
}
if err := e.stores.Surfaces.Seed(ctx, s.ID, s.Title, s.Source, manifest); err != nil {
log.Printf("[pages] Failed to seed surface %q: %v", s.ID, err)
}
}
log.Printf("[pages] Seeded %d core surfaces into registry", len(e.surfaces))
}
// IsSurfaceEnabled checks if a surface is enabled in the registry.
// Chat and Admin are always enabled (system-critical).
// Returns true if the surface is not found (fail-open for backward compat).
func (e *Engine) IsSurfaceEnabled(surfaceID string) bool {
// Chat and Admin cannot be disabled — they're system-critical
if surfaceID == "chat" || surfaceID == "admin" {
return true
}
ctx := context.Background()
sr, err := e.stores.Surfaces.Get(ctx, surfaceID)
if err != nil || sr == nil {
return true // Not in registry = assume enabled (backward compat)
}
return sr.Enabled
}
// EnabledSurfaceIDs returns a list of enabled surface IDs for nav rendering.
func (e *Engine) EnabledSurfaceIDs() []string {
ctx := context.Background()
ids, err := e.stores.Surfaces.ListEnabled(ctx)
if err != nil {
log.Printf("[pages] Failed to load enabled surfaces: %v", err)
// Fallback: return all core surface IDs
var all []string
for _, s := range e.surfaces {
all = append(all, s.ID)
}
return all
}
return ids
}
// disabledRedirect returns a handler that redirects to the chat surface.
func (e *Engine) disabledRedirect() gin.HandlerFunc {
return func(c *gin.Context) {
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/")
}
}

View File

@@ -16,6 +16,11 @@
<link rel="stylesheet" href="{{.BasePath}}/css/panels.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/surfaces.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/splash.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/pane-container.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/chat-pane.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/user-menu.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/tool-grants.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/admin-surfaces.css?v={{.Version}}">
{{if eq .Surface "chat"}}{{template "css-chat" .}}{{end}}
{{if eq .Surface "editor"}}{{template "css-editor" .}}{{end}}
<style>
@@ -74,6 +79,7 @@
window.__SURFACE__ = '{{.Surface}}';
window.__PAGE_DATA__ = {{.Data | toJSON}};
window.__USER__ = {{.User | toJSON}};
{{if .Manifest}}window.__MANIFEST__ = {{.Manifest | toJSON}};{{end}}
</script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/app-state.js?v={{.Version}}"></script>
@@ -87,6 +93,13 @@
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-primitives-additions.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-core.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/pages.js?v={{.Version}}"></script>
{{/* v0.25.0: Component scripts — available on all surfaces */}}
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/user-menu.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/model-selector.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/file-tree.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/code-editor.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/note-editor.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/pane-container.js?v={{.Version}}"></script>
{{if eq .Surface "chat"}}{{template "scripts-chat" .}}{{end}}
{{if eq .Surface "admin"}}{{template "scripts-admin" .}}{{end}}

View File

@@ -3,12 +3,27 @@
Usage: {{template "chat-pane" dict "ID" "main"}}
Creates mount points: {ID}ChatMessages, {ID}ChatInput, {ID}SendBtn, {ID}ModelSel
ChatPane.create() in chat-pane.js binds to these IDs.
The header bar ({ID}ChatHeader) is hidden by default.
Standalone panes (editor assist) show it for chat switching + model selection.
*/}}
{{define "chat-pane"}}
<div class="chat-pane" id="{{.ID}}ChatPane">
<div class="chat-pane-header" id="{{.ID}}ChatHeader" style="display:none;">
<div class="chat-pane-header-left">
<select class="chat-pane-chat-select" id="{{.ID}}ChatSelect" title="Switch chat">
<option value="">New conversation</option>
</select>
<button class="chat-pane-new-btn" id="{{.ID}}ChatNewBtn" title="New chat">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
<div class="chat-pane-header-right">
<div class="chat-pane-model-sel" id="{{.ID}}ModelSel"></div>
</div>
</div>
<div class="chat-pane-messages" id="{{.ID}}ChatMessages"></div>
<div class="chat-pane-input-bar">
<div class="chat-pane-model-sel" id="{{.ID}}ModelSel"></div>
<div class="chat-pane-input-wrap">
<div class="chat-pane-input" id="{{.ID}}ChatInput"></div>
<button class="chat-pane-send" id="{{.ID}}SendBtn" title="Send">

View File

@@ -0,0 +1,30 @@
{{/*
Code Editor Component — tabbed CM6 editor with tab bar, save, and status bar.
Usage: {{template "code-editor" dict "ID" "editor"}}
Creates mount points: {ID}EditorTabs, {ID}EditorContent, {ID}EditorWelcome,
{ID}EditorStatus, {ID}EditorStatusFile, {ID}EditorStatusLang, {ID}EditorStatusBranch.
CodeEditor.create() in code-editor.js binds to these IDs.
*/}}
{{define "code-editor"}}
<div class="code-editor" id="{{.ID}}CodeEditor">
<div class="code-editor-tabs" id="{{.ID}}EditorTabs">
{{/* Populated by CodeEditor.openFile() */}}
</div>
<div class="code-editor-content" id="{{.ID}}EditorContent">
<div class="code-editor-welcome" id="{{.ID}}EditorWelcome">
<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-3);">
<div style="text-align:center;">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity:0.12;display:inline-block;margin-bottom:10px;"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
<p style="margin:0;font-size:14px;">Open a file to start editing</p>
<p style="margin:6px 0 0;font-size:12px;">Select from the file tree or Ctrl+P</p>
</div>
</div>
</div>
</div>
<div class="code-editor-statusbar" id="{{.ID}}EditorStatus">
<span id="{{.ID}}EditorStatusFile"></span>
<span id="{{.ID}}EditorStatusLang"></span>
<span id="{{.ID}}EditorStatusBranch"></span>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,20 @@
{{/*
File Tree Component — workspace file browser with directory expand/collapse.
Usage: {{template "file-tree" dict "ID" "editor"}}
Creates mount points: {ID}FileTree, {ID}TreeHeader, {ID}TreeItems, {ID}TreeNewFile.
FileTree.create() in file-tree.js binds to these IDs.
*/}}
{{define "file-tree"}}
<div class="file-tree" id="{{.ID}}FileTree">
<div class="file-tree-header" id="{{.ID}}TreeHeader">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<span class="file-tree-title">Files</span>
<button class="icon-btn" id="{{.ID}}TreeNewFile" title="New file">
<svg width="12" height="12" 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>
</button>
</div>
<div class="file-tree-items" id="{{.ID}}TreeItems">
{{/* Populated by FileTree.refresh() */}}
</div>
</div>
{{end}}

View File

@@ -0,0 +1,19 @@
{{/*
Model Selector Component — chat model + persona dropdown with capability badges.
Usage: {{template "model-selector" dict "ID" ""}}
With empty ID, generates existing IDs: modelDropdown, modelDropdownBtn, etc.
With prefix: {{template "model-selector" dict "ID" "editor"}} → editorModelDropdown, etc.
ModelSelector.create() in model-selector.js binds to these IDs.
*/}}
{{define "model-selector"}}
<div id="{{.ID}}modelDropdown" class="model-dropdown">
<button id="{{.ID}}modelDropdownBtn" class="model-dropdown-btn">
<span id="{{.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="{{.ID}}modelDropdownMenu" class="model-dropdown-menu">
{{/* Populated by ModelSelector.update() */}}
</div>
</div>
<span id="{{.ID}}modelCaps" class="model-caps"></span>
{{end}}

View File

@@ -0,0 +1,86 @@
{{/*
Note Editor Component — notes list + editor/reader with folders, search, backlinks.
Usage: {{template "note-editor" dict "ID" "main"}}
Creates mount points for list view, editor view, and graph view.
NoteEditor.create() in note-editor.js binds to these IDs.
When used in the notes surface (standalone), notes.js wires event listeners
and integrates with PanelRegistry. When used in the editor surface (tabbed pane),
NoteEditor.create() provides independent lifecycle and event wiring.
*/}}
{{define "note-editor"}}
<div class="note-editor" id="{{.ID}}NoteEditor">
{{/* ── List View ───────────────────────── */}}
<div class="note-editor-list-view" id="{{.ID}}NotesListView">
<div class="notes-toolbar">
<button id="{{.ID}}NotesNewBtn" class="btn-small" title="New note">+ New</button>
<button id="{{.ID}}NotesTodayBtn" class="btn-small" title="Open daily note">📅 Today</button>
<button id="{{.ID}}NotesGraphBtn" class="btn-small" title="Note graph">🕸 Graph</button>
<button id="{{.ID}}NotesSelectModeBtn" class="btn-small" title="Select mode">☑ Select</button>
</div>
<div class="notes-search-row">
<input type="text" id="{{.ID}}NotesSearchInput" class="notes-search-input" placeholder="Search notes…" autocomplete="off">
</div>
<div class="notes-filter-row">
<select id="{{.ID}}NotesFolderFilter" class="notes-filter-select"><option value="">All folders</option></select>
<select id="{{.ID}}NotesSortSelect" class="notes-filter-select">
<option value="updated_desc">Last updated</option>
<option value="created_desc">Created</option>
<option value="alpha">Alphabetical</option>
</select>
</div>
<div id="{{.ID}}NotesList" class="notes-list"></div>
<div id="{{.ID}}NotesSelectionBar" class="notes-selection-bar" style="display:none;">
<label class="notes-select-all"><input type="checkbox" id="{{.ID}}NotesSelectAll"> All</label>
<span id="{{.ID}}NotesSelectedCount">0</span> selected
<button id="{{.ID}}NotesDeleteSelectedBtn" class="btn-small btn-danger">Delete</button>
<button id="{{.ID}}NotesCancelSelectBtn" class="btn-small">Cancel</button>
</div>
</div>
{{/* ── Editor View ─────────────────────── */}}
<div class="note-editor-editor-view" id="{{.ID}}NotesEditorView" style="display:none;">
<div class="notes-editor-header">
<button id="{{.ID}}NotesBackBtn" class="btn-small" title="Back to list">← Back</button>
<div style="flex:1"></div>
<button id="{{.ID}}NoteEditBtn" class="btn-small" style="display:none;">Edit</button>
<button id="{{.ID}}NotePreviewBtn" class="btn-small" style="display:none;">Preview</button>
<button id="{{.ID}}NoteCancelEditBtn" class="btn-small" style="display:none;">Cancel</button>
<button id="{{.ID}}NoteDeleteBtn" class="btn-small btn-danger" style="display:none;">Delete</button>
<button id="{{.ID}}NoteSaveBtn" class="btn-small btn-primary">Save</button>
</div>
{{/* Edit mode */}}
<div id="{{.ID}}NoteEditMode" class="notes-editor">
<input type="text" id="{{.ID}}NoteEditorTitle" class="notes-title-input" placeholder="Note title">
<div class="notes-meta-row">
<input type="text" id="{{.ID}}NoteEditorFolder" class="notes-folder-input" placeholder="Folder (e.g. work/project)">
<input type="text" id="{{.ID}}NoteEditorTags" class="notes-tags-input" placeholder="Tags (comma-separated)">
</div>
<div id="{{.ID}}NoteEditorContentContainer" class="notes-content-container">
<textarea id="{{.ID}}NoteEditorContent" rows="12" class="notes-content-input" placeholder="Write your note in markdown…"></textarea>
</div>
</div>
{{/* Read mode */}}
<div id="{{.ID}}NoteReadMode" style="display:none;">
<div class="notes-read-view">
<h3 id="{{.ID}}NoteReadTitle" class="note-read-title"></h3>
<div id="{{.ID}}NoteReadMeta" class="note-read-meta"></div>
<div id="{{.ID}}NoteReadContent" class="note-read-content msg-content"></div>
</div>
</div>
{{/* Backlinks */}}
<div id="{{.ID}}NoteBacklinks" class="note-backlinks" style="display:none;">
<div class="note-backlinks-header">
Backlinks <span id="{{.ID}}NoteBacklinksCount" class="badge">0</span>
</div>
<div id="{{.ID}}NoteBacklinksList" class="note-backlinks-list"></div>
</div>
</div>
{{/* ── Graph View ──────────────────────── */}}
<div class="note-editor-graph-view" id="{{.ID}}NotesGraphView" style="display:none;"></div>
</div>
{{end}}

View File

@@ -0,0 +1,40 @@
{{/*
User Menu Component — reusable user avatar + flyout dropdown.
Usage: {{template "user-menu" dict "ID" ""}}
With empty ID, generates existing IDs: userMenuBtn, userAvatar, etc.
With prefix: {{template "user-menu" dict "ID" "editor"}} → editorUserMenuBtn, etc.
UserMenu.create() in user-menu.js binds to these IDs.
*/}}
{{define "user-menu"}}
<div class="user-menu-wrap" id="{{.ID}}userMenuWrap">
<button id="{{.ID}}userMenuBtn" class="user-btn">
<div id="{{.ID}}userAvatar" class="user-avatar">
<span id="{{.ID}}avatarLetter">?</span>
</div>
<span id="{{.ID}}userName" class="sb-label">User</span>
</button>
<div id="{{.ID}}userFlyout" class="user-flyout">
<button id="{{.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="{{.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="{{.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="{{.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 Log</span>
</button>
<hr class="flyout-divider">
<button id="{{.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>
{{end}}

View File

@@ -32,7 +32,7 @@
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
Routing
</a>
<a href="{{$base}}/admin/settings" class="admin-cat-btn{{if eq $section "settings"}} active{{else if eq $section "storage"}} active{{else if eq $section "extensions"}} active{{end}}" data-cat="system">
<a href="{{$base}}/admin/settings" class="admin-cat-btn{{if eq $section "settings"}} active{{else if eq $section "storage"}} active{{else if eq $section "extensions"}} active{{else if eq $section "channels"}} active{{else if eq $section "surfaces"}} active{{end}}" data-cat="system">
<svg width="13" height="13" 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 0 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-.09"/></svg>
System
</a>
@@ -241,4 +241,5 @@
<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/admin-scaffold.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-surfaces.js?v={{.Version}}"></script>
{{end}}

View File

@@ -177,45 +177,20 @@ window.addEventListener('unhandledrejection', function(e) {
{{/* User / bottom */}}
<div class="sidebar-bottom">
{{if .SurfaceEnabled "notes"}}
<button id="notesBtn" class="sb-btn sidebar-notes-btn">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
<span class="sb-label">Notes</span>
</button>
{{/* Editor shortcut */}}
{{end}}
{{if .SurfaceEnabled "editor"}}
<a href="{{.BasePath}}/editor" class="sb-btn sidebar-editor-btn" title="Editor">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
<span class="sb-label">Editor</span>
</a>
{{end}}
<div class="sidebar-bottom-divider"></div>
<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 Log</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>
{{template "user-menu" dict "ID" ""}}
</div>
</div>
@@ -233,16 +208,7 @@ window.addEventListener('unhandledrejection', function(e) {
{{/* Chat header: model selector + caps + tokens + panel + notif */}}
<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>
{{template "model-selector" dict "ID" ""}}
<div class="chat-header-right">
<span id="chatTokenCount" class="token-count" title="Estimated token count"></span>
<span id="summarizedHistory" class="summarized-badge" style="display:none;" title="Earlier messages are summarized">📋 Summarized</span>

View File

@@ -1,127 +1,104 @@
{{/*
Editor surface — matches switchboard-prototype-editor.jsx.
IDE-like layout: topbar + file tree + tabs + code + chat pane.
editor-mode.js builds CodeMirror, file tree, etc.
Editor surface — v0.25.0 rebuild.
Three-pane layout: files | code-editor | <chat, notes>
Uses PaneContainer for resizable splits and tabbed right pane.
Components are SERVER-RENDERED via Go template partials into hidden
containers (#editorComponents). The boot script moves them into
pane slots created by PaneContainer. This ensures full DOM parity
with the chat surface — same buttons, same features, same behavior.
*/}}
{{define "surface-editor"}}
<div class="surface-editor">
<div class="surface-editor" id="editorSurface">
{{/* Top Bar */}}
<div class="editor-topbar">
<a href="{{.BasePath}}/" class="editor-topbar-back">
{{/* ── Top Bar ─────────────────────────── */}}
<div class="editor-topbar" id="editorTopbar">
<a href="{{.BasePath}}/" class="editor-topbar-back" title="Back to chat">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
Back
</a>
<div style="width:1px;height:18px;background:var(--border);"></div>
<div class="editor-topbar-sep"></div>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent);"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
<span style="font-size:13px;font-weight:600;" id="editorWorkspaceName">
{{if .Data}}{{.Data.WorkspaceName}}{{else}}Editor{{end}}
</span>
<div style="display:flex;align-items:center;gap:4px;background:var(--purple-dim);padding:2px 8px;border-radius:4px;">
{{/* Workspace selector dropdown */}}
<div class="editor-ws-selector" id="editorWsSelector">
<button class="editor-ws-selector-btn" id="editorWsSelectorBtn">
<span id="editorWorkspaceName">{{if .Data}}{{.Data.WorkspaceName}}{{else}}Editor{{end}}</span>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div class="editor-ws-dropdown" id="editorWsDropdown">
<div id="editorWsList" class="editor-ws-list">
{{/* Populated by JS */}}
</div>
<div class="editor-ws-dropdown-divider"></div>
<button class="editor-ws-dropdown-item editor-ws-new" id="editorWsNewBtn">
<svg width="12" height="12" 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>
New Workspace
</button>
</div>
</div>
<div class="editor-topbar-branch" id="editorBranchBadge" style="display:none;">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--purple)" stroke-width="2"><circle cx="12" cy="18" r="3"/><circle cx="12" cy="6" r="3"/><line x1="12" y1="9" x2="12" y2="15"/></svg>
<span style="font-size:11px;font-weight:600;color:var(--purple);font-family:var(--mono);">main</span>
<span id="editorBranchName" class="editor-topbar-branch-text">main</span>
</div>
<div style="flex:1;"></div>
<button class="icon-btn" id="editorNewFileBtn" title="New File">
<svg width="14" height="14" 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>
</button>
<button class="icon-btn" id="editorRefreshBtn" title="Refresh">
<button class="icon-btn" id="editorRefreshBtn" title="Refresh files">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</button>
<button class="icon-btn" id="editorSearchBtn" title="Search">
<svg 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>
</button>
<div style="width:1px;height:18px;background:var(--border);"></div>
<button class="icon-btn" id="editorToggleTree" title="Toggle file tree">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
</button>
<button class="icon-btn" id="editorToggleChat" title="Toggle chat">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
</button>
{{template "user-menu" dict "ID" ""}}
</div>
{{/* Body: tree + editor + chat */}}
<div class="editor-body" id="editorBody">
{{/* File Tree */}}
<div class="editor-tree" id="editorFileTree">
<div class="editor-tree-header">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<span style="font-size:11px;font-weight:600;color:var(--text-2);text-transform:uppercase;letter-spacing:0.4px;flex:1;">Files</span>
<button class="icon-btn" id="treeNewFileBtn" title="New file">
<svg width="12" height="12" 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>
</button>
{{/* ── Pane Body ───────────────────────── */}}
<div class="editor-body" id="editorBody"></div>
{{/* ── Bootstrap (no workspace) ────────── */}}
<div class="editor-bootstrap" id="editorBootstrap" style="display:none;">
<div class="editor-bootstrap-card">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="1.5" style="opacity:0.6;margin-bottom:12px;">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
<h3 style="margin:0 0 16px;font-size:16px;">Open a Workspace</h3>
<div id="editorBootstrapList" style="margin-bottom:16px;">
<div style="font-size:12px;color:var(--text-3);">Loading workspaces…</div>
</div>
<div class="editor-tree-files" id="editorTreeFiles">
{{/* Populated by editor-mode.js */}}
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<div style="flex:1;height:1px;background:var(--border);"></div>
<span style="font-size:11px;color:var(--text-3);text-transform:uppercase;">or create new</span>
<div style="flex:1;height:1px;background:var(--border);"></div>
</div>
<input type="text" id="editorBootstrapName" class="editor-bootstrap-input" placeholder="Workspace name" value="workspace">
<button id="editorBootstrapBtn" class="editor-bootstrap-btn">Create Workspace</button>
</div>
</div>
{{/* Main Editor Area */}}
<div class="editor-main" id="editorMain">
{{/* Tabs */}}
<div class="editor-tabs" id="editorTabs">
{{/* Populated by editor-mode.js */}}
</div>
{{/* Code Content */}}
<div class="editor-content" id="editorContent">
<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-3);">
<div style="text-align:center;">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity:0.12;display:inline-block;margin-bottom:10px;"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
<p style="margin:0;font-size:14px;">Open a file to start editing</p>
<p style="margin:6px 0 0;font-size:12px;">Select from the file tree or Ctrl+P</p>
</div>
</div>
</div>
{{/* Status Bar */}}
<div class="editor-statusbar" id="editorStatusbar">
<span>Ready</span>
</div>
</div>
{{/* Chat Pane (resizable) */}}
<div id="editorSplitHandle" style="width:5px;background:var(--border);cursor:col-resize;flex-shrink:0;display:none;"></div>
<div class="editor-chat" id="editorChatPane" style="display:none;">
<div class="editor-chat-header">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2"><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>
<span style="font-size:12px;font-weight:600;flex:1;">AI Chat</span>
<span class="badge badge-accent" id="editorChatModel">claude-sonnet-4-5</span>
</div>
<div class="editor-chat-messages" id="editorChatMessages">
{{/* Populated by editor-mode.js */}}
</div>
<div class="editor-chat-input">
<div style="display:flex;gap:8px;align-items:flex-end;">
<textarea id="editorChatInput" placeholder="Ask about this code…" rows="2"
style="flex:1;background:var(--input-bg,var(--bg));border:1px solid var(--border);color:var(--text);padding:8px 10px;border-radius:8px;font-size:12px;font-family:inherit;outline:none;resize:none;"></textarea>
<button id="editorChatSendBtn" style="background:var(--accent);color:#fff;border:none;border-radius:8px;padding:8px 14px;font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;">Send</button>
</div>
<div style="display:flex;gap:6px;margin-top:6px;font-size:10px;color:var(--text-3);">
<span id="editorChatContext">Context: none</span>
<span>&middot;</span>
<span>Cmd+L to focus</span>
</div>
</div>
</div>
{{/* ── Server-Rendered Components ──────── */}}
{{/* Hidden until moved into pane slots by editor-surface.js.
Full DOM from Go template partials = feature parity with chat surface. */}}
<div id="editorComponents" style="display:none;">
{{template "file-tree" dict "ID" "ed"}}
{{template "code-editor" dict "ID" "ed"}}
{{template "chat-pane" dict "ID" "ed"}}
{{template "note-editor" dict "ID" "edNotes"}}
</div>
</div>
<div id="toastContainer" class="toast-container"></div>
{{end}}
{{/* CSS: loaded via base.html (variables, layout, primitives, modals, chat, panels, surfaces, splash) */}}
{{/* ── CSS ─────────────────────────────────── */}}
{{define "css-editor"}}
<link rel="stylesheet" href="{{.BasePath}}/css/editor-mode.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/editor-surface.css?v={{.Version}}">
{{end}}
{{/* Scripts */}}
{{/* ── Scripts ─────────────────────────────── */}}
{{define "scripts-editor"}}
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/marked.min.js"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/purify.min.js"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/codemirror/codemirror.bundle.js?v={{.Version}}" onerror="console.warn('[CM6] Bundle not available')"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-format.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/editor-mode.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/chat-pane.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/editor-surface.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/app.js?v={{.Version}}"></script>
{{end}}

View File

@@ -47,6 +47,7 @@ type Stores struct {
CapOverrides CapabilityOverrideStore
RoutingPolicies RoutingPolicyStore
Sessions SessionStore
Surfaces SurfaceRegistryStore // v0.25.0: Surface lifecycle management
}
// =========================================

View File

@@ -40,5 +40,6 @@ func NewStores(db *sql.DB) store.Stores {
CapOverrides: NewCapOverrideStore(db),
RoutingPolicies: NewRoutingPolicyStore(db),
Sessions: NewSessionStore(),
Surfaces: NewSurfaceRegistryStore(),
}
}

View File

@@ -0,0 +1,117 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type SurfaceRegistryStore struct{}
func NewSurfaceRegistryStore() *SurfaceRegistryStore { return &SurfaceRegistryStore{} }
// Seed upserts a core surface. Called at startup by the page engine
// to ensure core surfaces exist in the registry. Does NOT overwrite
// the enabled state if the row already exists (admin toggle survives restart).
func (s *SurfaceRegistryStore) Seed(ctx context.Context, id, title, source string, manifest map[string]any) error {
manifestJSON := ToJSON(manifest)
_, err := DB.ExecContext(ctx, `
INSERT INTO surface_registry (id, title, source, manifest, enabled, installed_at, updated_at)
VALUES ($1, $2, $3, $4, true, NOW(), NOW())
ON CONFLICT (id) DO UPDATE SET
title = $2,
manifest = $4,
source = $3,
updated_at = NOW()`,
// Note: ON CONFLICT does NOT update 'enabled' — preserves admin toggle
id, title, source, manifestJSON)
return err
}
func (s *SurfaceRegistryStore) List(ctx context.Context) ([]store.SurfaceRegistration, error) {
rows, err := DB.QueryContext(ctx,
`SELECT id, title, manifest, enabled, source, installed_at, updated_at
FROM surface_registry ORDER BY source, title`)
if err != nil {
return nil, err
}
defer rows.Close()
var surfaces []store.SurfaceRegistration
for rows.Next() {
var sr store.SurfaceRegistration
var manifestJSON []byte
if err := rows.Scan(&sr.ID, &sr.Title, &manifestJSON, &sr.Enabled, &sr.Source, &sr.InstalledAt, &sr.UpdatedAt); err != nil {
continue
}
json.Unmarshal(manifestJSON, &sr.Manifest)
surfaces = append(surfaces, sr)
}
return surfaces, rows.Err()
}
func (s *SurfaceRegistryStore) Get(ctx context.Context, id string) (*store.SurfaceRegistration, error) {
var sr store.SurfaceRegistration
var manifestJSON []byte
err := DB.QueryRowContext(ctx,
`SELECT id, title, manifest, enabled, source, installed_at, updated_at
FROM surface_registry WHERE id = $1`, id).
Scan(&sr.ID, &sr.Title, &manifestJSON, &sr.Enabled, &sr.Source, &sr.InstalledAt, &sr.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
json.Unmarshal(manifestJSON, &sr.Manifest)
return &sr, nil
}
func (s *SurfaceRegistryStore) SetEnabled(ctx context.Context, id string, enabled bool) error {
result, err := DB.ExecContext(ctx,
`UPDATE surface_registry SET enabled = $2, updated_at = NOW() WHERE id = $1`,
id, enabled)
if err != nil {
return err
}
affected, _ := result.RowsAffected()
if affected == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *SurfaceRegistryStore) Delete(ctx context.Context, id string) error {
// Only allow deleting extension surfaces, not core
result, err := DB.ExecContext(ctx,
`DELETE FROM surface_registry WHERE id = $1 AND source = 'extension'`, id)
if err != nil {
return err
}
affected, _ := result.RowsAffected()
if affected == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *SurfaceRegistryStore) ListEnabled(ctx context.Context) ([]string, error) {
rows, err := DB.QueryContext(ctx,
`SELECT id FROM surface_registry WHERE enabled = true ORDER BY title`)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
continue
}
ids = append(ids, id)
}
return ids, rows.Err()
}

View File

@@ -0,0 +1,36 @@
package store
import "context"
// SurfaceRegistryStore — CRUD for the surface_registry table.
// Core surfaces are seeded at startup. Extension surfaces are managed via admin API.
type SurfaceRegistryStore interface {
// Seed upserts a core surface (called at startup by page engine).
Seed(ctx context.Context, id, title, source string, manifest map[string]any) error
// List returns all registered surfaces, ordered by title.
List(ctx context.Context) ([]SurfaceRegistration, error)
// Get returns a single surface by ID.
Get(ctx context.Context, id string) (*SurfaceRegistration, error)
// SetEnabled toggles a surface's enabled state.
SetEnabled(ctx context.Context, id string, enabled bool) error
// Delete removes an extension surface. Core surfaces cannot be deleted.
Delete(ctx context.Context, id string) error
// ListEnabled returns IDs of all enabled surfaces.
ListEnabled(ctx context.Context) ([]string, error)
}
// SurfaceRegistration is a row from surface_registry.
type SurfaceRegistration struct {
ID string `json:"id" db:"id"`
Title string `json:"title" db:"title"`
Manifest map[string]any `json:"manifest" db:"manifest"`
Enabled bool `json:"enabled" db:"enabled"`
Source string `json:"source" db:"source"`
InstalledAt string `json:"installed_at" db:"installed_at"`
UpdatedAt string `json:"updated_at" db:"updated_at"`
}

View File

@@ -17,7 +17,7 @@ func init() {
// calculator
// ═══════════════════════════════════════════
type CalculatorTool struct{}
type CalculatorTool struct{ BaseTool }
func (t *CalculatorTool) Definition() ToolDef {
return ToolDef{

View File

@@ -22,6 +22,7 @@ func RegisterConversationSearch(stores store.Stores) {
// ═══════════════════════════════════════════
type conversationSearchTool struct {
BaseTool
stores store.Stores
}

View File

@@ -15,7 +15,7 @@ func init() {
// datetime
// ═══════════════════════════════════════════
type DateTimeTool struct{}
type DateTimeTool struct{ BaseTool }
func (t *DateTimeTool) Definition() ToolDef {
return ToolDef{

View File

@@ -36,6 +36,7 @@ func RegisterFileRecall(stores store.Stores, objStore storage.ObjectStore) {
const maxImageBytes = 10 * 1024 * 1024 // 10 MB cap for base64 images
type fileRecallTool struct {
BaseTool
stores store.Stores
objStore storage.ObjectStore
}

View File

@@ -9,8 +9,7 @@ import (
)
// RegisterGitTools registers the 5 git-related workspace tools.
// Only meaningful when a workspace has git_remote_url set — the completion
// handler filters them out otherwise (via GitToolNames).
// Each declares RequireWorkspace + DenyVisitor availability via workspaceToolBase.
func RegisterGitTools(gitOps *workspace.GitOps) {
Register(&gitStatusTool{gitOps: gitOps})
Register(&gitDiffTool{gitOps: gitOps})
@@ -19,14 +18,12 @@ func RegisterGitTools(gitOps *workspace.GitOps) {
Register(&gitBranchTool{gitOps: gitOps})
}
// GitToolNames returns the names of all git tools for filtering.
func GitToolNames() []string {
return []string{"git_status", "git_diff", "git_commit", "git_log", "git_branch"}
}
// ── git_status ───────────────────────────────
type gitStatusTool struct{ gitOps *workspace.GitOps }
type gitStatusTool struct {
workspaceToolBase
gitOps *workspace.GitOps
}
func (t *gitStatusTool) Definition() ToolDef {
return ToolDef{
@@ -53,7 +50,10 @@ func (t *gitStatusTool) Execute(ctx context.Context, execCtx ExecutionContext, a
// ── git_diff ─────────────────────────────────
type gitDiffTool struct{ gitOps *workspace.GitOps }
type gitDiffTool struct {
workspaceToolBase
gitOps *workspace.GitOps
}
func (t *gitDiffTool) Definition() ToolDef {
return ToolDef{
@@ -92,7 +92,10 @@ func (t *gitDiffTool) Execute(ctx context.Context, execCtx ExecutionContext, arg
// ── git_commit ───────────────────────────────
type gitCommitTool struct{ gitOps *workspace.GitOps }
type gitCommitTool struct {
workspaceToolBase
gitOps *workspace.GitOps
}
func (t *gitCommitTool) Definition() ToolDef {
return ToolDef{
@@ -131,7 +134,10 @@ func (t *gitCommitTool) Execute(ctx context.Context, execCtx ExecutionContext, a
// ── git_log ──────────────────────────────────
type gitLogTool struct{ gitOps *workspace.GitOps }
type gitLogTool struct {
workspaceToolBase
gitOps *workspace.GitOps
}
func (t *gitLogTool) Definition() ToolDef {
return ToolDef{
@@ -171,7 +177,10 @@ func (t *gitLogTool) Execute(ctx context.Context, execCtx ExecutionContext, args
// ── git_branch ───────────────────────────────
type gitBranchTool struct{ gitOps *workspace.GitOps }
type gitBranchTool struct {
workspaceToolBase
gitOps *workspace.GitOps
}
func (t *gitBranchTool) Definition() ToolDef {
return ToolDef{

View File

@@ -28,6 +28,7 @@ func RegisterKBSearch(stores store.Stores, embedder *knowledge.Embedder) {
// ═══════════════════════════════════════════
type kbSearchTool struct {
BaseTool
stores store.Stores
embedder *knowledge.Embedder
}

View File

@@ -35,6 +35,7 @@ func RegisterMemoryTools(stores store.Stores, embedder *knowledge.Embedder) {
// ═══════════════════════════════════════════
type memorySaveTool struct {
visitorDeniedBase
stores store.Stores
embedder *knowledge.Embedder
}
@@ -147,6 +148,7 @@ func (t *memorySaveTool) embedMemory(ctx context.Context, m *models.Memory, user
// ═══════════════════════════════════════════
type memoryRecallTool struct {
visitorDeniedBase
stores store.Stores
embedder *knowledge.Embedder
}

View File

@@ -91,6 +91,7 @@ func vectorToString(vec []float64) string {
// ═══════════════════════════════════════════
type noteCreateTool struct {
visitorDeniedBase
stores store.Stores
embedder *knowledge.Embedder
}
@@ -168,6 +169,7 @@ func (t *noteCreateTool) Execute(ctx context.Context, execCtx ExecutionContext,
// ═══════════════════════════════════════════
type noteSearchTool struct {
visitorDeniedBase
stores store.Stores
embedder *knowledge.Embedder
}
@@ -347,6 +349,7 @@ type noteSearchResult struct {
// ═══════════════════════════════════════════
type noteUpdateTool struct {
visitorDeniedBase
stores store.Stores
embedder *knowledge.Embedder
}
@@ -445,7 +448,7 @@ func (t *noteUpdateTool) Execute(ctx context.Context, execCtx ExecutionContext,
// note_list
// ═══════════════════════════════════════════
type noteListTool struct{}
type noteListTool struct{ visitorDeniedBase }
func (t *noteListTool) Definition() ToolDef {
return ToolDef{

View File

@@ -0,0 +1,85 @@
package tools
// ── Built-in Predicates (v0.25.0) ──────────
//
// Predicates determine tool availability based on runtime context.
// Tools declare their availability by returning a Require from
// Availability(). The registry evaluates predicates in AvailableFor()
// before sending tool definitions to the LLM.
//
// AlwaysAvailable is defined in types.go (BaseTool references it).
// RequireWorkspace returns true when a workspace is bound to the
// channel. Workspace and git tools use this.
func RequireWorkspace(tc ToolContext) bool {
return tc.WorkspaceID != ""
}
// RequireWorkflow returns true when the channel is a workflow instance.
// Workflow-specific tools (e.g. workflow_advance) use this.
func RequireWorkflow(tc ToolContext) bool {
return tc.WorkflowID != ""
}
// RequireTeam returns true when the channel belongs to a team.
func RequireTeam(tc ToolContext) bool {
return tc.TeamID != ""
}
// DenyVisitor returns true for authenticated users, false for anonymous
// session participants (v0.24.3). Tools that expose personal data
// (memory, notes) or perform privileged operations use this.
func DenyVisitor(tc ToolContext) bool {
return !tc.IsVisitor
}
// All combines multiple predicates — all must pass for the tool to be
// available. Short-circuits on first failure.
func All(reqs ...Require) Require {
return func(tc ToolContext) bool {
for _, r := range reqs {
if !r(tc) {
return false
}
}
return true
}
}
// Any combines multiple predicates — at least one must pass.
// Short-circuits on first success.
func Any(reqs ...Require) Require {
return func(tc ToolContext) bool {
for _, r := range reqs {
if r(tc) {
return true
}
}
return false
}
}
// Not inverts a predicate.
func Not(req Require) Require {
return func(tc ToolContext) bool {
return !req(tc)
}
}
// ── Shared Base Types ───────────────────────
// Embedded in tool structs to provide common availability predicates
// without repeating Availability() overrides on every struct.
// workspaceToolBase provides RequireWorkspace + DenyVisitor availability.
// Embed in workspace and git tool structs.
type workspaceToolBase struct{ BaseTool }
var _requireWorkspaceAndDenyVisitor = All(RequireWorkspace, DenyVisitor)
func (workspaceToolBase) Availability() Require { return _requireWorkspaceAndDenyVisitor }
// visitorDeniedBase provides DenyVisitor availability.
// Embed in memory and notes tool structs.
type visitorDeniedBase struct{ BaseTool }
func (visitorDeniedBase) Availability() Require { return DenyVisitor }

View File

@@ -0,0 +1,376 @@
package tools
import (
"context"
"testing"
)
// ── Predicate Tests ─────────────────────────
func TestAlwaysAvailable(t *testing.T) {
cases := []ToolContext{
{},
{ChannelType: "direct"},
{IsVisitor: true},
{WorkspaceID: "ws-1", WorkflowID: "wf-1", TeamID: "t-1"},
}
for i, tc := range cases {
if !AlwaysAvailable(tc) {
t.Errorf("case %d: AlwaysAvailable returned false", i)
}
}
}
func TestRequireWorkspace(t *testing.T) {
if RequireWorkspace(ToolContext{}) {
t.Error("Expected false when WorkspaceID is empty")
}
if !RequireWorkspace(ToolContext{WorkspaceID: "ws-123"}) {
t.Error("Expected true when WorkspaceID is set")
}
}
func TestRequireWorkflow(t *testing.T) {
if RequireWorkflow(ToolContext{}) {
t.Error("Expected false when WorkflowID is empty")
}
if !RequireWorkflow(ToolContext{WorkflowID: "wf-456"}) {
t.Error("Expected true when WorkflowID is set")
}
}
func TestRequireTeam(t *testing.T) {
if RequireTeam(ToolContext{}) {
t.Error("Expected false when TeamID is empty")
}
if !RequireTeam(ToolContext{TeamID: "team-1"}) {
t.Error("Expected true when TeamID is set")
}
}
func TestDenyVisitor(t *testing.T) {
if !DenyVisitor(ToolContext{IsVisitor: false}) {
t.Error("Expected true for authenticated user")
}
if DenyVisitor(ToolContext{IsVisitor: true}) {
t.Error("Expected false for visitor")
}
}
func TestAll(t *testing.T) {
wsAndNonVisitor := All(RequireWorkspace, DenyVisitor)
cases := []struct {
name string
tc ToolContext
expect bool
}{
{"both satisfied", ToolContext{WorkspaceID: "ws-1", IsVisitor: false}, true},
{"no workspace", ToolContext{WorkspaceID: "", IsVisitor: false}, false},
{"is visitor", ToolContext{WorkspaceID: "ws-1", IsVisitor: true}, false},
{"neither satisfied", ToolContext{WorkspaceID: "", IsVisitor: true}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := wsAndNonVisitor(tc.tc); got != tc.expect {
t.Errorf("got %v, want %v", got, tc.expect)
}
})
}
}
func TestAllEmpty(t *testing.T) {
// All() with no predicates should pass (vacuous truth)
pred := All()
if !pred(ToolContext{}) {
t.Error("All() with no predicates should return true")
}
}
func TestAny(t *testing.T) {
wsOrWorkflow := Any(RequireWorkspace, RequireWorkflow)
cases := []struct {
name string
tc ToolContext
expect bool
}{
{"workspace only", ToolContext{WorkspaceID: "ws-1"}, true},
{"workflow only", ToolContext{WorkflowID: "wf-1"}, true},
{"both", ToolContext{WorkspaceID: "ws-1", WorkflowID: "wf-1"}, true},
{"neither", ToolContext{}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := wsOrWorkflow(tc.tc); got != tc.expect {
t.Errorf("got %v, want %v", got, tc.expect)
}
})
}
}
func TestAnyEmpty(t *testing.T) {
// Any() with no predicates should fail (no match)
pred := Any()
if pred(ToolContext{}) {
t.Error("Any() with no predicates should return false")
}
}
func TestNot(t *testing.T) {
notVisitor := Not(func(tc ToolContext) bool { return tc.IsVisitor })
if !notVisitor(ToolContext{IsVisitor: false}) {
t.Error("Not(visitor) should return true for non-visitor")
}
if notVisitor(ToolContext{IsVisitor: true}) {
t.Error("Not(visitor) should return false for visitor")
}
}
func TestComposedPredicates(t *testing.T) {
// workspace_create: available when NO workspace AND not visitor
workspaceCreate := All(Not(RequireWorkspace), DenyVisitor)
cases := []struct {
name string
tc ToolContext
expect bool
}{
{"no ws, authenticated", ToolContext{}, true},
{"has ws, authenticated", ToolContext{WorkspaceID: "ws-1"}, false},
{"no ws, visitor", ToolContext{IsVisitor: true}, false},
{"has ws, visitor", ToolContext{WorkspaceID: "ws-1", IsVisitor: true}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := workspaceCreate(tc.tc); got != tc.expect {
t.Errorf("got %v, want %v", got, tc.expect)
}
})
}
}
// ── BaseTool Tests ──────────────────────────
type testToolWithBase struct {
BaseTool
}
func (t *testToolWithBase) Definition() ToolDef {
return ToolDef{
Name: "always_tool",
Description: "test tool with BaseTool embed",
Parameters: MustJSON(map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}),
}
}
func (t *testToolWithBase) Execute(_ context.Context, _ ExecutionContext, _ string) (string, error) {
return `{"ok":true}`, nil
}
func TestBaseToolSatisfiesContextualTool(t *testing.T) {
tool := &testToolWithBase{}
// Must satisfy ContextualTool
ct, ok := interface{}(tool).(ContextualTool)
if !ok {
t.Fatal("testToolWithBase should satisfy ContextualTool interface")
}
// BaseTool.Availability() should return AlwaysAvailable
if !ct.Availability()(ToolContext{IsVisitor: true}) {
t.Error("BaseTool.Availability() should be AlwaysAvailable")
}
}
// ── AvailableFor Tests ──────────────────────
// testContextTool overrides availability with a custom predicate.
type testContextTool struct {
BaseTool
name string
req Require
}
func (t *testContextTool) Definition() ToolDef {
return ToolDef{
Name: t.name,
Description: "test contextual tool: " + t.name,
Parameters: MustJSON(map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}),
}
}
func (t *testContextTool) Execute(_ context.Context, _ ExecutionContext, _ string) (string, error) {
return `{"ok":true}`, nil
}
func (t *testContextTool) Availability() Require {
return t.req
}
// testPlainTool is a tool that does NOT implement ContextualTool —
// only the base Tool interface. It should always be included.
type testPlainTool struct {
name string
}
func (t *testPlainTool) Definition() ToolDef {
return ToolDef{
Name: t.name,
Description: "plain tool: " + t.name,
Parameters: MustJSON(map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}),
}
}
func (t *testPlainTool) Execute(_ context.Context, _ ExecutionContext, _ string) (string, error) {
return `{"ok":true}`, nil
}
func TestAvailableFor(t *testing.T) {
// Save and restore global registry
origRegistry := registry
defer func() { registry = origRegistry }()
registry = map[string]Tool{
"always_tool": &testToolWithBase{},
"ws_tool": &testContextTool{name: "ws_tool", req: RequireWorkspace},
"visitor_denied": &testContextTool{name: "visitor_denied", req: DenyVisitor},
"ws_no_visitor": &testContextTool{name: "ws_no_visitor", req: All(RequireWorkspace, DenyVisitor)},
"plain_tool": &testPlainTool{name: "plain_tool"},
}
cases := []struct {
name string
tctx ToolContext
disabled map[string]bool
expect []string // expected tool names (sorted doesn't matter, just set membership)
}{
{
name: "empty context — only always + plain",
tctx: ToolContext{},
expect: []string{"always_tool", "visitor_denied", "plain_tool"},
},
{
name: "workspace context — ws tools available",
tctx: ToolContext{WorkspaceID: "ws-1"},
expect: []string{"always_tool", "ws_tool", "visitor_denied", "ws_no_visitor", "plain_tool"},
},
{
name: "visitor with workspace — visitor_denied and ws_no_visitor excluded",
tctx: ToolContext{WorkspaceID: "ws-1", IsVisitor: true},
expect: []string{"always_tool", "ws_tool", "plain_tool"},
},
{
name: "disabled tool excluded",
tctx: ToolContext{WorkspaceID: "ws-1"},
disabled: map[string]bool{"ws_tool": true},
expect: []string{"always_tool", "visitor_denied", "ws_no_visitor", "plain_tool"},
},
{
name: "visitor no workspace — minimal set",
tctx: ToolContext{IsVisitor: true},
expect: []string{"always_tool", "plain_tool"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
disabled := tc.disabled
if disabled == nil {
disabled = map[string]bool{}
}
defs := AvailableFor(tc.tctx, disabled)
got := make(map[string]bool, len(defs))
for _, d := range defs {
got[d.Name] = true
}
// Check expected tools are present
for _, name := range tc.expect {
if !got[name] {
t.Errorf("expected tool %q not found in result", name)
}
}
// Check no unexpected tools
expect := make(map[string]bool, len(tc.expect))
for _, name := range tc.expect {
expect[name] = true
}
for name := range got {
if !expect[name] {
t.Errorf("unexpected tool %q in result", name)
}
}
})
}
}
func TestAvailableForBackwardCompat(t *testing.T) {
// AvailableFor with empty ToolContext should behave like
// the old AllDefinitionsFiltered for plain tools
origRegistry := registry
defer func() { registry = origRegistry }()
registry = map[string]Tool{
"tool_a": &testPlainTool{name: "tool_a"},
"tool_b": &testPlainTool{name: "tool_b"},
"tool_c": &testPlainTool{name: "tool_c"},
}
// No disabled, empty context — all plain tools should appear
defs := AvailableFor(ToolContext{}, map[string]bool{})
if len(defs) != 3 {
t.Errorf("Expected 3 tools, got %d", len(defs))
}
// Disable one
defs = AvailableFor(ToolContext{}, map[string]bool{"tool_b": true})
if len(defs) != 2 {
t.Errorf("Expected 2 tools, got %d", len(defs))
}
for _, d := range defs {
if d.Name == "tool_b" {
t.Error("tool_b should be filtered out")
}
}
}
// ── ToolContext from ExecutionContext ────────
func TestToolContextFromExecContext(t *testing.T) {
// Verify the fields map correctly between the two structs.
// This is a compile-time/structural test — when ToolContext is
// built from ExecutionContext in the completion handler, these
// fields must align.
exec := ExecutionContext{
UserID: "u-1",
ChannelID: "ch-1",
PersonaID: "p-1",
WorkspaceID: "ws-1",
WorkflowID: "wf-1",
TeamID: "t-1",
}
// Simulate what the completion handler will do
tc := ToolContext{
WorkspaceID: exec.WorkspaceID,
WorkflowID: exec.WorkflowID,
TeamID: exec.TeamID,
PersonaID: exec.PersonaID,
// ChannelType and IsVisitor come from channel record / gin context
}
if tc.WorkspaceID != "ws-1" {
t.Errorf("WorkspaceID mismatch: %q", tc.WorkspaceID)
}
if tc.WorkflowID != "wf-1" {
t.Errorf("WorkflowID mismatch: %q", tc.WorkflowID)
}
if tc.TeamID != "t-1" {
t.Errorf("TeamID mismatch: %q", tc.TeamID)
}
if tc.PersonaID != "p-1" {
t.Errorf("PersonaID mismatch: %q", tc.PersonaID)
}
}

View File

@@ -38,16 +38,31 @@ func AllDefinitions() []ToolDef {
// AllDefinitionsFiltered returns tool definitions excluding any names in the
// disabled set. Used when the frontend sends a disabled_tools list.
//
// Deprecated: use AvailableFor() for context-aware filtering. This wrapper
// remains for call sites that don't yet have a ToolContext.
func AllDefinitionsFiltered(disabled map[string]bool) []ToolDef {
if len(disabled) == 0 {
return AllDefinitions()
return AvailableFor(ToolContext{}, disabled)
}
// AvailableFor returns tool definitions available in the given context,
// excluding any names in the disabled set. Tools that implement
// ContextualTool have their Availability() predicate evaluated against
// tctx. Tools that only implement Tool (no predicate) are always included.
func AvailableFor(tctx ToolContext, disabled map[string]bool) []ToolDef {
defs := make([]ToolDef, 0, len(registry))
for _, t := range registry {
def := t.Definition()
if !disabled[def.Name] {
defs = append(defs, def)
if disabled[def.Name] {
continue
}
// Check context-aware availability if the tool declares it
if ct, ok := t.(ContextualTool); ok {
if !ct.Availability()(tctx) {
continue
}
}
defs = append(defs, def)
}
return defs
}

View File

@@ -39,12 +39,16 @@ type ToolResult struct {
// ── Tool Interface ──────────────────────────
// ExecutionContext provides tool implementations with the info they need.
// ExecutionContext provides tool implementations with the info they need
// at execution time. Populated from channel/request state in the
// completion handler.
type ExecutionContext struct {
UserID string
ChannelID string
PersonaID string // set when a persona is active — tools use for scoped KB access
WorkspaceID string // set when channel/project has a workspace bound
WorkflowID string // v0.25.0: set when channel is a workflow instance
TeamID string // v0.25.0: set when channel belongs to a team
}
// Tool is the interface every built-in tool implements.
@@ -57,6 +61,46 @@ type Tool interface {
Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error)
}
// ── Context-Aware Tool System (v0.25.0) ────
// ToolContext describes the runtime environment for tool availability
// checks. Built from channel metadata in the completion handler —
// separate from ExecutionContext because availability is checked before
// execution.
type ToolContext struct {
ChannelType string // "direct", "dm", "group", "channel", "workflow"
WorkspaceID string // non-empty when channel/project has a workspace
WorkflowID string // non-empty when channel is a workflow instance
TeamID string // non-empty when channel belongs to a team
PersonaID string // non-empty when a persona is active
IsVisitor bool // true for session_participants (v0.24.3)
}
// Require is a predicate that determines if a tool is available in a
// given context. Return true = tool is available, false = suppressed.
type Require func(ToolContext) bool
// ContextualTool extends Tool with an availability predicate. Tools
// that embed BaseTool get AlwaysAvailable by default. Tools that need
// context-dependent visibility override Availability().
type ContextualTool interface {
Tool
Availability() Require
}
// BaseTool provides default availability (always available). Embed in
// any tool struct for backward compat — no behavioral change.
type BaseTool struct{}
// Availability returns AlwaysAvailable. Override in tool structs that
// need context-dependent visibility.
func (BaseTool) Availability() Require { return AlwaysAvailable }
// AlwaysAvailable is the default predicate — tool is available in
// every context. Defined here (not in predicates.go) because BaseTool
// references it.
func AlwaysAvailable(_ ToolContext) bool { return true }
// ── Helpers ─────────────────────────────────
// MustJSON marshals v to a json.RawMessage, panicking on error.

View File

@@ -22,7 +22,7 @@ func init() {
// url_fetch
// ═══════════════════════════════════════════
type URLFetchTool struct{}
type URLFetchTool struct{ BaseTool }
func (t *URLFetchTool) Definition() ToolDef {
return ToolDef{

View File

@@ -16,7 +16,7 @@ func init() {
// web_search
// ═══════════════════════════════════════════
type WebSearchTool struct{}
type WebSearchTool struct{ BaseTool }
func (t *WebSearchTool) Definition() ToolDef {
return ToolDef{

View File

@@ -34,16 +34,6 @@ func RegisterWorkspaceSearchTool(stores store.Stores, embedder *knowledge.Embedd
Register(&workspaceSearchTool{stores: stores, embedder: embedder})
}
// WorkspaceToolNames returns the names of all workspace tools.
// Used by the completion handler to filter them out when no workspace is bound.
func WorkspaceToolNames() []string {
return []string{
"workspace_ls", "workspace_read", "workspace_write",
"workspace_rm", "workspace_mv", "workspace_patch",
"workspace_search",
}
}
// ── Shared ─────────────────────────────────
// loadWorkspace resolves the workspace from the execution context.
@@ -70,6 +60,7 @@ const (
// ═══════════════════════════════════════════
type workspaceLsTool struct {
workspaceToolBase
stores store.Stores
wfs *workspace.FS
}
@@ -136,6 +127,7 @@ func (t *workspaceLsTool) Execute(ctx context.Context, execCtx ExecutionContext,
// ═══════════════════════════════════════════
type workspaceReadTool struct {
workspaceToolBase
stores store.Stores
wfs *workspace.FS
}
@@ -242,6 +234,7 @@ func isTextContentType(ct string) bool {
// ═══════════════════════════════════════════
type workspaceWriteTool struct {
workspaceToolBase
stores store.Stores
wfs *workspace.FS
}
@@ -296,6 +289,7 @@ func (t *workspaceWriteTool) Execute(ctx context.Context, execCtx ExecutionConte
// ═══════════════════════════════════════════
type workspaceRmTool struct {
workspaceToolBase
stores store.Stores
wfs *workspace.FS
}
@@ -348,6 +342,7 @@ func (t *workspaceRmTool) Execute(ctx context.Context, execCtx ExecutionContext,
// ═══════════════════════════════════════════
type workspaceMvTool struct {
workspaceToolBase
stores store.Stores
wfs *workspace.FS
}
@@ -401,6 +396,7 @@ func (t *workspaceMvTool) Execute(ctx context.Context, execCtx ExecutionContext,
// ═══════════════════════════════════════════
type workspacePatchTool struct {
workspaceToolBase
stores store.Stores
wfs *workspace.FS
}

View File

@@ -15,6 +15,7 @@ import (
// ── workspace_search ────────────────────────
type workspaceSearchTool struct {
workspaceToolBase
stores store.Stores
embedder *knowledge.Embedder
}

153
src/css/admin-surfaces.css Normal file
View File

@@ -0,0 +1,153 @@
/* ==========================================
Chat Switchboard — Admin Surfaces
==========================================
v0.25.0: Surface lifecycle management UI.
========================================== */
.admin-surface-list {
display: flex;
flex-direction: column;
gap: 1px;
background: var(--border, #2a2a2e);
border: 1px solid var(--border, #2a2a2e);
border-radius: 8px;
overflow: hidden;
}
.admin-surface-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
background: var(--bg-secondary, #151517);
}
.admin-surface-row:hover {
background: var(--bg-tertiary, #1e1e22);
}
.admin-surface-info {
flex: 1;
min-width: 0;
}
.admin-surface-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.admin-surface-name {
font-size: 14px;
font-weight: 600;
color: var(--text, #eee);
}
.admin-surface-badge {
font-size: 10px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.admin-surface-badge--core {
background: var(--accent-dim, rgba(179, 138, 78, 0.15));
color: var(--accent, #b38a4e);
}
.admin-surface-badge--extension {
background: var(--purple-dim, rgba(160, 120, 255, 0.1));
color: var(--purple, #a078ff);
}
.admin-surface-badge--required {
background: var(--bg-raised, #2a2a2e);
color: var(--text-3, #555);
}
.admin-surface-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: var(--text-3, #555);
}
.admin-surface-id {
font-family: var(--mono, 'SF Mono', monospace);
font-size: 11px;
}
.admin-surface-route {
font-family: var(--mono, 'SF Mono', monospace);
font-size: 11px;
color: var(--text-3, #555);
}
.admin-surface-actions {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
/* Toggle switch */
.admin-surface-toggle {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.admin-surface-toggle--locked {
opacity: 0.5;
cursor: not-allowed;
}
.admin-surface-toggle input[type="checkbox"] {
width: 36px;
height: 20px;
appearance: none;
-webkit-appearance: none;
background: var(--bg-raised, #2a2a2e);
border-radius: 10px;
position: relative;
cursor: pointer;
transition: background 0.2s;
flex-shrink: 0;
}
.admin-surface-toggle input[type="checkbox"]:checked {
background: var(--accent, #b38a4e);
}
.admin-surface-toggle input[type="checkbox"]::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
background: #fff;
border-radius: 50%;
transition: transform 0.2s;
}
.admin-surface-toggle input[type="checkbox"]:checked::after {
transform: translateX(16px);
}
.admin-surface-toggle input[type="checkbox"]:disabled {
cursor: not-allowed;
}
.admin-surface-toggle-label {
font-size: 12px;
color: var(--text-2, #999);
min-width: 56px;
}

412
src/css/chat-pane.css Normal file
View File

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

View File

@@ -1,288 +0,0 @@
/* ==========================================
Editor Surface (v0.21.5)
IDE-like layout: file tree | code + chat
========================================== */
/* ── Header ──────────────────────────────── */
.editor-header {
display: flex; align-items: center; justify-content: space-between;
padding: 6px 12px; gap: 8px;
font-size: 13px; color: var(--text-secondary);
border-bottom: 1px solid var(--border);
min-height: 36px; box-sizing: border-box;
}
.editor-header-left { display: flex; align-items: center; gap: 8px; min-width: 0; }
.editor-ws-name { font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.editor-branch { font-size: 12px; color: var(--text-tertiary); white-space: nowrap; }
.editor-header-right { display: flex; align-items: center; gap: 4px; }
.editor-hdr-btn {
background: none; border: none; color: var(--text-secondary);
cursor: pointer; padding: 4px; border-radius: 4px;
display: flex; align-items: center;
}
.editor-hdr-btn:hover { background: var(--hover); color: var(--text-primary); }
.editor-hdr-label { font-size: 12px; margin-left: 3px; }
.editor-hdr-sep { width: 1px; height: 16px; background: var(--border); margin: 0 2px; }
/* Chat toggle */
.editor-chat-toggle.active { color: var(--accent); }
/* Export dropdown */
.editor-export-wrap { position: relative; }
.editor-export-menu {
display: none; position: absolute; right: 0; top: 100%;
background: var(--bg-primary); border: 1px solid var(--border);
border-radius: 6px; padding: 4px 0; z-index: 100;
box-shadow: 0 4px 16px rgba(0,0,0,0.2); min-width: 160px;
}
.editor-export-menu.open { display: block; }
.editor-export-item {
display: block; width: 100%; padding: 6px 12px; font-size: 13px;
background: none; border: none; text-align: left; cursor: pointer;
color: var(--text-primary);
}
.editor-export-item:hover { background: var(--hover); }
/* ── File Tree (sidebar-content region) ──── */
.editor-file-tree {
flex: 1; overflow-y: auto; overflow-x: hidden;
font-size: 13px; user-select: none;
display: flex; flex-direction: column;
}
.editor-tree-header {
display: flex; align-items: center; justify-content: space-between;
padding: 6px 8px; flex-shrink: 0;
border-bottom: 1px solid var(--border);
}
.editor-tree-title { font-size: 11px; font-weight: 600; text-transform: uppercase; color: var(--text-tertiary); letter-spacing: 0.5px; }
.editor-tree-add {
background: none; border: none; color: var(--text-tertiary);
cursor: pointer; padding: 2px; border-radius: 4px;
display: flex; align-items: center;
}
.editor-tree-add:hover { background: var(--hover); color: var(--accent); }
.editor-tree-content { flex: 1; overflow-y: auto; padding: 4px 0; }
.editor-file-tree.drag-over { outline: 2px dashed var(--accent, #5865f2); outline-offset: -2px; background: rgba(88,101,242,0.05); }
.editor-tree-row {
display: flex; align-items: center; gap: 4px;
padding: 3px 8px; cursor: pointer;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
border-radius: 4px; margin: 0 4px;
}
.editor-tree-row:hover { background: var(--hover); }
.editor-tree-row.active { background: var(--active-bg, rgba(59,130,246,0.12)); color: var(--accent); }
.editor-tree-arrow { width: 14px; text-align: center; font-size: 10px; color: var(--text-tertiary); flex-shrink: 0; }
.editor-tree-arrow.expanded { /* already rotated via character */ }
.editor-tree-icon { flex-shrink: 0; font-size: 14px; line-height: 1; }
.editor-tree-name { overflow: hidden; text-overflow: ellipsis; }
/* Git status indicators (v0.21.6) */
.editor-tree-row.git-modified .editor-tree-name { color: var(--warning, #e5a50a); }
.editor-tree-row.git-added .editor-tree-name { color: var(--success, #22c55e); }
.editor-tree-row.git-untracked .editor-tree-name { color: var(--text-tertiary); font-style: italic; }
.editor-tree-row.git-deleted .editor-tree-name { color: var(--error, #ef4444); text-decoration: line-through; }
.editor-tree-row.git-modified::after,
.editor-tree-row.git-added::after,
.editor-tree-row.git-untracked::after {
font-size: 10px; margin-left: auto; padding-right: 4px; flex-shrink: 0;
color: var(--text-tertiary);
}
.editor-tree-row.git-modified::after { content: 'M'; color: var(--warning, #e5a50a); }
.editor-tree-row.git-added::after { content: 'A'; color: var(--success, #22c55e); }
.editor-tree-row.git-untracked::after { content: 'U'; }
.editor-tree-loading, .editor-tree-empty, .editor-tree-error {
padding: 16px; text-align: center; color: var(--text-tertiary); font-size: 12px;
}
/* ── Main Split Layout ───────────────────── */
.editor-main {
display: flex; flex: 1; min-height: 0; overflow: hidden;
}
.editor-left-pane {
display: flex; flex-direction: column; min-width: 200px; overflow: hidden;
}
.editor-right-pane {
display: flex; flex-direction: column; min-width: 200px; overflow: hidden;
border-left: 1px solid var(--border);
}
/* Resize handle */
.editor-split-handle {
width: 5px; cursor: col-resize; flex-shrink: 0;
background: transparent; position: relative; z-index: 5;
}
.editor-split-handle:hover,
.editor-split-handle:active {
background: var(--accent);
opacity: 0.3;
}
/* ── Tab Bar ─────────────────────────────── */
.editor-tab-bar {
display: flex; overflow-x: auto; overflow-y: hidden;
border-bottom: 1px solid var(--border);
min-height: 32px; flex-shrink: 0;
scrollbar-width: none;
}
.editor-tab-bar::-webkit-scrollbar { height: 0; }
.editor-tab {
display: flex; align-items: center; gap: 4px;
padding: 4px 10px; font-size: 12px;
cursor: pointer; white-space: nowrap;
border-right: 1px solid var(--border);
color: var(--text-secondary);
flex-shrink: 0; max-width: 180px;
position: relative;
}
.editor-tab:hover { background: var(--hover); }
.editor-tab.active {
background: var(--bg-primary);
color: var(--text-primary);
border-bottom: 2px solid var(--accent);
}
.editor-tab.modified .editor-tab-name { font-style: italic; }
.editor-tab-icon { font-size: 13px; flex-shrink: 0; }
.editor-tab-name { overflow: hidden; text-overflow: ellipsis; }
.editor-tab-modified { color: var(--accent); font-size: 10px; margin-left: 2px; }
.editor-tab-close {
background: none; border: none; color: var(--text-tertiary);
cursor: pointer; font-size: 11px; padding: 0 2px;
border-radius: 3px; opacity: 0; transition: opacity 0.1s;
margin-left: 4px;
}
.editor-tab:hover .editor-tab-close { opacity: 1; }
.editor-tab-close:hover { background: var(--hover); color: var(--text-primary); }
/* ── Code Editor Area ────────────────────── */
.editor-code-area {
flex: 1; min-height: 0; position: relative; overflow: hidden;
}
.editor-cm-wrap {
position: absolute; inset: 0; overflow: hidden;
}
.editor-cm-wrap .cm-editor { height: 100%; }
.editor-cm-wrap .cm-scroller { overflow: auto; }
/* Textarea fallback */
.editor-textarea-fallback {
width: 100%; height: 100%; resize: none; border: none; outline: none;
background: var(--bg-primary); color: var(--text-primary);
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
font-size: 13px; line-height: 1.5;
padding: 8px 12px; box-sizing: border-box;
tab-size: 4;
}
/* Welcome screen */
.editor-welcome {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
}
.editor-welcome-inner { text-align: center; color: var(--text-tertiary); }
.editor-welcome-inner p { margin: 8px 0; font-size: 14px; }
.editor-welcome-hint { font-size: 12px; opacity: 0.6; }
.editor-welcome-hint kbd {
background: var(--hover); border: 1px solid var(--border);
border-radius: 3px; padding: 1px 5px; font-size: 11px;
font-family: inherit;
}
/* ── Chat Panel (right pane) ─────────────── */
.editor-chat-header {
padding: 6px 12px; font-size: 12px; font-weight: 600;
color: var(--text-secondary); border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.editor-chat-messages {
flex: 1; overflow-y: auto; min-height: 0;
}
/* Inherit chat message styles when embedded */
.editor-chat-messages .messages { height: auto; overflow: visible; }
.editor-chat-input { flex-shrink: 0; max-height: 140px; overflow: hidden; }
/* Inherit input-area styles — constrain for narrow pane */
.editor-chat-input .input-area { border-top: 1px solid var(--border); padding: 0 8px 8px; }
.editor-chat-input .input-wrap { max-width: none; }
.editor-chat-input .input-wrap textarea,
.editor-chat-input #messageInputWrap .cm-editor { max-height: 80px; padding: 8px; font-size: 13px; }
.editor-chat-input .input-meta { min-height: 0; padding: 2px 4px 0; }
.editor-chat-input .input-toolbar { gap: 2px; }
.editor-chat-input .input-toolbar button { padding: 4px; }
/* ── Status Bar (footer) ─────────────────── */
.editor-status-bar {
display: flex; align-items: center; gap: 8px;
padding: 2px 12px; font-size: 11px;
color: var(--text-tertiary);
border-top: 1px solid var(--border);
min-height: 22px; flex-shrink: 0;
background: var(--bg-secondary);
}
.editor-status-sep { opacity: 0.3; }
.editor-status-right { margin-left: auto; display: flex; gap: 8px; }
.editor-status-branch { font-size: 11px; }
.editor-status-file { max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.editor-status-save { font-size: 11px; }
.editor-status-save.unsaved { color: var(--warning, #e5a50a); }
.editor-status-words { font-size: 11px; }
/* ── Context Menu ────────────────────────── */
.editor-ctx-menu {
position: fixed; z-index: 1000;
background: var(--bg-primary); border: 1px solid var(--border);
border-radius: 6px; padding: 4px 0;
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
min-width: 120px; font-size: 13px;
}
.editor-ctx-item {
padding: 6px 12px; cursor: pointer;
color: var(--text-primary);
}
.editor-ctx-item:hover { background: var(--hover); }
/* ── Quick Open Overlay ──────────────────── */
.editor-quick-open {
position: fixed; inset: 0; z-index: 2000;
background: rgba(0,0,0,0.4);
display: flex; align-items: flex-start; justify-content: center;
padding-top: 15vh;
}
.editor-qo-dialog {
background: var(--bg-primary); border: 1px solid var(--border);
border-radius: 8px; width: 460px; max-width: 90vw;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
overflow: hidden;
}
.editor-qo-input {
width: 100%; padding: 10px 14px; font-size: 14px;
border: none; outline: none; box-sizing: border-box;
background: var(--bg-primary); color: var(--text-primary);
border-bottom: 1px solid var(--border);
}
.editor-qo-results { max-height: 300px; overflow-y: auto; }
.editor-qo-row {
padding: 6px 14px; cursor: pointer; font-size: 13px;
display: flex; align-items: center; gap: 6px;
color: var(--text-primary);
}
.editor-qo-row:hover { background: var(--hover); }
.editor-qo-icon { font-size: 14px; flex-shrink: 0; }
/* ── Mobile: single pane ─────────────────── */
@media (max-width: 768px) {
.editor-main { flex-direction: column; }
.editor-left-pane { flex: 1 !important; min-width: 0; }
.editor-right-pane { min-width: 0; border-left: none; border-top: 1px solid var(--border); max-height: 40vh; }
.editor-split-handle { display: none; }
}

535
src/css/editor-surface.css Normal file
View File

@@ -0,0 +1,535 @@
/* ==========================================
Chat Switchboard — Editor Surface (v0.25.0)
==========================================
Replaces editor-mode.css for the pane-based editor.
Covers: topbar, bootstrap, and component-specific
overrides within the editor context.
========================================== */
/* ── Surface Shell ─────────────────────────── */
.surface-editor {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
background: var(--bg, #0e0e10);
}
/* ── Topbar ────────────────────────────────── */
.editor-topbar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
height: 40px;
flex-shrink: 0;
background: var(--bg-secondary, #151517);
border-bottom: 1px solid var(--border, #2a2a2e);
font-size: 13px;
position: relative;
z-index: 20;
}
.editor-topbar-back {
display: flex;
align-items: center;
gap: 4px;
color: var(--text-3, #777);
text-decoration: none;
font-size: 12px;
font-weight: 500;
padding: 4px 8px;
border-radius: 4px;
transition: color 0.15s, background 0.15s;
}
.editor-topbar-back:hover {
color: var(--text, #eee);
background: var(--bg-tertiary, #1e1e22);
}
.editor-topbar-sep {
width: 1px;
height: 18px;
background: var(--border, #2a2a2e);
}
.editor-topbar-name {
font-size: 13px;
font-weight: 600;
color: var(--text, #eee);
}
/* ── Workspace Selector ────────────────────── */
.editor-ws-selector {
position: relative;
}
.editor-ws-selector-btn {
display: flex;
align-items: center;
gap: 6px;
background: none;
border: 1px solid transparent;
color: var(--text, #eee);
font-size: 13px;
font-weight: 600;
font-family: inherit;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.editor-ws-selector-btn:hover {
border-color: var(--border, #2a2a2e);
background: var(--bg-tertiary, #1e1e22);
}
.editor-ws-dropdown {
display: none;
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
background: var(--bg-secondary, #1a1a1e);
border: 1px solid var(--border, #2a2a2e);
border-radius: 8px;
min-width: 220px;
max-height: 320px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
padding: 4px 0;
}
.editor-ws-dropdown.open { display: block; }
.editor-ws-list {
max-height: 240px;
overflow-y: auto;
}
.editor-ws-dropdown-item {
display: block;
width: 100%;
text-align: left;
background: none;
border: none;
color: var(--text, #eee);
font-size: 12px;
font-family: inherit;
padding: 8px 12px;
cursor: pointer;
transition: background 0.1s;
}
.editor-ws-dropdown-item:hover {
background: var(--bg-tertiary, #1e1e22);
}
.editor-ws-dropdown-item.active {
color: var(--accent, #b38a4e);
font-weight: 600;
}
.editor-ws-dropdown-divider {
height: 1px;
background: var(--border, #2a2a2e);
margin: 4px 0;
}
.editor-ws-new {
display: flex;
align-items: center;
gap: 6px;
color: var(--accent, #b38a4e);
}
.editor-topbar-branch {
display: flex;
align-items: center;
gap: 4px;
background: var(--purple-dim, rgba(160, 120, 255, 0.1));
padding: 2px 8px;
border-radius: 4px;
}
.editor-topbar-branch-text {
font-size: 11px;
font-weight: 600;
color: var(--purple, #a078ff);
font-family: var(--mono, 'SF Mono', monospace);
}
/* ── Body ──────────────────────────────────── */
.editor-body {
flex: 1;
min-height: 0;
overflow: hidden;
}
/* ── Bootstrap (no workspace) ──────────────── */
.editor-bootstrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.editor-bootstrap-card {
text-align: center;
padding: 40px;
background: var(--bg-secondary, #151517);
border: 1px solid var(--border, #2a2a2e);
border-radius: 12px;
max-width: 360px;
}
.editor-bootstrap-input {
width: 100%;
padding: 8px 12px;
background: var(--bg, #0e0e10);
border: 1px solid var(--border, #2a2a2e);
border-radius: 6px;
color: var(--text, #eee);
font-size: 13px;
font-family: inherit;
outline: none;
margin-bottom: 12px;
box-sizing: border-box;
}
.editor-bootstrap-input:focus {
border-color: var(--accent, #b38a4e);
}
.editor-bootstrap-btn {
width: 100%;
padding: 10px 16px;
background: var(--accent, #b38a4e);
color: #fff;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: opacity 0.15s;
}
.editor-bootstrap-btn:hover { opacity: 0.9; }
.editor-bootstrap-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* Workspace list in bootstrap */
.editor-bootstrap-ws-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 10px 12px;
background: var(--bg, #0e0e10);
border: 1px solid var(--border, #2a2a2e);
border-radius: 6px;
color: var(--text, #eee);
font-size: 13px;
font-family: inherit;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
margin-bottom: 6px;
text-align: left;
}
.editor-bootstrap-ws-item:hover {
border-color: var(--accent, #b38a4e);
background: var(--bg-tertiary, #1e1e22);
}
.editor-bootstrap-ws-name {
font-weight: 600;
}
.editor-bootstrap-ws-date {
font-size: 11px;
color: var(--text-3, #777);
}
/* User menu in editor topbar — uses user-menu.css base styles.
No overrides needed: user-menu.css defaults to drop-down (topbar) mode.
Sidebar context flipped via .sidebar parent selector in user-menu.css. */
/* ── FileTree overrides (in editor context) ── */
.surface-editor .file-tree {
height: 100%;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border, #2a2a2e);
}
.surface-editor .file-tree-header {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-bottom: 1px solid var(--border, #2a2a2e);
}
.surface-editor .file-tree-title {
font-size: 11px;
font-weight: 600;
color: var(--text-2, #999);
text-transform: uppercase;
letter-spacing: 0.4px;
flex: 1;
}
.surface-editor .file-tree-items {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.surface-editor .file-tree-row {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
cursor: pointer;
font-size: 12px;
color: var(--text-2, #999);
transition: background 0.1s;
}
.surface-editor .file-tree-row:hover {
background: var(--bg-tertiary, #1e1e22);
}
.surface-editor .file-tree-row.active {
background: var(--accent-dim, rgba(179, 138, 78, 0.15));
color: var(--text, #eee);
}
.surface-editor .file-tree-arrow {
width: 12px;
font-size: 10px;
color: var(--text-3, #555);
text-align: center;
}
.surface-editor .file-tree-icon {
font-size: 13px;
width: 18px;
text-align: center;
}
.surface-editor .file-tree-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Git status indicators */
.surface-editor .file-tree-row.git-modified .file-tree-name { color: var(--warning, #e5a842); }
.surface-editor .file-tree-row.git-added .file-tree-name { color: var(--success, #4caf50); }
.surface-editor .file-tree-row.git-untracked .file-tree-name { color: var(--text-3, #555); font-style: italic; }
.surface-editor .file-tree-row.git-deleted .file-tree-name { color: var(--danger, #f44336); text-decoration: line-through; }
/* Context menu */
.file-tree-ctx-menu {
position: fixed;
background: var(--bg-secondary, #1a1a1e);
border: 1px solid var(--border, #2a2a2e);
border-radius: 6px;
padding: 4px 0;
min-width: 120px;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
}
.file-tree-ctx-item {
padding: 6px 12px;
font-size: 12px;
color: var(--text, #eee);
cursor: pointer;
}
.file-tree-ctx-item:hover {
background: var(--bg-tertiary, #1e1e22);
}
/* ── CodeEditor overrides ──────────────────── */
.surface-editor .code-editor {
height: 100%;
display: flex;
flex-direction: column;
}
.surface-editor .code-editor-tabs {
display: flex;
align-items: center;
gap: 0;
background: var(--bg-secondary, #151517);
border-bottom: 1px solid var(--border, #2a2a2e);
height: 32px;
overflow-x: auto;
flex-shrink: 0;
}
.surface-editor .code-editor-tabs::-webkit-scrollbar { height: 0; }
.surface-editor .code-editor-tab {
display: flex;
align-items: center;
gap: 4px;
padding: 0 12px;
height: 100%;
font-size: 12px;
color: var(--text-3, #777);
cursor: pointer;
border-right: 1px solid var(--border, #2a2a2e);
transition: background 0.1s;
white-space: nowrap;
}
.surface-editor .code-editor-tab:hover { background: var(--bg-tertiary, #1e1e22); }
.surface-editor .code-editor-tab.active { color: var(--text, #eee); background: var(--bg, #0e0e10); }
.surface-editor .code-editor-tab.modified .code-editor-tab-modified { color: var(--warning, #e5a842); }
.surface-editor .code-editor-tab-icon { font-size: 12px; }
.surface-editor .code-editor-tab-modified { font-size: 10px; color: var(--text-3); }
.surface-editor .code-editor-tab-close {
background: none;
border: none;
color: var(--text-3, #555);
font-size: 12px;
cursor: pointer;
padding: 0 2px;
margin-left: 4px;
border-radius: 2px;
line-height: 1;
}
.surface-editor .code-editor-tab-close:hover {
background: var(--danger-dim, rgba(244, 67, 54, 0.15));
color: var(--danger, #f44336);
}
.surface-editor .code-editor-content {
flex: 1;
min-height: 0;
overflow: hidden;
position: relative;
}
.surface-editor .code-editor-welcome {
height: 100%;
}
.surface-editor .code-editor-cm-wrap {
height: 100%;
overflow: auto;
}
.surface-editor .code-editor-cm-wrap .cm-editor {
height: 100%;
}
.surface-editor .code-editor-statusbar {
display: flex;
align-items: center;
gap: 16px;
padding: 0 12px;
height: 24px;
flex-shrink: 0;
background: var(--bg-secondary, #151517);
border-top: 1px solid var(--border, #2a2a2e);
font-size: 11px;
color: var(--text-3, #777);
font-family: var(--mono, 'SF Mono', monospace);
}
.surface-editor .code-editor-textarea-fallback {
width: 100%;
height: 100%;
background: var(--bg, #0e0e10);
color: var(--text, #eee);
border: none;
padding: 12px;
font-family: var(--mono, 'SF Mono', monospace);
font-size: 13px;
resize: none;
outline: none;
box-sizing: border-box;
}
/* ── Tabbed assist pane overrides ──────────── */
.surface-editor .pane-tabbed {
border-left: 1px solid var(--border, #2a2a2e);
}
/* ChatPane in editor tabbed pane */
.surface-editor .chat-pane {
position: absolute;
inset: 0;
}
/* Notes in editor pane */
.surface-editor .note-editor {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.surface-editor .note-editor-list-view {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.surface-editor .notes-list {
flex: 1;
overflow-y: auto;
}
/* Compact notes toolbar for narrow pane */
.surface-editor .notes-toolbar {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 6px 8px;
border-bottom: 1px solid var(--border, #2a2a2e);
}
.surface-editor .notes-toolbar .btn-small {
font-size: 11px;
padding: 3px 6px;
}
.surface-editor .notes-search-row {
padding: 4px 8px;
}
.surface-editor .notes-filter-row {
padding: 2px 8px 4px;
display: flex;
gap: 4px;
}
.surface-editor .notes-filter-select {
font-size: 11px;
flex: 1;
min-width: 0;
}

167
src/css/pane-container.css Normal file
View File

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

41
src/css/tool-grants.css Normal file
View File

@@ -0,0 +1,41 @@
/* v0.25.0: Tool grants section in persona form */
.tool-grants-list {
border: 1px solid var(--border, #2a2a2e);
border-radius: 6px;
padding: 8px;
background: var(--bg, #0e0e10);
}
.tool-grants-category {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-3, #777);
padding: 6px 0 2px;
margin-top: 4px;
}
.tool-grants-category:first-child {
margin-top: 0;
padding-top: 0;
}
.tool-grants-item {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 4px;
font-size: 12px;
color: var(--text, #eee);
cursor: pointer;
border-radius: 4px;
}
.tool-grants-item:hover {
background: var(--bg-tertiary, #1e1e22);
}
.tool-grants-item input[type="checkbox"] {
margin: 0;
}

162
src/css/user-menu.css Normal file
View File

@@ -0,0 +1,162 @@
/* ==========================================
Chat Switchboard — UserMenu Component CSS
==========================================
v0.25.0-cs11.1: Self-contained styles for the UserMenu
component. Works in two contexts:
1. Sidebar (flyout pops UP) — default in .sidebar
2. Topbar (flyout drops DOWN) — default standalone
========================================== */
/* ── Wrapper ───────────────────────────────── */
.user-menu-wrap {
position: relative;
display: flex;
align-items: center;
}
/* ── Trigger Button ────────────────────────── */
.user-menu-wrap .user-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: var(--radius, 6px);
background: none;
border: none;
color: var(--text, #eee);
cursor: pointer;
width: 100%;
font-family: inherit;
transition: background 0.15s;
}
.user-menu-wrap .user-btn:hover {
background: var(--bg-hover, rgba(255,255,255,0.06));
}
/* ── Avatar ────────────────────────────────── */
.user-menu-wrap .user-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--accent-dim, rgba(179, 138, 78, 0.2));
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
color: var(--accent, #b38a4e);
flex-shrink: 0;
position: relative;
overflow: hidden;
}
.user-menu-wrap .user-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
.user-menu-wrap .sb-label {
font-size: 12px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Flyout (default: drops DOWN for topbar) ─ */
.user-menu-wrap .user-flyout {
display: none;
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
background: var(--bg-raised, #1a1a1e);
border: 1px solid var(--border-light, #333);
border-radius: var(--radius-lg, 8px);
padding: 4px;
min-width: 170px;
z-index: 200;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
}
.user-menu-wrap .user-flyout.open {
display: block;
}
/* ── Flyout Items ──────────────────────────── */
.user-menu-wrap .flyout-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: var(--radius, 6px);
background: none;
border: none;
color: var(--text-2, #999);
cursor: pointer;
font-size: 13px;
font-family: inherit;
width: 100%;
transition: background 0.15s, color 0.15s;
}
.user-menu-wrap .flyout-item:hover {
background: var(--bg-hover, rgba(255,255,255,0.06));
color: var(--text, #eee);
}
.user-menu-wrap .flyout-item svg {
flex-shrink: 0;
opacity: 0.7;
}
.user-menu-wrap .flyout-danger {
color: var(--danger, #f44336);
}
.user-menu-wrap .flyout-danger:hover {
background: var(--danger-dim, rgba(244, 67, 54, 0.1));
color: var(--danger, #f44336);
}
.user-menu-wrap .flyout-divider {
border: none;
height: 1px;
background: var(--border, #2a2a2e);
margin: 4px 0;
}
/* ── Sidebar Context (flyout pops UP) ──────── */
/* When user-menu is inside .sidebar, flip the flyout direction */
.sidebar .user-menu-wrap .user-flyout {
top: auto;
bottom: 100%;
margin-top: 0;
margin-bottom: 4px;
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.5);
}
/* Sidebar collapsed: hide label, center avatar */
.sidebar.collapsed .user-menu-wrap .user-btn {
justify-content: center;
padding: 6px 0;
gap: 0;
}
.sidebar.collapsed .user-menu-wrap .sb-label {
display: none;
}
.sidebar.collapsed .user-menu-wrap .user-flyout {
left: 8px;
right: 8px;
}

View File

@@ -264,6 +264,9 @@
'</div>' +
'<div id="adminArchivedList" class="admin-list"><div class="loading">Loading...</div></div>';
SCAFFOLDING.surfaces =
'<div id="adminSurfacesContent"></div>';
// === Add button actions ==============================================
@@ -312,21 +315,23 @@
if (!section) return;
// -- Category / nav resolution -----------
var catMap = {
users:'people', teams:'people', groups:'people',
providers:'ai', models:'ai', personas:'ai', roles:'ai', knowledgeBases:'ai', memory:'ai',
health:'routing', routing:'routing', capabilities:'routing',
settings:'system', storage:'system', extensions:'system', channels:'system',
usage:'monitoring', audit:'monitoring', stats:'monitoring',
};
var cat = catMap[section] || 'people';
var secMap = {
people: [{id:'users',l:'Users'},{id:'teams',l:'Teams'},{id:'groups',l:'Groups'}],
ai: [{id:'providers',l:'Providers'},{id:'models',l:'Models'},{id:'personas',l:'Personas'},{id:'roles',l:'Roles'},{id:'knowledgeBases',l:'Knowledge'},{id:'memory',l:'Memory'}],
routing: [{id:'health',l:'Health'},{id:'routing',l:'Routing'},{id:'capabilities',l:'Capabilities'}],
system: [{id:'settings',l:'Settings'},{id:'storage',l:'Storage'},{id:'extensions',l:'Extensions'},{id:'channels',l:'Channels'}],
monitoring: [{id:'usage',l:'Usage'},{id:'audit',l:'Audit'},{id:'stats',l:'Stats'}],
};
// Derive from ADMIN_SECTIONS + ADMIN_LABELS (ui-admin.js) — single source of truth.
// admin-scaffold.js previously had its own duplicate catMap/secMap.
var cat = 'people';
if (typeof ADMIN_SECTIONS !== 'undefined') {
for (var c in ADMIN_SECTIONS) {
if (ADMIN_SECTIONS[c].indexOf(section) !== -1) { cat = c; break; }
}
}
var secMap;
if (typeof ADMIN_SECTIONS !== 'undefined' && typeof ADMIN_LABELS !== 'undefined') {
secMap = {};
for (var c in ADMIN_SECTIONS) {
secMap[c] = ADMIN_SECTIONS[c].map(function(id) {
return { id: id, l: ADMIN_LABELS[id] || id };
});
}
}
// Rebuild left nav for current category
var navEl = document.getElementById('adminNav');

198
src/js/admin-surfaces.js Normal file
View File

@@ -0,0 +1,198 @@
// ==========================================
// Chat Switchboard — Admin Surfaces UI
// ==========================================
// v0.25.0: Surface lifecycle management.
// Renders in the "Surfaces" admin section via ADMIN_LOADERS.
async function _loadAdminSurfaces() {
const container = document.getElementById('adminSurfacesContent');
if (!container) return;
container.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<p style="font-size:13px;color:var(--text-2);margin:0;line-height:1.6;flex:1;">
Manage which surfaces are available. Disabled surfaces redirect to Chat and are hidden from navigation.
</p>
<button id="adminSurfaceInstallBtn" class="btn-small" style="margin-left:16px;white-space:nowrap;">
+ Install Surface
</button>
</div>
<div id="adminSurfaceInstallForm" style="display:none;margin-bottom:16px;padding:16px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;">
<div style="font-size:13px;font-weight:600;margin-bottom:8px;">Install Surface Archive</div>
<p style="font-size:12px;color:var(--text-3);margin:0 0 12px;">Upload a <code>.surface</code> or <code>.zip</code> archive containing manifest.json, js/, css/, and assets/.</p>
<div style="display:flex;align-items:center;gap:8px;">
<input type="file" id="adminSurfaceFile" accept=".surface,.zip" style="font-size:12px;color:var(--text-2);">
<button id="adminSurfaceUploadBtn" class="btn-small btn-primary" disabled>Upload</button>
<button id="adminSurfaceCancelBtn" class="btn-small">Cancel</button>
</div>
<div id="adminSurfaceInstallStatus" style="font-size:12px;margin-top:8px;"></div>
</div>
<div id="adminSurfaceList" class="admin-surface-list">
<div style="padding:12px;font-size:12px;color:var(--text-3);">Loading…</div>
</div>
`;
// Wire install form
var installBtn = document.getElementById('adminSurfaceInstallBtn');
var installForm = document.getElementById('adminSurfaceInstallForm');
var fileInput = document.getElementById('adminSurfaceFile');
var uploadBtn = document.getElementById('adminSurfaceUploadBtn');
var cancelBtn = document.getElementById('adminSurfaceCancelBtn');
var statusEl = document.getElementById('adminSurfaceInstallStatus');
if (installBtn) installBtn.addEventListener('click', function() {
installForm.style.display = '';
installBtn.style.display = 'none';
});
if (cancelBtn) cancelBtn.addEventListener('click', function() {
installForm.style.display = 'none';
installBtn.style.display = '';
fileInput.value = '';
uploadBtn.disabled = true;
statusEl.textContent = '';
});
if (fileInput) fileInput.addEventListener('change', function() {
uploadBtn.disabled = !fileInput.files.length;
});
if (uploadBtn) uploadBtn.addEventListener('click', async function() {
var file = fileInput.files[0];
if (!file) return;
uploadBtn.disabled = true;
uploadBtn.textContent = 'Uploading…';
statusEl.textContent = '';
statusEl.style.color = 'var(--text-3)';
try {
var formData = new FormData();
formData.append('file', file);
var base = window.__BASE__ || '';
var resp = await fetch(base + '/api/v1/admin/surfaces/install', {
method: 'POST',
headers: API._authHeaders ? (function() { var h = API._authHeaders(); delete h['Content-Type']; return h; })() : {},
body: formData,
});
var data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Upload failed');
statusEl.style.color = 'var(--success)';
statusEl.textContent = 'Installed: ' + (data.title || data.id);
if (typeof UI !== 'undefined') UI.toast('Surface installed: ' + (data.title || data.id), 'success');
// Refresh the list
installForm.style.display = 'none';
installBtn.style.display = '';
fileInput.value = '';
_loadSurfaceList();
} catch (e) {
statusEl.style.color = 'var(--danger)';
statusEl.textContent = 'Error: ' + e.message;
uploadBtn.disabled = false;
uploadBtn.textContent = 'Upload';
}
});
_loadSurfaceList();
}
async function _loadSurfaceList() {
var listEl = document.getElementById('adminSurfaceList');
if (!listEl) return;
try {
var resp = await API._get('/api/v1/admin/surfaces');
var surfaces = resp.surfaces || [];
if (surfaces.length === 0) {
listEl.innerHTML = '<div style="padding:12px;font-size:12px;color:var(--text-3);">No surfaces registered</div>';
return;
}
listEl.innerHTML = '';
surfaces.forEach(function(s) {
var row = document.createElement('div');
row.className = 'admin-surface-row';
row.dataset.surfaceId = s.id;
var isProtected = s.id === 'chat' || s.id === 'admin';
var sourceLabel = s.source === 'core' ? 'Core' : 'Extension';
var route = s.manifest && s.manifest.route ? s.manifest.route : '';
row.innerHTML =
'<div class="admin-surface-info">' +
'<div class="admin-surface-title">' +
'<span class="admin-surface-name">' + _surfEsc(s.title) + '</span>' +
'<span class="admin-surface-badge admin-surface-badge--' + s.source + '">' + sourceLabel + '</span>' +
(isProtected ? '<span class="admin-surface-badge admin-surface-badge--required">Required</span>' : '') +
'</div>' +
'<div class="admin-surface-meta">' +
'<span class="admin-surface-id">' + _surfEsc(s.id) + '</span>' +
(route ? '<span class="admin-surface-route">' + _surfEsc(route) + '</span>' : '') +
'</div>' +
'</div>' +
'<div class="admin-surface-actions">' +
'<label class="admin-surface-toggle' + (isProtected ? ' admin-surface-toggle--locked' : '') + '">' +
'<input type="checkbox"' + (s.enabled ? ' checked' : '') + (isProtected ? ' disabled' : '') +
' data-surface-id="' + _surfEsc(s.id) + '">' +
'<span class="admin-surface-toggle-label">' + (s.enabled ? 'Enabled' : 'Disabled') + '</span>' +
'</label>' +
(s.source === 'extension' ? '<button class="btn-small btn-danger admin-surface-uninstall" data-surface-id="' + _surfEsc(s.id) + '">Uninstall</button>' : '') +
'</div>';
// Toggle handler
var checkbox = row.querySelector('input[type="checkbox"]');
if (checkbox && !isProtected) {
checkbox.addEventListener('change', (function(surface) {
return async function() {
var id = this.dataset.surfaceId;
var action = this.checked ? 'enable' : 'disable';
var label = row.querySelector('.admin-surface-toggle-label');
var cb = this;
try {
await API._put('/api/v1/admin/surfaces/' + id + '/' + action);
if (label) label.textContent = cb.checked ? 'Enabled' : 'Disabled';
if (typeof UI !== 'undefined') {
UI.toast(surface.title + ' ' + (cb.checked ? 'enabled' : 'disabled') + ' — takes effect on next page load', 'success');
}
} catch (e) {
cb.checked = !cb.checked;
if (label) label.textContent = cb.checked ? 'Enabled' : 'Disabled';
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
}
};
})(s));
}
// Uninstall handler
var uninstallBtn = row.querySelector('.admin-surface-uninstall');
if (uninstallBtn) {
uninstallBtn.addEventListener('click', (function(surface) {
return async function() {
var ok = typeof showConfirm === 'function'
? await showConfirm('Uninstall ' + surface.title + '? This removes all files.')
: window.confirm('Uninstall ' + surface.title + '?');
if (!ok) return;
try {
await API._del('/api/v1/admin/surfaces/' + surface.id);
row.remove();
if (typeof UI !== 'undefined') UI.toast(surface.title + ' uninstalled', 'success');
} catch (e) {
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
}
};
})(s));
}
listEl.appendChild(row);
});
} catch (e) {
listEl.innerHTML = '<div style="padding:12px;font-size:12px;color:var(--danger);">Failed to load: ' + _surfEsc(e.message) + '</div>';
}
}
function _surfEsc(s) {
var d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}

View File

@@ -68,8 +68,52 @@ async function init() {
async function startApp() {
hideSplash();
UI.restoreSidebar();
await loadSettings();
// v0.25.0: Surface-aware initialization.
// Common init runs on all surfaces. Chat-specific init only on chat.
const surface = window.__SURFACE__ || 'chat';
// ── Common init (all surfaces) ──────────
if (typeof loadSettings === 'function') await loadSettings();
// Guard: if token was invalidated during startup
if (!API.isAuthed) {
console.warn('⚠️ Auth lost during startup, returning to login');
showSplash(null);
return;
}
UI.updateUser();
UI.showAdminButton(API.isAdmin);
UI.restoreAppearance();
// Load models for all surfaces (editor needs them for model selector)
await fetchModels();
// Connect EventBus WebSocket (non-blocking)
try {
Events.connect((window.__BASE__ || '') + '/ws');
Events.on('ws.connected', () => {
if (typeof ToolsToggle !== 'undefined') ToolsToggle.refresh();
});
} catch (e) {
console.warn('EventBus connect error:', e.message);
}
// Signal that common init is complete — surface-specific JS can react
document.dispatchEvent(new CustomEvent('sb:ready', { detail: { surface } }));
// ── Chat surface init ───────────────────
if (surface === 'chat') {
await _startChatSurface();
}
// Editor, notes, admin, settings — their surface-specific JS handles
// the rest via DOMContentLoaded listeners in their own boot scripts.
}
// Chat-surface-specific initialization (extracted from monolithic startApp)
async function _startChatSurface() {
// v0.23.1: Load folders and channels before rendering sidebar
await loadFolders();
await loadChannels();
@@ -86,8 +130,7 @@ async function startApp() {
await loadChats();
await loadProjects();
await loadProjectChannelPositions(); // v0.19.2: load channel order per project
await fetchModels();
// app-state.js fetchModels populates App.models; UI updates are surface-specific
// Models already loaded in common init; update chat-surface-specific UI
if (typeof UI !== 'undefined') {
UI.updateModelSelector();
UI.updateCapabilityBadges();
@@ -96,14 +139,6 @@ async function startApp() {
// v0.23.2: Load user list for @mention autocomplete
await loadUsers();
// Guard: if token was invalidated during startup (401 → refresh fail → clearTokens),
// kick back to login instead of rendering a broken UI.
if (!API.isAuthed) {
console.warn('⚠️ Auth lost during startup, returning to login');
showSplash(null);
return;
}
await initBanners();
initFiles();
@@ -150,29 +185,14 @@ async function startApp() {
UI.showEmptyState();
}
UI.updateUser();
UI.showAdminButton(API.isAdmin);
initListeners();
// Connect EventBus WebSocket (non-blocking, graceful if /ws not available yet)
try {
Events.connect((window.__BASE__ || '') + '/ws');
// Once WS connects, re-fetch tools so browser extension tools appear
// (ToolsToggle.init runs before WS is up → server omits browser tools)
Events.on('ws.connected', () => {
if (typeof ToolsToggle !== 'undefined') ToolsToggle.refresh();
});
// Admin-only: persistent fallback alert banner (v0.17.0)
Events.on('role.fallback', (payload) => {
if (App.user?.role !== 'admin') return;
const data = typeof payload === 'string' ? JSON.parse(payload) : payload;
showFallbackBanner(data);
});
} catch (e) {
console.warn('EventBus WebSocket not available:', e.message);
}
console.log('✅ Chat Switchboard ready');
}

341
src/js/code-editor.js Normal file
View File

@@ -0,0 +1,341 @@
// ==========================================
// Chat Switchboard — CodeEditor Component
// ==========================================
// v0.25.0: Extracted from editor-mode.js (lines 536-940).
// Tabbed code editor with CM6 integration, auto-save, language detection,
// modified indicators, and status bar.
//
// Usage:
// const editor = CodeEditor.create({
// id: 'editor',
// workspaceId: 'ws-123',
// onSave: (path, content) => fileTree.refresh(),
// onActivate: (path) => fileTree.setActiveFile(path),
// });
// await editor.openFile('src/main.go');
// editor.getActiveFile(); // → 'src/main.go'
// editor.isModified('src/main.go'); // → false
// await editor.saveAll();
// editor.destroy();
const CodeEditor = {
_instances: new Map(),
create(opts) {
const pfx = opts.id || 'editor';
const instance = {
id: pfx,
tabsEl: document.getElementById(pfx + 'EditorTabs'),
contentEl: document.getElementById(pfx + 'EditorContent'),
welcomeEl: document.getElementById(pfx + 'EditorWelcome'),
statusEl: document.getElementById(pfx + 'EditorStatus'),
statusFileEl: document.getElementById(pfx + 'EditorStatusFile'),
statusLangEl: document.getElementById(pfx + 'EditorStatusLang'),
statusBranchEl: document.getElementById(pfx + 'EditorStatusBranch'),
workspaceId: opts.workspaceId || null,
onSave: opts.onSave || null,
onActivate: opts.onActivate || null,
_openFiles: new Map(), // path → { tab, editorWrap, editor, content, modified, language }
_activeFile: null,
_listeners: [],
// ── Public API ──────────────────────
setWorkspace(wsId) { this.workspaceId = wsId; },
async openFile(path, content, language) {
if (this._openFiles.has(path)) {
this.activateFile(path);
return;
}
// Fetch if content not provided
if (content === undefined) {
try {
content = await API.readWorkspaceFile(this.workspaceId, path);
} catch (e) {
console.error('[CodeEditor] Failed to read:', e);
if (typeof UI !== 'undefined') UI.toast('Failed to open ' + path, 'error');
return;
}
}
const lang = language || _ceDetectLanguage(path);
// Create tab
const tab = document.createElement('div');
tab.className = 'code-editor-tab';
tab.dataset.path = path;
const fileName = path.split('/').pop();
tab.innerHTML =
'<span class="code-editor-tab-icon">' + _ceFileIcon(fileName) + '</span>' +
'<span class="code-editor-tab-name">' + _ceEsc(fileName) + '</span>' +
'<span class="code-editor-tab-modified" style="display:none">●</span>' +
'<button class="code-editor-tab-close" title="Close">✕</button>';
const self = this;
tab.addEventListener('click', (e) => {
if (!e.target.closest('.code-editor-tab-close')) self.activateFile(path);
});
tab.querySelector('.code-editor-tab-close').addEventListener('click', (e) => {
e.stopPropagation();
self.closeFile(path);
});
if (this.tabsEl) this.tabsEl.appendChild(tab);
// Create editor wrapper
const editorWrap = document.createElement('div');
editorWrap.className = 'code-editor-cm-wrap';
editorWrap.style.display = 'none';
if (this.contentEl) this.contentEl.appendChild(editorWrap);
// Create CM6 editor or textarea fallback
let editor = null;
if (window.CM?.codeEditor) {
editor = CM.codeEditor(editorWrap, {
language: lang,
value: content,
lineNumbers: true,
});
if (editor.onUpdate) {
editor.onUpdate(() => self._markModified(path));
}
} else {
const ta = document.createElement('textarea');
ta.className = 'code-editor-textarea-fallback';
ta.value = content;
ta.addEventListener('input', () => self._markModified(path));
editorWrap.appendChild(ta);
editor = {
getValue: () => ta.value,
setValue: (v) => { ta.value = v; },
focus: () => ta.focus(),
destroy: () => {},
_textarea: true,
};
}
this._openFiles.set(path, {
tab, editorWrap, editor, content, modified: false, language: lang,
});
this.activateFile(path);
},
activateFile(path) {
if (this._activeFile === path) return;
// Auto-save previous if modified
if (this._activeFile && this._openFiles.has(this._activeFile)) {
const prev = this._openFiles.get(this._activeFile);
if (prev.modified) this.saveFile(this._activeFile);
prev.tab.classList.remove('active');
prev.editorWrap.style.display = 'none';
}
this._activeFile = path;
const f = this._openFiles.get(path);
if (!f) return;
f.tab.classList.add('active');
f.editorWrap.style.display = '';
if (this.welcomeEl) this.welcomeEl.style.display = 'none';
this._updateStatusBar();
if (this.onActivate) this.onActivate(path);
if (f.editor?.focus) setTimeout(() => f.editor.focus(), 10);
},
async closeFile(path) {
const f = this._openFiles.get(path);
if (!f) return;
if (f.modified) {
const save = await this._confirmClose(path);
if (save === null) return; // cancelled
if (save) await this.saveFile(path);
}
f.tab.remove();
f.editorWrap.remove();
if (f.editor?.destroy) f.editor.destroy();
this._openFiles.delete(path);
if (this._activeFile === path) {
this._activeFile = null;
const remaining = Array.from(this._openFiles.keys());
if (remaining.length) {
this.activateFile(remaining[remaining.length - 1]);
} else {
if (this.welcomeEl) this.welcomeEl.style.display = '';
this._updateStatusBar();
}
}
},
async saveFile(path) {
const f = this._openFiles.get(path);
if (!f) return;
const content = f.editor?.getValue?.() ?? '';
try {
await API.writeWorkspaceFile(this.workspaceId, path, content);
f.content = content;
f.modified = false;
f.tab.querySelector('.code-editor-tab-modified').style.display = 'none';
f.tab.classList.remove('modified');
if (path === this._activeFile) this._updateStatusBar();
if (typeof UI !== 'undefined') UI.toast('Saved ' + path.split('/').pop(), 'success');
if (this.onSave) this.onSave(path, content);
} catch (e) {
console.error('[CodeEditor] Save failed:', e);
if (typeof UI !== 'undefined') UI.toast('Failed to save: ' + e.message, 'error');
}
},
async saveAll() {
for (const [path, f] of this._openFiles) {
if (f.modified) await this.saveFile(path);
}
},
getActiveFile() { return this._activeFile; },
isModified(path) {
const f = this._openFiles.get(path);
return f ? f.modified : false;
},
hasUnsaved() {
for (const [, f] of this._openFiles) {
if (f.modified) return true;
}
return false;
},
getOpenFiles() { return Array.from(this._openFiles.keys()); },
// ── Git Branch Display ──────────────
setBranch(branch) {
if (this.statusBranchEl) {
this.statusBranchEl.textContent = branch ? '⎇ ' + branch : '';
}
},
// ── Keyboard Shortcuts ──────────────
bind() {
this._on(document, 'keydown', (e) => {
// Ctrl/Cmd+S — save active file
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
if (this._activeFile) this.saveFile(this._activeFile);
}
});
},
// ── Lifecycle ───────────────────────
destroy() {
// Destroy all CM6 instances
for (const [, f] of this._openFiles) {
if (f.editor?.destroy) f.editor.destroy();
f.tab.remove();
f.editorWrap.remove();
}
this._openFiles.clear();
this._activeFile = null;
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
this._listeners = [];
CodeEditor._instances.delete(this.id);
},
// ── Internal ────────────────────────
_markModified(path) {
const f = this._openFiles.get(path);
if (!f || f.modified) return;
const currentVal = f.editor?.getValue?.() ?? '';
if (currentVal === f.content) return;
f.modified = true;
f.tab.querySelector('.code-editor-tab-modified').style.display = '';
f.tab.classList.add('modified');
if (path === this._activeFile) this._updateStatusBar();
},
_updateStatusBar() {
if (!this.statusFileEl) return;
if (!this._activeFile) {
this.statusFileEl.textContent = 'No file open';
if (this.statusLangEl) this.statusLangEl.textContent = '';
return;
}
const f = this._openFiles.get(this._activeFile);
this.statusFileEl.textContent = this._activeFile + (f?.modified ? ' ●' : '');
if (this.statusLangEl && f) {
this.statusLangEl.textContent = f.language || '';
}
},
async _confirmClose(path) {
const fileName = path.split('/').pop();
if (typeof showConfirm === 'function') {
return showConfirm('Save changes to ' + fileName + '?', {
confirmLabel: 'Save', cancelLabel: 'Discard', showCancel: true
});
}
return Promise.resolve(true);
},
_on(el, event, handler) {
if (!el) return;
el.addEventListener(event, handler);
this._listeners.push({ el, event, handler });
},
};
CodeEditor._instances.set(pfx, instance);
return instance;
},
get(id) { return this._instances.get(id) || null; },
};
// ── Helpers ─────────────────────────────────
function _ceEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function _ceFileIcon(name) {
const ext = name.split('.').pop()?.toLowerCase();
const icons = {
js: '📜', ts: '📜', jsx: '⚛', tsx: '⚛',
go: '🔵', py: '🐍', rs: '🦀', rb: '💎',
html: '🌐', css: '🎨', scss: '🎨', less: '🎨',
json: '📋', yaml: '📋', yml: '📋', toml: '📋',
md: '📝', txt: '📄', svg: '🖼',
sql: '🗃', sh: '⚙', bash: '⚙', zsh: '⚙',
dockerfile: '🐳', makefile: '🔧',
mod: '📦', sum: '📦', lock: '🔒',
};
return icons[ext] || '📄';
}
function _ceDetectLanguage(path) {
const ext = path.split('.').pop()?.toLowerCase();
const map = {
js: 'javascript', mjs: 'javascript', cjs: 'javascript',
ts: 'typescript', tsx: 'typescript', jsx: 'javascript',
go: 'go', py: 'python', rs: 'rust', rb: 'ruby',
html: 'html', htm: 'html', css: 'css', scss: 'css', less: 'css',
json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml',
md: 'markdown', mdx: 'markdown',
sql: 'sql', sh: 'shell', bash: 'shell', zsh: 'shell',
xml: 'xml', svg: 'xml',
c: 'cpp', cpp: 'cpp', h: 'cpp', hpp: 'cpp',
java: 'java', kt: 'java',
php: 'php', pl: 'perl',
dockerfile: 'dockerfile',
};
return map[ext] || 'text';
}

File diff suppressed because it is too large Load Diff

662
src/js/editor-surface.js Normal file
View File

@@ -0,0 +1,662 @@
// ==========================================
// Chat Switchboard — Editor Surface (v0.25.0)
// ==========================================
// Uses PaneContainer to mount a three-pane layout and moves
// server-rendered component partials into pane slots.
// Components are rendered by Go templates into #editorComponents
// (hidden) — this JS moves them into the right pane slots.
// ==========================================
(function() {
'use strict';
document.addEventListener('DOMContentLoaded', () => {
if (window.__SURFACE__ !== 'editor') return;
const pageData = window.__PAGE_DATA__ || {};
const wsId = pageData.WorkspaceID;
const wsName = pageData.WorkspaceName || 'Workspace';
// Wire user menu
_initUserMenu();
// Wire workspace selector
_initWsSelector(wsId);
// No workspace — show bootstrap
if (!wsId) {
_showBootstrap();
return;
}
_mountEditor(wsId, wsName);
});
// ── User Menu ───────────────────────────
function _initUserMenu() {
if (typeof UserMenu === 'undefined') return;
const menu = UserMenu.create({ id: '' });
UserMenu.primary = menu;
menu.setUser(API.user || window.__USER__);
menu.bind({
onSettings: () => { window.location.href = (window.__BASE__ || '') + '/settings'; },
onAdmin: () => { window.location.href = (window.__BASE__ || '') + '/admin'; },
onDebug: () => { if (typeof openDebugModal === 'function') openDebugModal(); },
onSignout: () => { if (typeof handleLogout === 'function') handleLogout(); },
});
menu.showAdmin(API.isAdmin);
}
// ── Workspace Selector ──────────────────
function _initWsSelector(currentWsId) {
const btn = document.getElementById('editorWsSelectorBtn');
const dropdown = document.getElementById('editorWsDropdown');
if (!btn || !dropdown) return;
btn.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = dropdown.classList.toggle('open');
if (isOpen) _loadWsDropdown(currentWsId);
});
document.addEventListener('click', (e) => {
if (!e.target.closest('#editorWsSelector')) dropdown.classList.remove('open');
});
// New workspace button
document.getElementById('editorWsNewBtn')?.addEventListener('click', async () => {
dropdown.classList.remove('open');
const name = window.prompt('Workspace name:');
if (!name) return;
try {
const userId = API.user?.id || window.__USER__?.id;
if (!userId) throw new Error('Not authenticated');
const resp = await API.createWorkspace({ name: name.trim(), owner_type: 'user', owner_id: userId });
const newId = resp.id || resp.data?.id;
if (newId) {
window.location.href = (window.__BASE__ || '') + '/editor/' + newId;
}
} catch (e) {
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
}
});
}
async function _loadWsDropdown(currentWsId) {
const listEl = document.getElementById('editorWsList');
if (!listEl) return;
listEl.innerHTML = '<div style="padding:6px 12px;font-size:11px;color:var(--text-3)">Loading…</div>';
try {
const resp = await API._get('/api/v1/workspaces');
const workspaces = resp.data || resp || [];
listEl.innerHTML = '';
if (workspaces.length === 0) {
listEl.innerHTML = '<div style="padding:6px 12px;font-size:11px;color:var(--text-3)">No workspaces</div>';
return;
}
workspaces.forEach(ws => {
const item = document.createElement('button');
item.className = 'editor-ws-dropdown-item' + (ws.id === currentWsId ? ' active' : '');
item.textContent = ws.name || ws.id?.slice(0, 8);
item.addEventListener('click', () => {
window.location.href = (window.__BASE__ || '') + '/editor/' + ws.id;
});
listEl.appendChild(item);
});
} catch (_) {
listEl.innerHTML = '<div style="padding:6px 12px;font-size:11px;color:var(--text-3)">Failed to load</div>';
}
}
// ── Bootstrap (no workspace) ────────────
function _showBootstrap() {
const body = document.getElementById('editorBody');
const bootstrap = document.getElementById('editorBootstrap');
if (body) body.style.display = 'none';
if (bootstrap) bootstrap.style.display = '';
_loadBootstrapList();
const btn = document.getElementById('editorBootstrapBtn');
const input = document.getElementById('editorBootstrapName');
if (!btn || !input) return;
btn.addEventListener('click', async () => {
const name = input.value.trim() || 'workspace';
btn.disabled = true;
btn.textContent = 'Creating…';
try {
const userId = API.user?.id || window.__USER__?.id;
if (!userId) throw new Error('Not authenticated');
const resp = await API.createWorkspace({ name, owner_type: 'user', owner_id: userId });
const newId = resp.id || resp.data?.id;
if (!newId) throw new Error('No workspace ID returned');
// Navigate to the new workspace
window.location.href = (window.__BASE__ || '') + '/editor/' + newId;
} catch (e) {
btn.disabled = false;
btn.textContent = 'Create Workspace';
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
}
});
}
async function _loadBootstrapList() {
const listEl = document.getElementById('editorBootstrapList');
if (!listEl) return;
try {
const resp = await API._get('/api/v1/workspaces');
const workspaces = resp.data || resp || [];
if (workspaces.length === 0) {
listEl.innerHTML = '<div style="font-size:12px;color:var(--text-3);">No workspaces yet</div>';
return;
}
listEl.innerHTML = '';
workspaces.forEach(ws => {
const item = document.createElement('button');
item.className = 'editor-bootstrap-ws-item';
item.innerHTML =
'<span class="editor-bootstrap-ws-name">' + _edEsc(ws.name || ws.id?.slice(0, 8)) + '</span>' +
'<span class="editor-bootstrap-ws-date">' + _edEsc(ws.created_at ? new Date(ws.created_at).toLocaleDateString() : '') + '</span>';
item.addEventListener('click', () => {
window.location.href = (window.__BASE__ || '') + '/editor/' + ws.id;
});
listEl.appendChild(item);
});
} catch (_) {
listEl.innerHTML = '<div style="font-size:12px;color:var(--text-3);">Failed to load workspaces</div>';
}
}
// ── Mount Pane Layout ───────────────────
function _mountEditor(wsId, wsName) {
const body = document.getElementById('editorBody');
if (!body || typeof PaneContainer === 'undefined') {
console.error('[EditorSurface] Missing body or PaneContainer');
return;
}
// Mount the 'editor' preset: files | editor | <chat, notes>
const layout = PaneContainer.mount(body, 'editor', { workspaceId: wsId });
if (!layout) return;
// ── Move server-rendered components into pane slots ──
// Components were rendered by Go templates into #editorComponents (hidden).
// We move them into the pane slots created by PaneContainer.
const components = document.getElementById('editorComponents');
// File Tree → files pane
const filesPaneInfo = layout._panes.get('files');
const fileTreeEl = document.getElementById('edFileTree');
if (filesPaneInfo?.el && fileTreeEl) {
filesPaneInfo.el.appendChild(fileTreeEl);
}
const fileTree = FileTree.create({ id: 'ed', workspaceId: wsId,
onSelect: (path) => codeEditor.openFile(path),
onDelete: (path) => _deleteFile(wsId, path, fileTree, codeEditor),
onNewFile: () => _createNewFile(wsId, fileTree),
});
filesPaneInfo.component = fileTree;
fileTree.bind();
// Code Editor → editor pane
const editorPaneInfo = layout._panes.get('editor');
const codeEditorEl = document.getElementById('edCodeEditor');
if (editorPaneInfo?.el && codeEditorEl) {
editorPaneInfo.el.appendChild(codeEditorEl);
}
const codeEditor = CodeEditor.create({ id: 'ed', workspaceId: wsId,
onSave: () => fileTree.refresh(),
onActivate: (path) => fileTree.setActiveFile(path),
});
editorPaneInfo.component = codeEditor;
codeEditor.bind();
// Assist pane (tabbed: chat + notes)
const assistPaneInfo = layout._panes.get('assist');
if (assistPaneInfo?.tabs) {
// Chat tab — move server-rendered ChatPane partial
const chatPanel = assistPaneInfo.getTabPanel('chat');
const chatPaneEl = document.getElementById('edChatPane');
if (chatPanel && chatPaneEl) {
chatPanel.appendChild(chatPaneEl);
// Create ChatPane instance from the server-rendered mount points
if (typeof ChatPane !== 'undefined') {
const chatPane = ChatPane.create({
id: 'ed',
messagesEl: document.getElementById('edChatMessages'),
inputEl: document.getElementById('edChatInput'),
sendBtnEl: document.getElementById('edSendBtn'),
modelSelEl: document.getElementById('edModelSel'),
toolbarEl: document.getElementById('edToolbar'),
standalone: true,
});
chatPane.showWelcome();
_initAssistChat(chatPane, codeEditor);
const chatTab = assistPaneInfo.tabs.find(t => t.id === 'chat');
if (chatTab) chatTab.instance = chatPane;
}
}
// Notes tab — move server-rendered NoteEditor partial
const notesPanel = assistPaneInfo.getTabPanel('notes');
const noteEditorEl = document.getElementById('edNotesNoteEditor');
if (notesPanel && noteEditorEl) {
notesPanel.appendChild(noteEditorEl);
if (typeof NoteEditor !== 'undefined') {
const noteEditor = NoteEditor.create({ id: 'edNotes', onOpenGraph: null });
noteEditor.bind();
const notesTab = assistPaneInfo.tabs.find(t => t.id === 'notes');
if (notesTab) {
notesTab.instance = noteEditor;
// Lazy-load on first tab click
notesTab.btn.addEventListener('click', () => {
if (!notesTab._loaded) {
notesTab._loaded = true;
noteEditor.loadNotes();
noteEditor.loadFolders();
}
});
}
}
}
}
// Remove the hidden container
if (components) components.remove();
// Git branch
_refreshGitBranch(wsId, codeEditor);
// Toolbar
document.getElementById('editorRefreshBtn')?.addEventListener('click', () => {
fileTree.refresh();
_refreshGitBranch(wsId, codeEditor);
});
// Initial load
fileTree.refresh();
console.log('[EditorSurface] Mounted for workspace', wsId);
}
// ── File Operations ─────────────────────
async function _deleteFile(wsId, path, fileTree, codeEditor) {
const ok = typeof showConfirm === 'function'
? await showConfirm('Delete ' + path + '?')
: window.confirm('Delete ' + path + '?');
if (!ok) return;
try {
await API.deleteWorkspaceFile(wsId, path);
if (codeEditor.getOpenFiles().includes(path)) await codeEditor.closeFile(path);
fileTree.refresh();
} catch (e) {
if (typeof UI !== 'undefined') UI.toast('Delete failed: ' + e.message, 'error');
}
}
async function _createNewFile(wsId, fileTree) {
const name = window.prompt('File name (e.g. src/main.go):');
if (!name) return;
try {
await API.writeWorkspaceFile(wsId, name.trim(), '');
fileTree.refresh();
if (typeof UI !== 'undefined') UI.toast('Created ' + name.trim(), 'success');
} catch (e) {
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
}
}
// ── Git Branch ──────────────────────────
async function _refreshGitBranch(wsId, codeEditor) {
try {
const resp = await API.getWorkspaceGitBranches(wsId);
const branch = resp.current || null;
if (branch) {
const badge = document.getElementById('editorBranchBadge');
const name = document.getElementById('editorBranchName');
if (badge) badge.style.display = '';
if (name) name.textContent = branch;
}
if (codeEditor) codeEditor.setBranch(branch);
} catch (_) {
// Git not configured — hide branch badge
}
}
// ── Helpers ──────────────────────────────
function _edEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
// ── Assist Chat (channel-based, standalone) ──
// Full chat pane for the editor: model selector, chat history,
// new chat, SSE streaming. Uses real channels/messages API.
// No dependency on chat.js — fully standalone.
function _initAssistChat(chatPane, codeEditor) {
const inputEl = chatPane.inputEl;
const sendBtn = chatPane.sendBtnEl;
const headerEl = document.getElementById('edChatHeader');
const selectEl = document.getElementById('edChatSelect');
const newBtnEl = document.getElementById('edChatNewBtn');
const modelSelEl = document.getElementById('edModelSel');
if (!inputEl) return;
// Show the header bar
if (headerEl) headerEl.style.display = '';
// State
let _channelId = null;
let _messages = [];
let _sending = false;
let _abortController = null;
let _selectedModel = App.settings?.model || '';
// ── Model Selector ──────────────────────
async function _initModelSelector() {
if (!modelSelEl) return;
// Ensure models are loaded
if (!App.models?.length && typeof fetchModels === 'function') {
await fetchModels();
}
const models = App.models || [];
if (!models.length) return;
const sel = document.createElement('select');
sel.className = 'chat-pane-model-select';
sel.title = 'Select model';
models.forEach(m => {
if (m.hidden) return;
const opt = document.createElement('option');
opt.value = m.isPersona ? (m.personaId || m.id) : m.id;
const prefix = m.isPersona ? '🎭 ' : '';
opt.textContent = prefix + (m.name || m.id);
if (m.id === _selectedModel || m.personaId === _selectedModel) {
opt.selected = true;
}
sel.appendChild(opt);
});
sel.addEventListener('change', () => {
_selectedModel = sel.value;
});
modelSelEl.innerHTML = '';
modelSelEl.appendChild(sel);
}
// ── Chat History Selector ───────────────
async function _loadChatHistory() {
if (!selectEl) return;
try {
const resp = await API.listChannels(1, 20, 'direct');
const channels = resp.data || resp || [];
selectEl.innerHTML = '<option value="">New conversation</option>';
channels.forEach(ch => {
const opt = document.createElement('option');
opt.value = ch.id;
opt.textContent = ch.title || 'Untitled';
if (ch.id === _channelId) opt.selected = true;
selectEl.appendChild(opt);
});
} catch (e) {
console.warn('[AssistChat] Failed to load history:', e.message);
}
}
if (selectEl) {
selectEl.addEventListener('change', () => {
const id = selectEl.value;
if (id) {
_switchToChannel(id);
} else {
_newChat();
}
});
}
if (newBtnEl) {
newBtnEl.addEventListener('click', _newChat);
}
// ── Switch Channel ──────────────────────
async function _switchToChannel(channelId) {
_channelId = channelId;
_messages = [];
chatPane.clear();
chatPane.appendTyping();
try {
const resp = await API._get('/api/v1/channels/' + channelId + '/messages?page=1&per_page=100');
const msgs = resp.data || resp || [];
_messages = msgs;
chatPane.removeTyping();
_renderAllMessages();
} catch (e) {
chatPane.removeTyping();
_appendSystemMsg('Failed to load messages: ' + e.message);
}
}
function _newChat() {
_channelId = null;
_messages = [];
chatPane.showWelcome();
if (selectEl) selectEl.value = '';
}
// ── Create Textarea ─────────────────────
const ta = document.createElement('textarea');
ta.placeholder = 'Ask about your code…';
ta.rows = 1;
inputEl.appendChild(ta);
ta.addEventListener('input', () => {
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, 160) + 'px';
});
// ── Send ────────────────────────────────
async function sendMessage() {
const text = ta.value.trim();
if (!text || _sending) return;
_sending = true;
if (sendBtn) sendBtn.disabled = true;
ta.value = '';
ta.style.height = 'auto';
// Create channel if new conversation
if (!_channelId) {
try {
const title = text.slice(0, 50) + (text.length > 50 ? '…' : '');
const resp = await API.createChannel(title, _selectedModel, '', 'direct');
_channelId = resp.id;
// Update dropdown
_loadChatHistory();
} catch (e) {
_appendSystemMsg('Failed to create chat: ' + e.message);
_sending = false;
if (sendBtn) sendBtn.disabled = false;
return;
}
}
// Render user message
_messages.push({ role: 'user', content: text });
_renderAllMessages();
// Build context from active file
const fileCtx = _getFileContext(codeEditor);
// Stream the completion
try {
_abortController = new AbortController();
// If we have file context, prepend it to the message
const content = fileCtx
? text + '\n\n[Context: Currently editing ' + fileCtx.path + ']\n```\n' + fileCtx.content + '\n```'
: text;
const resp = await API.streamCompletion(
_channelId, content, _selectedModel,
_abortController.signal
);
await _handleStream(resp, chatPane);
} catch (e) {
if (e.name !== 'AbortError') {
_appendSystemMsg('Error: ' + e.message);
}
}
_abortController = null;
_sending = false;
if (sendBtn) sendBtn.disabled = false;
ta.focus();
}
if (sendBtn) sendBtn.addEventListener('click', sendMessage);
ta.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// ── SSE Stream Handler ──────────────────
async function _handleStream(response, pane) {
const messagesEl = pane.messagesEl;
if (!messagesEl) return;
// Create assistant bubble
const div = document.createElement('div');
div.className = 'chat-msg chat-msg--assistant chat-msg--streaming';
const contentEl = document.createElement('div');
contentEl.className = 'chat-msg__content';
div.appendChild(contentEl);
messagesEl.appendChild(div);
let content = '';
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
const delta = parsed.choices?.[0]?.delta?.content;
if (delta) {
content += delta;
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
contentEl.innerHTML = DOMPurify.sanitize(marked.parse(content));
} else {
contentEl.textContent = content;
}
pane.scrollToBottom();
}
} catch (_) { /* skip unparseable lines */ }
}
}
} catch (e) {
if (e.name !== 'AbortError') {
content += '\n\n**[Stream error: ' + e.message + ']**';
}
}
div.classList.remove('chat-msg--streaming');
_messages.push({ role: 'assistant', content });
}
// ── Rendering ───────────────────────────
function _renderAllMessages() {
if (!chatPane.messagesEl) return;
chatPane.messagesEl.innerHTML = '';
_messages.forEach(m => {
const div = document.createElement('div');
div.className = 'chat-msg chat-msg--' + m.role;
const content = document.createElement('div');
content.className = 'chat-msg__content';
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
content.innerHTML = DOMPurify.sanitize(marked.parse(m.content || ''));
} else {
content.textContent = m.content || '';
}
div.appendChild(content);
chatPane.messagesEl.appendChild(div);
});
chatPane.scrollToBottom();
}
function _appendSystemMsg(text) {
const div = document.createElement('div');
div.className = 'chat-msg chat-msg--system';
div.innerHTML = '<div class="chat-msg__content" style="color:var(--danger,#f44336);font-size:12px;">' + _edEsc(text) + '</div>';
chatPane.messagesEl?.appendChild(div);
chatPane.scrollToBottom();
}
function _getFileContext(editor) {
if (!editor) return null;
try {
const openFiles = editor.getOpenFiles();
if (!openFiles.length) return null;
// Find active tab
const activeTab = document.querySelector('.code-editor-tab.active');
const path = activeTab?.dataset?.path || openFiles[0];
// Get content from the editor instances
const inst = CodeEditor._instances?.get('ed');
if (inst?._files) {
const file = inst._files.get(path);
if (file?.view) {
const content = file.view.state.doc.toString();
return { path, content: content.slice(0, 4000) };
}
}
} catch (_) {}
return null;
}
// ── Init ────────────────────────────────
// Defer model selector and chat history loading until app common init
// completes (fetchModels, auth, etc.). The sb:ready event fires from
// startApp() after App.models is populated.
function _deferredInit() {
_initModelSelector();
_loadChatHistory();
}
if (App.models?.length) {
// Already loaded (unlikely but possible)
_deferredInit();
} else {
document.addEventListener('sb:ready', _deferredInit, { once: true });
}
}
})();

278
src/js/file-tree.js Normal file
View File

@@ -0,0 +1,278 @@
// ==========================================
// Chat Switchboard — FileTree Component
// ==========================================
// v0.25.0: Extracted from editor-mode.js (lines 336-535).
// Workspace file browser with directory expand/collapse, git status
// badges, context menu, and file icon mapping.
//
// Usage:
// const tree = FileTree.create({
// id: 'editor',
// workspaceId: 'ws-123',
// onSelect: (path) => codeEditor.openFile(path),
// onDelete: (path) => { ... },
// onNewFile: () => { ... },
// });
// await tree.refresh();
// tree.setActiveFile('src/main.go');
// tree.destroy();
const FileTree = {
_instances: new Map(),
create(opts) {
const pfx = opts.id || 'editor';
const instance = {
id: pfx,
itemsEl: document.getElementById(pfx + 'TreeItems'),
newFileEl: document.getElementById(pfx + 'TreeNewFile'),
workspaceId: opts.workspaceId || null,
onSelect: opts.onSelect || null,
onDelete: opts.onDelete || null,
onNewFile: opts.onNewFile || null,
_treeData: [],
_expandedDirs: new Set(['']),
_gitStatusMap: new Map(),
_activeFile: null,
_listeners: [],
// ── Public API ──────────────────────
setWorkspace(wsId) { this.workspaceId = wsId; },
async refresh() {
if (!this.workspaceId) return;
try {
const resp = await API.listWorkspaceFiles(this.workspaceId, '', true);
this._treeData = resp.files || resp.data || resp || [];
this._render();
} catch (e) {
console.error('[FileTree] Failed to load:', e);
if (this.itemsEl) this.itemsEl.innerHTML = '<div class="file-tree-error">Failed to load files</div>';
}
// Non-blocking git status refresh
this._refreshGitStatus();
},
setActiveFile(path) {
this._activeFile = path;
if (!this.itemsEl) return;
this.itemsEl.querySelectorAll('.file-tree-row').forEach(row => {
row.classList.toggle('active', row.dataset.path === path);
});
},
getActiveFile() { return this._activeFile; },
// ── Git Status ──────────────────────
async _refreshGitStatus() {
if (!this.workspaceId) return;
this._gitStatusMap.clear();
try {
const resp = await API.getWorkspaceGitStatus(this.workspaceId);
const files = resp.files || [];
for (const f of files) {
this._gitStatusMap.set(f.path, f.status || '?');
}
// Apply to rendered rows
if (this.itemsEl) {
this.itemsEl.querySelectorAll('.file-tree-row').forEach(row => {
const p = row.dataset.path;
const st = this._gitStatusMap.get(p);
row.classList.remove('git-modified', 'git-added', 'git-untracked', 'git-deleted');
if (st === 'M') row.classList.add('git-modified');
else if (st === 'A') row.classList.add('git-added');
else if (st === '?') row.classList.add('git-untracked');
else if (st === 'D') row.classList.add('git-deleted');
});
}
} catch (_) {
// Git not configured — ignore
}
},
// ── Tree Rendering ──────────────────
_render() {
if (!this.itemsEl) return;
this.itemsEl.innerHTML = '';
if (!this._treeData.length) {
this.itemsEl.innerHTML = '<div class="file-tree-empty">No files</div>';
return;
}
const tree = this._buildStructure(this._treeData);
this._renderNodes(tree, this.itemsEl, 0);
},
_buildStructure(files) {
const root = { name: '', path: '', isDir: true, children: [], file: null };
const dirs = new Map();
dirs.set('', root);
const sorted = [...files].sort((a, b) => {
if (a.is_directory !== b.is_directory) return a.is_directory ? -1 : 1;
return a.path.localeCompare(b.path);
});
for (const f of sorted) {
const parts = f.path.split('/');
const name = parts[parts.length - 1];
const parentPath = parts.slice(0, -1).join('/');
let parent = dirs.get(parentPath);
if (!parent) {
let accumulated = '';
for (let i = 0; i < parts.length - 1; i++) {
const prev = accumulated;
accumulated = accumulated ? accumulated + '/' + parts[i] : parts[i];
if (!dirs.has(accumulated)) {
const dirNode = { name: parts[i], path: accumulated, isDir: true, children: [], file: null };
dirs.set(accumulated, dirNode);
const p = dirs.get(prev);
if (p) p.children.push(dirNode);
}
}
parent = dirs.get(parentPath);
}
if (f.is_directory) {
let existing = dirs.get(f.path);
if (!existing) {
existing = { name, path: f.path, isDir: true, children: [], file: f };
dirs.set(f.path, existing);
if (parent) parent.children.push(existing);
} else {
existing.file = f;
}
} else {
const node = { name, path: f.path, isDir: false, children: [], file: f };
if (parent) parent.children.push(node);
}
}
return root.children;
},
_renderNodes(nodes, container, depth) {
const self = this;
for (const node of nodes) {
const row = document.createElement('div');
row.className = 'file-tree-row' + (node.path === this._activeFile ? ' active' : '');
row.style.paddingLeft = (12 + depth * 16) + 'px';
row.dataset.path = node.path;
if (node.isDir) {
const expanded = this._expandedDirs.has(node.path);
row.innerHTML =
'<span class="file-tree-arrow ' + (expanded ? 'expanded' : '') + '">' + (expanded ? '▾' : '▸') + '</span>' +
'<span class="file-tree-icon">📁</span>' +
'<span class="file-tree-name">' + _ftEsc(node.name) + '</span>';
row.addEventListener('click', () => {
if (self._expandedDirs.has(node.path)) {
self._expandedDirs.delete(node.path);
} else {
self._expandedDirs.add(node.path);
}
self._render();
});
container.appendChild(row);
if (expanded && node.children.length) {
this._renderNodes(node.children, container, depth + 1);
}
} else {
row.innerHTML =
'<span class="file-tree-icon">' + _ftFileIcon(node.name) + '</span>' +
'<span class="file-tree-name">' + _ftEsc(node.name) + '</span>';
row.addEventListener('click', () => {
if (self.onSelect) self.onSelect(node.path);
});
row.addEventListener('contextmenu', (e) => {
e.preventDefault();
self._showContextMenu(e, node);
});
container.appendChild(row);
}
}
},
// ── Context Menu ────────────────────
_showContextMenu(e, node) {
const existing = document.querySelector('.file-tree-ctx-menu');
if (existing) existing.remove();
const menu = document.createElement('div');
menu.className = 'file-tree-ctx-menu';
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
const items = [
{ label: 'Open', action: () => { if (this.onSelect) this.onSelect(node.path); } },
{ label: 'Delete', action: () => { if (this.onDelete) this.onDelete(node.path); } },
];
for (const item of items) {
const row = document.createElement('div');
row.className = 'file-tree-ctx-item';
row.textContent = item.label;
row.addEventListener('click', () => { menu.remove(); item.action(); });
menu.appendChild(row);
}
document.body.appendChild(menu);
const dismiss = (ev) => {
if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', dismiss); }
};
setTimeout(() => document.addEventListener('click', dismiss), 0);
},
// ── Event Wiring ────────────────────
bind() {
this._on(this.newFileEl, 'click', () => {
if (this.onNewFile) this.onNewFile();
});
},
// ── Lifecycle ───────────────────────
destroy() {
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
this._listeners = [];
this._treeData = [];
this._expandedDirs = new Set(['']);
this._gitStatusMap.clear();
FileTree._instances.delete(this.id);
},
_on(el, event, handler) {
if (!el) return;
el.addEventListener(event, handler);
this._listeners.push({ el, event, handler });
},
};
FileTree._instances.set(pfx, instance);
return instance;
},
get(id) { return this._instances.get(id) || null; },
};
// ── Helpers ─────────────────────────────────
function _ftEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function _ftFileIcon(name) {
const ext = name.split('.').pop()?.toLowerCase();
const icons = {
js: '📜', ts: '📜', jsx: '⚛', tsx: '⚛',
go: '🔵', py: '🐍', rs: '🦀', rb: '💎',
html: '🌐', css: '🎨', scss: '🎨', less: '🎨',
json: '📋', yaml: '📋', yml: '📋', toml: '📋',
md: '📝', txt: '📄', svg: '🖼',
sql: '🗃', sh: '⚙', bash: '⚙', zsh: '⚙',
dockerfile: '🐳', makefile: '🔧',
mod: '📦', sum: '📦', lock: '🔒',
};
return icons[ext] || '📄';
}

191
src/js/model-selector.js Normal file
View File

@@ -0,0 +1,191 @@
// ==========================================
// Chat Switchboard — ModelSelector Component
// ==========================================
// v0.25.0: Extracted from ui-core.js.
// Grouped dropdown: personas (global, team, personal), models, BYOK.
// Pattern: Go template partial (model-selector.html) + JS factory (this file).
//
// Usage:
// const sel = ModelSelector.create({ id: 'main', onChange: (id, label) => { ... } });
// sel.setModels(App.models);
// sel.select(modelId);
// sel.getSelected(); // → current model ID
// sel.destroy();
const ModelSelector = {
primary: null,
_instances: new Map(),
create(opts) {
const pfx = opts.id || '';
const instance = {
id: pfx,
dropdownEl: document.getElementById(pfx + 'modelDropdown'),
btnEl: document.getElementById(pfx + 'modelDropdownBtn'),
labelEl: document.getElementById(pfx + 'modelDropdownLabel'),
menuEl: document.getElementById(pfx + 'modelDropdownMenu'),
capsEl: document.getElementById(pfx + 'modelCaps'),
onChange: opts.onChange || null,
_value: '',
_models: [],
_listeners: [],
// ── Selection ───────────────────────
getSelected() { return this._value; },
select(id, label) {
this._value = id;
if (this.labelEl) this.labelEl.textContent = label || id || 'Select a model';
// Highlight
if (this.menuEl) {
this.menuEl.querySelectorAll('.model-dropdown-item').forEach(el => {
el.classList.toggle('selected', el.dataset.value === id);
});
}
this._updateCaps();
if (this.onChange) this.onChange(id, label);
},
// ── Model list ──────────────────────
setModels(models) {
this._models = models || [];
this._rebuild();
},
_rebuild() {
if (!this.menuEl) return;
this.menuEl.innerHTML = '';
if (this._models.length === 0) {
this.menuEl.innerHTML = '<div class="model-dropdown-item" style="color:var(--text-3);cursor:default">No models loaded</div>';
this.select('', 'No models loaded');
return;
}
const globalPersonas = this._models.filter(m => m.isPersona && m.personaScope === 'global');
const teamPersonas = this._models.filter(m => m.isPersona && m.personaScope === 'team');
const personalPersonas = this._models.filter(m => m.isPersona && m.personaScope === 'personal');
const models = this._models.filter(m => !m.isPersona && !m.hidden);
const addGroup = (label, items) => {
if (items.length === 0) return;
const hdr = document.createElement('div');
hdr.className = 'model-dropdown-group';
hdr.textContent = label;
this.menuEl.appendChild(hdr);
items.forEach(m => this.menuEl.appendChild(this._createItem(m)));
};
addGroup('\u26A1 Personas', globalPersonas);
// Group team personas by team name
const teamGroups = {};
teamPersonas.forEach(m => {
const tn = m.personaTeamName || 'Team';
(teamGroups[tn] = teamGroups[tn] || []).push(m);
});
Object.keys(teamGroups).sort().forEach(tn => {
addGroup('\uD83D\uDC65 ' + tn, teamGroups[tn]);
});
addGroup('\uD83D\uDD27 My Personas', personalPersonas);
addGroup('Models', models.filter(m => m.source !== 'personal'));
addGroup('\uD83D\uDD11 My Providers', models.filter(m => m.source === 'personal'));
},
_createItem(m) {
const self = this;
const div = document.createElement('div');
div.className = 'model-dropdown-item';
div.dataset.value = m.id;
const avatar = m.personaAvatar ? '<img src="' + _msEsc(m.personaAvatar) + '" class="dropdown-avatar" alt="">' : '';
const handle = m.isPersona && m.personaHandle ? '<span class="item-handle">@' + _msEsc(m.personaHandle) + '</span>' : '';
const teamBadge = m.source === 'team' && m.teamName ? '<span class="badge-team" style="font-size:9px;padding:0 4px">\uD83D\uDC65 ' + _msEsc(m.teamName) + '</span>' : '';
const provider = m.provider ? '<span class="item-provider">' + _msEsc(m.provider) + '</span>' : '';
div.innerHTML = avatar + '<span class="item-label">' + _msEsc(m.name || m.id) + handle + '</span>' + teamBadge + provider;
div.addEventListener('click', () => {
self.select(m.id, (m.name || m.id) + (m.provider ? ' (' + m.provider + ')' : ''));
self.close();
});
return div;
},
// ── Restore selection ───────────────
/**
* Restore selection from a preferred ID, falling back through
* admin default → first visible.
*/
restore(preferredId, adminDefault) {
const visible = this._models.filter(m => !m.hidden);
if (visible.length === 0) { this.select('', 'No visible models'); return; }
const byId = (id) => visible.find(m => m.id === id || m.baseModelId === id);
const match = byId(preferredId) || byId(adminDefault) || visible[0];
this.select(match.id, (match.name || match.id) + (match.provider ? ' (' + match.provider + ')' : ''));
},
// ── Dropdown toggle ─────────────────
open() { if (this.menuEl) this.menuEl.classList.add('open'); },
close() { if (this.menuEl) this.menuEl.classList.remove('open'); },
toggle() { if (this.menuEl) this.menuEl.classList.toggle('open'); },
// ── Capability badges ───────────────
_updateCaps() {
if (!this.capsEl) return;
if (!this._value) { this.capsEl.innerHTML = ''; return; }
const m = this._models.find(m => m.id === this._value);
const caps = m?.capabilities;
if (!caps || Object.keys(caps).length === 0) { this.capsEl.innerHTML = ''; return; }
// Use global renderCapBadges if available (from ui-core.js)
if (typeof renderCapBadges === 'function') {
this.capsEl.innerHTML = renderCapBadges(caps);
}
},
getSelectedCaps() {
const m = this._models.find(m => m.id === this._value);
return m?.capabilities || {};
},
// ── Event wiring ────────────────────
bind() {
this._on(this.btnEl, 'click', (e) => {
e.stopPropagation();
this.toggle();
});
this._on(document, 'click', (e) => {
if (!e.target.closest('#' + pfx + 'modelDropdown')) this.close();
});
},
// ── Lifecycle ───────────────────────
destroy() {
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
this._listeners = [];
ModelSelector._instances.delete(this.id);
if (ModelSelector.primary === this) ModelSelector.primary = null;
},
_on(el, event, handler) {
if (!el) return;
el.addEventListener(event, handler);
this._listeners.push({ el, event, handler });
},
};
ModelSelector._instances.set(pfx, instance);
return instance;
},
get(id) { return this._instances.get(id) || null; },
};
// HTML-escape for model selector content
function _msEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }

450
src/js/note-editor.js Normal file
View File

@@ -0,0 +1,450 @@
// ==========================================
// Chat Switchboard — NoteEditor Component
// ==========================================
// v0.25.0: Component wrapper for notes list + editor.
// Hydrates the server-rendered note-editor.html partial.
// Can run standalone (in editor tabbed pane) or coexist with
// the existing notes.js panel system (on chat/notes surfaces).
//
// Usage:
// const notes = NoteEditor.create({
// id: 'main', // matches template prefix
// onOpenGraph: () => { ... },
// });
// notes.bind();
// await notes.loadNotes();
// notes.destroy();
//
// The existing notes.js + PanelRegistry integration continues to
// work on the chat surface. This component is for NEW mount points
// (editor pane, future notes-studio layout).
const NoteEditor = {
_instances: new Map(),
create(opts) {
const pfx = opts.id || 'main';
const instance = {
id: pfx,
rootEl: document.getElementById(pfx + 'NoteEditor'),
listViewEl: document.getElementById(pfx + 'NotesListView'),
editorViewEl: document.getElementById(pfx + 'NotesEditorView'),
graphViewEl: document.getElementById(pfx + 'NotesGraphView'),
listEl: document.getElementById(pfx + 'NotesList'),
searchEl: document.getElementById(pfx + 'NotesSearchInput'),
folderFilterEl: document.getElementById(pfx + 'NotesFolderFilter'),
sortEl: document.getElementById(pfx + 'NotesSortSelect'),
titleEl: document.getElementById(pfx + 'NoteEditorTitle'),
folderEl: document.getElementById(pfx + 'NoteEditorFolder'),
tagsEl: document.getElementById(pfx + 'NoteEditorTags'),
contentContainerEl: document.getElementById(pfx + 'NoteEditorContentContainer'),
readTitleEl: document.getElementById(pfx + 'NoteReadTitle'),
readMetaEl: document.getElementById(pfx + 'NoteReadMeta'),
readContentEl: document.getElementById(pfx + 'NoteReadContent'),
editModeEl: document.getElementById(pfx + 'NoteEditMode'),
readModeEl: document.getElementById(pfx + 'NoteReadMode'),
backlinksEl: document.getElementById(pfx + 'NoteBacklinks'),
backlinksListEl: document.getElementById(pfx + 'NoteBacklinksList'),
backlinksCountEl: document.getElementById(pfx + 'NoteBacklinksCount'),
onOpenGraph: opts.onOpenGraph || null,
_editingNoteId: null,
_currentNote: null,
_sort: 'updated_desc',
_cmEditor: null,
_listeners: [],
// ── View Switching ───────────────────
showList() {
if (this.listViewEl) this.listViewEl.style.display = '';
if (this.editorViewEl) this.editorViewEl.style.display = 'none';
if (this.graphViewEl) this.graphViewEl.style.display = 'none';
this._destroyCmEditor();
},
showEditor() {
if (this.listViewEl) this.listViewEl.style.display = 'none';
if (this.editorViewEl) this.editorViewEl.style.display = '';
if (this.graphViewEl) this.graphViewEl.style.display = 'none';
},
showGraph() {
if (this.listViewEl) this.listViewEl.style.display = 'none';
if (this.editorViewEl) this.editorViewEl.style.display = 'none';
if (this.graphViewEl) this.graphViewEl.style.display = '';
},
// ── Note List ───────────────────────
async loadNotes(folder, searchQuery) {
if (!this.listEl) return;
this.listEl.innerHTML = '<div class="notes-loading">Loading…</div>';
try {
let notes;
if (searchQuery) {
const resp = await API.searchNotes(searchQuery);
notes = resp.data || [];
} else {
const folderVal = folder || this.folderFilterEl?.value || '';
const resp = await API.listNotes(100, 0, folderVal, '', this._sort);
notes = resp.data || [];
}
if (notes.length === 0) {
this.listEl.innerHTML = '<div class="notes-empty">' +
(searchQuery ? 'No results found' : 'No notes yet') + '</div>';
return;
}
this.listEl.innerHTML = '';
const self = this;
notes.forEach(note => {
const item = document.createElement('div');
item.className = 'note-item';
item.dataset.noteId = note.id;
const title = note.title || 'Untitled';
const preview = (note.content || '').slice(0, 80).replace(/\n/g, ' ');
const date = note.updated_at ? new Date(note.updated_at).toLocaleDateString() : '';
item.innerHTML =
'<div class="note-content-col">' +
'<div class="note-title">' + _neEsc(title) + '</div>' +
'<div class="note-preview">' + _neEsc(preview) + '</div>' +
'<div class="note-date">' + _neEsc(date) + '</div>' +
'</div>';
item.addEventListener('click', () => self.openNote(note.id));
self.listEl.appendChild(item);
});
} catch (e) {
this.listEl.innerHTML = '<div class="notes-empty">Failed to load: ' + _neEsc(e.message) + '</div>';
}
},
async loadFolders() {
if (!this.folderFilterEl) return;
try {
const resp = await API.listNoteFolders();
const folders = resp.data || resp || [];
// Keep "All folders" option, rebuild the rest
this.folderFilterEl.innerHTML = '<option value="">All folders</option>';
folders.forEach(f => {
const opt = document.createElement('option');
opt.value = f;
opt.textContent = f;
this.folderFilterEl.appendChild(opt);
});
} catch (_) { /* ignore */ }
},
// ── Note CRUD ───────────────────────
async openNote(noteId) {
this.showEditor();
if (noteId) {
this._editingNoteId = noteId;
try {
this._currentNote = await API.getNote(noteId);
this._populateFields(this._currentNote);
this._showReadMode();
} catch (e) {
if (typeof UI !== 'undefined') UI.toast('Failed to load note: ' + e.message, 'error');
this.showList();
}
} else {
this._editingNoteId = null;
this._currentNote = null;
this._clearFields();
this._showEditMode();
if (this.titleEl) this.titleEl.focus();
}
},
async saveNote() {
const title = this.titleEl?.value?.trim() || '';
const content = this._getContent();
const folder = this.folderEl?.value?.trim() || '';
const tagsStr = this.tagsEl?.value || '';
const tags = tagsStr.split(',').map(t => t.trim()).filter(Boolean);
if (!title && !content) {
if (typeof UI !== 'undefined') UI.toast('Note is empty', 'warning');
return;
}
try {
if (this._editingNoteId) {
await API.updateNote(this._editingNoteId, { title, content, folder_path: folder, tags });
} else {
const resp = await API.createNote(title, content, folder, tags);
this._editingNoteId = resp.id || resp.data?.id;
}
this._currentNote = { ...this._currentNote, title, content, folder_path: folder, tags };
this._showReadMode();
if (typeof UI !== 'undefined') UI.toast('Note saved', 'success');
} catch (e) {
if (typeof UI !== 'undefined') UI.toast('Save failed: ' + e.message, 'error');
}
},
async deleteNote() {
if (!this._editingNoteId) return;
const ok = typeof showConfirm === 'function'
? await showConfirm('Delete this note?')
: window.confirm('Delete this note?');
if (!ok) return;
try {
await API.deleteNote(this._editingNoteId);
if (typeof UI !== 'undefined') UI.toast('Note deleted', 'success');
this._editingNoteId = null;
this._currentNote = null;
this.showList();
this.loadNotes();
} catch (e) {
if (typeof UI !== 'undefined') UI.toast('Delete failed: ' + e.message, 'error');
}
},
// ── Read / Edit / Preview Modes ─────
_showReadMode() {
if (this.editModeEl) this.editModeEl.style.display = 'none';
if (this.readModeEl) this.readModeEl.style.display = '';
const n = this._currentNote;
if (!n) return;
if (this.readTitleEl) this.readTitleEl.textContent = n.title || 'Untitled';
if (this.readMetaEl) {
const parts = [];
if (n.folder_path) parts.push('📁 ' + n.folder_path);
if (n.tags?.length) parts.push('🏷 ' + n.tags.join(', '));
if (n.updated_at) parts.push(new Date(n.updated_at).toLocaleString());
this.readMetaEl.textContent = parts.join(' · ');
}
if (this.readContentEl) {
if (typeof marked !== 'undefined') {
const raw = typeof marked.parse === 'function' ? marked.parse(n.content || '') : marked(n.content || '');
this.readContentEl.innerHTML = typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(raw) : raw;
} else {
this.readContentEl.textContent = n.content || '';
}
}
// Show edit/delete buttons for read mode
this._setBtn('NoteEditBtn', true);
this._setBtn('NotePreviewBtn', false);
this._setBtn('NoteCancelEditBtn', false);
this._setBtn('NoteDeleteBtn', true);
},
_showEditMode() {
if (this.editModeEl) this.editModeEl.style.display = '';
if (this.readModeEl) this.readModeEl.style.display = 'none';
this._setBtn('NoteEditBtn', false);
this._setBtn('NotePreviewBtn', true);
this._setBtn('NoteCancelEditBtn', !!this._currentNote);
this._setBtn('NoteDeleteBtn', !!this._editingNoteId);
},
_showPreview() {
// Toggle between edit and read-mode preview
if (this.readModeEl?.style.display === 'none') {
// Show preview of current edits
const tempNote = {
title: this.titleEl?.value || '',
content: this._getContent(),
folder_path: this.folderEl?.value || '',
tags: (this.tagsEl?.value || '').split(',').map(t => t.trim()).filter(Boolean),
};
const saved = this._currentNote;
this._currentNote = tempNote;
this._showReadMode();
this._currentNote = saved;
// Swap buttons for preview-of-edits state
this._setBtn('NoteEditBtn', true);
this._setBtn('NotePreviewBtn', false);
} else {
this._showEditMode();
}
},
// ── CM6 Editor Management ───────────
_getContent() {
if (this._cmEditor) return this._cmEditor.getValue();
const ta = this.contentContainerEl?.querySelector('textarea');
return ta ? ta.value : '';
},
_setContent(text) {
if (this._cmEditor) { this._cmEditor.setValue(text); return; }
// Lazy-init CM6
if (this.contentContainerEl && window.CM?.noteEditor) {
this.contentContainerEl.innerHTML = '';
this._cmEditor = CM.noteEditor(this.contentContainerEl, {
value: text,
darkMode: document.documentElement.getAttribute('data-theme') !== 'light',
onLink: (title) => this._navigateToLink(title),
linkCompleter: async (query) => {
if (!query || query.length < 1) return [];
try {
const resp = await API.searchNoteTitles(query, 8);
return (resp.data || []).map(n => ({ label: n.title, id: n.id }));
} catch { return []; }
},
});
return;
}
// Textarea fallback
const ta = this.contentContainerEl?.querySelector('textarea');
if (ta) ta.value = text;
},
_destroyCmEditor() {
if (this._cmEditor?.destroy) this._cmEditor.destroy();
this._cmEditor = null;
},
async _navigateToLink(title) {
try {
const resp = await API.searchNotes(title);
const notes = resp.data || [];
const match = notes.find(n => n.title?.toLowerCase() === title.toLowerCase());
if (match) {
this.openNote(match.id);
} else {
// Create new note with this title
this._editingNoteId = null;
this._currentNote = null;
this._clearFields();
if (this.titleEl) this.titleEl.value = title;
this._showEditMode();
}
} catch (_) {}
},
// ── Field Helpers ───────────────────
_populateFields(note) {
if (this.titleEl) this.titleEl.value = note.title || '';
if (this.folderEl) this.folderEl.value = note.folder_path || '';
if (this.tagsEl) this.tagsEl.value = (note.tags || []).join(', ');
this._setContent(note.content || '');
},
_clearFields() {
if (this.titleEl) this.titleEl.value = '';
if (this.folderEl) this.folderEl.value = '';
if (this.tagsEl) this.tagsEl.value = '';
this._setContent('');
},
_setBtn(suffix, show) {
const el = document.getElementById(pfx + suffix);
if (el) el.style.display = show ? '' : 'none';
},
// ── Event Wiring ────────────────────
bind() {
const $ = (id) => document.getElementById(pfx + id);
const self = this;
// Toolbar
this._on($('NotesNewBtn'), 'click', () => self.openNote(null));
this._on($('NotesTodayBtn'), 'click', () => self._openDailyNote());
this._on($('NotesGraphBtn'), 'click', () => {
if (self.onOpenGraph) self.onOpenGraph();
});
// Editor header
this._on($('NotesBackBtn'), 'click', () => { self.showList(); self.loadNotes(); self.loadFolders(); });
this._on($('NoteSaveBtn'), 'click', () => self.saveNote());
this._on($('NoteDeleteBtn'), 'click', () => self.deleteNote());
this._on($('NoteEditBtn'), 'click', () => self._showEditMode());
this._on($('NotePreviewBtn'), 'click', () => self._showPreview());
this._on($('NoteCancelEditBtn'), 'click', () => {
if (self._currentNote) { self._populateFields(self._currentNote); self._showReadMode(); }
else self.showList();
});
// Filters
this._on(this.folderFilterEl, 'change', () => {
if (self.searchEl) self.searchEl.value = '';
self.loadNotes(self.folderFilterEl.value);
});
this._on(this.sortEl, 'change', () => {
self._sort = self.sortEl.value;
self.loadNotes();
});
// Search with debounce
let timer;
this._on(this.searchEl, 'input', () => {
clearTimeout(timer);
const q = self.searchEl.value.trim();
timer = setTimeout(() => {
if (q.length >= 2) {
if (self.folderFilterEl) self.folderFilterEl.value = '';
self.loadNotes(null, q);
} else if (q.length === 0) {
self.loadNotes();
}
}, 300);
});
},
// ── Daily Note ──────────────────────
async _openDailyNote() {
const today = new Date().toISOString().slice(0, 10);
try {
const resp = await API.searchNotes(today);
const notes = resp.data || [];
const daily = notes.find(n => n.title === today || n.title?.startsWith(today));
if (daily) {
this.openNote(daily.id);
} else {
this._editingNoteId = null;
this._currentNote = null;
this._clearFields();
if (this.titleEl) this.titleEl.value = today;
this._setContent('# ' + today + '\n\n');
this.showEditor();
this._showEditMode();
}
} catch (e) {
if (typeof UI !== 'undefined') UI.toast('Failed to open daily note: ' + e.message, 'error');
}
},
// ── Lifecycle ───────────────────────
destroy() {
this._destroyCmEditor();
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
this._listeners = [];
this._editingNoteId = null;
this._currentNote = null;
NoteEditor._instances.delete(this.id);
},
_on(el, event, handler) {
if (!el) return;
el.addEventListener(event, handler);
this._listeners.push({ el, event, handler });
},
};
NoteEditor._instances.set(pfx, instance);
return instance;
},
get(id) { return this._instances.get(id) || null; },
};
function _neEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }

554
src/js/pane-container.js Normal file
View File

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

View File

@@ -9,7 +9,7 @@ const ADMIN_SECTIONS = {
people: ['users', 'teams', 'groups'],
ai: ['providers', 'models', 'personas', 'roles', 'knowledgeBases', 'memory'],
routing: ['health', 'routing', 'capabilities'],
system: ['settings', 'storage', 'extensions'],
system: ['settings', 'storage', 'extensions', 'channels', 'surfaces'],
monitoring: ['usage', 'audit', 'stats'],
};
@@ -17,7 +17,7 @@ const ADMIN_LABELS = {
users: 'Users', teams: 'Teams', groups: 'Groups',
providers: 'Providers', models: 'Models', personas: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge', memory: 'Memory',
health: 'Health', routing: 'Routing', capabilities: 'Capabilities',
settings: 'Settings', storage: 'Storage', extensions: 'Extensions',
settings: 'Settings', storage: 'Storage', extensions: 'Extensions', channels: 'Channels', surfaces: 'Surfaces',
usage: 'Usage', audit: 'Audit', stats: 'Stats',
};
@@ -41,6 +41,7 @@ const ADMIN_LOADERS = {
settings: () => UI.loadAdminSettings(),
storage: () => typeof loadAdminStorage === 'function' ? loadAdminStorage() : null,
extensions: () => UI.loadAdminExtensions(),
surfaces: () => typeof _loadAdminSurfaces === 'function' ? _loadAdminSurfaces() : null,
health: () => UI.loadAdminHealth(),
routing: () => UI.loadAdminRouting(),
capabilities: () => UI.loadAdminCapabilities(),

View File

@@ -78,6 +78,11 @@ function renderPersonaForm(containerEl, options = {}) {
<textarea id="${pfx}_memoryExtractionPrompt" rows="2" placeholder="Override the default extraction prompt for this persona..."></textarea>
</div>
</div>
<div class="form-group tool-grants-section" style="border-top:1px solid var(--border);padding-top:8px;margin-top:4px">
<label class="checkbox-label" style="font-weight:500"><input type="checkbox" id="${pfx}_toolsAll" checked> All tools (inherit from context)</label>
<p class="form-hint" style="margin:2px 0 6px">When unchecked, restrict this persona to only the selected tools below.</p>
<div id="${pfx}_toolsList" class="tool-grants-list" style="display:none;max-height:200px;overflow-y:auto;padding:4px 0"></div>
</div>
<div class="form-row">
<button class="btn-small btn-primary" id="${pfx}_submitBtn">Create</button>
<button class="btn-small" id="${pfx}_cancelBtn">Cancel</button>
@@ -127,6 +132,18 @@ function renderPersonaForm(containerEl, options = {}) {
if (options.onSubmit) options.onSubmit(form.getValues());
});
// v0.25.0: Tool grants — toggle "All tools" checkbox shows/hides tool list
const toolsAllCb = document.getElementById(`${pfx}_toolsAll`);
const toolsListEl = document.getElementById(`${pfx}_toolsList`);
if (toolsAllCb && toolsListEl) {
toolsAllCb.addEventListener('change', () => {
toolsListEl.style.display = toolsAllCb.checked ? 'none' : '';
if (!toolsAllCb.checked && toolsListEl.children.length === 0) {
form.loadToolList();
}
});
}
const form = {
getValues() {
const v = {
@@ -152,6 +169,14 @@ function renderPersonaForm(containerEl, options = {}) {
if (memCb) v.memory_enabled = memCb.checked;
const memPrompt = document.getElementById(`${pfx}_memoryExtractionPrompt`)?.value?.trim();
if (memPrompt) v.memory_extraction_prompt = memPrompt;
// v0.25.0: Tool grants
const toolsAllCb = document.getElementById(`${pfx}_toolsAll`);
if (toolsAllCb && !toolsAllCb.checked) {
const checked = document.querySelectorAll(`#${pfx}_toolsList input[type="checkbox"]:checked`);
v.tool_grants = Array.from(checked).map(cb => cb.value);
} else {
v.tool_grants = []; // empty = inherit all
}
return v;
},
setValues(p) {
@@ -168,6 +193,8 @@ function renderPersonaForm(containerEl, options = {}) {
// Memory fields (v0.18.0)
if (el('memoryEnabled')) el('memoryEnabled').checked = p.memory_enabled !== false;
if (el('memoryExtractionPrompt')) el('memoryExtractionPrompt').value = p.memory_extraction_prompt || '';
// v0.25.0: Tool grants — load and apply
if (p.id) form.loadToolGrants(p.id);
},
clearForm() {
['name','handle','desc','prompt','temp','maxTokens'].forEach(f => {
@@ -209,6 +236,63 @@ function renderPersonaForm(containerEl, options = {}) {
},
getModelSelect() { return document.getElementById(`${pfx}_model`); },
getConfigSelect() { return showConfig ? document.getElementById(`${pfx}_config`) : null; },
// v0.25.0: Tool grants management
async loadToolList() {
const listEl = document.getElementById(`${pfx}_toolsList`);
if (!listEl) return;
try {
const resp = await API._get('/api/v1/tools');
const tools = resp.tools || resp.data || resp || [];
listEl.innerHTML = '';
// Group by category
const groups = {};
tools.forEach(t => {
const cat = t.category || 'other';
(groups[cat] = groups[cat] || []).push(t);
});
Object.keys(groups).sort().forEach(cat => {
const hdr = document.createElement('div');
hdr.className = 'tool-grants-category';
hdr.textContent = cat.charAt(0).toUpperCase() + cat.slice(1);
listEl.appendChild(hdr);
groups[cat].forEach(t => {
const label = document.createElement('label');
label.className = 'tool-grants-item';
label.innerHTML = `<input type="checkbox" value="${t.name}"> ${t.display_name || t.name}`;
listEl.appendChild(label);
});
});
} catch (e) {
listEl.innerHTML = '<div class="form-hint">Failed to load tools</div>';
}
},
async loadToolGrants(personaId) {
const toolsAllCb = document.getElementById(`${pfx}_toolsAll`);
const listEl = document.getElementById(`${pfx}_toolsList`);
if (!toolsAllCb || !listEl) return;
try {
const resp = await API._get(`/api/v1/personas/${personaId}/tool-grants`);
const grants = resp.data || [];
if (grants.length === 0) {
toolsAllCb.checked = true;
listEl.style.display = 'none';
} else {
toolsAllCb.checked = false;
listEl.style.display = '';
await form.loadToolList();
const grantSet = new Set(grants);
listEl.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.checked = grantSet.has(cb.value);
});
}
} catch (_) {
// Endpoint may not exist yet — default to all tools
toolsAllCb.checked = true;
listEl.style.display = 'none';
}
},
};
return form;
}
@@ -247,8 +331,10 @@ const UI = {
},
restoreSidebar() {
const el = document.getElementById('sidebar');
if (!el) return; // not all surfaces have a sidebar (e.g. editor)
if (window.innerWidth <= 768 || localStorage.getItem('sb_sidebar') === '1') {
document.getElementById('sidebar').classList.add('collapsed');
el.classList.add('collapsed');
}
},
@@ -279,7 +365,8 @@ const UI = {
},
closeUserMenu() {
document.getElementById('userFlyout').classList.remove('open');
const el = document.getElementById('userFlyout');
if (el) el.classList.remove('open');
},
// ── Sidebar Section Collapse ──────────────
@@ -1191,6 +1278,7 @@ const UI = {
const letter = (name[0] || '?').toUpperCase();
const avatarEl = document.getElementById('userAvatar');
const letterEl = document.getElementById('avatarLetter');
if (!avatarEl || !letterEl) return; // not all surfaces have user menu DOM
// Show avatar image or initial letter
let existingImg = avatarEl.querySelector('.user-avatar-img');
if (API.user?.avatar) {
@@ -1214,11 +1302,13 @@ const UI = {
letterEl.style.display = '';
letterEl.textContent = letter;
}
document.getElementById('userName').textContent = name;
const nameEl = document.getElementById('userName');
if (nameEl) nameEl.textContent = name;
},
showAdminButton(show) {
document.getElementById('menuAdmin').style.display = show ? '' : 'none';
const adminEl = document.getElementById('menuAdmin');
if (adminEl) adminEl.style.display = show ? '' : 'none';
var dbg = document.getElementById('menuDebug');
if (dbg) dbg.style.display = show ? '' : 'none';
},
@@ -1402,5 +1492,43 @@ const UI = {
const el = document.getElementById('chatMessages');
el.scrollTop = el.scrollHeight;
}
},
// ── Appearance (scale + font) ────────────
// Shared across ALL surfaces. Previously in ui-settings.js
// which only loaded on chat/settings — editor surface was skipped.
applyAppearance(scale, msgFont) {
const z = scale === 100 ? '' : scale / 100;
// Zoom content areas — covers both chat and editor surfaces
document.querySelectorAll(
'.sidebar, .workspace-primary, .workspace-secondary, ' +
'.modal-overlay, .admin-panel, ' +
'.surface-editor, .surface-notes'
).forEach(el => el.style.zoom = z);
const splash = document.getElementById('splashGate');
if (splash) splash.style.zoom = z;
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');
localStorage.setItem('cs-appearance', JSON.stringify({ scale, msgFont }));
// Recheck tab overflow after layout settles
requestAnimationFrame(() => {
document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(t => {
if (typeof checkTabsOverflow === 'function') checkTabsOverflow(t);
});
});
},
restoreAppearance() {
try {
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
const scale = prefs.scale || 100;
const msgFont = prefs.msgFont || 14;
if (scale !== 100 || msgFont !== 14) {
UI.applyAppearance(scale, msgFont);
} else {
// Still set the CSS variable for default
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');
}
} catch (_) { /* corrupt localStorage — ignore */ }
}
};

View File

@@ -256,19 +256,7 @@ Object.assign(UI, {
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
},
applyAppearance(scale, msgFont) {
const z = scale === 100 ? '' : scale / 100;
// Zoom content areas + modals + panels (but NOT .app or banners)
document.querySelectorAll('.sidebar, .workspace-primary, .workspace-secondary, .modal-overlay, .admin-panel').forEach(el => el.style.zoom = z);
const splash = document.getElementById('splashGate');
if (splash) splash.style.zoom = z;
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');
localStorage.setItem('cs-appearance', JSON.stringify({ scale, msgFont }));
// Recheck tab overflow after layout settles with new zoom
requestAnimationFrame(() => {
document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(t => { if (typeof checkTabsOverflow === 'function') checkTabsOverflow(t); });
});
},
// applyAppearance — moved to ui-core.js (v0.25.0-cs11.1) so all surfaces can use it.
async checkUserProvidersAllowed() {
const notice = document.getElementById('userProvidersDisabled');

154
src/js/user-menu.js Normal file
View File

@@ -0,0 +1,154 @@
// ==========================================
// Chat Switchboard — UserMenu Component
// ==========================================
// v0.25.0: Extracted from chat.html + ui-core.js.
// Reusable across all authenticated surfaces.
// Pattern: Go template partial (user-menu.html) + JS factory (this file).
//
// Usage:
// const menu = UserMenu.create({ id: 'global' });
// menu.setUser({ display_name: 'Jeff', username: 'jeff', avatar: null });
// menu.showAdmin(true);
// menu.destroy();
const UserMenu = {
primary: null,
_instances: new Map(),
create(opts) {
const pfx = opts.id || '';
const instance = {
id: pfx,
btnEl: document.getElementById(pfx + 'userMenuBtn'),
avatarEl: document.getElementById(pfx + 'userAvatar'),
letterEl: document.getElementById(pfx + 'avatarLetter'),
nameEl: document.getElementById(pfx + 'userName'),
flyoutEl: document.getElementById(pfx + 'userFlyout'),
settingsEl: document.getElementById(pfx + 'menuSettings'),
adminEl: document.getElementById(pfx + 'menuAdmin'),
teamAdminEl: document.getElementById(pfx + 'menuTeamAdmin'),
debugEl: document.getElementById(pfx + 'menuDebug'),
signoutEl: document.getElementById(pfx + 'menuSignout'),
_open: false,
_listeners: [],
// ── User display ────────────────────
setUser(user) {
if (!user) return;
const name = user.display_name || user.username || 'User';
const letter = (name[0] || '?').toUpperCase();
// Avatar image or initial letter
if (this.avatarEl) {
let img = this.avatarEl.querySelector('.user-avatar-img');
if (user.avatar) {
if (this.letterEl) this.letterEl.style.display = 'none';
if (img) {
img.src = user.avatar;
} else {
img = document.createElement('img');
img.src = user.avatar;
img.alt = '';
img.className = 'user-avatar-img';
img.onerror = function() {
this.remove();
if (instance.letterEl) {
instance.letterEl.style.display = '';
instance.letterEl.textContent = letter;
}
};
this.avatarEl.insertBefore(img, this.letterEl);
}
} else {
if (img) img.remove();
if (this.letterEl) {
this.letterEl.style.display = '';
this.letterEl.textContent = letter;
}
}
}
if (this.nameEl) this.nameEl.textContent = name;
},
// ── Visibility controls ─────────────
showAdmin(show) {
if (this.adminEl) this.adminEl.style.display = show ? '' : 'none';
if (this.debugEl) this.debugEl.style.display = show ? '' : 'none';
},
showTeamAdmin(show) {
if (this.teamAdminEl) this.teamAdminEl.style.display = show ? '' : 'none';
},
// ── Flyout toggle ───────────────────
open() {
if (this.flyoutEl) this.flyoutEl.classList.add('open');
this._open = true;
},
close() {
if (this.flyoutEl) this.flyoutEl.classList.remove('open');
this._open = false;
},
toggle() {
if (this._open) this.close(); else this.open();
},
// ── Event wiring ────────────────────
/**
* Wire event handlers. Accepts callbacks for menu actions.
* Call once after create().
*/
bind(handlers) {
handlers = handlers || {};
// Toggle flyout on avatar click
this._on(this.btnEl, 'click', (e) => {
e.stopPropagation();
this.toggle();
});
// Close on outside click
this._on(document, 'click', (e) => {
if (this._open && !e.target.closest('#' + pfx + 'userMenuWrap')) {
this.close();
}
});
// Menu items
this._on(this.settingsEl, 'click', () => { this.close(); if (handlers.onSettings) handlers.onSettings(); });
this._on(this.adminEl, 'click', () => { this.close(); if (handlers.onAdmin) handlers.onAdmin(); });
this._on(this.teamAdminEl, 'click', () => { this.close(); if (handlers.onTeamAdmin) handlers.onTeamAdmin(); });
this._on(this.debugEl, 'click', () => { this.close(); if (handlers.onDebug) handlers.onDebug(); });
this._on(this.signoutEl, 'click', () => { this.close(); if (handlers.onSignout) handlers.onSignout(); });
},
// ── Lifecycle ───────────────────────
destroy() {
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
this._listeners = [];
UserMenu._instances.delete(this.id);
if (UserMenu.primary === this) UserMenu.primary = null;
},
// ── Internal ────────────────────────
_on(el, event, handler) {
if (!el) return;
el.addEventListener(event, handler);
this._listeners.push({ el, event, handler });
},
};
UserMenu._instances.set(pfx, instance);
return instance;
},
get(id) { return this._instances.get(id) || null; },
};