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

@@ -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}}