Changeset 0.25.0 (#160)
This commit is contained in:
1288
docs/ROADMAP.md
1288
docs/ROADMAP.md
File diff suppressed because it is too large
Load Diff
350
docs/SURFACES.md
Normal file
350
docs/SURFACES.md
Normal file
@@ -0,0 +1,350 @@
|
||||
# Surface Development Guide
|
||||
|
||||
**Version:** 0.25.0
|
||||
**Audience:** Anyone with admin access to a Chat Switchboard instance
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
A surface is a full-screen application mode in Chat Switchboard. The platform ships with core surfaces (Chat, Notes, Settings, Admin, Workflow) that cannot be uninstalled. Extension surfaces are uploaded as `.surface` archives via the Admin panel and can be enabled, disabled, and uninstalled at runtime.
|
||||
|
||||
This guide covers everything needed to build, package, install, and manage surfaces using only a running instance — no access to the Go codebase required.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
Create a minimal surface in under 5 minutes:
|
||||
|
||||
### 1. Create the files
|
||||
|
||||
**manifest.json:**
|
||||
```json
|
||||
{
|
||||
"id": "hello",
|
||||
"version": "1.0.0",
|
||||
"title": "Hello World",
|
||||
"description": "Minimal surface example",
|
||||
"route": "/hello",
|
||||
"auth": "authenticated",
|
||||
"scripts": ["js/hello.js"],
|
||||
"styles": ["css/hello.css"],
|
||||
"source": "extension"
|
||||
}
|
||||
```
|
||||
|
||||
**js/hello.js:**
|
||||
```javascript
|
||||
(function() {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (window.__SURFACE__ !== 'hello') return;
|
||||
|
||||
var root = document.getElementById('surfaceRoot');
|
||||
if (!root) return;
|
||||
|
||||
root.innerHTML =
|
||||
'<div style="display:flex;flex-direction:column;height:100%;background:var(--bg);color:var(--text);font-family:inherit;">' +
|
||||
'<div style="display:flex;align-items:center;gap:8px;padding:0 12px;height:40px;background:var(--bg-secondary);border-bottom:1px solid var(--border);position:relative;z-index:20;">' +
|
||||
'<a href="' + (window.__BASE__ || '') + '/" style="color:var(--text-3);text-decoration:none;font-size:12px;">← Back</a>' +
|
||||
'<span style="font-size:13px;font-weight:600;">Hello World</span>' +
|
||||
'</div>' +
|
||||
'<div style="flex:1;display:flex;align-items:center;justify-content:center;">' +
|
||||
'<div style="text-align:center;">' +
|
||||
'<div style="font-size:48px;margin-bottom:16px;">👋</div>' +
|
||||
'<h1 style="font-size:24px;font-weight:600;margin:0 0 8px;">Hello from a Surface</h1>' +
|
||||
'<p style="color:var(--text-3);margin:0;" id="helloStatus">Waiting for app init…</p>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
document.addEventListener('sb:ready', function() {
|
||||
var el = document.getElementById('helloStatus');
|
||||
if (el) {
|
||||
el.textContent = 'Logged in as ' + (API.user?.username || 'unknown') +
|
||||
' | ' + (App.models?.length || 0) + ' models loaded';
|
||||
}
|
||||
}, { once: true });
|
||||
});
|
||||
})();
|
||||
```
|
||||
|
||||
**css/hello.css:**
|
||||
```css
|
||||
/* Empty for minimal example. Real surfaces use CSS classes with var(--*) tokens. */
|
||||
```
|
||||
|
||||
### 2. Package
|
||||
|
||||
```bash
|
||||
zip -r hello.surface manifest.json js/ css/
|
||||
```
|
||||
|
||||
### 3. Install
|
||||
|
||||
**Admin → System → Surfaces → + Install Surface**. Upload `hello.surface`.
|
||||
|
||||
### 4. Visit
|
||||
|
||||
Navigate to `https://your-instance/hello`.
|
||||
|
||||
---
|
||||
|
||||
## How Surface Loading Works
|
||||
|
||||
```
|
||||
Browser requests /hello
|
||||
→ Router matches route from surface_registry
|
||||
→ Auth middleware runs (based on manifest "auth" field)
|
||||
→ base.html renders with:
|
||||
- Common CSS (variables, layout, components)
|
||||
- Common JS (api.js, events.js, ui-core.js, all component factories)
|
||||
- Your surface CSS and JS from manifest
|
||||
- app.js (boot script)
|
||||
→ DOMContentLoaded fires:
|
||||
1. Your surface JS runs — build DOM, create components
|
||||
2. app.js init() runs — auth, settings, models, WebSocket
|
||||
3. 'sb:ready' CustomEvent fires when init is complete
|
||||
```
|
||||
|
||||
### Critical: Boot Timing
|
||||
|
||||
Your JS runs BEFORE app init completes.
|
||||
|
||||
| Available at DOMContentLoaded | Available at sb:ready |
|
||||
|---|---|
|
||||
| `window.__SURFACE__`, `window.__BASE__` | `App.models`, `App.settings` |
|
||||
| All component factories | `API.user`, `API.isAdmin` |
|
||||
| DOM ready | `Events` WebSocket connected |
|
||||
|
||||
**Rule:** Anything needing models, user info, or settings goes in `sb:ready`:
|
||||
|
||||
```javascript
|
||||
document.addEventListener('sb:ready', function() {
|
||||
// Safe to use App.models, API.user, App.settings
|
||||
}, { once: true });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## manifest.json Reference
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `id` | Yes | Unique identifier. Lowercase, no spaces. |
|
||||
| `title` | Yes | Display name in admin panel and nav. |
|
||||
| `version` | No | Semver for display. |
|
||||
| `description` | No | Short description for admin. |
|
||||
| `route` | No | URL pattern. Gin params: `/thing/:id`. Defaults to `/{id}`. |
|
||||
| `alt_routes` | No | Additional URL patterns. |
|
||||
| `auth` | No | `"authenticated"` (default), `"admin"`, `"session"`, `"public"`. |
|
||||
| `scripts` | No | JS files relative to archive root. |
|
||||
| `styles` | No | CSS files relative to archive root. |
|
||||
| `components` | No | Component IDs used (documentation only). |
|
||||
| `source` | No | Always `"extension"` for uploaded surfaces. |
|
||||
|
||||
---
|
||||
|
||||
## .surface Archive Format
|
||||
|
||||
```
|
||||
my-surface.surface (ZIP file)
|
||||
├── manifest.json ← Required
|
||||
├── js/
|
||||
│ └── my-surface.js ← Boot script(s)
|
||||
├── css/
|
||||
│ └── my-surface.css ← Styles
|
||||
└── assets/ ← Optional: images, fonts
|
||||
```
|
||||
|
||||
Standard ZIP. `.surface` extension is conventional; `.zip` also accepted.
|
||||
|
||||
---
|
||||
|
||||
## CSS Theme Tokens
|
||||
|
||||
Use these instead of hardcoded colors:
|
||||
|
||||
```css
|
||||
/* Backgrounds */
|
||||
var(--bg) var(--bg-secondary) var(--bg-tertiary) var(--bg-raised) var(--bg-hover)
|
||||
|
||||
/* Text */
|
||||
var(--text) var(--text-2) var(--text-3)
|
||||
|
||||
/* Accent */
|
||||
var(--accent) var(--accent-dim) var(--accent-hover)
|
||||
|
||||
/* Borders */
|
||||
var(--border) var(--border-light)
|
||||
|
||||
/* Semantic */
|
||||
var(--success) var(--warning) var(--danger) var(--purple)
|
||||
var(--success-dim) var(--warning-dim) var(--danger-dim) var(--purple-dim)
|
||||
|
||||
/* Type & Layout */
|
||||
var(--mono) var(--msg-font) var(--radius) var(--radius-lg) var(--transition)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reusable Components
|
||||
|
||||
All loaded by `base.html` on every surface. No imports needed.
|
||||
|
||||
### UserMenu
|
||||
|
||||
```javascript
|
||||
// Build DOM into a container
|
||||
container.innerHTML =
|
||||
'<div class="user-menu-wrap" id="userMenuWrap">' +
|
||||
'<button id="userMenuBtn" class="user-btn">' +
|
||||
'<div id="userAvatar" class="user-avatar"><span id="avatarLetter">?</span></div>' +
|
||||
'<span id="userName" class="sb-label">User</span>' +
|
||||
'</button>' +
|
||||
'<div id="userFlyout" class="user-flyout">' +
|
||||
'<button id="menuSettings" class="flyout-item">Settings</button>' +
|
||||
'<button id="menuAdmin" class="flyout-item" style="display:none">Admin</button>' +
|
||||
'<button id="menuDebug" class="flyout-item" style="display:none">Debug</button>' +
|
||||
'<hr class="flyout-divider">' +
|
||||
'<button id="menuSignout" class="flyout-item flyout-danger">Sign Out</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
// Wire after auth (sb:ready)
|
||||
var menu = UserMenu.create({ id: '' });
|
||||
menu.setUser(API.user);
|
||||
menu.bind({
|
||||
onSettings: function() { location.href = (window.__BASE__ || '') + '/settings'; },
|
||||
onAdmin: function() { location.href = (window.__BASE__ || '') + '/admin'; },
|
||||
onSignout: function() { if (typeof handleLogout === 'function') handleLogout(); },
|
||||
});
|
||||
menu.showAdmin(API.isAdmin);
|
||||
```
|
||||
|
||||
### ChatPane
|
||||
|
||||
```javascript
|
||||
// Build DOM
|
||||
slot.innerHTML =
|
||||
'<div class="chat-pane" id="xChatPane">' +
|
||||
'<div class="chat-pane-messages" id="xChatMessages"></div>' +
|
||||
'<div class="chat-pane-input-bar">' +
|
||||
'<div class="chat-pane-input-wrap">' +
|
||||
'<div class="chat-pane-input" id="xChatInput"></div>' +
|
||||
'<button class="chat-pane-send" id="xSendBtn">Send</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
// Create instance
|
||||
var pane = ChatPane.create({
|
||||
id: 'x',
|
||||
messagesEl: document.getElementById('xChatMessages'),
|
||||
inputEl: document.getElementById('xChatInput'),
|
||||
sendBtnEl: document.getElementById('xSendBtn'),
|
||||
standalone: true,
|
||||
});
|
||||
pane.showWelcome();
|
||||
```
|
||||
|
||||
### PaneContainer
|
||||
|
||||
```javascript
|
||||
PaneContainer.registerPreset('my-layout', {
|
||||
type: 'split', direction: 'horizontal',
|
||||
sizes: [250, null, 350],
|
||||
children: [
|
||||
{ type: 'leaf', id: 'nav', size: 250, minSize: 150 },
|
||||
{ type: 'leaf', id: 'main' }, // flex pane (no size)
|
||||
{ type: 'tabbed', id: 'assist', size: 350, minSize: 200,
|
||||
tabs: [{ id: 'chat', label: 'Chat' }, { id: 'tools', label: 'Tools' }]
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
var layout = PaneContainer.mount(document.getElementById('body'), 'my-layout');
|
||||
var mainEl = layout._panes.get('main').el;
|
||||
var chatTab = layout._panes.get('assist').getTabPanel('chat');
|
||||
```
|
||||
|
||||
**Rules:** One child per split must omit `size` (flex pane). Drag handles cap at 60%. Sizes persist in localStorage.
|
||||
|
||||
---
|
||||
|
||||
## Making API Calls
|
||||
|
||||
```javascript
|
||||
// CRUD
|
||||
var data = await API._get('/api/v1/channels');
|
||||
var resp = await API._post('/api/v1/channels', { title: 'New', type: 'direct' });
|
||||
await API._put('/api/v1/channels/' + id, { title: 'Updated' });
|
||||
await API._del('/api/v1/channels/' + id);
|
||||
|
||||
// Streaming (SSE)
|
||||
var resp = await API.streamCompletion(channelId, content, model, abortSignal);
|
||||
var reader = resp.body.getReader();
|
||||
```
|
||||
|
||||
Auth tokens and base path are handled automatically.
|
||||
|
||||
---
|
||||
|
||||
## Admin: Surface Management
|
||||
|
||||
**Location:** Admin → System → Surfaces
|
||||
|
||||
| Action | Description |
|
||||
|---|---|
|
||||
| **+ Install Surface** | Upload `.surface` or `.zip` archive |
|
||||
| **Toggle** | Enable/disable. Disabled surfaces redirect to Chat. |
|
||||
| **Uninstall** | Remove extension surface (files + DB entry). |
|
||||
|
||||
**Protected:** Chat and Admin toggles are locked — cannot be disabled.
|
||||
|
||||
### API
|
||||
|
||||
```bash
|
||||
# List
|
||||
curl -H "$AUTH" "$BASE/api/v1/admin/surfaces"
|
||||
|
||||
# Install
|
||||
curl -H "$AUTH" -F "file=@hello.surface" "$BASE/api/v1/admin/surfaces/install"
|
||||
|
||||
# Disable / Enable
|
||||
curl -X PUT -H "$AUTH" "$BASE/api/v1/admin/surfaces/editor/disable"
|
||||
curl -X PUT -H "$AUTH" "$BASE/api/v1/admin/surfaces/editor/enable"
|
||||
|
||||
# Uninstall
|
||||
curl -X DELETE -H "$AUTH" "$BASE/api/v1/admin/surfaces/hello"
|
||||
|
||||
# Public: list enabled (for nav)
|
||||
curl -H "$AUTH" "$BASE/api/v1/surfaces"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] `manifest.json` has `id` and `title`
|
||||
- [ ] Boot script checks `window.__SURFACE__` guard
|
||||
- [ ] DOM built in JS (no Go template dependency)
|
||||
- [ ] Deferred init via `sb:ready`
|
||||
- [ ] Topbar: `position: relative; z-index: 20`
|
||||
- [ ] All colors use `var(--*)` tokens
|
||||
- [ ] Back link: `window.__BASE__ + '/'`
|
||||
- [ ] Graceful with 0 models
|
||||
- [ ] Archive: only `manifest.json`, `js/`, `css/`, `assets/`
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| Surface doesn't appear after install | Routes registered at startup | Restart server or toggle disable/enable |
|
||||
| "Cannot overwrite core surface" | ID conflicts with chat/notes/etc. | Choose different ID |
|
||||
| `API is undefined` | Code runs before app init | Move to `sb:ready` listener |
|
||||
| No styles applied | CSS path mismatch | Check manifest `styles` matches archive structure |
|
||||
| Flyout hidden behind content | Missing stacking context | Add `z-index: 20` to topbar |
|
||||
| Drag handle snaps | v0.25.0 known issue | Sub-pixel rounding; partially mitigated |
|
||||
17
server/database/migrations/022_surfaces.sql
Normal file
17
server/database/migrations/022_surfaces.sql
Normal 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';
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) ──
|
||||
|
||||
@@ -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
297
server/handlers/surfaces.go
Normal 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})
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -39,9 +39,28 @@ type Engine struct {
|
||||
cfg *config.Config
|
||||
stores store.Stores
|
||||
loaders map[string]DataLoaderFunc
|
||||
surfaces []SurfaceManifest // v0.25.0: registered surface definitions
|
||||
devMode bool
|
||||
}
|
||||
|
||||
// SurfaceManifest describes a surface's registration. Core surfaces are
|
||||
// registered in Go at startup. Extension surfaces will be registered from
|
||||
// manifest files (future).
|
||||
type SurfaceManifest struct {
|
||||
ID string `json:"id"` // unique identifier: "chat", "editor", "my-dashboard"
|
||||
Route string `json:"route"` // primary URL pattern: "/", "/editor/:wsId"
|
||||
AltRoutes []string `json:"alt_routes"` // additional URL patterns: ["/chat/:chatID"]
|
||||
Title string `json:"title"` // human-readable: "Chat", "Editor"
|
||||
Template string `json:"template"` // Go template name: "surface-chat", "surface-editor"
|
||||
Components []string `json:"components"` // component IDs used: ["chat-pane", "file-tree"]
|
||||
DataRequires []string `json:"data_requires"` // data loader keys: ["workspace", "models"]
|
||||
Scripts []string `json:"scripts"` // JS files (surface-specific, beyond base.html common)
|
||||
Styles []string `json:"styles"` // CSS files (surface-specific)
|
||||
Auth string `json:"auth"` // "authenticated", "public", "session", "admin"
|
||||
Layout string `json:"layout"` // default pane layout preset: "single", "editor"
|
||||
Source string `json:"source"` // "core" or "extension"
|
||||
}
|
||||
|
||||
// BannerConfig holds environment banner settings.
|
||||
type BannerConfig struct {
|
||||
Text string `json:"text"`
|
||||
@@ -66,6 +85,12 @@ type PageData struct {
|
||||
Theme string // "dark", "light", or "" (= use default)
|
||||
SurfaceSettings any // JSON-serialized to window.__SETTINGS__
|
||||
|
||||
// v0.25.0: Surface manifest — layout preset, components, etc.
|
||||
Manifest *SurfaceManifest `json:"manifest,omitempty"`
|
||||
|
||||
// v0.25.0: Enabled surface IDs for conditional nav rendering.
|
||||
EnabledSurfaces []string `json:"-"`
|
||||
|
||||
// v0.22.7: Login/splash page fields
|
||||
InstanceName string // branding: instance display name
|
||||
LogoURL string // branding: custom logo URL
|
||||
@@ -74,6 +99,17 @@ type PageData struct {
|
||||
AuthMode string // v0.24.1: "builtin", "mtls", "oidc"
|
||||
}
|
||||
|
||||
// SurfaceEnabled returns true if the given surface ID is in the EnabledSurfaces list.
|
||||
// Used by templates: {{if .SurfaceEnabled "editor"}}
|
||||
func (pd PageData) SurfaceEnabled(id string) bool {
|
||||
for _, s := range pd.EnabledSurfaces {
|
||||
if s == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// UserContext is the authenticated user's info available to templates.
|
||||
type UserContext struct {
|
||||
ID string `json:"id"`
|
||||
@@ -96,9 +132,74 @@ func New(cfg *config.Config, stores store.Stores) *Engine {
|
||||
}
|
||||
e.parseTemplates()
|
||||
e.registerLoaders()
|
||||
e.registerCoreSurfaces()
|
||||
e.SeedSurfaces() // v0.25.0: Write core manifests to DB (preserves admin toggle state)
|
||||
return e
|
||||
}
|
||||
|
||||
// registerCoreSurfaces populates the surface manifest list with the five
|
||||
// core surfaces. Extension surfaces will be appended at startup from
|
||||
// extension manifests (future).
|
||||
func (e *Engine) registerCoreSurfaces() {
|
||||
e.surfaces = []SurfaceManifest{
|
||||
{
|
||||
ID: "chat", Route: "/", AltRoutes: []string{"/chat/:chatID"},
|
||||
Title: "Chat", Template: "surface-chat", Auth: "authenticated",
|
||||
Components: []string{"chat-pane", "model-selector", "user-menu"},
|
||||
DataRequires: []string{"chat"},
|
||||
Layout: "single", Source: "core",
|
||||
},
|
||||
{
|
||||
ID: "editor", Route: "/editor/:wsId", AltRoutes: []string{"/editor"},
|
||||
Title: "Editor", Template: "surface-editor", Auth: "authenticated",
|
||||
Components: []string{"file-tree", "code-editor", "chat-pane", "note-editor", "model-selector"},
|
||||
DataRequires: []string{"editor"},
|
||||
Layout: "editor", Source: "extension", // Dogfood: editor is the first non-core surface
|
||||
},
|
||||
{
|
||||
ID: "notes", Route: "/notes", AltRoutes: []string{"/notes/:noteId"},
|
||||
Title: "Notes", Template: "surface-notes", Auth: "authenticated",
|
||||
Components: []string{"note-editor", "chat-pane"},
|
||||
DataRequires: []string{"notes"},
|
||||
Layout: "single", Source: "core",
|
||||
},
|
||||
{
|
||||
ID: "admin", Route: "/admin/:section", AltRoutes: []string{"/admin"},
|
||||
Title: "Admin", Template: "surface-admin", Auth: "admin",
|
||||
DataRequires: []string{"admin"},
|
||||
Layout: "single", Source: "core",
|
||||
},
|
||||
{
|
||||
ID: "settings", Route: "/settings/:section", AltRoutes: []string{"/settings"},
|
||||
Title: "Settings", Template: "surface-settings", Auth: "authenticated",
|
||||
DataRequires: []string{"settings"},
|
||||
Layout: "single", Source: "core",
|
||||
},
|
||||
{
|
||||
ID: "workflow", Route: "/w/:id",
|
||||
Title: "Workflow", Template: "workflow", Auth: "session",
|
||||
Components: []string{"chat-pane"},
|
||||
DataRequires: []string{"workflow"},
|
||||
Layout: "single", Source: "core",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetSurface returns the manifest for a surface by ID, or nil if not found.
|
||||
func (e *Engine) GetSurface(id string) *SurfaceManifest {
|
||||
for i := range e.surfaces {
|
||||
if e.surfaces[i].ID == id {
|
||||
return &e.surfaces[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Surfaces returns all registered surface manifests.
|
||||
func (e *Engine) Surfaces() []SurfaceManifest {
|
||||
return e.surfaces
|
||||
}
|
||||
|
||||
// parseTemplates compiles all embedded templates with the custom FuncMap.
|
||||
func (e *Engine) parseTemplates() {
|
||||
funcMap := template.FuncMap{
|
||||
@@ -195,10 +296,152 @@ func (e *Engine) RenderSurface(surfaceID string) gin.HandlerFunc {
|
||||
Section: section,
|
||||
User: user,
|
||||
Data: data,
|
||||
Manifest: e.GetSurface(surfaceID),
|
||||
EnabledSurfaces: e.EnabledSurfaceIDs(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// PageRouteMiddleware holds middleware handlers for each auth level.
|
||||
// Passed to RegisterPageRoutes by main.go.
|
||||
type PageRouteMiddleware struct {
|
||||
Authenticated gin.HandlerFunc // AuthOrRedirect — for chat, editor, notes, settings
|
||||
Admin []gin.HandlerFunc // AuthOrRedirect + RequireAdminPage
|
||||
Session gin.HandlerFunc // AuthOrSession — for workflow
|
||||
}
|
||||
|
||||
// RegisterPageRoutes generates Gin routes from the registered surface manifests.
|
||||
// Replaces manual per-surface route blocks in main.go.
|
||||
//
|
||||
// Groups surfaces by auth level to avoid redundant middleware chains.
|
||||
// Workflow surfaces use RenderWorkflow() (separate template + data model).
|
||||
// All other surfaces use RenderSurface().
|
||||
func (e *Engine) RegisterPageRoutes(base *gin.RouterGroup, mw PageRouteMiddleware) {
|
||||
// Group surfaces by auth type
|
||||
byAuth := map[string][]SurfaceManifest{}
|
||||
for _, s := range e.surfaces {
|
||||
byAuth[s.Auth] = append(byAuth[s.Auth], s)
|
||||
}
|
||||
|
||||
// Authenticated surfaces — single middleware group
|
||||
if surfaces, ok := byAuth["authenticated"]; ok && mw.Authenticated != nil {
|
||||
group := base.Group("")
|
||||
group.Use(mw.Authenticated)
|
||||
for _, s := range surfaces {
|
||||
if !e.IsSurfaceEnabled(s.ID) {
|
||||
// Disabled surface → redirect to chat
|
||||
registerRoutes(group, s, e.disabledRedirect())
|
||||
continue
|
||||
}
|
||||
handler := e.RenderSurface(s.ID)
|
||||
registerRoutes(group, s, handler)
|
||||
}
|
||||
}
|
||||
|
||||
// Admin surfaces — auth + admin role middleware
|
||||
if surfaces, ok := byAuth["admin"]; ok && len(mw.Admin) > 0 {
|
||||
for _, s := range surfaces {
|
||||
prefix := routePrefix(s.Route)
|
||||
group := base.Group(prefix)
|
||||
for _, m := range mw.Admin {
|
||||
group.Use(m)
|
||||
}
|
||||
// Admin surfaces are always enabled (needed to manage other surfaces)
|
||||
handler := e.RenderSurface(s.ID)
|
||||
registerRoutesRelative(group, prefix, s, handler)
|
||||
}
|
||||
}
|
||||
|
||||
// Session surfaces (workflow) — session middleware
|
||||
if surfaces, ok := byAuth["session"]; ok && mw.Session != nil {
|
||||
for _, s := range surfaces {
|
||||
if !e.IsSurfaceEnabled(s.ID) {
|
||||
prefix := routePrefix(s.Route)
|
||||
group := base.Group(prefix)
|
||||
group.Use(mw.Session)
|
||||
registerRoutesRelative(group, prefix, s, e.disabledRedirect())
|
||||
continue
|
||||
}
|
||||
prefix := routePrefix(s.Route)
|
||||
group := base.Group(prefix)
|
||||
group.Use(mw.Session)
|
||||
handler := e.surfaceHandler(s)
|
||||
registerRoutesRelative(group, prefix, s, handler)
|
||||
}
|
||||
}
|
||||
|
||||
// Public surfaces — no middleware
|
||||
if surfaces, ok := byAuth["public"]; ok {
|
||||
for _, s := range surfaces {
|
||||
if !e.IsSurfaceEnabled(s.ID) {
|
||||
registerRoutes(base, s, e.disabledRedirect())
|
||||
continue
|
||||
}
|
||||
handler := e.RenderSurface(s.ID)
|
||||
registerRoutes(base, s, handler)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[pages] Registered routes for %d surfaces", len(e.surfaces))
|
||||
}
|
||||
|
||||
// surfaceHandler returns the appropriate Gin handler for a surface.
|
||||
func (e *Engine) surfaceHandler(s SurfaceManifest) gin.HandlerFunc {
|
||||
if s.ID == "workflow" {
|
||||
return e.RenderWorkflow()
|
||||
}
|
||||
return e.RenderSurface(s.ID)
|
||||
}
|
||||
|
||||
// registerRoutes adds the primary route and alt routes for a surface.
|
||||
// Used for surfaces in the root group (authenticated).
|
||||
func registerRoutes(group *gin.RouterGroup, s SurfaceManifest, handler gin.HandlerFunc) {
|
||||
group.GET(s.Route, handler)
|
||||
for _, alt := range s.AltRoutes {
|
||||
if alt != s.Route {
|
||||
group.GET(alt, handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// registerRoutesRelative adds routes relative to a group prefix.
|
||||
// Used for admin/session surfaces with their own prefix group.
|
||||
// "/admin/:section" with prefix "/admin" → "/:section"
|
||||
func registerRoutesRelative(group *gin.RouterGroup, prefix string, s SurfaceManifest, handler gin.HandlerFunc) {
|
||||
rel := stripPrefix(s.Route, prefix)
|
||||
group.GET(rel, handler)
|
||||
for _, alt := range s.AltRoutes {
|
||||
r := stripPrefix(alt, prefix)
|
||||
if r != rel {
|
||||
group.GET(r, handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// routePrefix extracts the first path segment as a group prefix.
|
||||
// "/admin/:section" → "/admin", "/w/:id" → "/w", "/" → ""
|
||||
func routePrefix(route string) string {
|
||||
trimmed := strings.TrimPrefix(route, "/")
|
||||
parts := strings.SplitN(trimmed, "/", 2)
|
||||
if len(parts) == 0 || parts[0] == "" || strings.HasPrefix(parts[0], ":") {
|
||||
return ""
|
||||
}
|
||||
return "/" + parts[0]
|
||||
}
|
||||
|
||||
// stripPrefix removes a prefix from a route path. Returns "" if the
|
||||
// entire path IS the prefix (which Gin interprets as the group root).
|
||||
func stripPrefix(route, prefix string) string {
|
||||
if prefix == "" {
|
||||
return route
|
||||
}
|
||||
rel := strings.TrimPrefix(route, prefix)
|
||||
if rel == "" {
|
||||
rel = ""
|
||||
}
|
||||
return rel
|
||||
}
|
||||
|
||||
// RenderLogin serves the standalone login page.
|
||||
func (e *Engine) RenderLogin() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
70
server/pages/pages_surfaces.go
Normal file
70
server/pages/pages_surfaces.go
Normal 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+"/")
|
||||
}
|
||||
}
|
||||
@@ -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}}
|
||||
|
||||
@@ -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">
|
||||
|
||||
30
server/pages/templates/components/code-editor.html
Normal file
30
server/pages/templates/components/code-editor.html
Normal 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}}
|
||||
20
server/pages/templates/components/file-tree.html
Normal file
20
server/pages/templates/components/file-tree.html
Normal 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}}
|
||||
19
server/pages/templates/components/model-selector.html
Normal file
19
server/pages/templates/components/model-selector.html
Normal 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}}
|
||||
86
server/pages/templates/components/note-editor.html
Normal file
86
server/pages/templates/components/note-editor.html
Normal 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}}
|
||||
40
server/pages/templates/components/user-menu.html
Normal file
40
server/pages/templates/components/user-menu.html
Normal 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}}
|
||||
@@ -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}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,127 +1,104 @@
|
||||
{{/*
|
||||
Editor surface — matches switchboard-prototype-editor.jsx.
|
||||
IDE-like layout: topbar + file tree + tabs + code + chat pane.
|
||||
editor-mode.js builds CodeMirror, file tree, etc.
|
||||
Editor surface — v0.25.0 rebuild.
|
||||
Three-pane layout: files | code-editor | <chat, notes>
|
||||
Uses PaneContainer for resizable splits and tabbed right pane.
|
||||
|
||||
Components are SERVER-RENDERED via Go template partials into hidden
|
||||
containers (#editorComponents). The boot script moves them into
|
||||
pane slots created by PaneContainer. This ensures full DOM parity
|
||||
with the chat surface — same buttons, same features, same behavior.
|
||||
*/}}
|
||||
|
||||
{{define "surface-editor"}}
|
||||
<div class="surface-editor">
|
||||
<div class="surface-editor" id="editorSurface">
|
||||
|
||||
{{/* Top Bar */}}
|
||||
<div class="editor-topbar">
|
||||
<a href="{{.BasePath}}/" class="editor-topbar-back">
|
||||
{{/* ── Top Bar ─────────────────────────── */}}
|
||||
<div class="editor-topbar" id="editorTopbar">
|
||||
<a href="{{.BasePath}}/" class="editor-topbar-back" title="Back to chat">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||
Back
|
||||
</a>
|
||||
<div style="width:1px;height:18px;background:var(--border);"></div>
|
||||
<div class="editor-topbar-sep"></div>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent);"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||||
<span style="font-size:13px;font-weight:600;" id="editorWorkspaceName">
|
||||
{{if .Data}}{{.Data.WorkspaceName}}{{else}}Editor{{end}}
|
||||
</span>
|
||||
<div style="display:flex;align-items:center;gap:4px;background:var(--purple-dim);padding:2px 8px;border-radius:4px;">
|
||||
|
||||
{{/* Workspace selector dropdown */}}
|
||||
<div class="editor-ws-selector" id="editorWsSelector">
|
||||
<button class="editor-ws-selector-btn" id="editorWsSelectorBtn">
|
||||
<span id="editorWorkspaceName">{{if .Data}}{{.Data.WorkspaceName}}{{else}}Editor{{end}}</span>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
<div class="editor-ws-dropdown" id="editorWsDropdown">
|
||||
<div id="editorWsList" class="editor-ws-list">
|
||||
{{/* Populated by JS */}}
|
||||
</div>
|
||||
<div class="editor-ws-dropdown-divider"></div>
|
||||
<button class="editor-ws-dropdown-item editor-ws-new" id="editorWsNewBtn">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
New Workspace
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-topbar-branch" id="editorBranchBadge" style="display:none;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--purple)" stroke-width="2"><circle cx="12" cy="18" r="3"/><circle cx="12" cy="6" r="3"/><line x1="12" y1="9" x2="12" y2="15"/></svg>
|
||||
<span style="font-size:11px;font-weight:600;color:var(--purple);font-family:var(--mono);">main</span>
|
||||
<span id="editorBranchName" class="editor-topbar-branch-text">main</span>
|
||||
</div>
|
||||
<div style="flex:1;"></div>
|
||||
<button class="icon-btn" id="editorNewFileBtn" title="New File">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn" id="editorRefreshBtn" title="Refresh">
|
||||
<button class="icon-btn" id="editorRefreshBtn" title="Refresh files">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn" id="editorSearchBtn" title="Search">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
</button>
|
||||
<div style="width:1px;height:18px;background:var(--border);"></div>
|
||||
<button class="icon-btn" id="editorToggleTree" title="Toggle file tree">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn" id="editorToggleChat" title="Toggle chat">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
|
||||
</button>
|
||||
{{template "user-menu" dict "ID" ""}}
|
||||
</div>
|
||||
|
||||
{{/* Body: tree + editor + chat */}}
|
||||
<div class="editor-body" id="editorBody">
|
||||
{{/* File Tree */}}
|
||||
<div class="editor-tree" id="editorFileTree">
|
||||
<div class="editor-tree-header">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
<span style="font-size:11px;font-weight:600;color:var(--text-2);text-transform:uppercase;letter-spacing:0.4px;flex:1;">Files</span>
|
||||
<button class="icon-btn" id="treeNewFileBtn" title="New file">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</button>
|
||||
{{/* ── Pane Body ───────────────────────── */}}
|
||||
<div class="editor-body" id="editorBody"></div>
|
||||
|
||||
{{/* ── Bootstrap (no workspace) ────────── */}}
|
||||
<div class="editor-bootstrap" id="editorBootstrap" style="display:none;">
|
||||
<div class="editor-bootstrap-card">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="1.5" style="opacity:0.6;margin-bottom:12px;">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
<h3 style="margin:0 0 16px;font-size:16px;">Open a Workspace</h3>
|
||||
<div id="editorBootstrapList" style="margin-bottom:16px;">
|
||||
<div style="font-size:12px;color:var(--text-3);">Loading workspaces…</div>
|
||||
</div>
|
||||
<div class="editor-tree-files" id="editorTreeFiles">
|
||||
{{/* Populated by editor-mode.js */}}
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<div style="flex:1;height:1px;background:var(--border);"></div>
|
||||
<span style="font-size:11px;color:var(--text-3);text-transform:uppercase;">or create new</span>
|
||||
<div style="flex:1;height:1px;background:var(--border);"></div>
|
||||
</div>
|
||||
<input type="text" id="editorBootstrapName" class="editor-bootstrap-input" placeholder="Workspace name" value="workspace">
|
||||
<button id="editorBootstrapBtn" class="editor-bootstrap-btn">Create Workspace</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* Main Editor Area */}}
|
||||
<div class="editor-main" id="editorMain">
|
||||
{{/* Tabs */}}
|
||||
<div class="editor-tabs" id="editorTabs">
|
||||
{{/* Populated by editor-mode.js */}}
|
||||
</div>
|
||||
|
||||
{{/* Code Content */}}
|
||||
<div class="editor-content" id="editorContent">
|
||||
<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-3);">
|
||||
<div style="text-align:center;">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity:0.12;display:inline-block;margin-bottom:10px;"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
|
||||
<p style="margin:0;font-size:14px;">Open a file to start editing</p>
|
||||
<p style="margin:6px 0 0;font-size:12px;">Select from the file tree or Ctrl+P</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* Status Bar */}}
|
||||
<div class="editor-statusbar" id="editorStatusbar">
|
||||
<span>Ready</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* Chat Pane (resizable) */}}
|
||||
<div id="editorSplitHandle" style="width:5px;background:var(--border);cursor:col-resize;flex-shrink:0;display:none;"></div>
|
||||
<div class="editor-chat" id="editorChatPane" style="display:none;">
|
||||
<div class="editor-chat-header">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
<span style="font-size:12px;font-weight:600;flex:1;">AI Chat</span>
|
||||
<span class="badge badge-accent" id="editorChatModel">claude-sonnet-4-5</span>
|
||||
</div>
|
||||
<div class="editor-chat-messages" id="editorChatMessages">
|
||||
{{/* Populated by editor-mode.js */}}
|
||||
</div>
|
||||
<div class="editor-chat-input">
|
||||
<div style="display:flex;gap:8px;align-items:flex-end;">
|
||||
<textarea id="editorChatInput" placeholder="Ask about this code…" rows="2"
|
||||
style="flex:1;background:var(--input-bg,var(--bg));border:1px solid var(--border);color:var(--text);padding:8px 10px;border-radius:8px;font-size:12px;font-family:inherit;outline:none;resize:none;"></textarea>
|
||||
<button id="editorChatSendBtn" style="background:var(--accent);color:#fff;border:none;border-radius:8px;padding:8px 14px;font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;">Send</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;margin-top:6px;font-size:10px;color:var(--text-3);">
|
||||
<span id="editorChatContext">Context: none</span>
|
||||
<span>·</span>
|
||||
<span>Cmd+L to focus</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/* ── Server-Rendered Components ──────── */}}
|
||||
{{/* Hidden until moved into pane slots by editor-surface.js.
|
||||
Full DOM from Go template partials = feature parity with chat surface. */}}
|
||||
<div id="editorComponents" style="display:none;">
|
||||
{{template "file-tree" dict "ID" "ed"}}
|
||||
{{template "code-editor" dict "ID" "ed"}}
|
||||
{{template "chat-pane" dict "ID" "ed"}}
|
||||
{{template "note-editor" dict "ID" "edNotes"}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toastContainer" class="toast-container"></div>
|
||||
{{end}}
|
||||
|
||||
{{/* CSS: loaded via base.html (variables, layout, primitives, modals, chat, panels, surfaces, splash) */}}
|
||||
{{/* ── CSS ─────────────────────────────────── */}}
|
||||
{{define "css-editor"}}
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/editor-mode.css?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/editor-surface.css?v={{.Version}}">
|
||||
{{end}}
|
||||
|
||||
{{/* Scripts */}}
|
||||
{{/* ── Scripts ─────────────────────────────── */}}
|
||||
{{define "scripts-editor"}}
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/marked.min.js"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/purify.min.js"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/codemirror/codemirror.bundle.js?v={{.Version}}" onerror="console.warn('[CM6] Bundle not available')"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-format.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/editor-mode.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/chat-pane.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/editor-surface.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/app.js?v={{.Version}}"></script>
|
||||
{{end}}
|
||||
|
||||
@@ -47,6 +47,7 @@ type Stores struct {
|
||||
CapOverrides CapabilityOverrideStore
|
||||
RoutingPolicies RoutingPolicyStore
|
||||
Sessions SessionStore
|
||||
Surfaces SurfaceRegistryStore // v0.25.0: Surface lifecycle management
|
||||
}
|
||||
|
||||
// =========================================
|
||||
|
||||
@@ -40,5 +40,6 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
CapOverrides: NewCapOverrideStore(db),
|
||||
RoutingPolicies: NewRoutingPolicyStore(db),
|
||||
Sessions: NewSessionStore(),
|
||||
Surfaces: NewSurfaceRegistryStore(),
|
||||
}
|
||||
}
|
||||
|
||||
117
server/store/postgres/surface_registry.go
Normal file
117
server/store/postgres/surface_registry.go
Normal 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()
|
||||
}
|
||||
36
server/store/surface_registry_iface.go
Normal file
36
server/store/surface_registry_iface.go
Normal 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"`
|
||||
}
|
||||
@@ -17,7 +17,7 @@ func init() {
|
||||
// calculator
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type CalculatorTool struct{}
|
||||
type CalculatorTool struct{ BaseTool }
|
||||
|
||||
func (t *CalculatorTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
|
||||
@@ -22,6 +22,7 @@ func RegisterConversationSearch(stores store.Stores) {
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type conversationSearchTool struct {
|
||||
BaseTool
|
||||
stores store.Stores
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ func init() {
|
||||
// datetime
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type DateTimeTool struct{}
|
||||
type DateTimeTool struct{ BaseTool }
|
||||
|
||||
func (t *DateTimeTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -28,6 +28,7 @@ func RegisterKBSearch(stores store.Stores, embedder *knowledge.Embedder) {
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type kbSearchTool struct {
|
||||
BaseTool
|
||||
stores store.Stores
|
||||
embedder *knowledge.Embedder
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
85
server/tools/predicates.go
Normal file
85
server/tools/predicates.go
Normal 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 }
|
||||
376
server/tools/predicates_test.go
Normal file
376
server/tools/predicates_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -22,7 +22,7 @@ func init() {
|
||||
// url_fetch
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type URLFetchTool struct{}
|
||||
type URLFetchTool struct{ BaseTool }
|
||||
|
||||
func (t *URLFetchTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
|
||||
@@ -16,7 +16,7 @@ func init() {
|
||||
// web_search
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type WebSearchTool struct{}
|
||||
type WebSearchTool struct{ BaseTool }
|
||||
|
||||
func (t *WebSearchTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
// ── workspace_search ────────────────────────
|
||||
|
||||
type workspaceSearchTool struct {
|
||||
workspaceToolBase
|
||||
stores store.Stores
|
||||
embedder *knowledge.Embedder
|
||||
}
|
||||
|
||||
153
src/css/admin-surfaces.css
Normal file
153
src/css/admin-surfaces.css
Normal file
@@ -0,0 +1,153 @@
|
||||
/* ==========================================
|
||||
Chat Switchboard — Admin Surfaces
|
||||
==========================================
|
||||
v0.25.0: Surface lifecycle management UI.
|
||||
========================================== */
|
||||
|
||||
.admin-surface-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
background: var(--border, #2a2a2e);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-surface-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg-secondary, #151517);
|
||||
}
|
||||
|
||||
.admin-surface-row:hover {
|
||||
background: var(--bg-tertiary, #1e1e22);
|
||||
}
|
||||
|
||||
.admin-surface-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-surface-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.admin-surface-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
.admin-surface-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.admin-surface-badge--core {
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.15));
|
||||
color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
.admin-surface-badge--extension {
|
||||
background: var(--purple-dim, rgba(160, 120, 255, 0.1));
|
||||
color: var(--purple, #a078ff);
|
||||
}
|
||||
|
||||
.admin-surface-badge--required {
|
||||
background: var(--bg-raised, #2a2a2e);
|
||||
color: var(--text-3, #555);
|
||||
}
|
||||
|
||||
.admin-surface-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-3, #555);
|
||||
}
|
||||
|
||||
.admin-surface-id {
|
||||
font-family: var(--mono, 'SF Mono', monospace);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.admin-surface-route {
|
||||
font-family: var(--mono, 'SF Mono', monospace);
|
||||
font-size: 11px;
|
||||
color: var(--text-3, #555);
|
||||
}
|
||||
|
||||
.admin-surface-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Toggle switch */
|
||||
.admin-surface-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.admin-surface-toggle--locked {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.admin-surface-toggle input[type="checkbox"] {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: var(--bg-raised, #2a2a2e);
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.admin-surface-toggle input[type="checkbox"]:checked {
|
||||
background: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
.admin-surface-toggle input[type="checkbox"]::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.admin-surface-toggle input[type="checkbox"]:checked::after {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.admin-surface-toggle input[type="checkbox"]:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.admin-surface-toggle-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-2, #999);
|
||||
min-width: 56px;
|
||||
}
|
||||
412
src/css/chat-pane.css
Normal file
412
src/css/chat-pane.css
Normal file
@@ -0,0 +1,412 @@
|
||||
/* ==========================================
|
||||
Chat Switchboard — ChatPane Component CSS
|
||||
==========================================
|
||||
v0.25.0-cs11.1: Self-contained styles for the ChatPane
|
||||
component. Works in any context — sidebar chat, editor
|
||||
assist pane, standalone embed. No external CSS dependencies.
|
||||
========================================== */
|
||||
|
||||
/* ── Container ─────────────────────────────── */
|
||||
|
||||
.chat-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
/* ── Header Bar (standalone mode) ──────────── */
|
||||
|
||||
.chat-pane-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
background: var(--bg-secondary, #151517);
|
||||
flex-shrink: 0;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.chat-pane-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-pane-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-pane-chat-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--bg, #0e0e10);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 4px;
|
||||
color: var(--text, #eee);
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-pane-chat-select:focus {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chat-pane-new-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
background: none;
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 4px;
|
||||
color: var(--text-2, #999);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.chat-pane-new-btn:hover {
|
||||
color: var(--accent, #b38a4e);
|
||||
border-color: var(--accent, #b38a4e);
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.1));
|
||||
}
|
||||
|
||||
/* Model selector in header */
|
||||
.chat-pane-model-select {
|
||||
background: var(--bg, #0e0e10);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 4px;
|
||||
color: var(--text-2, #999);
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-pane-model-select:focus {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
outline: none;
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
/* ── Messages Area ─────────────────────────── */
|
||||
|
||||
.chat-pane-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scroll-behavior: smooth;
|
||||
padding: 12px 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── Message Bubbles ───────────────────────── */
|
||||
|
||||
.chat-msg {
|
||||
padding: 8px 16px;
|
||||
font-size: var(--msg-font, 14px);
|
||||
line-height: 1.65;
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
.chat-msg + .chat-msg {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* User messages — right-aligned bubble */
|
||||
.chat-msg--user {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.chat-msg--user .chat-msg__content {
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.15));
|
||||
color: var(--text, #eee);
|
||||
padding: 8px 14px;
|
||||
border-radius: 12px 12px 2px 12px;
|
||||
max-width: 85%;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Assistant messages — left-aligned */
|
||||
.chat-msg--assistant {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.chat-msg--assistant .chat-msg__content {
|
||||
max-width: 100%;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* ── Streaming indicator ───────────────────── */
|
||||
|
||||
.chat-msg--streaming .chat-msg__content::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 14px;
|
||||
background: var(--accent, #b38a4e);
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
animation: cp-cursor-blink 0.8s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes cp-cursor-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* ── Typing dots ───────────────────────────── */
|
||||
|
||||
.chat-msg--typing {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
/* System/error messages */
|
||||
.chat-msg--system {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-msg--typing .typing-dots {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.chat-msg--typing .typing-dots span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-3, #555);
|
||||
animation: cp-dot-bounce 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.chat-msg--typing .typing-dots span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.chat-msg--typing .typing-dots span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes cp-dot-bounce {
|
||||
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
||||
30% { transform: translateY(-4px); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Markdown content ──────────────────────── */
|
||||
|
||||
.chat-msg__content p { margin: 0 0 0.5em; }
|
||||
.chat-msg__content p:last-child { margin-bottom: 0; }
|
||||
|
||||
.chat-msg__content pre {
|
||||
background: var(--bg-secondary, #151517);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
overflow-x: auto;
|
||||
margin: 8px 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
font-family: var(--mono, 'SF Mono', monospace);
|
||||
}
|
||||
|
||||
.chat-msg__content code {
|
||||
background: var(--bg-secondary, #151517);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.88em;
|
||||
font-family: var(--mono, 'SF Mono', monospace);
|
||||
}
|
||||
|
||||
.chat-msg__content pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.chat-msg__content ul, .chat-msg__content ol {
|
||||
margin: 4px 0;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.chat-msg__content blockquote {
|
||||
border-left: 3px solid var(--accent, #b38a4e);
|
||||
padding: 4px 12px;
|
||||
margin: 8px 0;
|
||||
color: var(--text-2, #999);
|
||||
}
|
||||
|
||||
.chat-msg__content a {
|
||||
color: var(--accent, #b38a4e);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chat-msg__content a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.chat-msg__content table {
|
||||
border-collapse: collapse;
|
||||
margin: 8px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-msg__content th,
|
||||
.chat-msg__content td {
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.chat-msg__content th {
|
||||
background: var(--bg-secondary, #151517);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Welcome / Empty State ─────────────────── */
|
||||
|
||||
.chat-welcome {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.chat-welcome__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #eee);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.chat-welcome__hint {
|
||||
font-size: 13px;
|
||||
color: var(--text-3, #555);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Input Bar ─────────────────────────────── */
|
||||
|
||||
.chat-pane-input-bar {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--border, #2a2a2e);
|
||||
background: var(--bg-secondary, #151517);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.chat-pane-input-wrap {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
background: var(--bg, #0e0e10);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.chat-pane-input-wrap:focus-within {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
.chat-pane-input {
|
||||
flex: 1;
|
||||
min-height: 20px;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
font-size: 13px;
|
||||
color: var(--text, #eee);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* CM6 editor inside chat-pane-input */
|
||||
.chat-pane-input .cm-editor {
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-pane-input .cm-editor .cm-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chat-pane-input .cm-editor .cm-line {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chat-pane-input .cm-editor.cm-focused {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Textarea fallback */
|
||||
.chat-pane-input textarea {
|
||||
width: 100%;
|
||||
min-height: 20px;
|
||||
max-height: 160px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text, #eee);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ── Send Button ───────────────────────────── */
|
||||
|
||||
.chat-pane-send {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--accent, #b38a4e);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.chat-pane-send:hover {
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.15));
|
||||
color: var(--accent-hover, #c9a05e);
|
||||
}
|
||||
|
||||
.chat-pane-send:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Toolbar ───────────────────────────────── */
|
||||
|
||||
.chat-pane-toolbar {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.chat-pane-toolbar:empty {
|
||||
display: none;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
/* ==========================================
|
||||
Editor Surface (v0.21.5)
|
||||
IDE-like layout: file tree | code + chat
|
||||
========================================== */
|
||||
|
||||
/* ── Header ──────────────────────────────── */
|
||||
|
||||
.editor-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 6px 12px; gap: 8px;
|
||||
font-size: 13px; color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-height: 36px; box-sizing: border-box;
|
||||
}
|
||||
.editor-header-left { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.editor-ws-name { font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.editor-branch { font-size: 12px; color: var(--text-tertiary); white-space: nowrap; }
|
||||
.editor-header-right { display: flex; align-items: center; gap: 4px; }
|
||||
.editor-hdr-btn {
|
||||
background: none; border: none; color: var(--text-secondary);
|
||||
cursor: pointer; padding: 4px; border-radius: 4px;
|
||||
display: flex; align-items: center;
|
||||
}
|
||||
.editor-hdr-btn:hover { background: var(--hover); color: var(--text-primary); }
|
||||
.editor-hdr-label { font-size: 12px; margin-left: 3px; }
|
||||
.editor-hdr-sep { width: 1px; height: 16px; background: var(--border); margin: 0 2px; }
|
||||
|
||||
/* Chat toggle */
|
||||
.editor-chat-toggle.active { color: var(--accent); }
|
||||
|
||||
/* Export dropdown */
|
||||
.editor-export-wrap { position: relative; }
|
||||
.editor-export-menu {
|
||||
display: none; position: absolute; right: 0; top: 100%;
|
||||
background: var(--bg-primary); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 4px 0; z-index: 100;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.2); min-width: 160px;
|
||||
}
|
||||
.editor-export-menu.open { display: block; }
|
||||
.editor-export-item {
|
||||
display: block; width: 100%; padding: 6px 12px; font-size: 13px;
|
||||
background: none; border: none; text-align: left; cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.editor-export-item:hover { background: var(--hover); }
|
||||
|
||||
/* ── File Tree (sidebar-content region) ──── */
|
||||
|
||||
.editor-file-tree {
|
||||
flex: 1; overflow-y: auto; overflow-x: hidden;
|
||||
font-size: 13px; user-select: none;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.editor-tree-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 6px 8px; flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.editor-tree-title { font-size: 11px; font-weight: 600; text-transform: uppercase; color: var(--text-tertiary); letter-spacing: 0.5px; }
|
||||
.editor-tree-add {
|
||||
background: none; border: none; color: var(--text-tertiary);
|
||||
cursor: pointer; padding: 2px; border-radius: 4px;
|
||||
display: flex; align-items: center;
|
||||
}
|
||||
.editor-tree-add:hover { background: var(--hover); color: var(--accent); }
|
||||
.editor-tree-content { flex: 1; overflow-y: auto; padding: 4px 0; }
|
||||
.editor-file-tree.drag-over { outline: 2px dashed var(--accent, #5865f2); outline-offset: -2px; background: rgba(88,101,242,0.05); }
|
||||
.editor-tree-row {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
padding: 3px 8px; cursor: pointer;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
border-radius: 4px; margin: 0 4px;
|
||||
}
|
||||
.editor-tree-row:hover { background: var(--hover); }
|
||||
.editor-tree-row.active { background: var(--active-bg, rgba(59,130,246,0.12)); color: var(--accent); }
|
||||
.editor-tree-arrow { width: 14px; text-align: center; font-size: 10px; color: var(--text-tertiary); flex-shrink: 0; }
|
||||
.editor-tree-arrow.expanded { /* already rotated via character */ }
|
||||
.editor-tree-icon { flex-shrink: 0; font-size: 14px; line-height: 1; }
|
||||
.editor-tree-name { overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* Git status indicators (v0.21.6) */
|
||||
.editor-tree-row.git-modified .editor-tree-name { color: var(--warning, #e5a50a); }
|
||||
.editor-tree-row.git-added .editor-tree-name { color: var(--success, #22c55e); }
|
||||
.editor-tree-row.git-untracked .editor-tree-name { color: var(--text-tertiary); font-style: italic; }
|
||||
.editor-tree-row.git-deleted .editor-tree-name { color: var(--error, #ef4444); text-decoration: line-through; }
|
||||
.editor-tree-row.git-modified::after,
|
||||
.editor-tree-row.git-added::after,
|
||||
.editor-tree-row.git-untracked::after {
|
||||
font-size: 10px; margin-left: auto; padding-right: 4px; flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.editor-tree-row.git-modified::after { content: 'M'; color: var(--warning, #e5a50a); }
|
||||
.editor-tree-row.git-added::after { content: 'A'; color: var(--success, #22c55e); }
|
||||
.editor-tree-row.git-untracked::after { content: 'U'; }
|
||||
|
||||
.editor-tree-loading, .editor-tree-empty, .editor-tree-error {
|
||||
padding: 16px; text-align: center; color: var(--text-tertiary); font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── Main Split Layout ───────────────────── */
|
||||
|
||||
.editor-main {
|
||||
display: flex; flex: 1; min-height: 0; overflow: hidden;
|
||||
}
|
||||
.editor-left-pane {
|
||||
display: flex; flex-direction: column; min-width: 200px; overflow: hidden;
|
||||
}
|
||||
.editor-right-pane {
|
||||
display: flex; flex-direction: column; min-width: 200px; overflow: hidden;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Resize handle */
|
||||
.editor-split-handle {
|
||||
width: 5px; cursor: col-resize; flex-shrink: 0;
|
||||
background: transparent; position: relative; z-index: 5;
|
||||
}
|
||||
.editor-split-handle:hover,
|
||||
.editor-split-handle:active {
|
||||
background: var(--accent);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* ── Tab Bar ─────────────────────────────── */
|
||||
|
||||
.editor-tab-bar {
|
||||
display: flex; overflow-x: auto; overflow-y: hidden;
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-height: 32px; flex-shrink: 0;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.editor-tab-bar::-webkit-scrollbar { height: 0; }
|
||||
|
||||
.editor-tab {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
padding: 4px 10px; font-size: 12px;
|
||||
cursor: pointer; white-space: nowrap;
|
||||
border-right: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0; max-width: 180px;
|
||||
position: relative;
|
||||
}
|
||||
.editor-tab:hover { background: var(--hover); }
|
||||
.editor-tab.active {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border-bottom: 2px solid var(--accent);
|
||||
}
|
||||
.editor-tab.modified .editor-tab-name { font-style: italic; }
|
||||
.editor-tab-icon { font-size: 13px; flex-shrink: 0; }
|
||||
.editor-tab-name { overflow: hidden; text-overflow: ellipsis; }
|
||||
.editor-tab-modified { color: var(--accent); font-size: 10px; margin-left: 2px; }
|
||||
.editor-tab-close {
|
||||
background: none; border: none; color: var(--text-tertiary);
|
||||
cursor: pointer; font-size: 11px; padding: 0 2px;
|
||||
border-radius: 3px; opacity: 0; transition: opacity 0.1s;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.editor-tab:hover .editor-tab-close { opacity: 1; }
|
||||
.editor-tab-close:hover { background: var(--hover); color: var(--text-primary); }
|
||||
|
||||
/* ── Code Editor Area ────────────────────── */
|
||||
|
||||
.editor-code-area {
|
||||
flex: 1; min-height: 0; position: relative; overflow: hidden;
|
||||
}
|
||||
.editor-cm-wrap {
|
||||
position: absolute; inset: 0; overflow: hidden;
|
||||
}
|
||||
.editor-cm-wrap .cm-editor { height: 100%; }
|
||||
.editor-cm-wrap .cm-scroller { overflow: auto; }
|
||||
|
||||
/* Textarea fallback */
|
||||
.editor-textarea-fallback {
|
||||
width: 100%; height: 100%; resize: none; border: none; outline: none;
|
||||
background: var(--bg-primary); color: var(--text-primary);
|
||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
font-size: 13px; line-height: 1.5;
|
||||
padding: 8px 12px; box-sizing: border-box;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
/* Welcome screen */
|
||||
.editor-welcome {
|
||||
position: absolute; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.editor-welcome-inner { text-align: center; color: var(--text-tertiary); }
|
||||
.editor-welcome-inner p { margin: 8px 0; font-size: 14px; }
|
||||
.editor-welcome-hint { font-size: 12px; opacity: 0.6; }
|
||||
.editor-welcome-hint kbd {
|
||||
background: var(--hover); border: 1px solid var(--border);
|
||||
border-radius: 3px; padding: 1px 5px; font-size: 11px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* ── Chat Panel (right pane) ─────────────── */
|
||||
|
||||
.editor-chat-header {
|
||||
padding: 6px 12px; font-size: 12px; font-weight: 600;
|
||||
color: var(--text-secondary); border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.editor-chat-messages {
|
||||
flex: 1; overflow-y: auto; min-height: 0;
|
||||
}
|
||||
/* Inherit chat message styles when embedded */
|
||||
.editor-chat-messages .messages { height: auto; overflow: visible; }
|
||||
.editor-chat-input { flex-shrink: 0; max-height: 140px; overflow: hidden; }
|
||||
/* Inherit input-area styles — constrain for narrow pane */
|
||||
.editor-chat-input .input-area { border-top: 1px solid var(--border); padding: 0 8px 8px; }
|
||||
.editor-chat-input .input-wrap { max-width: none; }
|
||||
.editor-chat-input .input-wrap textarea,
|
||||
.editor-chat-input #messageInputWrap .cm-editor { max-height: 80px; padding: 8px; font-size: 13px; }
|
||||
.editor-chat-input .input-meta { min-height: 0; padding: 2px 4px 0; }
|
||||
.editor-chat-input .input-toolbar { gap: 2px; }
|
||||
.editor-chat-input .input-toolbar button { padding: 4px; }
|
||||
|
||||
/* ── Status Bar (footer) ─────────────────── */
|
||||
|
||||
.editor-status-bar {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 2px 12px; font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
border-top: 1px solid var(--border);
|
||||
min-height: 22px; flex-shrink: 0;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.editor-status-sep { opacity: 0.3; }
|
||||
.editor-status-right { margin-left: auto; display: flex; gap: 8px; }
|
||||
.editor-status-branch { font-size: 11px; }
|
||||
.editor-status-file { max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.editor-status-save { font-size: 11px; }
|
||||
.editor-status-save.unsaved { color: var(--warning, #e5a50a); }
|
||||
.editor-status-words { font-size: 11px; }
|
||||
|
||||
/* ── Context Menu ────────────────────────── */
|
||||
|
||||
.editor-ctx-menu {
|
||||
position: fixed; z-index: 1000;
|
||||
background: var(--bg-primary); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 4px 0;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
|
||||
min-width: 120px; font-size: 13px;
|
||||
}
|
||||
.editor-ctx-item {
|
||||
padding: 6px 12px; cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.editor-ctx-item:hover { background: var(--hover); }
|
||||
|
||||
/* ── Quick Open Overlay ──────────────────── */
|
||||
|
||||
.editor-quick-open {
|
||||
position: fixed; inset: 0; z-index: 2000;
|
||||
background: rgba(0,0,0,0.4);
|
||||
display: flex; align-items: flex-start; justify-content: center;
|
||||
padding-top: 15vh;
|
||||
}
|
||||
.editor-qo-dialog {
|
||||
background: var(--bg-primary); border: 1px solid var(--border);
|
||||
border-radius: 8px; width: 460px; max-width: 90vw;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
|
||||
overflow: hidden;
|
||||
}
|
||||
.editor-qo-input {
|
||||
width: 100%; padding: 10px 14px; font-size: 14px;
|
||||
border: none; outline: none; box-sizing: border-box;
|
||||
background: var(--bg-primary); color: var(--text-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.editor-qo-results { max-height: 300px; overflow-y: auto; }
|
||||
.editor-qo-row {
|
||||
padding: 6px 14px; cursor: pointer; font-size: 13px;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.editor-qo-row:hover { background: var(--hover); }
|
||||
.editor-qo-icon { font-size: 14px; flex-shrink: 0; }
|
||||
|
||||
/* ── Mobile: single pane ─────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.editor-main { flex-direction: column; }
|
||||
.editor-left-pane { flex: 1 !important; min-width: 0; }
|
||||
.editor-right-pane { min-width: 0; border-left: none; border-top: 1px solid var(--border); max-height: 40vh; }
|
||||
.editor-split-handle { display: none; }
|
||||
}
|
||||
535
src/css/editor-surface.css
Normal file
535
src/css/editor-surface.css
Normal file
@@ -0,0 +1,535 @@
|
||||
/* ==========================================
|
||||
Chat Switchboard — Editor Surface (v0.25.0)
|
||||
==========================================
|
||||
Replaces editor-mode.css for the pane-based editor.
|
||||
Covers: topbar, bootstrap, and component-specific
|
||||
overrides within the editor context.
|
||||
========================================== */
|
||||
|
||||
/* ── Surface Shell ─────────────────────────── */
|
||||
|
||||
.surface-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
/* ── Topbar ────────────────────────────────── */
|
||||
|
||||
.editor-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-secondary, #151517);
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
font-size: 13px;
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.editor-topbar-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--text-3, #777);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.editor-topbar-back:hover {
|
||||
color: var(--text, #eee);
|
||||
background: var(--bg-tertiary, #1e1e22);
|
||||
}
|
||||
|
||||
.editor-topbar-sep {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: var(--border, #2a2a2e);
|
||||
}
|
||||
|
||||
.editor-topbar-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
/* ── Workspace Selector ────────────────────── */
|
||||
|
||||
.editor-ws-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editor-ws-selector-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text, #eee);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.editor-ws-selector-btn:hover {
|
||||
border-color: var(--border, #2a2a2e);
|
||||
background: var(--bg-tertiary, #1e1e22);
|
||||
}
|
||||
|
||||
.editor-ws-dropdown {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--bg-secondary, #1a1a1e);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 8px;
|
||||
min-width: 220px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.editor-ws-dropdown.open { display: block; }
|
||||
|
||||
.editor-ws-list {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.editor-ws-dropdown-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text, #eee);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.editor-ws-dropdown-item:hover {
|
||||
background: var(--bg-tertiary, #1e1e22);
|
||||
}
|
||||
|
||||
.editor-ws-dropdown-item.active {
|
||||
color: var(--accent, #b38a4e);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.editor-ws-dropdown-divider {
|
||||
height: 1px;
|
||||
background: var(--border, #2a2a2e);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.editor-ws-new {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
.editor-topbar-branch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--purple-dim, rgba(160, 120, 255, 0.1));
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.editor-topbar-branch-text {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--purple, #a078ff);
|
||||
font-family: var(--mono, 'SF Mono', monospace);
|
||||
}
|
||||
|
||||
/* ── Body ──────────────────────────────────── */
|
||||
|
||||
.editor-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Bootstrap (no workspace) ──────────────── */
|
||||
|
||||
.editor-bootstrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.editor-bootstrap-card {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
background: var(--bg-secondary, #151517);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 12px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.editor-bootstrap-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg, #0e0e10);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
color: var(--text, #eee);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
margin-bottom: 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.editor-bootstrap-input:focus {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
.editor-bootstrap-btn {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
background: var(--accent, #b38a4e);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.editor-bootstrap-btn:hover { opacity: 0.9; }
|
||||
.editor-bootstrap-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* Workspace list in bootstrap */
|
||||
.editor-bootstrap-ws-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg, #0e0e10);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
color: var(--text, #eee);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
margin-bottom: 6px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.editor-bootstrap-ws-item:hover {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
background: var(--bg-tertiary, #1e1e22);
|
||||
}
|
||||
|
||||
.editor-bootstrap-ws-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.editor-bootstrap-ws-date {
|
||||
font-size: 11px;
|
||||
color: var(--text-3, #777);
|
||||
}
|
||||
|
||||
/* User menu in editor topbar — uses user-menu.css base styles.
|
||||
No overrides needed: user-menu.css defaults to drop-down (topbar) mode.
|
||||
Sidebar context flipped via .sidebar parent selector in user-menu.css. */
|
||||
|
||||
/* ── FileTree overrides (in editor context) ── */
|
||||
|
||||
.surface-editor .file-tree {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border, #2a2a2e);
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2, #999);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-items {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text-2, #999);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-row:hover {
|
||||
background: var(--bg-tertiary, #1e1e22);
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-row.active {
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.15));
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-arrow {
|
||||
width: 12px;
|
||||
font-size: 10px;
|
||||
color: var(--text-3, #555);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-icon {
|
||||
font-size: 13px;
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Git status indicators */
|
||||
.surface-editor .file-tree-row.git-modified .file-tree-name { color: var(--warning, #e5a842); }
|
||||
.surface-editor .file-tree-row.git-added .file-tree-name { color: var(--success, #4caf50); }
|
||||
.surface-editor .file-tree-row.git-untracked .file-tree-name { color: var(--text-3, #555); font-style: italic; }
|
||||
.surface-editor .file-tree-row.git-deleted .file-tree-name { color: var(--danger, #f44336); text-decoration: line-through; }
|
||||
|
||||
/* Context menu */
|
||||
.file-tree-ctx-menu {
|
||||
position: fixed;
|
||||
background: var(--bg-secondary, #1a1a1e);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
padding: 4px 0;
|
||||
min-width: 120px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.file-tree-ctx-item {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text, #eee);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-tree-ctx-item:hover {
|
||||
background: var(--bg-tertiary, #1e1e22);
|
||||
}
|
||||
|
||||
/* ── CodeEditor overrides ──────────────────── */
|
||||
|
||||
.surface-editor .code-editor {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
background: var(--bg-secondary, #151517);
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
height: 32px;
|
||||
overflow-x: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-tabs::-webkit-scrollbar { height: 0; }
|
||||
|
||||
.surface-editor .code-editor-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 12px;
|
||||
height: 100%;
|
||||
font-size: 12px;
|
||||
color: var(--text-3, #777);
|
||||
cursor: pointer;
|
||||
border-right: 1px solid var(--border, #2a2a2e);
|
||||
transition: background 0.1s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-tab:hover { background: var(--bg-tertiary, #1e1e22); }
|
||||
.surface-editor .code-editor-tab.active { color: var(--text, #eee); background: var(--bg, #0e0e10); }
|
||||
.surface-editor .code-editor-tab.modified .code-editor-tab-modified { color: var(--warning, #e5a842); }
|
||||
|
||||
.surface-editor .code-editor-tab-icon { font-size: 12px; }
|
||||
.surface-editor .code-editor-tab-modified { font-size: 10px; color: var(--text-3); }
|
||||
|
||||
.surface-editor .code-editor-tab-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-3, #555);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
margin-left: 4px;
|
||||
border-radius: 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-tab-close:hover {
|
||||
background: var(--danger-dim, rgba(244, 67, 54, 0.15));
|
||||
color: var(--danger, #f44336);
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-welcome {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-cm-wrap {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-cm-wrap .cm-editor {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 0 12px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-secondary, #151517);
|
||||
border-top: 1px solid var(--border, #2a2a2e);
|
||||
font-size: 11px;
|
||||
color: var(--text-3, #777);
|
||||
font-family: var(--mono, 'SF Mono', monospace);
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-textarea-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg, #0e0e10);
|
||||
color: var(--text, #eee);
|
||||
border: none;
|
||||
padding: 12px;
|
||||
font-family: var(--mono, 'SF Mono', monospace);
|
||||
font-size: 13px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── Tabbed assist pane overrides ──────────── */
|
||||
|
||||
.surface-editor .pane-tabbed {
|
||||
border-left: 1px solid var(--border, #2a2a2e);
|
||||
}
|
||||
|
||||
/* ChatPane in editor tabbed pane */
|
||||
.surface-editor .chat-pane {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
/* Notes in editor pane */
|
||||
.surface-editor .note-editor {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.surface-editor .note-editor-list-view {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.surface-editor .notes-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Compact notes toolbar for narrow pane */
|
||||
.surface-editor .notes-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
}
|
||||
|
||||
.surface-editor .notes-toolbar .btn-small {
|
||||
font-size: 11px;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
.surface-editor .notes-search-row {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.surface-editor .notes-filter-row {
|
||||
padding: 2px 8px 4px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.surface-editor .notes-filter-select {
|
||||
font-size: 11px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
167
src/css/pane-container.css
Normal file
167
src/css/pane-container.css
Normal file
@@ -0,0 +1,167 @@
|
||||
/* ==========================================
|
||||
Chat Switchboard — Pane Container
|
||||
==========================================
|
||||
v0.25.0: Styles for the multi-pane layout system.
|
||||
Covers: pane container, leaf panes, tabbed panes,
|
||||
drag handles, and tab bars.
|
||||
========================================== */
|
||||
|
||||
/* ── Container ─────────────────────────────── */
|
||||
|
||||
.pane-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Split ─────────────────────────────────── */
|
||||
|
||||
.pane-split--horizontal {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pane-split--vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Leaf Pane ─────────────────────────────── */
|
||||
|
||||
.pane-leaf {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pane-minimized {
|
||||
max-width: 0 !important;
|
||||
max-height: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
/* ── Tabbed Pane ───────────────────────────── */
|
||||
|
||||
.pane-tabbed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Tab Bar */
|
||||
.pane-tab-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
background: var(--bg-secondary, #1a1a1e);
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
flex-shrink: 0;
|
||||
height: 32px;
|
||||
padding: 0 4px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.pane-tab-bar::-webkit-scrollbar { height: 0; }
|
||||
|
||||
.pane-tab-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-3, #777);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.pane-tab-btn:hover {
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
.pane-tab-btn--active {
|
||||
color: var(--accent, #b38a4e);
|
||||
border-bottom-color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
/* Tab Content */
|
||||
.pane-tab-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pane-tab-panel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* When panel is hidden (inactive tab), override absolute positioning */
|
||||
.pane-tab-panel[style*="display: none"] {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/* ── Drag Handle ───────────────────────────── */
|
||||
|
||||
.pane-handle {
|
||||
flex-shrink: 0;
|
||||
background: var(--border, #2a2a2e);
|
||||
transition: background 0.15s;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.pane-handle--horizontal {
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.pane-handle--vertical {
|
||||
height: 4px;
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
.pane-handle:hover,
|
||||
.pane-handle--active {
|
||||
background: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
/* ── Responsive ────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* On mobile, splits collapse to vertical stack */
|
||||
.pane-split--horizontal {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pane-handle--horizontal {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
/* Hide non-primary panes on mobile */
|
||||
.pane-split--horizontal > .pane:not(:first-child) {
|
||||
display: none;
|
||||
}
|
||||
.pane-split--horizontal > .pane-handle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
41
src/css/tool-grants.css
Normal file
41
src/css/tool-grants.css
Normal file
@@ -0,0 +1,41 @@
|
||||
/* v0.25.0: Tool grants section in persona form */
|
||||
.tool-grants-list {
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
.tool-grants-category {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-3, #777);
|
||||
padding: 6px 0 2px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.tool-grants-category:first-child {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.tool-grants-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text, #eee);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tool-grants-item:hover {
|
||||
background: var(--bg-tertiary, #1e1e22);
|
||||
}
|
||||
|
||||
.tool-grants-item input[type="checkbox"] {
|
||||
margin: 0;
|
||||
}
|
||||
162
src/css/user-menu.css
Normal file
162
src/css/user-menu.css
Normal file
@@ -0,0 +1,162 @@
|
||||
/* ==========================================
|
||||
Chat Switchboard — UserMenu Component CSS
|
||||
==========================================
|
||||
v0.25.0-cs11.1: Self-contained styles for the UserMenu
|
||||
component. Works in two contexts:
|
||||
1. Sidebar (flyout pops UP) — default in .sidebar
|
||||
2. Topbar (flyout drops DOWN) — default standalone
|
||||
========================================== */
|
||||
|
||||
/* ── Wrapper ───────────────────────────────── */
|
||||
|
||||
.user-menu-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ── Trigger Button ────────────────────────── */
|
||||
|
||||
.user-menu-wrap .user-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius, 6px);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text, #eee);
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.user-menu-wrap .user-btn:hover {
|
||||
background: var(--bg-hover, rgba(255,255,255,0.06));
|
||||
}
|
||||
|
||||
/* ── Avatar ────────────────────────────────── */
|
||||
|
||||
.user-menu-wrap .user-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.2));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--accent, #b38a4e);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-menu-wrap .user-avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.user-menu-wrap .sb-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Flyout (default: drops DOWN for topbar) ─ */
|
||||
|
||||
.user-menu-wrap .user-flyout {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--bg-raised, #1a1a1e);
|
||||
border: 1px solid var(--border-light, #333);
|
||||
border-radius: var(--radius-lg, 8px);
|
||||
padding: 4px;
|
||||
min-width: 170px;
|
||||
z-index: 200;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.user-menu-wrap .user-flyout.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Flyout Items ──────────────────────────── */
|
||||
|
||||
.user-menu-wrap .flyout-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius, 6px);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-2, #999);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.user-menu-wrap .flyout-item:hover {
|
||||
background: var(--bg-hover, rgba(255,255,255,0.06));
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
.user-menu-wrap .flyout-item svg {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.user-menu-wrap .flyout-danger {
|
||||
color: var(--danger, #f44336);
|
||||
}
|
||||
|
||||
.user-menu-wrap .flyout-danger:hover {
|
||||
background: var(--danger-dim, rgba(244, 67, 54, 0.1));
|
||||
color: var(--danger, #f44336);
|
||||
}
|
||||
|
||||
.user-menu-wrap .flyout-divider {
|
||||
border: none;
|
||||
height: 1px;
|
||||
background: var(--border, #2a2a2e);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* ── Sidebar Context (flyout pops UP) ──────── */
|
||||
/* When user-menu is inside .sidebar, flip the flyout direction */
|
||||
|
||||
.sidebar .user-menu-wrap .user-flyout {
|
||||
top: auto;
|
||||
bottom: 100%;
|
||||
margin-top: 0;
|
||||
margin-bottom: 4px;
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Sidebar collapsed: hide label, center avatar */
|
||||
.sidebar.collapsed .user-menu-wrap .user-btn {
|
||||
justify-content: center;
|
||||
padding: 6px 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .user-menu-wrap .sb-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .user-menu-wrap .user-flyout {
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
@@ -264,6 +264,9 @@
|
||||
'</div>' +
|
||||
'<div id="adminArchivedList" class="admin-list"><div class="loading">Loading...</div></div>';
|
||||
|
||||
SCAFFOLDING.surfaces =
|
||||
'<div id="adminSurfacesContent"></div>';
|
||||
|
||||
|
||||
// === Add button actions ==============================================
|
||||
|
||||
@@ -312,21 +315,23 @@
|
||||
if (!section) return;
|
||||
|
||||
// -- Category / nav resolution -----------
|
||||
var catMap = {
|
||||
users:'people', teams:'people', groups:'people',
|
||||
providers:'ai', models:'ai', personas:'ai', roles:'ai', knowledgeBases:'ai', memory:'ai',
|
||||
health:'routing', routing:'routing', capabilities:'routing',
|
||||
settings:'system', storage:'system', extensions:'system', channels:'system',
|
||||
usage:'monitoring', audit:'monitoring', stats:'monitoring',
|
||||
};
|
||||
var cat = catMap[section] || 'people';
|
||||
var secMap = {
|
||||
people: [{id:'users',l:'Users'},{id:'teams',l:'Teams'},{id:'groups',l:'Groups'}],
|
||||
ai: [{id:'providers',l:'Providers'},{id:'models',l:'Models'},{id:'personas',l:'Personas'},{id:'roles',l:'Roles'},{id:'knowledgeBases',l:'Knowledge'},{id:'memory',l:'Memory'}],
|
||||
routing: [{id:'health',l:'Health'},{id:'routing',l:'Routing'},{id:'capabilities',l:'Capabilities'}],
|
||||
system: [{id:'settings',l:'Settings'},{id:'storage',l:'Storage'},{id:'extensions',l:'Extensions'},{id:'channels',l:'Channels'}],
|
||||
monitoring: [{id:'usage',l:'Usage'},{id:'audit',l:'Audit'},{id:'stats',l:'Stats'}],
|
||||
};
|
||||
// Derive from ADMIN_SECTIONS + ADMIN_LABELS (ui-admin.js) — single source of truth.
|
||||
// admin-scaffold.js previously had its own duplicate catMap/secMap.
|
||||
var cat = 'people';
|
||||
if (typeof ADMIN_SECTIONS !== 'undefined') {
|
||||
for (var c in ADMIN_SECTIONS) {
|
||||
if (ADMIN_SECTIONS[c].indexOf(section) !== -1) { cat = c; break; }
|
||||
}
|
||||
}
|
||||
var secMap;
|
||||
if (typeof ADMIN_SECTIONS !== 'undefined' && typeof ADMIN_LABELS !== 'undefined') {
|
||||
secMap = {};
|
||||
for (var c in ADMIN_SECTIONS) {
|
||||
secMap[c] = ADMIN_SECTIONS[c].map(function(id) {
|
||||
return { id: id, l: ADMIN_LABELS[id] || id };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild left nav for current category
|
||||
var navEl = document.getElementById('adminNav');
|
||||
|
||||
198
src/js/admin-surfaces.js
Normal file
198
src/js/admin-surfaces.js
Normal file
@@ -0,0 +1,198 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — Admin Surfaces UI
|
||||
// ==========================================
|
||||
// v0.25.0: Surface lifecycle management.
|
||||
// Renders in the "Surfaces" admin section via ADMIN_LOADERS.
|
||||
|
||||
async function _loadAdminSurfaces() {
|
||||
const container = document.getElementById('adminSurfacesContent');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
|
||||
<p style="font-size:13px;color:var(--text-2);margin:0;line-height:1.6;flex:1;">
|
||||
Manage which surfaces are available. Disabled surfaces redirect to Chat and are hidden from navigation.
|
||||
</p>
|
||||
<button id="adminSurfaceInstallBtn" class="btn-small" style="margin-left:16px;white-space:nowrap;">
|
||||
+ Install Surface
|
||||
</button>
|
||||
</div>
|
||||
<div id="adminSurfaceInstallForm" style="display:none;margin-bottom:16px;padding:16px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;">
|
||||
<div style="font-size:13px;font-weight:600;margin-bottom:8px;">Install Surface Archive</div>
|
||||
<p style="font-size:12px;color:var(--text-3);margin:0 0 12px;">Upload a <code>.surface</code> or <code>.zip</code> archive containing manifest.json, js/, css/, and assets/.</p>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<input type="file" id="adminSurfaceFile" accept=".surface,.zip" style="font-size:12px;color:var(--text-2);">
|
||||
<button id="adminSurfaceUploadBtn" class="btn-small btn-primary" disabled>Upload</button>
|
||||
<button id="adminSurfaceCancelBtn" class="btn-small">Cancel</button>
|
||||
</div>
|
||||
<div id="adminSurfaceInstallStatus" style="font-size:12px;margin-top:8px;"></div>
|
||||
</div>
|
||||
<div id="adminSurfaceList" class="admin-surface-list">
|
||||
<div style="padding:12px;font-size:12px;color:var(--text-3);">Loading…</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Wire install form
|
||||
var installBtn = document.getElementById('adminSurfaceInstallBtn');
|
||||
var installForm = document.getElementById('adminSurfaceInstallForm');
|
||||
var fileInput = document.getElementById('adminSurfaceFile');
|
||||
var uploadBtn = document.getElementById('adminSurfaceUploadBtn');
|
||||
var cancelBtn = document.getElementById('adminSurfaceCancelBtn');
|
||||
var statusEl = document.getElementById('adminSurfaceInstallStatus');
|
||||
|
||||
if (installBtn) installBtn.addEventListener('click', function() {
|
||||
installForm.style.display = '';
|
||||
installBtn.style.display = 'none';
|
||||
});
|
||||
if (cancelBtn) cancelBtn.addEventListener('click', function() {
|
||||
installForm.style.display = 'none';
|
||||
installBtn.style.display = '';
|
||||
fileInput.value = '';
|
||||
uploadBtn.disabled = true;
|
||||
statusEl.textContent = '';
|
||||
});
|
||||
if (fileInput) fileInput.addEventListener('change', function() {
|
||||
uploadBtn.disabled = !fileInput.files.length;
|
||||
});
|
||||
if (uploadBtn) uploadBtn.addEventListener('click', async function() {
|
||||
var file = fileInput.files[0];
|
||||
if (!file) return;
|
||||
uploadBtn.disabled = true;
|
||||
uploadBtn.textContent = 'Uploading…';
|
||||
statusEl.textContent = '';
|
||||
statusEl.style.color = 'var(--text-3)';
|
||||
|
||||
try {
|
||||
var formData = new FormData();
|
||||
formData.append('file', file);
|
||||
var base = window.__BASE__ || '';
|
||||
var resp = await fetch(base + '/api/v1/admin/surfaces/install', {
|
||||
method: 'POST',
|
||||
headers: API._authHeaders ? (function() { var h = API._authHeaders(); delete h['Content-Type']; return h; })() : {},
|
||||
body: formData,
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || 'Upload failed');
|
||||
|
||||
statusEl.style.color = 'var(--success)';
|
||||
statusEl.textContent = 'Installed: ' + (data.title || data.id);
|
||||
if (typeof UI !== 'undefined') UI.toast('Surface installed: ' + (data.title || data.id), 'success');
|
||||
|
||||
// Refresh the list
|
||||
installForm.style.display = 'none';
|
||||
installBtn.style.display = '';
|
||||
fileInput.value = '';
|
||||
_loadSurfaceList();
|
||||
} catch (e) {
|
||||
statusEl.style.color = 'var(--danger)';
|
||||
statusEl.textContent = 'Error: ' + e.message;
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.textContent = 'Upload';
|
||||
}
|
||||
});
|
||||
|
||||
_loadSurfaceList();
|
||||
}
|
||||
|
||||
async function _loadSurfaceList() {
|
||||
var listEl = document.getElementById('adminSurfaceList');
|
||||
if (!listEl) return;
|
||||
|
||||
try {
|
||||
var resp = await API._get('/api/v1/admin/surfaces');
|
||||
var surfaces = resp.surfaces || [];
|
||||
|
||||
if (surfaces.length === 0) {
|
||||
listEl.innerHTML = '<div style="padding:12px;font-size:12px;color:var(--text-3);">No surfaces registered</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = '';
|
||||
surfaces.forEach(function(s) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'admin-surface-row';
|
||||
row.dataset.surfaceId = s.id;
|
||||
|
||||
var isProtected = s.id === 'chat' || s.id === 'admin';
|
||||
var sourceLabel = s.source === 'core' ? 'Core' : 'Extension';
|
||||
var route = s.manifest && s.manifest.route ? s.manifest.route : '';
|
||||
|
||||
row.innerHTML =
|
||||
'<div class="admin-surface-info">' +
|
||||
'<div class="admin-surface-title">' +
|
||||
'<span class="admin-surface-name">' + _surfEsc(s.title) + '</span>' +
|
||||
'<span class="admin-surface-badge admin-surface-badge--' + s.source + '">' + sourceLabel + '</span>' +
|
||||
(isProtected ? '<span class="admin-surface-badge admin-surface-badge--required">Required</span>' : '') +
|
||||
'</div>' +
|
||||
'<div class="admin-surface-meta">' +
|
||||
'<span class="admin-surface-id">' + _surfEsc(s.id) + '</span>' +
|
||||
(route ? '<span class="admin-surface-route">' + _surfEsc(route) + '</span>' : '') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="admin-surface-actions">' +
|
||||
'<label class="admin-surface-toggle' + (isProtected ? ' admin-surface-toggle--locked' : '') + '">' +
|
||||
'<input type="checkbox"' + (s.enabled ? ' checked' : '') + (isProtected ? ' disabled' : '') +
|
||||
' data-surface-id="' + _surfEsc(s.id) + '">' +
|
||||
'<span class="admin-surface-toggle-label">' + (s.enabled ? 'Enabled' : 'Disabled') + '</span>' +
|
||||
'</label>' +
|
||||
(s.source === 'extension' ? '<button class="btn-small btn-danger admin-surface-uninstall" data-surface-id="' + _surfEsc(s.id) + '">Uninstall</button>' : '') +
|
||||
'</div>';
|
||||
|
||||
// Toggle handler
|
||||
var checkbox = row.querySelector('input[type="checkbox"]');
|
||||
if (checkbox && !isProtected) {
|
||||
checkbox.addEventListener('change', (function(surface) {
|
||||
return async function() {
|
||||
var id = this.dataset.surfaceId;
|
||||
var action = this.checked ? 'enable' : 'disable';
|
||||
var label = row.querySelector('.admin-surface-toggle-label');
|
||||
var cb = this;
|
||||
|
||||
try {
|
||||
await API._put('/api/v1/admin/surfaces/' + id + '/' + action);
|
||||
if (label) label.textContent = cb.checked ? 'Enabled' : 'Disabled';
|
||||
if (typeof UI !== 'undefined') {
|
||||
UI.toast(surface.title + ' ' + (cb.checked ? 'enabled' : 'disabled') + ' — takes effect on next page load', 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
cb.checked = !cb.checked;
|
||||
if (label) label.textContent = cb.checked ? 'Enabled' : 'Disabled';
|
||||
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
};
|
||||
})(s));
|
||||
}
|
||||
|
||||
// Uninstall handler
|
||||
var uninstallBtn = row.querySelector('.admin-surface-uninstall');
|
||||
if (uninstallBtn) {
|
||||
uninstallBtn.addEventListener('click', (function(surface) {
|
||||
return async function() {
|
||||
var ok = typeof showConfirm === 'function'
|
||||
? await showConfirm('Uninstall ' + surface.title + '? This removes all files.')
|
||||
: window.confirm('Uninstall ' + surface.title + '?');
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await API._del('/api/v1/admin/surfaces/' + surface.id);
|
||||
row.remove();
|
||||
if (typeof UI !== 'undefined') UI.toast(surface.title + ' uninstalled', 'success');
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
};
|
||||
})(s));
|
||||
}
|
||||
|
||||
listEl.appendChild(row);
|
||||
});
|
||||
} catch (e) {
|
||||
listEl.innerHTML = '<div style="padding:12px;font-size:12px;color:var(--danger);">Failed to load: ' + _surfEsc(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function _surfEsc(s) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = s || '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
@@ -68,8 +68,52 @@ async function init() {
|
||||
async function startApp() {
|
||||
hideSplash();
|
||||
UI.restoreSidebar();
|
||||
await loadSettings();
|
||||
|
||||
// v0.25.0: Surface-aware initialization.
|
||||
// Common init runs on all surfaces. Chat-specific init only on chat.
|
||||
const surface = window.__SURFACE__ || 'chat';
|
||||
|
||||
// ── Common init (all surfaces) ──────────
|
||||
if (typeof loadSettings === 'function') await loadSettings();
|
||||
|
||||
// Guard: if token was invalidated during startup
|
||||
if (!API.isAuthed) {
|
||||
console.warn('⚠️ Auth lost during startup, returning to login');
|
||||
showSplash(null);
|
||||
return;
|
||||
}
|
||||
|
||||
UI.updateUser();
|
||||
UI.showAdminButton(API.isAdmin);
|
||||
UI.restoreAppearance();
|
||||
|
||||
// Load models for all surfaces (editor needs them for model selector)
|
||||
await fetchModels();
|
||||
|
||||
// Connect EventBus WebSocket (non-blocking)
|
||||
try {
|
||||
Events.connect((window.__BASE__ || '') + '/ws');
|
||||
Events.on('ws.connected', () => {
|
||||
if (typeof ToolsToggle !== 'undefined') ToolsToggle.refresh();
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('EventBus connect error:', e.message);
|
||||
}
|
||||
|
||||
// Signal that common init is complete — surface-specific JS can react
|
||||
document.dispatchEvent(new CustomEvent('sb:ready', { detail: { surface } }));
|
||||
|
||||
// ── Chat surface init ───────────────────
|
||||
if (surface === 'chat') {
|
||||
await _startChatSurface();
|
||||
}
|
||||
|
||||
// Editor, notes, admin, settings — their surface-specific JS handles
|
||||
// the rest via DOMContentLoaded listeners in their own boot scripts.
|
||||
}
|
||||
|
||||
// Chat-surface-specific initialization (extracted from monolithic startApp)
|
||||
async function _startChatSurface() {
|
||||
// v0.23.1: Load folders and channels before rendering sidebar
|
||||
await loadFolders();
|
||||
await loadChannels();
|
||||
@@ -86,8 +130,7 @@ async function startApp() {
|
||||
await loadChats();
|
||||
await loadProjects();
|
||||
await loadProjectChannelPositions(); // v0.19.2: load channel order per project
|
||||
await fetchModels();
|
||||
// app-state.js fetchModels populates App.models; UI updates are surface-specific
|
||||
// Models already loaded in common init; update chat-surface-specific UI
|
||||
if (typeof UI !== 'undefined') {
|
||||
UI.updateModelSelector();
|
||||
UI.updateCapabilityBadges();
|
||||
@@ -96,14 +139,6 @@ async function startApp() {
|
||||
// v0.23.2: Load user list for @mention autocomplete
|
||||
await loadUsers();
|
||||
|
||||
// Guard: if token was invalidated during startup (401 → refresh fail → clearTokens),
|
||||
// kick back to login instead of rendering a broken UI.
|
||||
if (!API.isAuthed) {
|
||||
console.warn('⚠️ Auth lost during startup, returning to login');
|
||||
showSplash(null);
|
||||
return;
|
||||
}
|
||||
|
||||
await initBanners();
|
||||
initFiles();
|
||||
|
||||
@@ -150,29 +185,14 @@ async function startApp() {
|
||||
UI.showEmptyState();
|
||||
}
|
||||
|
||||
UI.updateUser();
|
||||
UI.showAdminButton(API.isAdmin);
|
||||
initListeners();
|
||||
|
||||
// Connect EventBus WebSocket (non-blocking, graceful if /ws not available yet)
|
||||
try {
|
||||
Events.connect((window.__BASE__ || '') + '/ws');
|
||||
|
||||
// Once WS connects, re-fetch tools so browser extension tools appear
|
||||
// (ToolsToggle.init runs before WS is up → server omits browser tools)
|
||||
Events.on('ws.connected', () => {
|
||||
if (typeof ToolsToggle !== 'undefined') ToolsToggle.refresh();
|
||||
});
|
||||
|
||||
// Admin-only: persistent fallback alert banner (v0.17.0)
|
||||
Events.on('role.fallback', (payload) => {
|
||||
if (App.user?.role !== 'admin') return;
|
||||
const data = typeof payload === 'string' ? JSON.parse(payload) : payload;
|
||||
showFallbackBanner(data);
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('EventBus WebSocket not available:', e.message);
|
||||
}
|
||||
|
||||
console.log('✅ Chat Switchboard ready');
|
||||
}
|
||||
|
||||
341
src/js/code-editor.js
Normal file
341
src/js/code-editor.js
Normal file
@@ -0,0 +1,341 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — CodeEditor Component
|
||||
// ==========================================
|
||||
// v0.25.0: Extracted from editor-mode.js (lines 536-940).
|
||||
// Tabbed code editor with CM6 integration, auto-save, language detection,
|
||||
// modified indicators, and status bar.
|
||||
//
|
||||
// Usage:
|
||||
// const editor = CodeEditor.create({
|
||||
// id: 'editor',
|
||||
// workspaceId: 'ws-123',
|
||||
// onSave: (path, content) => fileTree.refresh(),
|
||||
// onActivate: (path) => fileTree.setActiveFile(path),
|
||||
// });
|
||||
// await editor.openFile('src/main.go');
|
||||
// editor.getActiveFile(); // → 'src/main.go'
|
||||
// editor.isModified('src/main.go'); // → false
|
||||
// await editor.saveAll();
|
||||
// editor.destroy();
|
||||
|
||||
const CodeEditor = {
|
||||
_instances: new Map(),
|
||||
|
||||
create(opts) {
|
||||
const pfx = opts.id || 'editor';
|
||||
const instance = {
|
||||
id: pfx,
|
||||
tabsEl: document.getElementById(pfx + 'EditorTabs'),
|
||||
contentEl: document.getElementById(pfx + 'EditorContent'),
|
||||
welcomeEl: document.getElementById(pfx + 'EditorWelcome'),
|
||||
statusEl: document.getElementById(pfx + 'EditorStatus'),
|
||||
statusFileEl: document.getElementById(pfx + 'EditorStatusFile'),
|
||||
statusLangEl: document.getElementById(pfx + 'EditorStatusLang'),
|
||||
statusBranchEl: document.getElementById(pfx + 'EditorStatusBranch'),
|
||||
workspaceId: opts.workspaceId || null,
|
||||
onSave: opts.onSave || null,
|
||||
onActivate: opts.onActivate || null,
|
||||
_openFiles: new Map(), // path → { tab, editorWrap, editor, content, modified, language }
|
||||
_activeFile: null,
|
||||
_listeners: [],
|
||||
|
||||
// ── Public API ──────────────────────
|
||||
|
||||
setWorkspace(wsId) { this.workspaceId = wsId; },
|
||||
|
||||
async openFile(path, content, language) {
|
||||
if (this._openFiles.has(path)) {
|
||||
this.activateFile(path);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch if content not provided
|
||||
if (content === undefined) {
|
||||
try {
|
||||
content = await API.readWorkspaceFile(this.workspaceId, path);
|
||||
} catch (e) {
|
||||
console.error('[CodeEditor] Failed to read:', e);
|
||||
if (typeof UI !== 'undefined') UI.toast('Failed to open ' + path, 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const lang = language || _ceDetectLanguage(path);
|
||||
|
||||
// Create tab
|
||||
const tab = document.createElement('div');
|
||||
tab.className = 'code-editor-tab';
|
||||
tab.dataset.path = path;
|
||||
const fileName = path.split('/').pop();
|
||||
tab.innerHTML =
|
||||
'<span class="code-editor-tab-icon">' + _ceFileIcon(fileName) + '</span>' +
|
||||
'<span class="code-editor-tab-name">' + _ceEsc(fileName) + '</span>' +
|
||||
'<span class="code-editor-tab-modified" style="display:none">●</span>' +
|
||||
'<button class="code-editor-tab-close" title="Close">✕</button>';
|
||||
|
||||
const self = this;
|
||||
tab.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.code-editor-tab-close')) self.activateFile(path);
|
||||
});
|
||||
tab.querySelector('.code-editor-tab-close').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
self.closeFile(path);
|
||||
});
|
||||
if (this.tabsEl) this.tabsEl.appendChild(tab);
|
||||
|
||||
// Create editor wrapper
|
||||
const editorWrap = document.createElement('div');
|
||||
editorWrap.className = 'code-editor-cm-wrap';
|
||||
editorWrap.style.display = 'none';
|
||||
if (this.contentEl) this.contentEl.appendChild(editorWrap);
|
||||
|
||||
// Create CM6 editor or textarea fallback
|
||||
let editor = null;
|
||||
if (window.CM?.codeEditor) {
|
||||
editor = CM.codeEditor(editorWrap, {
|
||||
language: lang,
|
||||
value: content,
|
||||
lineNumbers: true,
|
||||
});
|
||||
if (editor.onUpdate) {
|
||||
editor.onUpdate(() => self._markModified(path));
|
||||
}
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.className = 'code-editor-textarea-fallback';
|
||||
ta.value = content;
|
||||
ta.addEventListener('input', () => self._markModified(path));
|
||||
editorWrap.appendChild(ta);
|
||||
editor = {
|
||||
getValue: () => ta.value,
|
||||
setValue: (v) => { ta.value = v; },
|
||||
focus: () => ta.focus(),
|
||||
destroy: () => {},
|
||||
_textarea: true,
|
||||
};
|
||||
}
|
||||
|
||||
this._openFiles.set(path, {
|
||||
tab, editorWrap, editor, content, modified: false, language: lang,
|
||||
});
|
||||
|
||||
this.activateFile(path);
|
||||
},
|
||||
|
||||
activateFile(path) {
|
||||
if (this._activeFile === path) return;
|
||||
|
||||
// Auto-save previous if modified
|
||||
if (this._activeFile && this._openFiles.has(this._activeFile)) {
|
||||
const prev = this._openFiles.get(this._activeFile);
|
||||
if (prev.modified) this.saveFile(this._activeFile);
|
||||
prev.tab.classList.remove('active');
|
||||
prev.editorWrap.style.display = 'none';
|
||||
}
|
||||
|
||||
this._activeFile = path;
|
||||
const f = this._openFiles.get(path);
|
||||
if (!f) return;
|
||||
|
||||
f.tab.classList.add('active');
|
||||
f.editorWrap.style.display = '';
|
||||
if (this.welcomeEl) this.welcomeEl.style.display = 'none';
|
||||
|
||||
this._updateStatusBar();
|
||||
if (this.onActivate) this.onActivate(path);
|
||||
if (f.editor?.focus) setTimeout(() => f.editor.focus(), 10);
|
||||
},
|
||||
|
||||
async closeFile(path) {
|
||||
const f = this._openFiles.get(path);
|
||||
if (!f) return;
|
||||
|
||||
if (f.modified) {
|
||||
const save = await this._confirmClose(path);
|
||||
if (save === null) return; // cancelled
|
||||
if (save) await this.saveFile(path);
|
||||
}
|
||||
|
||||
f.tab.remove();
|
||||
f.editorWrap.remove();
|
||||
if (f.editor?.destroy) f.editor.destroy();
|
||||
this._openFiles.delete(path);
|
||||
|
||||
if (this._activeFile === path) {
|
||||
this._activeFile = null;
|
||||
const remaining = Array.from(this._openFiles.keys());
|
||||
if (remaining.length) {
|
||||
this.activateFile(remaining[remaining.length - 1]);
|
||||
} else {
|
||||
if (this.welcomeEl) this.welcomeEl.style.display = '';
|
||||
this._updateStatusBar();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async saveFile(path) {
|
||||
const f = this._openFiles.get(path);
|
||||
if (!f) return;
|
||||
|
||||
const content = f.editor?.getValue?.() ?? '';
|
||||
try {
|
||||
await API.writeWorkspaceFile(this.workspaceId, path, content);
|
||||
f.content = content;
|
||||
f.modified = false;
|
||||
f.tab.querySelector('.code-editor-tab-modified').style.display = 'none';
|
||||
f.tab.classList.remove('modified');
|
||||
if (path === this._activeFile) this._updateStatusBar();
|
||||
if (typeof UI !== 'undefined') UI.toast('Saved ' + path.split('/').pop(), 'success');
|
||||
if (this.onSave) this.onSave(path, content);
|
||||
} catch (e) {
|
||||
console.error('[CodeEditor] Save failed:', e);
|
||||
if (typeof UI !== 'undefined') UI.toast('Failed to save: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async saveAll() {
|
||||
for (const [path, f] of this._openFiles) {
|
||||
if (f.modified) await this.saveFile(path);
|
||||
}
|
||||
},
|
||||
|
||||
getActiveFile() { return this._activeFile; },
|
||||
|
||||
isModified(path) {
|
||||
const f = this._openFiles.get(path);
|
||||
return f ? f.modified : false;
|
||||
},
|
||||
|
||||
hasUnsaved() {
|
||||
for (const [, f] of this._openFiles) {
|
||||
if (f.modified) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
getOpenFiles() { return Array.from(this._openFiles.keys()); },
|
||||
|
||||
// ── Git Branch Display ──────────────
|
||||
|
||||
setBranch(branch) {
|
||||
if (this.statusBranchEl) {
|
||||
this.statusBranchEl.textContent = branch ? '⎇ ' + branch : '';
|
||||
}
|
||||
},
|
||||
|
||||
// ── Keyboard Shortcuts ──────────────
|
||||
|
||||
bind() {
|
||||
this._on(document, 'keydown', (e) => {
|
||||
// Ctrl/Cmd+S — save active file
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
if (this._activeFile) this.saveFile(this._activeFile);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// ── Lifecycle ───────────────────────
|
||||
|
||||
destroy() {
|
||||
// Destroy all CM6 instances
|
||||
for (const [, f] of this._openFiles) {
|
||||
if (f.editor?.destroy) f.editor.destroy();
|
||||
f.tab.remove();
|
||||
f.editorWrap.remove();
|
||||
}
|
||||
this._openFiles.clear();
|
||||
this._activeFile = null;
|
||||
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
|
||||
this._listeners = [];
|
||||
CodeEditor._instances.delete(this.id);
|
||||
},
|
||||
|
||||
// ── Internal ────────────────────────
|
||||
|
||||
_markModified(path) {
|
||||
const f = this._openFiles.get(path);
|
||||
if (!f || f.modified) return;
|
||||
const currentVal = f.editor?.getValue?.() ?? '';
|
||||
if (currentVal === f.content) return;
|
||||
f.modified = true;
|
||||
f.tab.querySelector('.code-editor-tab-modified').style.display = '';
|
||||
f.tab.classList.add('modified');
|
||||
if (path === this._activeFile) this._updateStatusBar();
|
||||
},
|
||||
|
||||
_updateStatusBar() {
|
||||
if (!this.statusFileEl) return;
|
||||
if (!this._activeFile) {
|
||||
this.statusFileEl.textContent = 'No file open';
|
||||
if (this.statusLangEl) this.statusLangEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
const f = this._openFiles.get(this._activeFile);
|
||||
this.statusFileEl.textContent = this._activeFile + (f?.modified ? ' ●' : '');
|
||||
if (this.statusLangEl && f) {
|
||||
this.statusLangEl.textContent = f.language || '';
|
||||
}
|
||||
},
|
||||
|
||||
async _confirmClose(path) {
|
||||
const fileName = path.split('/').pop();
|
||||
if (typeof showConfirm === 'function') {
|
||||
return showConfirm('Save changes to ' + fileName + '?', {
|
||||
confirmLabel: 'Save', cancelLabel: 'Discard', showCancel: true
|
||||
});
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
|
||||
_on(el, event, handler) {
|
||||
if (!el) return;
|
||||
el.addEventListener(event, handler);
|
||||
this._listeners.push({ el, event, handler });
|
||||
},
|
||||
};
|
||||
|
||||
CodeEditor._instances.set(pfx, instance);
|
||||
return instance;
|
||||
},
|
||||
|
||||
get(id) { return this._instances.get(id) || null; },
|
||||
};
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
function _ceEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
|
||||
function _ceFileIcon(name) {
|
||||
const ext = name.split('.').pop()?.toLowerCase();
|
||||
const icons = {
|
||||
js: '📜', ts: '📜', jsx: '⚛', tsx: '⚛',
|
||||
go: '🔵', py: '🐍', rs: '🦀', rb: '💎',
|
||||
html: '🌐', css: '🎨', scss: '🎨', less: '🎨',
|
||||
json: '📋', yaml: '📋', yml: '📋', toml: '📋',
|
||||
md: '📝', txt: '📄', svg: '🖼',
|
||||
sql: '🗃', sh: '⚙', bash: '⚙', zsh: '⚙',
|
||||
dockerfile: '🐳', makefile: '🔧',
|
||||
mod: '📦', sum: '📦', lock: '🔒',
|
||||
};
|
||||
return icons[ext] || '📄';
|
||||
}
|
||||
|
||||
function _ceDetectLanguage(path) {
|
||||
const ext = path.split('.').pop()?.toLowerCase();
|
||||
const map = {
|
||||
js: 'javascript', mjs: 'javascript', cjs: 'javascript',
|
||||
ts: 'typescript', tsx: 'typescript', jsx: 'javascript',
|
||||
go: 'go', py: 'python', rs: 'rust', rb: 'ruby',
|
||||
html: 'html', htm: 'html', css: 'css', scss: 'css', less: 'css',
|
||||
json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml',
|
||||
md: 'markdown', mdx: 'markdown',
|
||||
sql: 'sql', sh: 'shell', bash: 'shell', zsh: 'shell',
|
||||
xml: 'xml', svg: 'xml',
|
||||
c: 'cpp', cpp: 'cpp', h: 'cpp', hpp: 'cpp',
|
||||
java: 'java', kt: 'java',
|
||||
php: 'php', pl: 'perl',
|
||||
dockerfile: 'dockerfile',
|
||||
};
|
||||
return map[ext] || 'text';
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
662
src/js/editor-surface.js
Normal file
662
src/js/editor-surface.js
Normal file
@@ -0,0 +1,662 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — Editor Surface (v0.25.0)
|
||||
// ==========================================
|
||||
// Uses PaneContainer to mount a three-pane layout and moves
|
||||
// server-rendered component partials into pane slots.
|
||||
// Components are rendered by Go templates into #editorComponents
|
||||
// (hidden) — this JS moves them into the right pane slots.
|
||||
// ==========================================
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (window.__SURFACE__ !== 'editor') return;
|
||||
|
||||
const pageData = window.__PAGE_DATA__ || {};
|
||||
const wsId = pageData.WorkspaceID;
|
||||
const wsName = pageData.WorkspaceName || 'Workspace';
|
||||
|
||||
// Wire user menu
|
||||
_initUserMenu();
|
||||
|
||||
// Wire workspace selector
|
||||
_initWsSelector(wsId);
|
||||
|
||||
// No workspace — show bootstrap
|
||||
if (!wsId) {
|
||||
_showBootstrap();
|
||||
return;
|
||||
}
|
||||
|
||||
_mountEditor(wsId, wsName);
|
||||
});
|
||||
|
||||
// ── User Menu ───────────────────────────
|
||||
|
||||
function _initUserMenu() {
|
||||
if (typeof UserMenu === 'undefined') return;
|
||||
const menu = UserMenu.create({ id: '' });
|
||||
UserMenu.primary = menu;
|
||||
menu.setUser(API.user || window.__USER__);
|
||||
menu.bind({
|
||||
onSettings: () => { window.location.href = (window.__BASE__ || '') + '/settings'; },
|
||||
onAdmin: () => { window.location.href = (window.__BASE__ || '') + '/admin'; },
|
||||
onDebug: () => { if (typeof openDebugModal === 'function') openDebugModal(); },
|
||||
onSignout: () => { if (typeof handleLogout === 'function') handleLogout(); },
|
||||
});
|
||||
menu.showAdmin(API.isAdmin);
|
||||
}
|
||||
|
||||
// ── Workspace Selector ──────────────────
|
||||
|
||||
function _initWsSelector(currentWsId) {
|
||||
const btn = document.getElementById('editorWsSelectorBtn');
|
||||
const dropdown = document.getElementById('editorWsDropdown');
|
||||
if (!btn || !dropdown) return;
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const isOpen = dropdown.classList.toggle('open');
|
||||
if (isOpen) _loadWsDropdown(currentWsId);
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('#editorWsSelector')) dropdown.classList.remove('open');
|
||||
});
|
||||
|
||||
// New workspace button
|
||||
document.getElementById('editorWsNewBtn')?.addEventListener('click', async () => {
|
||||
dropdown.classList.remove('open');
|
||||
const name = window.prompt('Workspace name:');
|
||||
if (!name) return;
|
||||
try {
|
||||
const userId = API.user?.id || window.__USER__?.id;
|
||||
if (!userId) throw new Error('Not authenticated');
|
||||
const resp = await API.createWorkspace({ name: name.trim(), owner_type: 'user', owner_id: userId });
|
||||
const newId = resp.id || resp.data?.id;
|
||||
if (newId) {
|
||||
window.location.href = (window.__BASE__ || '') + '/editor/' + newId;
|
||||
}
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function _loadWsDropdown(currentWsId) {
|
||||
const listEl = document.getElementById('editorWsList');
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = '<div style="padding:6px 12px;font-size:11px;color:var(--text-3)">Loading…</div>';
|
||||
|
||||
try {
|
||||
const resp = await API._get('/api/v1/workspaces');
|
||||
const workspaces = resp.data || resp || [];
|
||||
listEl.innerHTML = '';
|
||||
|
||||
if (workspaces.length === 0) {
|
||||
listEl.innerHTML = '<div style="padding:6px 12px;font-size:11px;color:var(--text-3)">No workspaces</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
workspaces.forEach(ws => {
|
||||
const item = document.createElement('button');
|
||||
item.className = 'editor-ws-dropdown-item' + (ws.id === currentWsId ? ' active' : '');
|
||||
item.textContent = ws.name || ws.id?.slice(0, 8);
|
||||
item.addEventListener('click', () => {
|
||||
window.location.href = (window.__BASE__ || '') + '/editor/' + ws.id;
|
||||
});
|
||||
listEl.appendChild(item);
|
||||
});
|
||||
} catch (_) {
|
||||
listEl.innerHTML = '<div style="padding:6px 12px;font-size:11px;color:var(--text-3)">Failed to load</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bootstrap (no workspace) ────────────
|
||||
|
||||
function _showBootstrap() {
|
||||
const body = document.getElementById('editorBody');
|
||||
const bootstrap = document.getElementById('editorBootstrap');
|
||||
if (body) body.style.display = 'none';
|
||||
if (bootstrap) bootstrap.style.display = '';
|
||||
|
||||
_loadBootstrapList();
|
||||
|
||||
const btn = document.getElementById('editorBootstrapBtn');
|
||||
const input = document.getElementById('editorBootstrapName');
|
||||
if (!btn || !input) return;
|
||||
|
||||
btn.addEventListener('click', async () => {
|
||||
const name = input.value.trim() || 'workspace';
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Creating…';
|
||||
try {
|
||||
const userId = API.user?.id || window.__USER__?.id;
|
||||
if (!userId) throw new Error('Not authenticated');
|
||||
const resp = await API.createWorkspace({ name, owner_type: 'user', owner_id: userId });
|
||||
const newId = resp.id || resp.data?.id;
|
||||
if (!newId) throw new Error('No workspace ID returned');
|
||||
// Navigate to the new workspace
|
||||
window.location.href = (window.__BASE__ || '') + '/editor/' + newId;
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Create Workspace';
|
||||
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function _loadBootstrapList() {
|
||||
const listEl = document.getElementById('editorBootstrapList');
|
||||
if (!listEl) return;
|
||||
try {
|
||||
const resp = await API._get('/api/v1/workspaces');
|
||||
const workspaces = resp.data || resp || [];
|
||||
if (workspaces.length === 0) {
|
||||
listEl.innerHTML = '<div style="font-size:12px;color:var(--text-3);">No workspaces yet</div>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = '';
|
||||
workspaces.forEach(ws => {
|
||||
const item = document.createElement('button');
|
||||
item.className = 'editor-bootstrap-ws-item';
|
||||
item.innerHTML =
|
||||
'<span class="editor-bootstrap-ws-name">' + _edEsc(ws.name || ws.id?.slice(0, 8)) + '</span>' +
|
||||
'<span class="editor-bootstrap-ws-date">' + _edEsc(ws.created_at ? new Date(ws.created_at).toLocaleDateString() : '') + '</span>';
|
||||
item.addEventListener('click', () => {
|
||||
window.location.href = (window.__BASE__ || '') + '/editor/' + ws.id;
|
||||
});
|
||||
listEl.appendChild(item);
|
||||
});
|
||||
} catch (_) {
|
||||
listEl.innerHTML = '<div style="font-size:12px;color:var(--text-3);">Failed to load workspaces</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mount Pane Layout ───────────────────
|
||||
|
||||
function _mountEditor(wsId, wsName) {
|
||||
const body = document.getElementById('editorBody');
|
||||
if (!body || typeof PaneContainer === 'undefined') {
|
||||
console.error('[EditorSurface] Missing body or PaneContainer');
|
||||
return;
|
||||
}
|
||||
|
||||
// Mount the 'editor' preset: files | editor | <chat, notes>
|
||||
const layout = PaneContainer.mount(body, 'editor', { workspaceId: wsId });
|
||||
if (!layout) return;
|
||||
|
||||
// ── Move server-rendered components into pane slots ──
|
||||
// Components were rendered by Go templates into #editorComponents (hidden).
|
||||
// We move them into the pane slots created by PaneContainer.
|
||||
|
||||
const components = document.getElementById('editorComponents');
|
||||
|
||||
// File Tree → files pane
|
||||
const filesPaneInfo = layout._panes.get('files');
|
||||
const fileTreeEl = document.getElementById('edFileTree');
|
||||
if (filesPaneInfo?.el && fileTreeEl) {
|
||||
filesPaneInfo.el.appendChild(fileTreeEl);
|
||||
}
|
||||
const fileTree = FileTree.create({ id: 'ed', workspaceId: wsId,
|
||||
onSelect: (path) => codeEditor.openFile(path),
|
||||
onDelete: (path) => _deleteFile(wsId, path, fileTree, codeEditor),
|
||||
onNewFile: () => _createNewFile(wsId, fileTree),
|
||||
});
|
||||
filesPaneInfo.component = fileTree;
|
||||
fileTree.bind();
|
||||
|
||||
// Code Editor → editor pane
|
||||
const editorPaneInfo = layout._panes.get('editor');
|
||||
const codeEditorEl = document.getElementById('edCodeEditor');
|
||||
if (editorPaneInfo?.el && codeEditorEl) {
|
||||
editorPaneInfo.el.appendChild(codeEditorEl);
|
||||
}
|
||||
const codeEditor = CodeEditor.create({ id: 'ed', workspaceId: wsId,
|
||||
onSave: () => fileTree.refresh(),
|
||||
onActivate: (path) => fileTree.setActiveFile(path),
|
||||
});
|
||||
editorPaneInfo.component = codeEditor;
|
||||
codeEditor.bind();
|
||||
|
||||
// Assist pane (tabbed: chat + notes)
|
||||
const assistPaneInfo = layout._panes.get('assist');
|
||||
if (assistPaneInfo?.tabs) {
|
||||
// Chat tab — move server-rendered ChatPane partial
|
||||
const chatPanel = assistPaneInfo.getTabPanel('chat');
|
||||
const chatPaneEl = document.getElementById('edChatPane');
|
||||
if (chatPanel && chatPaneEl) {
|
||||
chatPanel.appendChild(chatPaneEl);
|
||||
// Create ChatPane instance from the server-rendered mount points
|
||||
if (typeof ChatPane !== 'undefined') {
|
||||
const chatPane = ChatPane.create({
|
||||
id: 'ed',
|
||||
messagesEl: document.getElementById('edChatMessages'),
|
||||
inputEl: document.getElementById('edChatInput'),
|
||||
sendBtnEl: document.getElementById('edSendBtn'),
|
||||
modelSelEl: document.getElementById('edModelSel'),
|
||||
toolbarEl: document.getElementById('edToolbar'),
|
||||
standalone: true,
|
||||
});
|
||||
chatPane.showWelcome();
|
||||
_initAssistChat(chatPane, codeEditor);
|
||||
const chatTab = assistPaneInfo.tabs.find(t => t.id === 'chat');
|
||||
if (chatTab) chatTab.instance = chatPane;
|
||||
}
|
||||
}
|
||||
|
||||
// Notes tab — move server-rendered NoteEditor partial
|
||||
const notesPanel = assistPaneInfo.getTabPanel('notes');
|
||||
const noteEditorEl = document.getElementById('edNotesNoteEditor');
|
||||
if (notesPanel && noteEditorEl) {
|
||||
notesPanel.appendChild(noteEditorEl);
|
||||
if (typeof NoteEditor !== 'undefined') {
|
||||
const noteEditor = NoteEditor.create({ id: 'edNotes', onOpenGraph: null });
|
||||
noteEditor.bind();
|
||||
const notesTab = assistPaneInfo.tabs.find(t => t.id === 'notes');
|
||||
if (notesTab) {
|
||||
notesTab.instance = noteEditor;
|
||||
// Lazy-load on first tab click
|
||||
notesTab.btn.addEventListener('click', () => {
|
||||
if (!notesTab._loaded) {
|
||||
notesTab._loaded = true;
|
||||
noteEditor.loadNotes();
|
||||
noteEditor.loadFolders();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the hidden container
|
||||
if (components) components.remove();
|
||||
|
||||
// Git branch
|
||||
_refreshGitBranch(wsId, codeEditor);
|
||||
|
||||
// Toolbar
|
||||
document.getElementById('editorRefreshBtn')?.addEventListener('click', () => {
|
||||
fileTree.refresh();
|
||||
_refreshGitBranch(wsId, codeEditor);
|
||||
});
|
||||
|
||||
// Initial load
|
||||
fileTree.refresh();
|
||||
|
||||
console.log('[EditorSurface] Mounted for workspace', wsId);
|
||||
}
|
||||
|
||||
// ── File Operations ─────────────────────
|
||||
|
||||
async function _deleteFile(wsId, path, fileTree, codeEditor) {
|
||||
const ok = typeof showConfirm === 'function'
|
||||
? await showConfirm('Delete ' + path + '?')
|
||||
: window.confirm('Delete ' + path + '?');
|
||||
if (!ok) return;
|
||||
try {
|
||||
await API.deleteWorkspaceFile(wsId, path);
|
||||
if (codeEditor.getOpenFiles().includes(path)) await codeEditor.closeFile(path);
|
||||
fileTree.refresh();
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Delete failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function _createNewFile(wsId, fileTree) {
|
||||
const name = window.prompt('File name (e.g. src/main.go):');
|
||||
if (!name) return;
|
||||
try {
|
||||
await API.writeWorkspaceFile(wsId, name.trim(), '');
|
||||
fileTree.refresh();
|
||||
if (typeof UI !== 'undefined') UI.toast('Created ' + name.trim(), 'success');
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Git Branch ──────────────────────────
|
||||
|
||||
async function _refreshGitBranch(wsId, codeEditor) {
|
||||
try {
|
||||
const resp = await API.getWorkspaceGitBranches(wsId);
|
||||
const branch = resp.current || null;
|
||||
if (branch) {
|
||||
const badge = document.getElementById('editorBranchBadge');
|
||||
const name = document.getElementById('editorBranchName');
|
||||
if (badge) badge.style.display = '';
|
||||
if (name) name.textContent = branch;
|
||||
}
|
||||
if (codeEditor) codeEditor.setBranch(branch);
|
||||
} catch (_) {
|
||||
// Git not configured — hide branch badge
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────
|
||||
|
||||
function _edEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
|
||||
// ── Assist Chat (channel-based, standalone) ──
|
||||
// Full chat pane for the editor: model selector, chat history,
|
||||
// new chat, SSE streaming. Uses real channels/messages API.
|
||||
// No dependency on chat.js — fully standalone.
|
||||
|
||||
function _initAssistChat(chatPane, codeEditor) {
|
||||
const inputEl = chatPane.inputEl;
|
||||
const sendBtn = chatPane.sendBtnEl;
|
||||
const headerEl = document.getElementById('edChatHeader');
|
||||
const selectEl = document.getElementById('edChatSelect');
|
||||
const newBtnEl = document.getElementById('edChatNewBtn');
|
||||
const modelSelEl = document.getElementById('edModelSel');
|
||||
if (!inputEl) return;
|
||||
|
||||
// Show the header bar
|
||||
if (headerEl) headerEl.style.display = '';
|
||||
|
||||
// State
|
||||
let _channelId = null;
|
||||
let _messages = [];
|
||||
let _sending = false;
|
||||
let _abortController = null;
|
||||
let _selectedModel = App.settings?.model || '';
|
||||
|
||||
// ── Model Selector ──────────────────────
|
||||
async function _initModelSelector() {
|
||||
if (!modelSelEl) return;
|
||||
// Ensure models are loaded
|
||||
if (!App.models?.length && typeof fetchModels === 'function') {
|
||||
await fetchModels();
|
||||
}
|
||||
const models = App.models || [];
|
||||
if (!models.length) return;
|
||||
|
||||
const sel = document.createElement('select');
|
||||
sel.className = 'chat-pane-model-select';
|
||||
sel.title = 'Select model';
|
||||
|
||||
models.forEach(m => {
|
||||
if (m.hidden) return;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.isPersona ? (m.personaId || m.id) : m.id;
|
||||
const prefix = m.isPersona ? '🎭 ' : '';
|
||||
opt.textContent = prefix + (m.name || m.id);
|
||||
if (m.id === _selectedModel || m.personaId === _selectedModel) {
|
||||
opt.selected = true;
|
||||
}
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
|
||||
sel.addEventListener('change', () => {
|
||||
_selectedModel = sel.value;
|
||||
});
|
||||
|
||||
modelSelEl.innerHTML = '';
|
||||
modelSelEl.appendChild(sel);
|
||||
}
|
||||
|
||||
// ── Chat History Selector ───────────────
|
||||
async function _loadChatHistory() {
|
||||
if (!selectEl) return;
|
||||
try {
|
||||
const resp = await API.listChannels(1, 20, 'direct');
|
||||
const channels = resp.data || resp || [];
|
||||
selectEl.innerHTML = '<option value="">New conversation</option>';
|
||||
channels.forEach(ch => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = ch.id;
|
||||
opt.textContent = ch.title || 'Untitled';
|
||||
if (ch.id === _channelId) opt.selected = true;
|
||||
selectEl.appendChild(opt);
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[AssistChat] Failed to load history:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectEl) {
|
||||
selectEl.addEventListener('change', () => {
|
||||
const id = selectEl.value;
|
||||
if (id) {
|
||||
_switchToChannel(id);
|
||||
} else {
|
||||
_newChat();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (newBtnEl) {
|
||||
newBtnEl.addEventListener('click', _newChat);
|
||||
}
|
||||
|
||||
// ── Switch Channel ──────────────────────
|
||||
async function _switchToChannel(channelId) {
|
||||
_channelId = channelId;
|
||||
_messages = [];
|
||||
chatPane.clear();
|
||||
chatPane.appendTyping();
|
||||
|
||||
try {
|
||||
const resp = await API._get('/api/v1/channels/' + channelId + '/messages?page=1&per_page=100');
|
||||
const msgs = resp.data || resp || [];
|
||||
_messages = msgs;
|
||||
chatPane.removeTyping();
|
||||
_renderAllMessages();
|
||||
} catch (e) {
|
||||
chatPane.removeTyping();
|
||||
_appendSystemMsg('Failed to load messages: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function _newChat() {
|
||||
_channelId = null;
|
||||
_messages = [];
|
||||
chatPane.showWelcome();
|
||||
if (selectEl) selectEl.value = '';
|
||||
}
|
||||
|
||||
// ── Create Textarea ─────────────────────
|
||||
const ta = document.createElement('textarea');
|
||||
ta.placeholder = 'Ask about your code…';
|
||||
ta.rows = 1;
|
||||
inputEl.appendChild(ta);
|
||||
|
||||
ta.addEventListener('input', () => {
|
||||
ta.style.height = 'auto';
|
||||
ta.style.height = Math.min(ta.scrollHeight, 160) + 'px';
|
||||
});
|
||||
|
||||
// ── Send ────────────────────────────────
|
||||
async function sendMessage() {
|
||||
const text = ta.value.trim();
|
||||
if (!text || _sending) return;
|
||||
|
||||
_sending = true;
|
||||
if (sendBtn) sendBtn.disabled = true;
|
||||
ta.value = '';
|
||||
ta.style.height = 'auto';
|
||||
|
||||
// Create channel if new conversation
|
||||
if (!_channelId) {
|
||||
try {
|
||||
const title = text.slice(0, 50) + (text.length > 50 ? '…' : '');
|
||||
const resp = await API.createChannel(title, _selectedModel, '', 'direct');
|
||||
_channelId = resp.id;
|
||||
// Update dropdown
|
||||
_loadChatHistory();
|
||||
} catch (e) {
|
||||
_appendSystemMsg('Failed to create chat: ' + e.message);
|
||||
_sending = false;
|
||||
if (sendBtn) sendBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Render user message
|
||||
_messages.push({ role: 'user', content: text });
|
||||
_renderAllMessages();
|
||||
|
||||
// Build context from active file
|
||||
const fileCtx = _getFileContext(codeEditor);
|
||||
|
||||
// Stream the completion
|
||||
try {
|
||||
_abortController = new AbortController();
|
||||
|
||||
// If we have file context, prepend it to the message
|
||||
const content = fileCtx
|
||||
? text + '\n\n[Context: Currently editing ' + fileCtx.path + ']\n```\n' + fileCtx.content + '\n```'
|
||||
: text;
|
||||
|
||||
const resp = await API.streamCompletion(
|
||||
_channelId, content, _selectedModel,
|
||||
_abortController.signal
|
||||
);
|
||||
|
||||
await _handleStream(resp, chatPane);
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
_appendSystemMsg('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
_abortController = null;
|
||||
_sending = false;
|
||||
if (sendBtn) sendBtn.disabled = false;
|
||||
ta.focus();
|
||||
}
|
||||
|
||||
if (sendBtn) sendBtn.addEventListener('click', sendMessage);
|
||||
|
||||
ta.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
// ── SSE Stream Handler ──────────────────
|
||||
async function _handleStream(response, pane) {
|
||||
const messagesEl = pane.messagesEl;
|
||||
if (!messagesEl) return;
|
||||
|
||||
// Create assistant bubble
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg chat-msg--assistant chat-msg--streaming';
|
||||
const contentEl = document.createElement('div');
|
||||
contentEl.className = 'chat-msg__content';
|
||||
div.appendChild(contentEl);
|
||||
messagesEl.appendChild(div);
|
||||
|
||||
let content = '';
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const delta = parsed.choices?.[0]?.delta?.content;
|
||||
if (delta) {
|
||||
content += delta;
|
||||
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
|
||||
contentEl.innerHTML = DOMPurify.sanitize(marked.parse(content));
|
||||
} else {
|
||||
contentEl.textContent = content;
|
||||
}
|
||||
pane.scrollToBottom();
|
||||
}
|
||||
} catch (_) { /* skip unparseable lines */ }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
content += '\n\n**[Stream error: ' + e.message + ']**';
|
||||
}
|
||||
}
|
||||
|
||||
div.classList.remove('chat-msg--streaming');
|
||||
_messages.push({ role: 'assistant', content });
|
||||
}
|
||||
|
||||
// ── Rendering ───────────────────────────
|
||||
function _renderAllMessages() {
|
||||
if (!chatPane.messagesEl) return;
|
||||
chatPane.messagesEl.innerHTML = '';
|
||||
_messages.forEach(m => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg chat-msg--' + m.role;
|
||||
const content = document.createElement('div');
|
||||
content.className = 'chat-msg__content';
|
||||
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
|
||||
content.innerHTML = DOMPurify.sanitize(marked.parse(m.content || ''));
|
||||
} else {
|
||||
content.textContent = m.content || '';
|
||||
}
|
||||
div.appendChild(content);
|
||||
chatPane.messagesEl.appendChild(div);
|
||||
});
|
||||
chatPane.scrollToBottom();
|
||||
}
|
||||
|
||||
function _appendSystemMsg(text) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg chat-msg--system';
|
||||
div.innerHTML = '<div class="chat-msg__content" style="color:var(--danger,#f44336);font-size:12px;">' + _edEsc(text) + '</div>';
|
||||
chatPane.messagesEl?.appendChild(div);
|
||||
chatPane.scrollToBottom();
|
||||
}
|
||||
|
||||
function _getFileContext(editor) {
|
||||
if (!editor) return null;
|
||||
try {
|
||||
const openFiles = editor.getOpenFiles();
|
||||
if (!openFiles.length) return null;
|
||||
// Find active tab
|
||||
const activeTab = document.querySelector('.code-editor-tab.active');
|
||||
const path = activeTab?.dataset?.path || openFiles[0];
|
||||
// Get content from the editor instances
|
||||
const inst = CodeEditor._instances?.get('ed');
|
||||
if (inst?._files) {
|
||||
const file = inst._files.get(path);
|
||||
if (file?.view) {
|
||||
const content = file.view.state.doc.toString();
|
||||
return { path, content: content.slice(0, 4000) };
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Init ────────────────────────────────
|
||||
// Defer model selector and chat history loading until app common init
|
||||
// completes (fetchModels, auth, etc.). The sb:ready event fires from
|
||||
// startApp() after App.models is populated.
|
||||
function _deferredInit() {
|
||||
_initModelSelector();
|
||||
_loadChatHistory();
|
||||
}
|
||||
|
||||
if (App.models?.length) {
|
||||
// Already loaded (unlikely but possible)
|
||||
_deferredInit();
|
||||
} else {
|
||||
document.addEventListener('sb:ready', _deferredInit, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
})();
|
||||
278
src/js/file-tree.js
Normal file
278
src/js/file-tree.js
Normal file
@@ -0,0 +1,278 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — FileTree Component
|
||||
// ==========================================
|
||||
// v0.25.0: Extracted from editor-mode.js (lines 336-535).
|
||||
// Workspace file browser with directory expand/collapse, git status
|
||||
// badges, context menu, and file icon mapping.
|
||||
//
|
||||
// Usage:
|
||||
// const tree = FileTree.create({
|
||||
// id: 'editor',
|
||||
// workspaceId: 'ws-123',
|
||||
// onSelect: (path) => codeEditor.openFile(path),
|
||||
// onDelete: (path) => { ... },
|
||||
// onNewFile: () => { ... },
|
||||
// });
|
||||
// await tree.refresh();
|
||||
// tree.setActiveFile('src/main.go');
|
||||
// tree.destroy();
|
||||
|
||||
const FileTree = {
|
||||
_instances: new Map(),
|
||||
|
||||
create(opts) {
|
||||
const pfx = opts.id || 'editor';
|
||||
const instance = {
|
||||
id: pfx,
|
||||
itemsEl: document.getElementById(pfx + 'TreeItems'),
|
||||
newFileEl: document.getElementById(pfx + 'TreeNewFile'),
|
||||
workspaceId: opts.workspaceId || null,
|
||||
onSelect: opts.onSelect || null,
|
||||
onDelete: opts.onDelete || null,
|
||||
onNewFile: opts.onNewFile || null,
|
||||
_treeData: [],
|
||||
_expandedDirs: new Set(['']),
|
||||
_gitStatusMap: new Map(),
|
||||
_activeFile: null,
|
||||
_listeners: [],
|
||||
|
||||
// ── Public API ──────────────────────
|
||||
|
||||
setWorkspace(wsId) { this.workspaceId = wsId; },
|
||||
|
||||
async refresh() {
|
||||
if (!this.workspaceId) return;
|
||||
try {
|
||||
const resp = await API.listWorkspaceFiles(this.workspaceId, '', true);
|
||||
this._treeData = resp.files || resp.data || resp || [];
|
||||
this._render();
|
||||
} catch (e) {
|
||||
console.error('[FileTree] Failed to load:', e);
|
||||
if (this.itemsEl) this.itemsEl.innerHTML = '<div class="file-tree-error">Failed to load files</div>';
|
||||
}
|
||||
// Non-blocking git status refresh
|
||||
this._refreshGitStatus();
|
||||
},
|
||||
|
||||
setActiveFile(path) {
|
||||
this._activeFile = path;
|
||||
if (!this.itemsEl) return;
|
||||
this.itemsEl.querySelectorAll('.file-tree-row').forEach(row => {
|
||||
row.classList.toggle('active', row.dataset.path === path);
|
||||
});
|
||||
},
|
||||
|
||||
getActiveFile() { return this._activeFile; },
|
||||
|
||||
// ── Git Status ──────────────────────
|
||||
|
||||
async _refreshGitStatus() {
|
||||
if (!this.workspaceId) return;
|
||||
this._gitStatusMap.clear();
|
||||
try {
|
||||
const resp = await API.getWorkspaceGitStatus(this.workspaceId);
|
||||
const files = resp.files || [];
|
||||
for (const f of files) {
|
||||
this._gitStatusMap.set(f.path, f.status || '?');
|
||||
}
|
||||
// Apply to rendered rows
|
||||
if (this.itemsEl) {
|
||||
this.itemsEl.querySelectorAll('.file-tree-row').forEach(row => {
|
||||
const p = row.dataset.path;
|
||||
const st = this._gitStatusMap.get(p);
|
||||
row.classList.remove('git-modified', 'git-added', 'git-untracked', 'git-deleted');
|
||||
if (st === 'M') row.classList.add('git-modified');
|
||||
else if (st === 'A') row.classList.add('git-added');
|
||||
else if (st === '?') row.classList.add('git-untracked');
|
||||
else if (st === 'D') row.classList.add('git-deleted');
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// Git not configured — ignore
|
||||
}
|
||||
},
|
||||
|
||||
// ── Tree Rendering ──────────────────
|
||||
|
||||
_render() {
|
||||
if (!this.itemsEl) return;
|
||||
this.itemsEl.innerHTML = '';
|
||||
if (!this._treeData.length) {
|
||||
this.itemsEl.innerHTML = '<div class="file-tree-empty">No files</div>';
|
||||
return;
|
||||
}
|
||||
const tree = this._buildStructure(this._treeData);
|
||||
this._renderNodes(tree, this.itemsEl, 0);
|
||||
},
|
||||
|
||||
_buildStructure(files) {
|
||||
const root = { name: '', path: '', isDir: true, children: [], file: null };
|
||||
const dirs = new Map();
|
||||
dirs.set('', root);
|
||||
|
||||
const sorted = [...files].sort((a, b) => {
|
||||
if (a.is_directory !== b.is_directory) return a.is_directory ? -1 : 1;
|
||||
return a.path.localeCompare(b.path);
|
||||
});
|
||||
|
||||
for (const f of sorted) {
|
||||
const parts = f.path.split('/');
|
||||
const name = parts[parts.length - 1];
|
||||
const parentPath = parts.slice(0, -1).join('/');
|
||||
|
||||
let parent = dirs.get(parentPath);
|
||||
if (!parent) {
|
||||
let accumulated = '';
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const prev = accumulated;
|
||||
accumulated = accumulated ? accumulated + '/' + parts[i] : parts[i];
|
||||
if (!dirs.has(accumulated)) {
|
||||
const dirNode = { name: parts[i], path: accumulated, isDir: true, children: [], file: null };
|
||||
dirs.set(accumulated, dirNode);
|
||||
const p = dirs.get(prev);
|
||||
if (p) p.children.push(dirNode);
|
||||
}
|
||||
}
|
||||
parent = dirs.get(parentPath);
|
||||
}
|
||||
|
||||
if (f.is_directory) {
|
||||
let existing = dirs.get(f.path);
|
||||
if (!existing) {
|
||||
existing = { name, path: f.path, isDir: true, children: [], file: f };
|
||||
dirs.set(f.path, existing);
|
||||
if (parent) parent.children.push(existing);
|
||||
} else {
|
||||
existing.file = f;
|
||||
}
|
||||
} else {
|
||||
const node = { name, path: f.path, isDir: false, children: [], file: f };
|
||||
if (parent) parent.children.push(node);
|
||||
}
|
||||
}
|
||||
return root.children;
|
||||
},
|
||||
|
||||
_renderNodes(nodes, container, depth) {
|
||||
const self = this;
|
||||
for (const node of nodes) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'file-tree-row' + (node.path === this._activeFile ? ' active' : '');
|
||||
row.style.paddingLeft = (12 + depth * 16) + 'px';
|
||||
row.dataset.path = node.path;
|
||||
|
||||
if (node.isDir) {
|
||||
const expanded = this._expandedDirs.has(node.path);
|
||||
row.innerHTML =
|
||||
'<span class="file-tree-arrow ' + (expanded ? 'expanded' : '') + '">' + (expanded ? '▾' : '▸') + '</span>' +
|
||||
'<span class="file-tree-icon">📁</span>' +
|
||||
'<span class="file-tree-name">' + _ftEsc(node.name) + '</span>';
|
||||
row.addEventListener('click', () => {
|
||||
if (self._expandedDirs.has(node.path)) {
|
||||
self._expandedDirs.delete(node.path);
|
||||
} else {
|
||||
self._expandedDirs.add(node.path);
|
||||
}
|
||||
self._render();
|
||||
});
|
||||
container.appendChild(row);
|
||||
if (expanded && node.children.length) {
|
||||
this._renderNodes(node.children, container, depth + 1);
|
||||
}
|
||||
} else {
|
||||
row.innerHTML =
|
||||
'<span class="file-tree-icon">' + _ftFileIcon(node.name) + '</span>' +
|
||||
'<span class="file-tree-name">' + _ftEsc(node.name) + '</span>';
|
||||
row.addEventListener('click', () => {
|
||||
if (self.onSelect) self.onSelect(node.path);
|
||||
});
|
||||
row.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
self._showContextMenu(e, node);
|
||||
});
|
||||
container.appendChild(row);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Context Menu ────────────────────
|
||||
|
||||
_showContextMenu(e, node) {
|
||||
const existing = document.querySelector('.file-tree-ctx-menu');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'file-tree-ctx-menu';
|
||||
menu.style.left = e.clientX + 'px';
|
||||
menu.style.top = e.clientY + 'px';
|
||||
|
||||
const items = [
|
||||
{ label: 'Open', action: () => { if (this.onSelect) this.onSelect(node.path); } },
|
||||
{ label: 'Delete', action: () => { if (this.onDelete) this.onDelete(node.path); } },
|
||||
];
|
||||
for (const item of items) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'file-tree-ctx-item';
|
||||
row.textContent = item.label;
|
||||
row.addEventListener('click', () => { menu.remove(); item.action(); });
|
||||
menu.appendChild(row);
|
||||
}
|
||||
|
||||
document.body.appendChild(menu);
|
||||
const dismiss = (ev) => {
|
||||
if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', dismiss); }
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', dismiss), 0);
|
||||
},
|
||||
|
||||
// ── Event Wiring ────────────────────
|
||||
|
||||
bind() {
|
||||
this._on(this.newFileEl, 'click', () => {
|
||||
if (this.onNewFile) this.onNewFile();
|
||||
});
|
||||
},
|
||||
|
||||
// ── Lifecycle ───────────────────────
|
||||
|
||||
destroy() {
|
||||
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
|
||||
this._listeners = [];
|
||||
this._treeData = [];
|
||||
this._expandedDirs = new Set(['']);
|
||||
this._gitStatusMap.clear();
|
||||
FileTree._instances.delete(this.id);
|
||||
},
|
||||
|
||||
_on(el, event, handler) {
|
||||
if (!el) return;
|
||||
el.addEventListener(event, handler);
|
||||
this._listeners.push({ el, event, handler });
|
||||
},
|
||||
};
|
||||
|
||||
FileTree._instances.set(pfx, instance);
|
||||
return instance;
|
||||
},
|
||||
|
||||
get(id) { return this._instances.get(id) || null; },
|
||||
};
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
function _ftEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
|
||||
function _ftFileIcon(name) {
|
||||
const ext = name.split('.').pop()?.toLowerCase();
|
||||
const icons = {
|
||||
js: '📜', ts: '📜', jsx: '⚛', tsx: '⚛',
|
||||
go: '🔵', py: '🐍', rs: '🦀', rb: '💎',
|
||||
html: '🌐', css: '🎨', scss: '🎨', less: '🎨',
|
||||
json: '📋', yaml: '📋', yml: '📋', toml: '📋',
|
||||
md: '📝', txt: '📄', svg: '🖼',
|
||||
sql: '🗃', sh: '⚙', bash: '⚙', zsh: '⚙',
|
||||
dockerfile: '🐳', makefile: '🔧',
|
||||
mod: '📦', sum: '📦', lock: '🔒',
|
||||
};
|
||||
return icons[ext] || '📄';
|
||||
}
|
||||
191
src/js/model-selector.js
Normal file
191
src/js/model-selector.js
Normal file
@@ -0,0 +1,191 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — ModelSelector Component
|
||||
// ==========================================
|
||||
// v0.25.0: Extracted from ui-core.js.
|
||||
// Grouped dropdown: personas (global, team, personal), models, BYOK.
|
||||
// Pattern: Go template partial (model-selector.html) + JS factory (this file).
|
||||
//
|
||||
// Usage:
|
||||
// const sel = ModelSelector.create({ id: 'main', onChange: (id, label) => { ... } });
|
||||
// sel.setModels(App.models);
|
||||
// sel.select(modelId);
|
||||
// sel.getSelected(); // → current model ID
|
||||
// sel.destroy();
|
||||
|
||||
const ModelSelector = {
|
||||
primary: null,
|
||||
_instances: new Map(),
|
||||
|
||||
create(opts) {
|
||||
const pfx = opts.id || '';
|
||||
const instance = {
|
||||
id: pfx,
|
||||
dropdownEl: document.getElementById(pfx + 'modelDropdown'),
|
||||
btnEl: document.getElementById(pfx + 'modelDropdownBtn'),
|
||||
labelEl: document.getElementById(pfx + 'modelDropdownLabel'),
|
||||
menuEl: document.getElementById(pfx + 'modelDropdownMenu'),
|
||||
capsEl: document.getElementById(pfx + 'modelCaps'),
|
||||
onChange: opts.onChange || null,
|
||||
_value: '',
|
||||
_models: [],
|
||||
_listeners: [],
|
||||
|
||||
// ── Selection ───────────────────────
|
||||
|
||||
getSelected() { return this._value; },
|
||||
|
||||
select(id, label) {
|
||||
this._value = id;
|
||||
if (this.labelEl) this.labelEl.textContent = label || id || 'Select a model';
|
||||
// Highlight
|
||||
if (this.menuEl) {
|
||||
this.menuEl.querySelectorAll('.model-dropdown-item').forEach(el => {
|
||||
el.classList.toggle('selected', el.dataset.value === id);
|
||||
});
|
||||
}
|
||||
this._updateCaps();
|
||||
if (this.onChange) this.onChange(id, label);
|
||||
},
|
||||
|
||||
// ── Model list ──────────────────────
|
||||
|
||||
setModels(models) {
|
||||
this._models = models || [];
|
||||
this._rebuild();
|
||||
},
|
||||
|
||||
_rebuild() {
|
||||
if (!this.menuEl) return;
|
||||
this.menuEl.innerHTML = '';
|
||||
|
||||
if (this._models.length === 0) {
|
||||
this.menuEl.innerHTML = '<div class="model-dropdown-item" style="color:var(--text-3);cursor:default">No models loaded</div>';
|
||||
this.select('', 'No models loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
const globalPersonas = this._models.filter(m => m.isPersona && m.personaScope === 'global');
|
||||
const teamPersonas = this._models.filter(m => m.isPersona && m.personaScope === 'team');
|
||||
const personalPersonas = this._models.filter(m => m.isPersona && m.personaScope === 'personal');
|
||||
const models = this._models.filter(m => !m.isPersona && !m.hidden);
|
||||
|
||||
const addGroup = (label, items) => {
|
||||
if (items.length === 0) return;
|
||||
const hdr = document.createElement('div');
|
||||
hdr.className = 'model-dropdown-group';
|
||||
hdr.textContent = label;
|
||||
this.menuEl.appendChild(hdr);
|
||||
items.forEach(m => this.menuEl.appendChild(this._createItem(m)));
|
||||
};
|
||||
|
||||
addGroup('\u26A1 Personas', globalPersonas);
|
||||
|
||||
// Group team personas by team name
|
||||
const teamGroups = {};
|
||||
teamPersonas.forEach(m => {
|
||||
const tn = m.personaTeamName || 'Team';
|
||||
(teamGroups[tn] = teamGroups[tn] || []).push(m);
|
||||
});
|
||||
Object.keys(teamGroups).sort().forEach(tn => {
|
||||
addGroup('\uD83D\uDC65 ' + tn, teamGroups[tn]);
|
||||
});
|
||||
|
||||
addGroup('\uD83D\uDD27 My Personas', personalPersonas);
|
||||
addGroup('Models', models.filter(m => m.source !== 'personal'));
|
||||
addGroup('\uD83D\uDD11 My Providers', models.filter(m => m.source === 'personal'));
|
||||
},
|
||||
|
||||
_createItem(m) {
|
||||
const self = this;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'model-dropdown-item';
|
||||
div.dataset.value = m.id;
|
||||
const avatar = m.personaAvatar ? '<img src="' + _msEsc(m.personaAvatar) + '" class="dropdown-avatar" alt="">' : '';
|
||||
const handle = m.isPersona && m.personaHandle ? '<span class="item-handle">@' + _msEsc(m.personaHandle) + '</span>' : '';
|
||||
const teamBadge = m.source === 'team' && m.teamName ? '<span class="badge-team" style="font-size:9px;padding:0 4px">\uD83D\uDC65 ' + _msEsc(m.teamName) + '</span>' : '';
|
||||
const provider = m.provider ? '<span class="item-provider">' + _msEsc(m.provider) + '</span>' : '';
|
||||
div.innerHTML = avatar + '<span class="item-label">' + _msEsc(m.name || m.id) + handle + '</span>' + teamBadge + provider;
|
||||
div.addEventListener('click', () => {
|
||||
self.select(m.id, (m.name || m.id) + (m.provider ? ' (' + m.provider + ')' : ''));
|
||||
self.close();
|
||||
});
|
||||
return div;
|
||||
},
|
||||
|
||||
// ── Restore selection ───────────────
|
||||
|
||||
/**
|
||||
* Restore selection from a preferred ID, falling back through
|
||||
* admin default → first visible.
|
||||
*/
|
||||
restore(preferredId, adminDefault) {
|
||||
const visible = this._models.filter(m => !m.hidden);
|
||||
if (visible.length === 0) { this.select('', 'No visible models'); return; }
|
||||
|
||||
const byId = (id) => visible.find(m => m.id === id || m.baseModelId === id);
|
||||
const match = byId(preferredId) || byId(adminDefault) || visible[0];
|
||||
this.select(match.id, (match.name || match.id) + (match.provider ? ' (' + match.provider + ')' : ''));
|
||||
},
|
||||
|
||||
// ── Dropdown toggle ─────────────────
|
||||
|
||||
open() { if (this.menuEl) this.menuEl.classList.add('open'); },
|
||||
close() { if (this.menuEl) this.menuEl.classList.remove('open'); },
|
||||
toggle() { if (this.menuEl) this.menuEl.classList.toggle('open'); },
|
||||
|
||||
// ── Capability badges ───────────────
|
||||
|
||||
_updateCaps() {
|
||||
if (!this.capsEl) return;
|
||||
if (!this._value) { this.capsEl.innerHTML = ''; return; }
|
||||
const m = this._models.find(m => m.id === this._value);
|
||||
const caps = m?.capabilities;
|
||||
if (!caps || Object.keys(caps).length === 0) { this.capsEl.innerHTML = ''; return; }
|
||||
// Use global renderCapBadges if available (from ui-core.js)
|
||||
if (typeof renderCapBadges === 'function') {
|
||||
this.capsEl.innerHTML = renderCapBadges(caps);
|
||||
}
|
||||
},
|
||||
|
||||
getSelectedCaps() {
|
||||
const m = this._models.find(m => m.id === this._value);
|
||||
return m?.capabilities || {};
|
||||
},
|
||||
|
||||
// ── Event wiring ────────────────────
|
||||
|
||||
bind() {
|
||||
this._on(this.btnEl, 'click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggle();
|
||||
});
|
||||
this._on(document, 'click', (e) => {
|
||||
if (!e.target.closest('#' + pfx + 'modelDropdown')) this.close();
|
||||
});
|
||||
},
|
||||
|
||||
// ── Lifecycle ───────────────────────
|
||||
|
||||
destroy() {
|
||||
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
|
||||
this._listeners = [];
|
||||
ModelSelector._instances.delete(this.id);
|
||||
if (ModelSelector.primary === this) ModelSelector.primary = null;
|
||||
},
|
||||
|
||||
_on(el, event, handler) {
|
||||
if (!el) return;
|
||||
el.addEventListener(event, handler);
|
||||
this._listeners.push({ el, event, handler });
|
||||
},
|
||||
};
|
||||
|
||||
ModelSelector._instances.set(pfx, instance);
|
||||
return instance;
|
||||
},
|
||||
|
||||
get(id) { return this._instances.get(id) || null; },
|
||||
};
|
||||
|
||||
// HTML-escape for model selector content
|
||||
function _msEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
450
src/js/note-editor.js
Normal file
450
src/js/note-editor.js
Normal file
@@ -0,0 +1,450 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — NoteEditor Component
|
||||
// ==========================================
|
||||
// v0.25.0: Component wrapper for notes list + editor.
|
||||
// Hydrates the server-rendered note-editor.html partial.
|
||||
// Can run standalone (in editor tabbed pane) or coexist with
|
||||
// the existing notes.js panel system (on chat/notes surfaces).
|
||||
//
|
||||
// Usage:
|
||||
// const notes = NoteEditor.create({
|
||||
// id: 'main', // matches template prefix
|
||||
// onOpenGraph: () => { ... },
|
||||
// });
|
||||
// notes.bind();
|
||||
// await notes.loadNotes();
|
||||
// notes.destroy();
|
||||
//
|
||||
// The existing notes.js + PanelRegistry integration continues to
|
||||
// work on the chat surface. This component is for NEW mount points
|
||||
// (editor pane, future notes-studio layout).
|
||||
|
||||
const NoteEditor = {
|
||||
_instances: new Map(),
|
||||
|
||||
create(opts) {
|
||||
const pfx = opts.id || 'main';
|
||||
const instance = {
|
||||
id: pfx,
|
||||
rootEl: document.getElementById(pfx + 'NoteEditor'),
|
||||
listViewEl: document.getElementById(pfx + 'NotesListView'),
|
||||
editorViewEl: document.getElementById(pfx + 'NotesEditorView'),
|
||||
graphViewEl: document.getElementById(pfx + 'NotesGraphView'),
|
||||
listEl: document.getElementById(pfx + 'NotesList'),
|
||||
searchEl: document.getElementById(pfx + 'NotesSearchInput'),
|
||||
folderFilterEl: document.getElementById(pfx + 'NotesFolderFilter'),
|
||||
sortEl: document.getElementById(pfx + 'NotesSortSelect'),
|
||||
titleEl: document.getElementById(pfx + 'NoteEditorTitle'),
|
||||
folderEl: document.getElementById(pfx + 'NoteEditorFolder'),
|
||||
tagsEl: document.getElementById(pfx + 'NoteEditorTags'),
|
||||
contentContainerEl: document.getElementById(pfx + 'NoteEditorContentContainer'),
|
||||
readTitleEl: document.getElementById(pfx + 'NoteReadTitle'),
|
||||
readMetaEl: document.getElementById(pfx + 'NoteReadMeta'),
|
||||
readContentEl: document.getElementById(pfx + 'NoteReadContent'),
|
||||
editModeEl: document.getElementById(pfx + 'NoteEditMode'),
|
||||
readModeEl: document.getElementById(pfx + 'NoteReadMode'),
|
||||
backlinksEl: document.getElementById(pfx + 'NoteBacklinks'),
|
||||
backlinksListEl: document.getElementById(pfx + 'NoteBacklinksList'),
|
||||
backlinksCountEl: document.getElementById(pfx + 'NoteBacklinksCount'),
|
||||
onOpenGraph: opts.onOpenGraph || null,
|
||||
_editingNoteId: null,
|
||||
_currentNote: null,
|
||||
_sort: 'updated_desc',
|
||||
_cmEditor: null,
|
||||
_listeners: [],
|
||||
|
||||
// ── View Switching ───────────────────
|
||||
|
||||
showList() {
|
||||
if (this.listViewEl) this.listViewEl.style.display = '';
|
||||
if (this.editorViewEl) this.editorViewEl.style.display = 'none';
|
||||
if (this.graphViewEl) this.graphViewEl.style.display = 'none';
|
||||
this._destroyCmEditor();
|
||||
},
|
||||
|
||||
showEditor() {
|
||||
if (this.listViewEl) this.listViewEl.style.display = 'none';
|
||||
if (this.editorViewEl) this.editorViewEl.style.display = '';
|
||||
if (this.graphViewEl) this.graphViewEl.style.display = 'none';
|
||||
},
|
||||
|
||||
showGraph() {
|
||||
if (this.listViewEl) this.listViewEl.style.display = 'none';
|
||||
if (this.editorViewEl) this.editorViewEl.style.display = 'none';
|
||||
if (this.graphViewEl) this.graphViewEl.style.display = '';
|
||||
},
|
||||
|
||||
// ── Note List ───────────────────────
|
||||
|
||||
async loadNotes(folder, searchQuery) {
|
||||
if (!this.listEl) return;
|
||||
this.listEl.innerHTML = '<div class="notes-loading">Loading…</div>';
|
||||
|
||||
try {
|
||||
let notes;
|
||||
if (searchQuery) {
|
||||
const resp = await API.searchNotes(searchQuery);
|
||||
notes = resp.data || [];
|
||||
} else {
|
||||
const folderVal = folder || this.folderFilterEl?.value || '';
|
||||
const resp = await API.listNotes(100, 0, folderVal, '', this._sort);
|
||||
notes = resp.data || [];
|
||||
}
|
||||
|
||||
if (notes.length === 0) {
|
||||
this.listEl.innerHTML = '<div class="notes-empty">' +
|
||||
(searchQuery ? 'No results found' : 'No notes yet') + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
this.listEl.innerHTML = '';
|
||||
const self = this;
|
||||
notes.forEach(note => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'note-item';
|
||||
item.dataset.noteId = note.id;
|
||||
const title = note.title || 'Untitled';
|
||||
const preview = (note.content || '').slice(0, 80).replace(/\n/g, ' ');
|
||||
const date = note.updated_at ? new Date(note.updated_at).toLocaleDateString() : '';
|
||||
item.innerHTML =
|
||||
'<div class="note-content-col">' +
|
||||
'<div class="note-title">' + _neEsc(title) + '</div>' +
|
||||
'<div class="note-preview">' + _neEsc(preview) + '</div>' +
|
||||
'<div class="note-date">' + _neEsc(date) + '</div>' +
|
||||
'</div>';
|
||||
item.addEventListener('click', () => self.openNote(note.id));
|
||||
self.listEl.appendChild(item);
|
||||
});
|
||||
} catch (e) {
|
||||
this.listEl.innerHTML = '<div class="notes-empty">Failed to load: ' + _neEsc(e.message) + '</div>';
|
||||
}
|
||||
},
|
||||
|
||||
async loadFolders() {
|
||||
if (!this.folderFilterEl) return;
|
||||
try {
|
||||
const resp = await API.listNoteFolders();
|
||||
const folders = resp.data || resp || [];
|
||||
// Keep "All folders" option, rebuild the rest
|
||||
this.folderFilterEl.innerHTML = '<option value="">All folders</option>';
|
||||
folders.forEach(f => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = f;
|
||||
opt.textContent = f;
|
||||
this.folderFilterEl.appendChild(opt);
|
||||
});
|
||||
} catch (_) { /* ignore */ }
|
||||
},
|
||||
|
||||
// ── Note CRUD ───────────────────────
|
||||
|
||||
async openNote(noteId) {
|
||||
this.showEditor();
|
||||
if (noteId) {
|
||||
this._editingNoteId = noteId;
|
||||
try {
|
||||
this._currentNote = await API.getNote(noteId);
|
||||
this._populateFields(this._currentNote);
|
||||
this._showReadMode();
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Failed to load note: ' + e.message, 'error');
|
||||
this.showList();
|
||||
}
|
||||
} else {
|
||||
this._editingNoteId = null;
|
||||
this._currentNote = null;
|
||||
this._clearFields();
|
||||
this._showEditMode();
|
||||
if (this.titleEl) this.titleEl.focus();
|
||||
}
|
||||
},
|
||||
|
||||
async saveNote() {
|
||||
const title = this.titleEl?.value?.trim() || '';
|
||||
const content = this._getContent();
|
||||
const folder = this.folderEl?.value?.trim() || '';
|
||||
const tagsStr = this.tagsEl?.value || '';
|
||||
const tags = tagsStr.split(',').map(t => t.trim()).filter(Boolean);
|
||||
|
||||
if (!title && !content) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Note is empty', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (this._editingNoteId) {
|
||||
await API.updateNote(this._editingNoteId, { title, content, folder_path: folder, tags });
|
||||
} else {
|
||||
const resp = await API.createNote(title, content, folder, tags);
|
||||
this._editingNoteId = resp.id || resp.data?.id;
|
||||
}
|
||||
this._currentNote = { ...this._currentNote, title, content, folder_path: folder, tags };
|
||||
this._showReadMode();
|
||||
if (typeof UI !== 'undefined') UI.toast('Note saved', 'success');
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Save failed: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteNote() {
|
||||
if (!this._editingNoteId) return;
|
||||
const ok = typeof showConfirm === 'function'
|
||||
? await showConfirm('Delete this note?')
|
||||
: window.confirm('Delete this note?');
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await API.deleteNote(this._editingNoteId);
|
||||
if (typeof UI !== 'undefined') UI.toast('Note deleted', 'success');
|
||||
this._editingNoteId = null;
|
||||
this._currentNote = null;
|
||||
this.showList();
|
||||
this.loadNotes();
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Delete failed: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ── Read / Edit / Preview Modes ─────
|
||||
|
||||
_showReadMode() {
|
||||
if (this.editModeEl) this.editModeEl.style.display = 'none';
|
||||
if (this.readModeEl) this.readModeEl.style.display = '';
|
||||
|
||||
const n = this._currentNote;
|
||||
if (!n) return;
|
||||
|
||||
if (this.readTitleEl) this.readTitleEl.textContent = n.title || 'Untitled';
|
||||
if (this.readMetaEl) {
|
||||
const parts = [];
|
||||
if (n.folder_path) parts.push('📁 ' + n.folder_path);
|
||||
if (n.tags?.length) parts.push('🏷 ' + n.tags.join(', '));
|
||||
if (n.updated_at) parts.push(new Date(n.updated_at).toLocaleString());
|
||||
this.readMetaEl.textContent = parts.join(' · ');
|
||||
}
|
||||
if (this.readContentEl) {
|
||||
if (typeof marked !== 'undefined') {
|
||||
const raw = typeof marked.parse === 'function' ? marked.parse(n.content || '') : marked(n.content || '');
|
||||
this.readContentEl.innerHTML = typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(raw) : raw;
|
||||
} else {
|
||||
this.readContentEl.textContent = n.content || '';
|
||||
}
|
||||
}
|
||||
|
||||
// Show edit/delete buttons for read mode
|
||||
this._setBtn('NoteEditBtn', true);
|
||||
this._setBtn('NotePreviewBtn', false);
|
||||
this._setBtn('NoteCancelEditBtn', false);
|
||||
this._setBtn('NoteDeleteBtn', true);
|
||||
},
|
||||
|
||||
_showEditMode() {
|
||||
if (this.editModeEl) this.editModeEl.style.display = '';
|
||||
if (this.readModeEl) this.readModeEl.style.display = 'none';
|
||||
|
||||
this._setBtn('NoteEditBtn', false);
|
||||
this._setBtn('NotePreviewBtn', true);
|
||||
this._setBtn('NoteCancelEditBtn', !!this._currentNote);
|
||||
this._setBtn('NoteDeleteBtn', !!this._editingNoteId);
|
||||
},
|
||||
|
||||
_showPreview() {
|
||||
// Toggle between edit and read-mode preview
|
||||
if (this.readModeEl?.style.display === 'none') {
|
||||
// Show preview of current edits
|
||||
const tempNote = {
|
||||
title: this.titleEl?.value || '',
|
||||
content: this._getContent(),
|
||||
folder_path: this.folderEl?.value || '',
|
||||
tags: (this.tagsEl?.value || '').split(',').map(t => t.trim()).filter(Boolean),
|
||||
};
|
||||
const saved = this._currentNote;
|
||||
this._currentNote = tempNote;
|
||||
this._showReadMode();
|
||||
this._currentNote = saved;
|
||||
// Swap buttons for preview-of-edits state
|
||||
this._setBtn('NoteEditBtn', true);
|
||||
this._setBtn('NotePreviewBtn', false);
|
||||
} else {
|
||||
this._showEditMode();
|
||||
}
|
||||
},
|
||||
|
||||
// ── CM6 Editor Management ───────────
|
||||
|
||||
_getContent() {
|
||||
if (this._cmEditor) return this._cmEditor.getValue();
|
||||
const ta = this.contentContainerEl?.querySelector('textarea');
|
||||
return ta ? ta.value : '';
|
||||
},
|
||||
|
||||
_setContent(text) {
|
||||
if (this._cmEditor) { this._cmEditor.setValue(text); return; }
|
||||
|
||||
// Lazy-init CM6
|
||||
if (this.contentContainerEl && window.CM?.noteEditor) {
|
||||
this.contentContainerEl.innerHTML = '';
|
||||
this._cmEditor = CM.noteEditor(this.contentContainerEl, {
|
||||
value: text,
|
||||
darkMode: document.documentElement.getAttribute('data-theme') !== 'light',
|
||||
onLink: (title) => this._navigateToLink(title),
|
||||
linkCompleter: async (query) => {
|
||||
if (!query || query.length < 1) return [];
|
||||
try {
|
||||
const resp = await API.searchNoteTitles(query, 8);
|
||||
return (resp.data || []).map(n => ({ label: n.title, id: n.id }));
|
||||
} catch { return []; }
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Textarea fallback
|
||||
const ta = this.contentContainerEl?.querySelector('textarea');
|
||||
if (ta) ta.value = text;
|
||||
},
|
||||
|
||||
_destroyCmEditor() {
|
||||
if (this._cmEditor?.destroy) this._cmEditor.destroy();
|
||||
this._cmEditor = null;
|
||||
},
|
||||
|
||||
async _navigateToLink(title) {
|
||||
try {
|
||||
const resp = await API.searchNotes(title);
|
||||
const notes = resp.data || [];
|
||||
const match = notes.find(n => n.title?.toLowerCase() === title.toLowerCase());
|
||||
if (match) {
|
||||
this.openNote(match.id);
|
||||
} else {
|
||||
// Create new note with this title
|
||||
this._editingNoteId = null;
|
||||
this._currentNote = null;
|
||||
this._clearFields();
|
||||
if (this.titleEl) this.titleEl.value = title;
|
||||
this._showEditMode();
|
||||
}
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
// ── Field Helpers ───────────────────
|
||||
|
||||
_populateFields(note) {
|
||||
if (this.titleEl) this.titleEl.value = note.title || '';
|
||||
if (this.folderEl) this.folderEl.value = note.folder_path || '';
|
||||
if (this.tagsEl) this.tagsEl.value = (note.tags || []).join(', ');
|
||||
this._setContent(note.content || '');
|
||||
},
|
||||
|
||||
_clearFields() {
|
||||
if (this.titleEl) this.titleEl.value = '';
|
||||
if (this.folderEl) this.folderEl.value = '';
|
||||
if (this.tagsEl) this.tagsEl.value = '';
|
||||
this._setContent('');
|
||||
},
|
||||
|
||||
_setBtn(suffix, show) {
|
||||
const el = document.getElementById(pfx + suffix);
|
||||
if (el) el.style.display = show ? '' : 'none';
|
||||
},
|
||||
|
||||
// ── Event Wiring ────────────────────
|
||||
|
||||
bind() {
|
||||
const $ = (id) => document.getElementById(pfx + id);
|
||||
const self = this;
|
||||
|
||||
// Toolbar
|
||||
this._on($('NotesNewBtn'), 'click', () => self.openNote(null));
|
||||
this._on($('NotesTodayBtn'), 'click', () => self._openDailyNote());
|
||||
this._on($('NotesGraphBtn'), 'click', () => {
|
||||
if (self.onOpenGraph) self.onOpenGraph();
|
||||
});
|
||||
|
||||
// Editor header
|
||||
this._on($('NotesBackBtn'), 'click', () => { self.showList(); self.loadNotes(); self.loadFolders(); });
|
||||
this._on($('NoteSaveBtn'), 'click', () => self.saveNote());
|
||||
this._on($('NoteDeleteBtn'), 'click', () => self.deleteNote());
|
||||
this._on($('NoteEditBtn'), 'click', () => self._showEditMode());
|
||||
this._on($('NotePreviewBtn'), 'click', () => self._showPreview());
|
||||
this._on($('NoteCancelEditBtn'), 'click', () => {
|
||||
if (self._currentNote) { self._populateFields(self._currentNote); self._showReadMode(); }
|
||||
else self.showList();
|
||||
});
|
||||
|
||||
// Filters
|
||||
this._on(this.folderFilterEl, 'change', () => {
|
||||
if (self.searchEl) self.searchEl.value = '';
|
||||
self.loadNotes(self.folderFilterEl.value);
|
||||
});
|
||||
this._on(this.sortEl, 'change', () => {
|
||||
self._sort = self.sortEl.value;
|
||||
self.loadNotes();
|
||||
});
|
||||
|
||||
// Search with debounce
|
||||
let timer;
|
||||
this._on(this.searchEl, 'input', () => {
|
||||
clearTimeout(timer);
|
||||
const q = self.searchEl.value.trim();
|
||||
timer = setTimeout(() => {
|
||||
if (q.length >= 2) {
|
||||
if (self.folderFilterEl) self.folderFilterEl.value = '';
|
||||
self.loadNotes(null, q);
|
||||
} else if (q.length === 0) {
|
||||
self.loadNotes();
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
|
||||
// ── Daily Note ──────────────────────
|
||||
|
||||
async _openDailyNote() {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
try {
|
||||
const resp = await API.searchNotes(today);
|
||||
const notes = resp.data || [];
|
||||
const daily = notes.find(n => n.title === today || n.title?.startsWith(today));
|
||||
if (daily) {
|
||||
this.openNote(daily.id);
|
||||
} else {
|
||||
this._editingNoteId = null;
|
||||
this._currentNote = null;
|
||||
this._clearFields();
|
||||
if (this.titleEl) this.titleEl.value = today;
|
||||
this._setContent('# ' + today + '\n\n');
|
||||
this.showEditor();
|
||||
this._showEditMode();
|
||||
}
|
||||
} catch (e) {
|
||||
if (typeof UI !== 'undefined') UI.toast('Failed to open daily note: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ── Lifecycle ───────────────────────
|
||||
|
||||
destroy() {
|
||||
this._destroyCmEditor();
|
||||
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
|
||||
this._listeners = [];
|
||||
this._editingNoteId = null;
|
||||
this._currentNote = null;
|
||||
NoteEditor._instances.delete(this.id);
|
||||
},
|
||||
|
||||
_on(el, event, handler) {
|
||||
if (!el) return;
|
||||
el.addEventListener(event, handler);
|
||||
this._listeners.push({ el, event, handler });
|
||||
},
|
||||
};
|
||||
|
||||
NoteEditor._instances.set(pfx, instance);
|
||||
return instance;
|
||||
},
|
||||
|
||||
get(id) { return this._instances.get(id) || null; },
|
||||
};
|
||||
|
||||
function _neEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
554
src/js/pane-container.js
Normal file
554
src/js/pane-container.js
Normal file
@@ -0,0 +1,554 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — PaneContainer
|
||||
// ==========================================
|
||||
// v0.25.0: Composable multi-pane layout system.
|
||||
// Replaces the "one surface fills viewport" model with resizable
|
||||
// side-by-side panes. Supports leaf panes (single component) and
|
||||
// tabbed panes (N components, tab bar, one active at a time).
|
||||
//
|
||||
// Layout presets define the pane structure. The surface manifest
|
||||
// declares which preset to use via __MANIFEST__.layout.
|
||||
//
|
||||
// Usage:
|
||||
// const layout = PaneContainer.mount(
|
||||
// document.getElementById('editorBody'),
|
||||
// 'editor',
|
||||
// { workspaceId: 'ws-123' }
|
||||
// );
|
||||
// const fileTree = layout.getPane('files');
|
||||
// layout.resize('files', 250);
|
||||
// layout.destroy();
|
||||
|
||||
const PaneContainer = {
|
||||
_presets: {},
|
||||
_active: null,
|
||||
|
||||
// ── Layout Preset Registration ──────────
|
||||
|
||||
/**
|
||||
* Register a named layout preset.
|
||||
* @param {string} name - Preset name (e.g. 'single', 'editor', 'split')
|
||||
* @param {object} definition - Layout tree definition
|
||||
*
|
||||
* Definition format:
|
||||
* { type: 'leaf', id: 'main', component: 'chat-pane', opts: {} }
|
||||
* { type: 'tabbed', id: 'right', tabs: [{id,label,component,opts}], defaultTab: 0 }
|
||||
* { type: 'split', direction: 'horizontal', children: [def, def, ...], sizes: [220, null, 380] }
|
||||
*
|
||||
* sizes: array matching children. Numbers are initial px widths. null = flex:1 (fills remaining).
|
||||
*/
|
||||
registerPreset(name, definition) {
|
||||
this._presets[name] = definition;
|
||||
},
|
||||
|
||||
// ── Mount ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Mount a layout preset into a root element.
|
||||
* @param {HTMLElement} rootEl - Container element
|
||||
* @param {string} presetName - Registered preset name
|
||||
* @param {object} opts - Passed to component factories (e.g. { workspaceId })
|
||||
* @returns {object} Layout instance
|
||||
*/
|
||||
mount(rootEl, presetName, opts) {
|
||||
if (!rootEl) { console.error('[PaneContainer] No root element'); return null; }
|
||||
|
||||
const preset = this._presets[presetName];
|
||||
if (!preset) { console.error('[PaneContainer] Unknown preset:', presetName); return null; }
|
||||
|
||||
opts = opts || {};
|
||||
const surfaceId = window.__SURFACE__ || 'unknown';
|
||||
|
||||
// Build the DOM tree from the preset definition
|
||||
const panes = new Map(); // id → { el, component, type }
|
||||
const handles = [];
|
||||
|
||||
const buildNode = (def, parentEl) => {
|
||||
if (def.type === 'leaf') {
|
||||
return _buildLeafPane(def, parentEl, panes, opts);
|
||||
}
|
||||
if (def.type === 'tabbed') {
|
||||
return _buildTabbedPane(def, parentEl, panes, opts, surfaceId);
|
||||
}
|
||||
if (def.type === 'split') {
|
||||
return _buildSplit(def, parentEl, panes, handles, opts, surfaceId);
|
||||
}
|
||||
console.error('[PaneContainer] Unknown node type:', def.type);
|
||||
return null;
|
||||
};
|
||||
|
||||
rootEl.classList.add('pane-container');
|
||||
const rootNode = buildNode(preset, rootEl);
|
||||
|
||||
// Restore persisted sizes
|
||||
_restoreSizes(surfaceId, presetName, handles);
|
||||
|
||||
const instance = {
|
||||
rootEl,
|
||||
presetName,
|
||||
_panes: panes,
|
||||
_handles: handles,
|
||||
_rootNode: rootNode,
|
||||
|
||||
/** Get the component instance for a pane by ID. */
|
||||
getPane(id) {
|
||||
const p = panes.get(id);
|
||||
return p ? p.component : null;
|
||||
},
|
||||
|
||||
/** Get the pane element by ID. */
|
||||
getPaneEl(id) {
|
||||
const p = panes.get(id);
|
||||
return p ? p.el : null;
|
||||
},
|
||||
|
||||
/** Programmatic resize of a leaf pane. */
|
||||
resize(id, sizePx) {
|
||||
const p = panes.get(id);
|
||||
if (p?.el) {
|
||||
p.el.style.flexBasis = sizePx + 'px';
|
||||
p.el.style.flexGrow = '0';
|
||||
p.el.style.flexShrink = '0';
|
||||
}
|
||||
},
|
||||
|
||||
/** Minimize a pane (collapse to 0). */
|
||||
minimize(id) {
|
||||
const p = panes.get(id);
|
||||
if (!p?.el) return;
|
||||
p._prevBasis = p.el.style.flexBasis;
|
||||
p._prevGrow = p.el.style.flexGrow;
|
||||
p.el.style.flexBasis = '0px';
|
||||
p.el.style.flexGrow = '0';
|
||||
p.el.style.overflow = 'hidden';
|
||||
p.el.classList.add('pane-minimized');
|
||||
},
|
||||
|
||||
/** Restore a minimized pane. */
|
||||
restore(id) {
|
||||
const p = panes.get(id);
|
||||
if (!p?.el) return;
|
||||
p.el.style.flexBasis = p._prevBasis || '';
|
||||
p.el.style.flexGrow = p._prevGrow || '';
|
||||
p.el.style.overflow = '';
|
||||
p.el.classList.remove('pane-minimized');
|
||||
},
|
||||
|
||||
/** Tear down the entire layout. */
|
||||
destroy() {
|
||||
// Destroy all component instances
|
||||
for (const [, p] of panes) {
|
||||
if (p.component?.destroy) p.component.destroy();
|
||||
// Tabbed: destroy all tab components
|
||||
if (p.tabs) {
|
||||
for (const tab of p.tabs) {
|
||||
if (tab.component?.destroy) tab.component.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
panes.clear();
|
||||
handles.length = 0;
|
||||
rootEl.innerHTML = '';
|
||||
rootEl.classList.remove('pane-container');
|
||||
PaneContainer._active = null;
|
||||
},
|
||||
};
|
||||
|
||||
PaneContainer._active = instance;
|
||||
return instance;
|
||||
},
|
||||
|
||||
/** Get the currently active layout instance. */
|
||||
active() { return this._active; },
|
||||
};
|
||||
|
||||
// ── Leaf Pane Builder ───────────────────────
|
||||
|
||||
function _buildLeafPane(def, parentEl, panes, opts) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'pane pane-leaf';
|
||||
el.dataset.paneId = def.id;
|
||||
|
||||
if (def.size) {
|
||||
el.style.flexBasis = def.size + 'px';
|
||||
el.style.flexGrow = '0';
|
||||
el.style.flexShrink = '0';
|
||||
} else {
|
||||
el.style.flex = '1';
|
||||
el.style.minWidth = '0';
|
||||
el.dataset.paneFlex = '1'; // Mark as flexible — drag handles skip this pane
|
||||
}
|
||||
if (def.minSize) el.style.minWidth = def.minSize + 'px';
|
||||
|
||||
parentEl.appendChild(el);
|
||||
|
||||
// Component instantiation is deferred — the surface boot script
|
||||
// creates components and mounts them into pane elements.
|
||||
// We just register the pane slot here.
|
||||
panes.set(def.id, {
|
||||
el,
|
||||
component: null, // set by surface boot script
|
||||
type: 'leaf',
|
||||
def,
|
||||
});
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
// ── Tabbed Pane Builder ─────────────────────
|
||||
|
||||
function _buildTabbedPane(def, parentEl, panes, opts, surfaceId) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'pane pane-tabbed';
|
||||
el.dataset.paneId = def.id;
|
||||
|
||||
if (def.size) {
|
||||
el.style.flexBasis = def.size + 'px';
|
||||
el.style.flexGrow = '0';
|
||||
el.style.flexShrink = '0';
|
||||
} else {
|
||||
el.style.flex = '1';
|
||||
el.style.minWidth = '0';
|
||||
}
|
||||
if (def.minSize) el.style.minWidth = def.minSize + 'px';
|
||||
|
||||
// Tab bar
|
||||
const tabBar = document.createElement('div');
|
||||
tabBar.className = 'pane-tab-bar';
|
||||
el.appendChild(tabBar);
|
||||
|
||||
// Content area
|
||||
const contentArea = document.createElement('div');
|
||||
contentArea.className = 'pane-tab-content';
|
||||
el.appendChild(contentArea);
|
||||
|
||||
// Restore persisted active tab
|
||||
const persistKey = 'sb_tabs_' + surfaceId + '_' + def.id;
|
||||
let savedTab = 0;
|
||||
try { savedTab = parseInt(localStorage.getItem(persistKey)) || 0; } catch (_) {}
|
||||
if (savedTab >= (def.tabs || []).length) savedTab = 0;
|
||||
|
||||
const tabs = (def.tabs || []).map((tabDef, i) => {
|
||||
// Tab button
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'pane-tab-btn' + (i === savedTab ? ' pane-tab-btn--active' : '');
|
||||
btn.textContent = tabDef.label || tabDef.id;
|
||||
btn.dataset.tabIndex = i;
|
||||
tabBar.appendChild(btn);
|
||||
|
||||
// Tab panel (mount point for component)
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'pane-tab-panel';
|
||||
panel.dataset.tabId = tabDef.id;
|
||||
panel.style.display = i === savedTab ? '' : 'none';
|
||||
contentArea.appendChild(panel);
|
||||
|
||||
return {
|
||||
id: tabDef.id,
|
||||
label: tabDef.label,
|
||||
component: tabDef.component,
|
||||
opts: tabDef.opts || {},
|
||||
btn,
|
||||
panel,
|
||||
instance: null, // lazy-created on first activation
|
||||
_activated: i === savedTab,
|
||||
};
|
||||
});
|
||||
|
||||
// Tab click handler
|
||||
const activateTab = (index) => {
|
||||
tabs.forEach((tab, i) => {
|
||||
const isActive = i === index;
|
||||
tab.btn.classList.toggle('pane-tab-btn--active', isActive);
|
||||
tab.panel.style.display = isActive ? '' : 'none';
|
||||
if (isActive) tab._activated = true;
|
||||
});
|
||||
// Persist
|
||||
try { localStorage.setItem(persistKey, String(index)); } catch (_) {}
|
||||
};
|
||||
|
||||
tabs.forEach((tab, i) => {
|
||||
tab.btn.addEventListener('click', () => activateTab(i));
|
||||
});
|
||||
|
||||
parentEl.appendChild(el);
|
||||
|
||||
panes.set(def.id, {
|
||||
el,
|
||||
component: null, // tabbed panes don't have a single component
|
||||
type: 'tabbed',
|
||||
def,
|
||||
tabs,
|
||||
activateTab,
|
||||
getActiveTab() {
|
||||
return tabs.find((_, i) => tabs[i].btn.classList.contains('pane-tab-btn--active')) || tabs[0];
|
||||
},
|
||||
getTabPanel(tabId) {
|
||||
const t = tabs.find(t => t.id === tabId);
|
||||
return t ? t.panel : null;
|
||||
},
|
||||
});
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
// ── Split Builder ───────────────────────────
|
||||
|
||||
function _buildSplit(def, parentEl, panes, handles, opts, surfaceId) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'pane-split pane-split--' + (def.direction || 'horizontal');
|
||||
parentEl.appendChild(el);
|
||||
|
||||
const children = def.children || [];
|
||||
const sizes = def.sizes || [];
|
||||
|
||||
children.forEach((childDef, i) => {
|
||||
// Apply initial size from preset
|
||||
if (sizes[i] != null && childDef.size === undefined) {
|
||||
childDef.size = sizes[i];
|
||||
}
|
||||
|
||||
// Build child
|
||||
if (childDef.type === 'leaf') {
|
||||
_buildLeafPane(childDef, el, panes, opts);
|
||||
} else if (childDef.type === 'tabbed') {
|
||||
_buildTabbedPane(childDef, el, panes, opts, surfaceId);
|
||||
} else if (childDef.type === 'split') {
|
||||
_buildSplit(childDef, el, panes, handles, opts, surfaceId);
|
||||
}
|
||||
|
||||
// Add drag handle between children (not after last)
|
||||
if (i < children.length - 1) {
|
||||
const handle = _createHandle(def.direction || 'horizontal', el, i, surfaceId, def.id || 'root');
|
||||
handles.push(handle);
|
||||
}
|
||||
});
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
// ── Drag Handle ─────────────────────────────
|
||||
|
||||
function _createHandle(direction, splitEl, index, surfaceId, splitId) {
|
||||
const handle = document.createElement('div');
|
||||
handle.className = 'pane-handle pane-handle--' + direction;
|
||||
handle.dataset.handleIndex = index;
|
||||
|
||||
// Insert after the Nth child (child, handle, child, handle, child)
|
||||
// Children are at positions 0, 2, 4, ... and handles at 1, 3, ...
|
||||
// But since we add sequentially, handle goes after the (index)th pane
|
||||
const childEls = splitEl.querySelectorAll(':scope > .pane, :scope > .pane-split');
|
||||
const afterEl = childEls[index];
|
||||
if (afterEl?.nextSibling) {
|
||||
splitEl.insertBefore(handle, afterEl.nextSibling);
|
||||
} else {
|
||||
splitEl.appendChild(handle);
|
||||
}
|
||||
|
||||
// Drag logic
|
||||
let startX, startY, leftEl, rightEl, startLeftBasis, startRightBasis;
|
||||
|
||||
const onMouseDown = (e) => {
|
||||
e.preventDefault();
|
||||
const allPanes = Array.from(splitEl.querySelectorAll(':scope > .pane, :scope > .pane-split'));
|
||||
// Find the two panes adjacent to this handle
|
||||
const handleIdx = Array.from(splitEl.children).indexOf(handle);
|
||||
leftEl = splitEl.children[handleIdx - 1];
|
||||
rightEl = splitEl.children[handleIdx + 1];
|
||||
if (!leftEl || !rightEl) return;
|
||||
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
// Use current rendered size (matches what flex layout computed)
|
||||
startLeftBasis = leftEl.getBoundingClientRect().width;
|
||||
startRightBasis = rightEl.getBoundingClientRect().width;
|
||||
|
||||
// Immediately lock the non-flex panes to their current sizes
|
||||
// so the first move delta doesn't cause a visual snap
|
||||
if (leftEl.dataset.paneFlex !== '1') {
|
||||
leftEl.style.flexBasis = startLeftBasis + 'px';
|
||||
leftEl.style.flexGrow = '0';
|
||||
leftEl.style.flexShrink = '0';
|
||||
}
|
||||
if (rightEl.dataset.paneFlex !== '1') {
|
||||
rightEl.style.flexBasis = startRightBasis + 'px';
|
||||
rightEl.style.flexGrow = '0';
|
||||
rightEl.style.flexShrink = '0';
|
||||
}
|
||||
|
||||
handle.classList.add('pane-handle--active');
|
||||
document.body.style.cursor = direction === 'horizontal' ? 'col-resize' : 'row-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
const onMouseMove = (e) => {
|
||||
const delta = direction === 'horizontal'
|
||||
? e.clientX - startX
|
||||
: e.clientY - startY;
|
||||
|
||||
const leftIsFlex = leftEl.dataset.paneFlex === '1';
|
||||
const rightIsFlex = rightEl.dataset.paneFlex === '1';
|
||||
// Cap: no single fixed pane should exceed 60% of container
|
||||
const maxSize = splitEl.getBoundingClientRect().width * 0.6;
|
||||
|
||||
if (leftIsFlex) {
|
||||
// Only adjust the right (fixed) pane; left flex absorbs remainder
|
||||
const newRight = Math.min(maxSize, Math.max(50, startRightBasis - delta));
|
||||
rightEl.style.flexBasis = newRight + 'px';
|
||||
rightEl.style.flexGrow = '0';
|
||||
rightEl.style.flexShrink = '0';
|
||||
} else if (rightIsFlex) {
|
||||
// Only adjust the left (fixed) pane; right flex absorbs remainder
|
||||
const newLeft = Math.min(maxSize, Math.max(50, startLeftBasis + delta));
|
||||
leftEl.style.flexBasis = newLeft + 'px';
|
||||
leftEl.style.flexGrow = '0';
|
||||
leftEl.style.flexShrink = '0';
|
||||
} else {
|
||||
// Both fixed — adjust both (original behavior)
|
||||
const newLeft = Math.max(50, startLeftBasis + delta);
|
||||
const newRight = Math.max(50, startRightBasis - delta);
|
||||
leftEl.style.flexBasis = newLeft + 'px';
|
||||
leftEl.style.flexGrow = '0';
|
||||
leftEl.style.flexShrink = '0';
|
||||
rightEl.style.flexBasis = newRight + 'px';
|
||||
rightEl.style.flexGrow = '0';
|
||||
rightEl.style.flexShrink = '0';
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
handle.classList.remove('pane-handle--active');
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
|
||||
// Persist sizes
|
||||
_persistSizes(surfaceId, splitEl);
|
||||
};
|
||||
|
||||
handle.addEventListener('mousedown', onMouseDown);
|
||||
|
||||
// Double-click resets to default
|
||||
handle.addEventListener('dblclick', () => {
|
||||
if (leftEl) {
|
||||
if (leftEl.dataset.paneFlex === '1') {
|
||||
leftEl.style.flex = '1'; leftEl.style.flexBasis = ''; leftEl.style.flexGrow = ''; leftEl.style.flexShrink = '';
|
||||
} else {
|
||||
leftEl.style.flexBasis = ''; leftEl.style.flexGrow = ''; leftEl.style.flexShrink = '';
|
||||
}
|
||||
}
|
||||
if (rightEl) {
|
||||
if (rightEl.dataset.paneFlex === '1') {
|
||||
rightEl.style.flex = '1'; rightEl.style.flexBasis = ''; rightEl.style.flexGrow = ''; rightEl.style.flexShrink = '';
|
||||
} else {
|
||||
rightEl.style.flexBasis = ''; rightEl.style.flexGrow = ''; rightEl.style.flexShrink = '';
|
||||
}
|
||||
}
|
||||
_persistSizes(surfaceId, splitEl);
|
||||
});
|
||||
|
||||
return { handle, splitEl, index, direction };
|
||||
}
|
||||
|
||||
// ── Persistence ─────────────────────────────
|
||||
|
||||
function _persistSizes(surfaceId, splitEl) {
|
||||
const key = 'sb_layout_' + surfaceId;
|
||||
try {
|
||||
// Collect sizes for fixed panes only (skip flex panes)
|
||||
const sizes = {};
|
||||
splitEl.querySelectorAll(':scope > .pane, :scope > .pane-split').forEach(child => {
|
||||
const id = child.dataset?.paneId;
|
||||
if (id && child.dataset.paneFlex !== '1') {
|
||||
sizes[id] = child.getBoundingClientRect().width;
|
||||
}
|
||||
});
|
||||
// Merge with existing persisted data
|
||||
let existing = {};
|
||||
try { existing = JSON.parse(localStorage.getItem(key) || '{}'); } catch (_) {}
|
||||
Object.assign(existing, sizes);
|
||||
localStorage.setItem(key, JSON.stringify(existing));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function _restoreSizes(surfaceId, presetName, handles) {
|
||||
const key = 'sb_layout_' + surfaceId;
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(key) || '{}');
|
||||
if (Object.keys(saved).length === 0) return;
|
||||
|
||||
// Find the split container to measure available width
|
||||
const splitEl = document.querySelector('.pane-split--horizontal');
|
||||
const containerWidth = splitEl ? splitEl.getBoundingClientRect().width : window.innerWidth;
|
||||
|
||||
// Validate: sum of all fixed pane sizes must leave at least 150px for flex pane
|
||||
const fixedPanes = document.querySelectorAll('.pane[data-pane-id]');
|
||||
let totalFixed = 0;
|
||||
fixedPanes.forEach(el => {
|
||||
const id = el.dataset.paneId;
|
||||
if (saved[id] && el.dataset.paneFlex !== '1') {
|
||||
totalFixed += saved[id];
|
||||
}
|
||||
});
|
||||
|
||||
// If saved sizes overflow, clear corrupt data and use defaults
|
||||
if (totalFixed > containerWidth - 150) {
|
||||
console.warn('[PaneContainer] Saved layout overflows viewport (' + totalFixed + 'px > ' + containerWidth + 'px), resetting');
|
||||
localStorage.removeItem(key);
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply saved sizes to pane elements (skip flex panes)
|
||||
fixedPanes.forEach(el => {
|
||||
const id = el.dataset.paneId;
|
||||
if (saved[id] && el.dataset.paneFlex !== '1') {
|
||||
el.style.flexBasis = saved[id] + 'px';
|
||||
el.style.flexGrow = '0';
|
||||
el.style.flexShrink = '0';
|
||||
}
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Built-in Presets ────────────────────────
|
||||
|
||||
// single: One leaf pane (default chat UX)
|
||||
PaneContainer.registerPreset('single', {
|
||||
type: 'leaf',
|
||||
id: 'main',
|
||||
component: 'chat-pane',
|
||||
});
|
||||
|
||||
// editor: files | code-editor | <chat, notes>
|
||||
PaneContainer.registerPreset('editor', {
|
||||
type: 'split',
|
||||
id: 'root',
|
||||
direction: 'horizontal',
|
||||
sizes: [220, null, 380],
|
||||
children: [
|
||||
{ type: 'leaf', id: 'files', component: 'file-tree', size: 220, minSize: 140 },
|
||||
{ type: 'leaf', id: 'editor', component: 'code-editor' },
|
||||
{
|
||||
type: 'tabbed', id: 'assist', size: 380, minSize: 200,
|
||||
tabs: [
|
||||
{ id: 'chat', label: 'Chat', component: 'chat-pane' },
|
||||
{ id: 'notes', label: 'Notes', component: 'note-editor' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// split: generic two-pane layout
|
||||
PaneContainer.registerPreset('split', {
|
||||
type: 'split',
|
||||
id: 'root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ type: 'leaf', id: 'primary', component: null },
|
||||
{ type: 'leaf', id: 'secondary', component: null },
|
||||
],
|
||||
});
|
||||
@@ -9,7 +9,7 @@ const ADMIN_SECTIONS = {
|
||||
people: ['users', 'teams', 'groups'],
|
||||
ai: ['providers', 'models', 'personas', 'roles', 'knowledgeBases', 'memory'],
|
||||
routing: ['health', 'routing', 'capabilities'],
|
||||
system: ['settings', 'storage', 'extensions'],
|
||||
system: ['settings', 'storage', 'extensions', 'channels', 'surfaces'],
|
||||
monitoring: ['usage', 'audit', 'stats'],
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ const ADMIN_LABELS = {
|
||||
users: 'Users', teams: 'Teams', groups: 'Groups',
|
||||
providers: 'Providers', models: 'Models', personas: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge', memory: 'Memory',
|
||||
health: 'Health', routing: 'Routing', capabilities: 'Capabilities',
|
||||
settings: 'Settings', storage: 'Storage', extensions: 'Extensions',
|
||||
settings: 'Settings', storage: 'Storage', extensions: 'Extensions', channels: 'Channels', surfaces: 'Surfaces',
|
||||
usage: 'Usage', audit: 'Audit', stats: 'Stats',
|
||||
};
|
||||
|
||||
@@ -41,6 +41,7 @@ const ADMIN_LOADERS = {
|
||||
settings: () => UI.loadAdminSettings(),
|
||||
storage: () => typeof loadAdminStorage === 'function' ? loadAdminStorage() : null,
|
||||
extensions: () => UI.loadAdminExtensions(),
|
||||
surfaces: () => typeof _loadAdminSurfaces === 'function' ? _loadAdminSurfaces() : null,
|
||||
health: () => UI.loadAdminHealth(),
|
||||
routing: () => UI.loadAdminRouting(),
|
||||
capabilities: () => UI.loadAdminCapabilities(),
|
||||
|
||||
@@ -78,6 +78,11 @@ function renderPersonaForm(containerEl, options = {}) {
|
||||
<textarea id="${pfx}_memoryExtractionPrompt" rows="2" placeholder="Override the default extraction prompt for this persona..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group tool-grants-section" style="border-top:1px solid var(--border);padding-top:8px;margin-top:4px">
|
||||
<label class="checkbox-label" style="font-weight:500"><input type="checkbox" id="${pfx}_toolsAll" checked> All tools (inherit from context)</label>
|
||||
<p class="form-hint" style="margin:2px 0 6px">When unchecked, restrict this persona to only the selected tools below.</p>
|
||||
<div id="${pfx}_toolsList" class="tool-grants-list" style="display:none;max-height:200px;overflow-y:auto;padding:4px 0"></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<button class="btn-small btn-primary" id="${pfx}_submitBtn">Create</button>
|
||||
<button class="btn-small" id="${pfx}_cancelBtn">Cancel</button>
|
||||
@@ -127,6 +132,18 @@ function renderPersonaForm(containerEl, options = {}) {
|
||||
if (options.onSubmit) options.onSubmit(form.getValues());
|
||||
});
|
||||
|
||||
// v0.25.0: Tool grants — toggle "All tools" checkbox shows/hides tool list
|
||||
const toolsAllCb = document.getElementById(`${pfx}_toolsAll`);
|
||||
const toolsListEl = document.getElementById(`${pfx}_toolsList`);
|
||||
if (toolsAllCb && toolsListEl) {
|
||||
toolsAllCb.addEventListener('change', () => {
|
||||
toolsListEl.style.display = toolsAllCb.checked ? 'none' : '';
|
||||
if (!toolsAllCb.checked && toolsListEl.children.length === 0) {
|
||||
form.loadToolList();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const form = {
|
||||
getValues() {
|
||||
const v = {
|
||||
@@ -152,6 +169,14 @@ function renderPersonaForm(containerEl, options = {}) {
|
||||
if (memCb) v.memory_enabled = memCb.checked;
|
||||
const memPrompt = document.getElementById(`${pfx}_memoryExtractionPrompt`)?.value?.trim();
|
||||
if (memPrompt) v.memory_extraction_prompt = memPrompt;
|
||||
// v0.25.0: Tool grants
|
||||
const toolsAllCb = document.getElementById(`${pfx}_toolsAll`);
|
||||
if (toolsAllCb && !toolsAllCb.checked) {
|
||||
const checked = document.querySelectorAll(`#${pfx}_toolsList input[type="checkbox"]:checked`);
|
||||
v.tool_grants = Array.from(checked).map(cb => cb.value);
|
||||
} else {
|
||||
v.tool_grants = []; // empty = inherit all
|
||||
}
|
||||
return v;
|
||||
},
|
||||
setValues(p) {
|
||||
@@ -168,6 +193,8 @@ function renderPersonaForm(containerEl, options = {}) {
|
||||
// Memory fields (v0.18.0)
|
||||
if (el('memoryEnabled')) el('memoryEnabled').checked = p.memory_enabled !== false;
|
||||
if (el('memoryExtractionPrompt')) el('memoryExtractionPrompt').value = p.memory_extraction_prompt || '';
|
||||
// v0.25.0: Tool grants — load and apply
|
||||
if (p.id) form.loadToolGrants(p.id);
|
||||
},
|
||||
clearForm() {
|
||||
['name','handle','desc','prompt','temp','maxTokens'].forEach(f => {
|
||||
@@ -209,6 +236,63 @@ function renderPersonaForm(containerEl, options = {}) {
|
||||
},
|
||||
getModelSelect() { return document.getElementById(`${pfx}_model`); },
|
||||
getConfigSelect() { return showConfig ? document.getElementById(`${pfx}_config`) : null; },
|
||||
|
||||
// v0.25.0: Tool grants management
|
||||
async loadToolList() {
|
||||
const listEl = document.getElementById(`${pfx}_toolsList`);
|
||||
if (!listEl) return;
|
||||
try {
|
||||
const resp = await API._get('/api/v1/tools');
|
||||
const tools = resp.tools || resp.data || resp || [];
|
||||
listEl.innerHTML = '';
|
||||
// Group by category
|
||||
const groups = {};
|
||||
tools.forEach(t => {
|
||||
const cat = t.category || 'other';
|
||||
(groups[cat] = groups[cat] || []).push(t);
|
||||
});
|
||||
Object.keys(groups).sort().forEach(cat => {
|
||||
const hdr = document.createElement('div');
|
||||
hdr.className = 'tool-grants-category';
|
||||
hdr.textContent = cat.charAt(0).toUpperCase() + cat.slice(1);
|
||||
listEl.appendChild(hdr);
|
||||
groups[cat].forEach(t => {
|
||||
const label = document.createElement('label');
|
||||
label.className = 'tool-grants-item';
|
||||
label.innerHTML = `<input type="checkbox" value="${t.name}"> ${t.display_name || t.name}`;
|
||||
listEl.appendChild(label);
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
listEl.innerHTML = '<div class="form-hint">Failed to load tools</div>';
|
||||
}
|
||||
},
|
||||
|
||||
async loadToolGrants(personaId) {
|
||||
const toolsAllCb = document.getElementById(`${pfx}_toolsAll`);
|
||||
const listEl = document.getElementById(`${pfx}_toolsList`);
|
||||
if (!toolsAllCb || !listEl) return;
|
||||
try {
|
||||
const resp = await API._get(`/api/v1/personas/${personaId}/tool-grants`);
|
||||
const grants = resp.data || [];
|
||||
if (grants.length === 0) {
|
||||
toolsAllCb.checked = true;
|
||||
listEl.style.display = 'none';
|
||||
} else {
|
||||
toolsAllCb.checked = false;
|
||||
listEl.style.display = '';
|
||||
await form.loadToolList();
|
||||
const grantSet = new Set(grants);
|
||||
listEl.querySelectorAll('input[type="checkbox"]').forEach(cb => {
|
||||
cb.checked = grantSet.has(cb.value);
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// Endpoint may not exist yet — default to all tools
|
||||
toolsAllCb.checked = true;
|
||||
listEl.style.display = 'none';
|
||||
}
|
||||
},
|
||||
};
|
||||
return form;
|
||||
}
|
||||
@@ -247,8 +331,10 @@ const UI = {
|
||||
},
|
||||
|
||||
restoreSidebar() {
|
||||
const el = document.getElementById('sidebar');
|
||||
if (!el) return; // not all surfaces have a sidebar (e.g. editor)
|
||||
if (window.innerWidth <= 768 || localStorage.getItem('sb_sidebar') === '1') {
|
||||
document.getElementById('sidebar').classList.add('collapsed');
|
||||
el.classList.add('collapsed');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -279,7 +365,8 @@ const UI = {
|
||||
},
|
||||
|
||||
closeUserMenu() {
|
||||
document.getElementById('userFlyout').classList.remove('open');
|
||||
const el = document.getElementById('userFlyout');
|
||||
if (el) el.classList.remove('open');
|
||||
},
|
||||
|
||||
// ── Sidebar Section Collapse ──────────────
|
||||
@@ -1191,6 +1278,7 @@ const UI = {
|
||||
const letter = (name[0] || '?').toUpperCase();
|
||||
const avatarEl = document.getElementById('userAvatar');
|
||||
const letterEl = document.getElementById('avatarLetter');
|
||||
if (!avatarEl || !letterEl) return; // not all surfaces have user menu DOM
|
||||
// Show avatar image or initial letter
|
||||
let existingImg = avatarEl.querySelector('.user-avatar-img');
|
||||
if (API.user?.avatar) {
|
||||
@@ -1214,11 +1302,13 @@ const UI = {
|
||||
letterEl.style.display = '';
|
||||
letterEl.textContent = letter;
|
||||
}
|
||||
document.getElementById('userName').textContent = name;
|
||||
const nameEl = document.getElementById('userName');
|
||||
if (nameEl) nameEl.textContent = name;
|
||||
},
|
||||
|
||||
showAdminButton(show) {
|
||||
document.getElementById('menuAdmin').style.display = show ? '' : 'none';
|
||||
const adminEl = document.getElementById('menuAdmin');
|
||||
if (adminEl) adminEl.style.display = show ? '' : 'none';
|
||||
var dbg = document.getElementById('menuDebug');
|
||||
if (dbg) dbg.style.display = show ? '' : 'none';
|
||||
},
|
||||
@@ -1402,5 +1492,43 @@ const UI = {
|
||||
const el = document.getElementById('chatMessages');
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
},
|
||||
|
||||
// ── Appearance (scale + font) ────────────
|
||||
// Shared across ALL surfaces. Previously in ui-settings.js
|
||||
// which only loaded on chat/settings — editor surface was skipped.
|
||||
|
||||
applyAppearance(scale, msgFont) {
|
||||
const z = scale === 100 ? '' : scale / 100;
|
||||
// Zoom content areas — covers both chat and editor surfaces
|
||||
document.querySelectorAll(
|
||||
'.sidebar, .workspace-primary, .workspace-secondary, ' +
|
||||
'.modal-overlay, .admin-panel, ' +
|
||||
'.surface-editor, .surface-notes'
|
||||
).forEach(el => el.style.zoom = z);
|
||||
const splash = document.getElementById('splashGate');
|
||||
if (splash) splash.style.zoom = z;
|
||||
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');
|
||||
localStorage.setItem('cs-appearance', JSON.stringify({ scale, msgFont }));
|
||||
// Recheck tab overflow after layout settles
|
||||
requestAnimationFrame(() => {
|
||||
document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(t => {
|
||||
if (typeof checkTabsOverflow === 'function') checkTabsOverflow(t);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
restoreAppearance() {
|
||||
try {
|
||||
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
|
||||
const scale = prefs.scale || 100;
|
||||
const msgFont = prefs.msgFont || 14;
|
||||
if (scale !== 100 || msgFont !== 14) {
|
||||
UI.applyAppearance(scale, msgFont);
|
||||
} else {
|
||||
// Still set the CSS variable for default
|
||||
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');
|
||||
}
|
||||
} catch (_) { /* corrupt localStorage — ignore */ }
|
||||
}
|
||||
};
|
||||
|
||||
@@ -256,19 +256,7 @@ Object.assign(UI, {
|
||||
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
|
||||
},
|
||||
|
||||
applyAppearance(scale, msgFont) {
|
||||
const z = scale === 100 ? '' : scale / 100;
|
||||
// Zoom content areas + modals + panels (but NOT .app or banners)
|
||||
document.querySelectorAll('.sidebar, .workspace-primary, .workspace-secondary, .modal-overlay, .admin-panel').forEach(el => el.style.zoom = z);
|
||||
const splash = document.getElementById('splashGate');
|
||||
if (splash) splash.style.zoom = z;
|
||||
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');
|
||||
localStorage.setItem('cs-appearance', JSON.stringify({ scale, msgFont }));
|
||||
// Recheck tab overflow after layout settles with new zoom
|
||||
requestAnimationFrame(() => {
|
||||
document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(t => { if (typeof checkTabsOverflow === 'function') checkTabsOverflow(t); });
|
||||
});
|
||||
},
|
||||
// applyAppearance — moved to ui-core.js (v0.25.0-cs11.1) so all surfaces can use it.
|
||||
|
||||
async checkUserProvidersAllowed() {
|
||||
const notice = document.getElementById('userProvidersDisabled');
|
||||
|
||||
154
src/js/user-menu.js
Normal file
154
src/js/user-menu.js
Normal file
@@ -0,0 +1,154 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — UserMenu Component
|
||||
// ==========================================
|
||||
// v0.25.0: Extracted from chat.html + ui-core.js.
|
||||
// Reusable across all authenticated surfaces.
|
||||
// Pattern: Go template partial (user-menu.html) + JS factory (this file).
|
||||
//
|
||||
// Usage:
|
||||
// const menu = UserMenu.create({ id: 'global' });
|
||||
// menu.setUser({ display_name: 'Jeff', username: 'jeff', avatar: null });
|
||||
// menu.showAdmin(true);
|
||||
// menu.destroy();
|
||||
|
||||
const UserMenu = {
|
||||
primary: null,
|
||||
_instances: new Map(),
|
||||
|
||||
create(opts) {
|
||||
const pfx = opts.id || '';
|
||||
const instance = {
|
||||
id: pfx,
|
||||
btnEl: document.getElementById(pfx + 'userMenuBtn'),
|
||||
avatarEl: document.getElementById(pfx + 'userAvatar'),
|
||||
letterEl: document.getElementById(pfx + 'avatarLetter'),
|
||||
nameEl: document.getElementById(pfx + 'userName'),
|
||||
flyoutEl: document.getElementById(pfx + 'userFlyout'),
|
||||
settingsEl: document.getElementById(pfx + 'menuSettings'),
|
||||
adminEl: document.getElementById(pfx + 'menuAdmin'),
|
||||
teamAdminEl: document.getElementById(pfx + 'menuTeamAdmin'),
|
||||
debugEl: document.getElementById(pfx + 'menuDebug'),
|
||||
signoutEl: document.getElementById(pfx + 'menuSignout'),
|
||||
_open: false,
|
||||
_listeners: [],
|
||||
|
||||
// ── User display ────────────────────
|
||||
|
||||
setUser(user) {
|
||||
if (!user) return;
|
||||
const name = user.display_name || user.username || 'User';
|
||||
const letter = (name[0] || '?').toUpperCase();
|
||||
|
||||
// Avatar image or initial letter
|
||||
if (this.avatarEl) {
|
||||
let img = this.avatarEl.querySelector('.user-avatar-img');
|
||||
if (user.avatar) {
|
||||
if (this.letterEl) this.letterEl.style.display = 'none';
|
||||
if (img) {
|
||||
img.src = user.avatar;
|
||||
} else {
|
||||
img = document.createElement('img');
|
||||
img.src = user.avatar;
|
||||
img.alt = '';
|
||||
img.className = 'user-avatar-img';
|
||||
img.onerror = function() {
|
||||
this.remove();
|
||||
if (instance.letterEl) {
|
||||
instance.letterEl.style.display = '';
|
||||
instance.letterEl.textContent = letter;
|
||||
}
|
||||
};
|
||||
this.avatarEl.insertBefore(img, this.letterEl);
|
||||
}
|
||||
} else {
|
||||
if (img) img.remove();
|
||||
if (this.letterEl) {
|
||||
this.letterEl.style.display = '';
|
||||
this.letterEl.textContent = letter;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.nameEl) this.nameEl.textContent = name;
|
||||
},
|
||||
|
||||
// ── Visibility controls ─────────────
|
||||
|
||||
showAdmin(show) {
|
||||
if (this.adminEl) this.adminEl.style.display = show ? '' : 'none';
|
||||
if (this.debugEl) this.debugEl.style.display = show ? '' : 'none';
|
||||
},
|
||||
|
||||
showTeamAdmin(show) {
|
||||
if (this.teamAdminEl) this.teamAdminEl.style.display = show ? '' : 'none';
|
||||
},
|
||||
|
||||
// ── Flyout toggle ───────────────────
|
||||
|
||||
open() {
|
||||
if (this.flyoutEl) this.flyoutEl.classList.add('open');
|
||||
this._open = true;
|
||||
},
|
||||
|
||||
close() {
|
||||
if (this.flyoutEl) this.flyoutEl.classList.remove('open');
|
||||
this._open = false;
|
||||
},
|
||||
|
||||
toggle() {
|
||||
if (this._open) this.close(); else this.open();
|
||||
},
|
||||
|
||||
// ── Event wiring ────────────────────
|
||||
|
||||
/**
|
||||
* Wire event handlers. Accepts callbacks for menu actions.
|
||||
* Call once after create().
|
||||
*/
|
||||
bind(handlers) {
|
||||
handlers = handlers || {};
|
||||
|
||||
// Toggle flyout on avatar click
|
||||
this._on(this.btnEl, 'click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggle();
|
||||
});
|
||||
|
||||
// Close on outside click
|
||||
this._on(document, 'click', (e) => {
|
||||
if (this._open && !e.target.closest('#' + pfx + 'userMenuWrap')) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Menu items
|
||||
this._on(this.settingsEl, 'click', () => { this.close(); if (handlers.onSettings) handlers.onSettings(); });
|
||||
this._on(this.adminEl, 'click', () => { this.close(); if (handlers.onAdmin) handlers.onAdmin(); });
|
||||
this._on(this.teamAdminEl, 'click', () => { this.close(); if (handlers.onTeamAdmin) handlers.onTeamAdmin(); });
|
||||
this._on(this.debugEl, 'click', () => { this.close(); if (handlers.onDebug) handlers.onDebug(); });
|
||||
this._on(this.signoutEl, 'click', () => { this.close(); if (handlers.onSignout) handlers.onSignout(); });
|
||||
},
|
||||
|
||||
// ── Lifecycle ───────────────────────
|
||||
|
||||
destroy() {
|
||||
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
|
||||
this._listeners = [];
|
||||
UserMenu._instances.delete(this.id);
|
||||
if (UserMenu.primary === this) UserMenu.primary = null;
|
||||
},
|
||||
|
||||
// ── Internal ────────────────────────
|
||||
|
||||
_on(el, event, handler) {
|
||||
if (!el) return;
|
||||
el.addEventListener(event, handler);
|
||||
this._listeners.push({ el, event, handler });
|
||||
},
|
||||
};
|
||||
|
||||
UserMenu._instances.set(pfx, instance);
|
||||
return instance;
|
||||
},
|
||||
|
||||
get(id) { return this._instances.get(id) || null; },
|
||||
};
|
||||
Reference in New Issue
Block a user