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

@@ -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{
@@ -191,14 +292,156 @@ func (e *Engine) RenderSurface(surfaceID string) gin.HandlerFunc {
}
e.Render(c, "base.html", PageData{
Surface: surfaceID,
Section: section,
User: user,
Data: data,
Surface: surfaceID,
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;">
<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>
</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">
<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>
</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">
{{/* 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 class="editor-tree-files" id="editorTreeFiles">
{{/* Populated by editor-mode.js */}}
</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 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 id="editorBranchName" class="editor-topbar-branch-text">main</span>
</div>
<div style="flex:1;"></div>
<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>
{{template "user-menu" dict "ID" ""}}
</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>
{{/* ── 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-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 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>
{{/* ── 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
}