Changeset 0.30.1 (#200)

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
2026-03-18 13:22:15 +00:00
committed by xcaliber
parent 7d14e6a439
commit 8fee53e440
23 changed files with 1294 additions and 846 deletions

View File

@@ -1 +1 @@
0.29.3
0.30.1

View File

@@ -235,10 +235,10 @@ Migrate core surfaces to `sw.*` SDK.
Depends on: v0.28.5, v0.30.0.
- [ ] `sw.notes()`, `sw.chat()`, `sw.panels()` real factories
- [ ] Core surface migration: chat, editor, notes, settings
- [ ] Phase 5 FE decomp: `import`/`export` statements
- [ ] Memory compaction: summarize, confidence decay, prune
- [x] `sw.notes()`, `sw.chat()`, `sw.panels()` real factories
- [x] Core surface migration: chat, editor, notes, settings
- [x] Phase 5 FE decomp: `import`/`export` statements
- [x] Memory compaction: summarize, confidence decay, prune
### v0.30.2 — Workflow Packages

View File

@@ -5,13 +5,15 @@ import (
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/memory"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// MemoryHandler provides REST endpoints for memory management.
type MemoryHandler struct {
stores store.Stores
stores store.Stores
compactor *memory.Compactor
}
// NewMemoryHandler creates a new memory handler.
@@ -19,6 +21,11 @@ func NewMemoryHandler(stores store.Stores) *MemoryHandler {
return &MemoryHandler{stores: stores}
}
// SetCompactor wires the compactor for the /compact endpoint.
func (h *MemoryHandler) SetCompactor(c *memory.Compactor) {
h.compactor = c
}
// ListMyMemories returns the current user's memories with optional filters.
// GET /api/v1/memories?status=active&query=...
func (h *MemoryHandler) ListMyMemories(c *gin.Context) {
@@ -254,3 +261,37 @@ func (h *MemoryHandler) BulkApprove(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"approved": approved})
}
// CompactMemories triggers memory compaction for the current user.
// POST /api/v1/memories/compact
func (h *MemoryHandler) CompactMemories(c *gin.Context) {
if h.compactor == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "compaction not available"})
return
}
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
teamID := c.GetString("team_id")
var tID *string
if teamID != "" {
tID = &teamID
}
result, err := h.compactor.Compact(c.Request.Context(), userID, tID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"before": result.Before,
"after": result.After,
"merged": result.Merged,
"archived": result.Archived,
})
}

View File

@@ -361,6 +361,7 @@ func main() {
// v0.27.3: task_create tool (AI spawns sub-tasks)
tools.RegisterTaskTools(stores)
memExtractor := memory.NewExtractor(stores, roleResolver, kbEmbedder)
memCompactor := memory.NewCompactor(stores, roleResolver)
memScanner := memory.NewScanner(memExtractor, stores, memory.ScannerConfig{})
memScanner.Start()
defer memScanner.Stop()
@@ -909,12 +910,14 @@ func main() {
// Memory management (v0.18.0)
memH := handlers.NewMemoryHandler(stores)
memH.SetCompactor(memCompactor)
protected.GET("/memories", memH.ListMyMemories)
protected.PUT("/memories/:id", memH.UpdateMemory)
protected.DELETE("/memories/:id", memH.DeleteMemory)
protected.POST("/memories/:id/approve", memH.ApproveMemory)
protected.POST("/memories/:id/reject", memH.RejectMemory)
protected.GET("/memories/count", memH.MemoryCount)
protected.POST("/memories/compact", memH.CompactMemories)
// Teams (user: my teams)
teams := handlers.NewTeamHandler(stores, keyResolver)

157
server/memory/compactor.go Normal file
View File

@@ -0,0 +1,157 @@
package memory
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/roles"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// compactionThreshold is the minimum number of active memories before
// compaction is considered worthwhile for a user.
const compactionThreshold = 30
// compactionPrompt instructs the utility model to merge related memories.
const compactionPrompt = `You are a memory compaction assistant. Given a set of user memories (key-value facts), merge related or redundant entries into fewer, more precise facts.
Rules:
- Combine facts about the same topic into a single entry
- Remove duplicates, keeping the most specific/recent version
- Preserve all unique information — do not drop facts
- Keep keys short (2-5 words, lowercase)
- Keep values concise (1-2 sentences max)
- Set confidence based on how well-supported the merged fact is
Respond ONLY with a JSON array of merged facts:
[{"key": "...", "value": "...", "confidence": 0.0-1.0}]
If no merging is possible, return the original facts unchanged.`
// Compactor merges related memories to reduce count and improve quality.
type Compactor struct {
stores store.Stores
roleResolver *roles.Resolver
}
// NewCompactor creates a memory compaction service.
func NewCompactor(stores store.Stores, rr *roles.Resolver) *Compactor {
return &Compactor{stores: stores, roleResolver: rr}
}
// CompactResult holds the outcome of a compaction run.
type CompactResult struct {
Before int `json:"before"`
After int `json:"after"`
Merged int `json:"merged"`
Archived int `json:"archived"`
}
// Compact loads active memories for a user, sends them to the utility model
// for merging, then upserts merged entries and archives originals.
func (c *Compactor) Compact(ctx context.Context, userID string, teamID *string) (*CompactResult, error) {
if c.stores.Memories == nil {
return nil, fmt.Errorf("memory store not available")
}
// Load all active user-scope memories
memories, err := c.stores.Memories.List(ctx, models.MemoryFilter{
Scope: models.MemoryScopeUser,
OwnerID: userID,
Status: models.MemoryStatusActive,
Limit: 500,
})
if err != nil {
return nil, fmt.Errorf("list memories: %w", err)
}
if len(memories) < compactionThreshold {
return &CompactResult{Before: len(memories), After: len(memories)}, nil
}
// Build memory text for the model
var sb strings.Builder
for i, m := range memories {
sb.WriteString(fmt.Sprintf("%d. [%s] %s (confidence: %.2f)\n", i+1, m.Key, m.Value, m.Confidence))
}
apiMessages := []providers.Message{
{Role: "system", Content: compactionPrompt},
{Role: "user", Content: fmt.Sprintf("Compact these %d memories:\n\n%s", len(memories), sb.String())},
}
result, err := c.roleResolver.Complete(ctx, "utility", userID, teamID, apiMessages)
if err != nil {
return nil, fmt.Errorf("compaction completion: %w", err)
}
// Parse merged facts
merged, err := parseExtractionResponse(result.Content)
if err != nil {
return nil, fmt.Errorf("parse compaction response: %w", err)
}
if len(merged) == 0 || len(merged) >= len(memories) {
// Model returned nothing useful or didn't reduce — skip
log.Printf("🧠 memory compaction: user %s — model returned %d facts (was %d), skipping",
userID, len(merged), len(memories))
return &CompactResult{Before: len(memories), After: len(memories)}, nil
}
// Archive all original memories
archived := 0
for _, m := range memories {
if err := c.stores.Memories.Archive(ctx, m.ID); err == nil {
archived++
}
}
// Upsert merged memories
for _, fact := range merged {
mem := &models.Memory{
Scope: models.MemoryScopeUser,
OwnerID: userID,
Key: fact.Key,
Value: fact.Value,
Confidence: fact.Confidence,
Status: models.MemoryStatusActive,
}
if err := c.stores.Memories.Upsert(ctx, mem); err != nil {
log.Printf("⚠ memory compaction upsert failed: key=%s: %v", fact.Key, err)
}
}
log.Printf("🧠 memory compaction: user %s — %d → %d memories (archived %d)",
userID, len(memories), len(merged), archived)
return &CompactResult{
Before: len(memories),
After: len(merged),
Merged: len(merged),
Archived: archived,
}, nil
}
// parseCompactionResponse parses the model's JSON array response.
// Reuses the same extractedFact struct from extractor.go.
func parseCompactionResponse(content string) ([]extractedFact, error) {
content = strings.TrimSpace(content)
// Strip markdown code fences if present
if strings.HasPrefix(content, "```") {
lines := strings.Split(content, "\n")
if len(lines) > 2 {
content = strings.Join(lines[1:len(lines)-1], "\n")
}
}
var facts []extractedFact
if err := json.Unmarshal([]byte(content), &facts); err != nil {
return nil, err
}
return facts, nil
}

View File

@@ -95,45 +95,81 @@ func (s *Scanner) scan() {
// If key is missing (err != nil or val == nil), extraction is enabled.
}
// ── Extraction ──
candidates, err := s.findCandidates(ctx)
if err != nil {
log.Printf("⚠ memory scanner: find candidates: %v", err)
return
}
if len(candidates) == 0 {
if len(candidates) > 0 {
sem := make(chan struct{}, s.cfg.Concurrency)
var wg sync.WaitGroup
for _, c := range candidates {
key := c.ChannelID + ":" + c.UserID
if _, loaded := s.inFlight.LoadOrStore(key, true); loaded {
continue
}
sem <- struct{}{}
wg.Add(1)
go func(cand candidate) {
defer wg.Done()
defer func() { <-sem }()
defer s.inFlight.Delete(cand.ChannelID + ":" + cand.UserID)
extractCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
if err := s.extractor.Extract(extractCtx, cand.ChannelID, cand.UserID, cand.TeamID, cand.PersonaID); err != nil {
log.Printf("⚠ memory extraction failed: channel=%s user=%s: %v",
cand.ChannelID, cand.UserID, err)
}
}(c)
}
wg.Wait()
}
// ── v0.30.1: Compaction (decay + prune) ──
// Runs after extraction on every tick. Gated by memory_compaction_enabled.
s.runCompaction(ctx)
}
// runCompaction applies confidence decay and pruning to stale memories.
func (s *Scanner) runCompaction(ctx context.Context) {
if s.stores.Memories == nil {
return
}
// Semaphore for concurrency control
sem := make(chan struct{}, s.cfg.Concurrency)
var wg sync.WaitGroup
for _, c := range candidates {
key := c.ChannelID + ":" + c.UserID
if _, loaded := s.inFlight.LoadOrStore(key, true); loaded {
continue // already in progress
}
sem <- struct{}{} // acquire
wg.Add(1)
go func(cand candidate) {
defer wg.Done()
defer func() { <-sem }() // release
defer s.inFlight.Delete(cand.ChannelID + ":" + cand.UserID)
extractCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
if err := s.extractor.Extract(extractCtx, cand.ChannelID, cand.UserID, cand.TeamID, cand.PersonaID); err != nil {
log.Printf("⚠ memory extraction failed: channel=%s user=%s: %v",
cand.ChannelID, cand.UserID, err)
// Check compaction kill switch (default: enabled)
if s.stores.GlobalConfig != nil {
val, err := s.stores.GlobalConfig.Get(ctx, "memory_compaction_enabled")
if err == nil && val != nil {
if enabled, ok := val["value"].(bool); ok && !enabled {
return
}
}(c)
}
}
wg.Wait()
// Decay: reduce confidence for memories not updated in 7 days
olderThan := time.Now().Add(-7 * 24 * time.Hour).UTC().Format(time.RFC3339)
decayed, err := s.stores.Memories.DecayConfidence(ctx, olderThan, 0.9)
if err != nil {
log.Printf("⚠ memory compaction: decay failed: %v", err)
} else if decayed > 0 {
log.Printf("🧠 memory compaction: decayed %d memories", decayed)
}
// Prune: archive memories with confidence below 0.15
pruned, err := s.stores.Memories.Prune(ctx, 0.15)
if err != nil {
log.Printf("⚠ memory compaction: prune failed: %v", err)
} else if pruned > 0 {
log.Printf("🧠 memory compaction: pruned %d memories", pruned)
}
}
// findCandidates queries for channels with enough new activity since last extraction.

View File

@@ -91,7 +91,7 @@
{{if .Manifest}}window.__MANIFEST__ = {{.Manifest | toJSON}};{{end}}
</script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/sb.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/sb.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/app-state.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/api.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}">

View File

@@ -209,7 +209,7 @@
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-settings.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-admin.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-handlers.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/settings-handlers.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/settings-handlers.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/knowledge-ui.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/persona-kb.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/memory-ui.js?v={{.Version}}"></script>

View File

@@ -515,6 +515,7 @@ window.addEventListener('unhandledrejection', function(e) {
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/ui-settings.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/ui-admin.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/tokens.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/note-panel.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/notes.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/note-graph.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/files.js?v={{$.Version}}"></script>
@@ -522,12 +523,12 @@ window.addEventListener('unhandledrejection', function(e) {
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/knowledge-ui.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/memory-ui.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/persona-kb.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/projects-ui.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/projects-ui.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/channel-models.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/notification-prefs.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/notifications.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/chat.js?v={{$.Version}}"></script>
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/settings-handlers.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/settings-handlers.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/admin-handlers.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/workflow-api.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/workflow-admin.js?v={{$.Version}}"></script>

View File

@@ -29,6 +29,7 @@
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/channel-models.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/tokens.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/extensions.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/note-panel.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/notes.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/note-graph.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/knowledge-ui.js?v={{.Version}}"></script>
@@ -37,7 +38,7 @@
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/notifications.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/panels.js?v={{.Version}}"></script>
{{/* debug.js now loaded in base.html for all surfaces */}}
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/projects-ui.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/projects-ui.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/app.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}">
// Notes surface boot: open notes panel in standalone mode

View File

@@ -172,7 +172,7 @@
{{/* Scripts */}}
{{define "scripts-settings"}}
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-settings.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/settings-handlers.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/settings-handlers.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/task-settings.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/git-credentials-ui.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/knowledge-ui.js?v={{.Version}}"></script>

View File

@@ -301,3 +301,36 @@ func (s *MemoryStore) UpsertExtractionLog(ctx context.Context, channelID, userID
`, channelID, userID, lastMessageID, count)
return err
}
// ── v0.30.1 — Memory Compaction ────────────────────────────────────────
func (s *MemoryStore) DecayConfidence(ctx context.Context, olderThan string, decayFactor float64) (int, error) {
res, err := DB.ExecContext(ctx, `
UPDATE memories
SET confidence = confidence * $1,
updated_at = now()
WHERE status = 'active'
AND updated_at < $2::timestamptz
AND confidence > 0.1
`, decayFactor, olderThan)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return int(n), nil
}
func (s *MemoryStore) Prune(ctx context.Context, belowConfidence float64) (int, error) {
res, err := DB.ExecContext(ctx, `
UPDATE memories
SET status = 'archived',
updated_at = now()
WHERE status = 'active'
AND confidence < $1
`, belowConfidence)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return int(n), nil
}

View File

@@ -285,3 +285,36 @@ func (s *MemoryStore) UpsertExtractionLog(ctx context.Context, channelID, userID
`, store.NewID(), channelID, userID, lastMessageID, count)
return err
}
// ── v0.30.1 — Memory Compaction ────────────────────────────────────────
func (s *MemoryStore) DecayConfidence(ctx context.Context, olderThan string, decayFactor float64) (int, error) {
res, err := DB.ExecContext(ctx, `
UPDATE memories
SET confidence = confidence * ?,
updated_at = datetime('now')
WHERE status = 'active'
AND updated_at < ?
AND confidence > 0.1
`, decayFactor, olderThan)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return int(n), nil
}
func (s *MemoryStore) Prune(ctx context.Context, belowConfidence float64) (int, error) {
res, err := DB.ExecContext(ctx, `
UPDATE memories
SET status = 'archived',
updated_at = datetime('now')
WHERE status = 'active'
AND confidence < ?
`, belowConfidence)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return int(n), nil
}

View File

@@ -64,4 +64,15 @@ type MemoryStore interface {
// UpsertExtractionLog creates or updates the memory extraction log entry.
UpsertExtractionLog(ctx context.Context, channelID, userID, lastMessageID string, count int) error
// ── v0.30.1 — Memory Compaction ──
// DecayConfidence reduces confidence for active memories not updated since olderThan.
// Formula: new_confidence = confidence * decayFactor. Skips memories with confidence <= 0.1.
// Returns the number of affected rows.
DecayConfidence(ctx context.Context, olderThan string, decayFactor float64) (int, error)
// Prune archives active memories whose confidence has dropped below the threshold.
// Returns the number of archived rows.
Prune(ctx context.Context, belowConfidence float64) (int, error)
}

View File

@@ -667,8 +667,8 @@ function _initAdminListeners() {
});
// Role fallback alerts (v0.17.0) — show admin banner when fallback fires
if (typeof Events !== 'undefined') {
Events.on('role.fallback', (payload) => {
if (typeof sw !== 'undefined') {
sw.on('role.fallback', (payload) => {
const banner = document.getElementById('roleFallbackBanner');
if (!banner) return;
const msg = payload?.message || 'A model role is using its fallback provider.';
@@ -704,8 +704,8 @@ async function deleteAdminExtension(id, name) {
var _extEditors = {};
// Listen for theme changes to update CM6 editors
if (typeof Events !== 'undefined') {
Events.on('theme.changed', (data) => {
if (typeof sw !== 'undefined') {
sw.on('theme.changed', (data) => {
const isDark = data.resolved === 'dark';
for (const editors of Object.values(_extEditors)) {
editors.manifest?.setDarkMode(isDark);
@@ -713,8 +713,7 @@ if (typeof Events !== 'undefined') {
}
});
// Listen for keymap changes to update CM6 editors in real time
Events.on('keymap.changed', (data) => {
sw.on('keymap.changed', (data) => {
const mode = data.mode || 'standard';
for (const editors of Object.values(_extEditors)) {
editors.manifest?.setKeymap(mode);

View File

@@ -91,7 +91,7 @@ async function startApp() {
}
UI.updateUser();
UI.showAdminButton(API.isAdmin);
UI.showAdminButton(sw.isAdmin);
UI.restoreAppearance();
// Load models for all surfaces (editor needs them for model selector)
@@ -100,7 +100,7 @@ async function startApp() {
// Connect EventBus WebSocket (non-blocking)
try {
Events.connect((window.__BASE__ || '') + '/ws');
Events.on('ws.connected', () => {
sw.on('ws.connected', () => {
if (typeof ToolsToggle !== 'undefined') ToolsToggle.refresh();
});
} catch (e) {
@@ -195,8 +195,8 @@ async function _startChatSurface() {
initListeners();
// Admin-only: persistent fallback alert banner (v0.17.0)
Events.on('role.fallback', (payload) => {
if (App.user?.role !== 'admin') return;
sw.on('role.fallback', (payload) => {
if (!sw.isAdmin) return;
const data = typeof payload === 'string' ? JSON.parse(payload) : payload;
showFallbackBanner(data);
});

View File

@@ -298,7 +298,7 @@ async function selectChat(chatId) {
updateChatTokenCount();
// Notify surfaces and extensions about channel switch (v0.21.6)
Events.emit('chat.switched', { chatId, projectId: chat.projectId || null }, { localOnly: true });
sw.emit('chat.switched', { chatId, projectId: chat.projectId || null }, { localOnly: true });
// Update browser URL to reflect selected chat
window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chatId}`);
@@ -437,7 +437,7 @@ async function newChat() {
}
if (typeof KnowledgeUI !== 'undefined') KnowledgeUI.onChatChanged();
// Notify surfaces — no channel selected (v0.21.6)
Events.emit('chat.switched', { chatId: null, projectId: null }, { localOnly: true });
sw.emit('chat.switched', { chatId: null, projectId: null }, { localOnly: true });
window.history.replaceState(null, '', `${window.__BASE__ || ''}/`);
}
@@ -537,7 +537,7 @@ async function newGroupChat() {
App.chats.unshift(chat);
App.setActive(chat.id, 'group');
UI.renderChatList();
Events.emit('chat.created', { chatId: chat.id, projectId: null }, { localOnly: true });
sw.emit('chat.created', { chatId: chat.id, projectId: null }, { localOnly: true });
window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chat.id}`);
// Load the channel model roster (populated by participant auto-add)
@@ -708,7 +708,7 @@ async function sendMessage() {
App.setActive(chat.id, 'direct');
UI.renderChatList();
// Notify surfaces about new chat creation (v0.21.6)
Events.emit('chat.created', { chatId: chat.id, projectId: chat.projectId || null }, { localOnly: true });
sw.emit('chat.created', { chatId: chat.id, projectId: chat.projectId || null }, { localOnly: true });
window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chat.id}`);
} catch (e) { UI.toast('Failed to create chat: ' + e.message, 'error'); return; }
}
@@ -1120,7 +1120,7 @@ function _initChatListeners() {
// AI-to-AI chain: handle messages arriving via WebSocket (v0.23.0)
// When a persona @mentions another persona, the follow-up completion
// runs server-side and delivers the result via WS, not SSE.
Events.on('message.created', (payload) => {
sw.on('message.created', (payload) => {
if (!payload || !payload.channel_id) return;
// v0.23.2: If this channel isn't in our sidebar yet, fetch and add it
@@ -1170,7 +1170,7 @@ function _initChatListeners() {
});
// Typing indicators from persona chain activity
Events.on('typing.start', (payload) => {
sw.on('typing.start', (payload) => {
if (!payload || payload.channel_id !== App.activeId) return;
if (payload.participant_type !== 'persona') return;
@@ -1192,16 +1192,16 @@ function _initChatListeners() {
container.scrollTop = container.scrollHeight;
});
Events.on('typing.stop', (payload) => {
sw.on('typing.stop', (payload) => {
if (!payload || payload.channel_id !== App.activeId) return;
document.getElementById('typingIndicator')?.remove();
document.getElementById('userTypingIndicator')?.remove();
});
// v0.23.2: Human typing indicator from other participants
Events.on('typing.user', (payload) => {
sw.on('typing.user', (payload) => {
if (!payload || payload.channel_id !== App.activeId) return;
if (payload.user_id === API.user?.id) return; // ignore self
if (payload.user_id === sw.user?.id) return; // ignore self
// Show or refresh typing indicator
let typing = document.getElementById('userTypingIndicator');
@@ -1231,7 +1231,7 @@ function _initChatListeners() {
});
// v0.23.2: User @mention notification
Events.on('user.mentioned', (payload) => {
sw.on('user.mentioned', (payload) => {
if (!payload) return;
const channelId = payload.channel_id;
// Increment unread badge on the channel sidebar item
@@ -1246,14 +1246,14 @@ function _initChatListeners() {
});
// v0.23.2: Live presence updates for sidebar online dots
Events.on('user.presence', (payload) => {
sw.on('user.presence', (payload) => {
if (!payload || !payload.user_id) return;
App.presence[payload.user_id] = payload.status || 'offline';
if (typeof UI !== 'undefined') UI.renderChannelsSection();
});
// v0.27.0: Workflow stage advanced — refresh stage bar + auto-switch persona
Events.on('workflow.advanced', (payload) => {
sw.on('workflow.advanced', (payload) => {
if (!payload) return;
const ac = App.activeConversation;
if (ac && ac.id === payload.channel_id) {
@@ -1264,7 +1264,7 @@ function _initChatListeners() {
});
// v0.27.0: Workflow completed — refresh stage bar
Events.on('workflow.completed', (payload) => {
sw.on('workflow.completed', (payload) => {
if (!payload) return;
const ac = App.activeConversation;
if (ac && ac.id === payload.channel_id) {
@@ -1274,9 +1274,9 @@ function _initChatListeners() {
});
// v0.27.0: Workflow assigned — refresh queue + toast
Events.on('workflow.assigned', (payload) => {
sw.on('workflow.assigned', (payload) => {
if (!payload) return;
if (payload.assigned_to === (API.user && API.user.id)) {
if (payload.assigned_to === sw.user?.id) {
UI.toast('Workflow stage "' + (payload.stage_name || '?') + '" assigned to you', 'info');
}
if (typeof WorkflowQueue !== 'undefined') WorkflowQueue.refresh();

View File

@@ -52,7 +52,7 @@
const name = window.prompt('Workspace name:');
if (!name) return;
try {
const userId = API.user?.id || window.__USER__?.id;
const userId = sw.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;
@@ -113,7 +113,7 @@
btn.disabled = true;
btn.textContent = 'Creating…';
try {
const userId = API.user?.id || window.__USER__?.id;
const userId = sw.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;

788
src/js/note-panel.js Normal file
View File

@@ -0,0 +1,788 @@
// ==========================================
// Chat Switchboard NotePanel Component
// ==========================================
// v0.30.1: Mountable, self-contained notes panel instances.
// Follows the ChatPane pattern: createComponentRegistry + componentMixin.
// NotePanel.primary is the backward-compat bridge (singleton used by notes.js shim).
//
// Exports: window.NotePanel
const NotePanel = {
...createComponentRegistry('NotePanel'),
create(opts) {
const id = opts.id || 'notes-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
const container = opts.container;
if (!container) {
console.error('[NotePanel] container is required');
return null;
}
const instance = componentMixin({
id,
container,
projectId: opts.projectId || null,
// ── State (was module-level in notes.js) ──
_editingNoteId: null,
_notesSelectMode: false,
_selectedNoteIds: new Set(),
_notesSort: 'updated_desc',
_noteEditor: null,
_currentNote: null,
_searchTimer: null,
// ── DOM query helper ──
$(sel) { return this.container.querySelector(sel); },
$$(sel) { return this.container.querySelectorAll(sel); },
// ── Notes List ──
async loadNotesList(folder, searchQuery) {
const list = this.$('#notesList');
if (!list) return;
list.innerHTML = '<div class="loading">Loading\u2026</div>';
try {
let data;
if (searchQuery) {
data = await API.searchNotes(searchQuery);
const results = data.data || [];
if (results.length === 0) {
list.innerHTML = '<div class="empty-hint">No results found</div>';
return;
}
list.innerHTML = results.map(n => this._noteListItem(n, true)).join('');
} else {
const folderVal = folder || this.$('#notesFolderFilter')?.value || '';
data = await API.listNotes(100, 0, folderVal, '', this._notesSort);
const notes = data.data || [];
if (notes.length === 0) {
list.innerHTML = `<div class="empty-hint">${folderVal ? 'No notes in this folder' : 'No notes yet. Create one or ask the AI to save a note.'}</div>`;
return;
}
list.innerHTML = notes.map(n => this._noteListItem(n, false)).join('');
}
} catch (e) {
list.innerHTML = `<div class="empty-hint">Failed to load: ${esc(e.message)}</div>`;
}
},
_noteListItem(note, isSearch) {
const tags = (note.tags || []).map(t => `<span class="note-tag">${esc(t)}</span>`).join('');
const folder = note.folder_path && note.folder_path !== '/' ? `<span class="note-folder">${esc(note.folder_path)}</span>` : '';
const preview = isSearch && note.headline
? `<div class="note-headline">${this._highlightHeadline(note.headline)}</div>`
: `<div class="note-preview">${esc((note.preview || note.content || '').slice(0, 120))}</div>`;
const time = _relativeTime(note.updated_at);
const checked = this._selectedNoteIds.has(note.id) ? 'checked' : '';
return `
<div class="note-item ${this._selectedNoteIds.has(note.id) ? 'selected' : ''}" data-note-id="${note.id}">
<div class="note-select-col" style="display:${this._notesSelectMode ? '' : 'none'}">
<input type="checkbox" class="note-checkbox" data-id="${note.id}" ${checked}
data-action="_toggleNoteSelectCb" data-args='${JSON.stringify([note.id])}' data-pass-el">
</div>
<div class="note-content-col" data-action="_noteItemClick" data-args='${JSON.stringify([note.id])}'">
<div class="note-item-header">
<span class="note-item-title">${esc(note.title)}</span>
<span class="note-item-time">${time}</span>
</div>
${preview}
<div class="note-item-meta">${folder}${tags}</div>
</div>
</div>`;
},
_highlightHeadline(headline) {
const escaped = esc(headline);
return escaped.replace(/\*\*(.+?)\*\*/g, '<mark>$1</mark>');
},
// ── Multi-select ──
_enterSelectMode() {
this._notesSelectMode = true;
this._selectedNoteIds.clear();
const bar = this.$('#notesSelectionBar');
if (bar) bar.style.display = '';
this.$$('.note-select-col').forEach(el => el.style.display = '');
this._updateSelectedCount();
},
_exitSelectMode() {
this._notesSelectMode = false;
this._selectedNoteIds.clear();
const bar = this.$('#notesSelectionBar');
if (bar) bar.style.display = 'none';
this.$$('.note-select-col').forEach(el => el.style.display = 'none');
this.$$('.note-item.selected').forEach(el => el.classList.remove('selected'));
this.$$('.note-checkbox').forEach(cb => cb.checked = false);
const sa = this.$('#notesSelectAll');
if (sa) sa.checked = false;
},
_toggleNoteSelect(noteId, forceState) {
if (!this._notesSelectMode) {
this._enterSelectMode();
}
const has = this._selectedNoteIds.has(noteId);
const newState = forceState !== undefined ? forceState : !has;
if (newState) this._selectedNoteIds.add(noteId);
else this._selectedNoteIds.delete(noteId);
const item = this.container.querySelector(`.note-item[data-note-id="${noteId}"]`);
if (item) {
item.classList.toggle('selected', newState);
const cb = item.querySelector('.note-checkbox');
if (cb) cb.checked = newState;
}
this._updateSelectedCount();
},
_toggleSelectAll(checked) {
this.$$('.note-checkbox').forEach(cb => {
const cbId = cb.dataset.id;
if (checked) this._selectedNoteIds.add(cbId);
else this._selectedNoteIds.delete(cbId);
cb.checked = checked;
cb.closest('.note-item')?.classList.toggle('selected', checked);
});
this._updateSelectedCount();
},
_updateSelectedCount() {
const el = this.$('#notesSelectedCount');
if (el) el.textContent = this._selectedNoteIds.size;
},
async _bulkDeleteSelected() {
if (this._selectedNoteIds.size === 0) return;
const count = this._selectedNoteIds.size;
if (!await showConfirm(`Delete ${count} note${count > 1 ? 's' : ''}?`)) return;
try {
const resp = await API.bulkDeleteNotes([...this._selectedNoteIds]);
UI.toast(`Deleted ${resp.deleted} note${resp.deleted !== 1 ? 's' : ''}`, 'success');
this._exitSelectMode();
await this.loadNotesList();
await this.loadNoteFolders();
} catch (e) { UI.toast(e.message, 'error'); }
},
// ── Folders ──
async loadNoteFolders() {
const sel = this.$('#notesFolderFilter');
if (!sel) return;
try {
const data = await API.listNoteFolders();
const folders = data.folders || [];
sel.innerHTML = '<option value="">All folders</option>';
folders.forEach(f => {
sel.innerHTML += `<option value="${esc(f.path)}">${esc(f.path)} (${f.count})</option>`;
});
} catch (e) { /* non-critical */ }
},
// ── View switching ──
showNotesList() {
const lv = this.$('#notesListView');
const ev = this.$('#notesEditorView');
const gv = this.$('#notesGraphView');
if (lv) lv.style.display = '';
if (ev) ev.style.display = 'none';
if (gv) gv.style.display = 'none';
this._editingNoteId = null;
this._destroyNoteEditor();
const list = this.$('#notesList');
if (list) list.innerHTML = '<div class="loading">Loading\u2026</div>';
},
// ── Note Editor ──
copyNoteContent() {
if (!this._currentNote) return;
const text = `# ${this._currentNote.title || ''}\n\n${this._currentNote.content || ''}`;
navigator.clipboard.writeText(text).then(() => UI.toast('Copied', 'success'));
},
async openNoteEditor(noteId) {
const lv = this.$('#notesListView');
const ev = this.$('#notesEditorView');
if (lv) lv.style.display = 'none';
if (ev) ev.style.display = '';
if (noteId) {
this._editingNoteId = noteId;
try {
this._currentNote = await API.getNote(noteId);
this._populateEditFields(this._currentNote);
this._showNoteReadMode();
} catch (e) {
UI.toast('Failed to load note: ' + e.message, 'error');
this.showNotesList();
}
} else {
this._editingNoteId = null;
this._currentNote = null;
this._clearEditFields();
this._showNoteEditMode();
this.$('#noteEditorTitle')?.focus();
}
},
_populateEditFields(note) {
const t = this.$('#noteEditorTitle');
const f = this.$('#noteEditorFolder');
const g = this.$('#noteEditorTags');
if (t) t.value = note.title || '';
if (f) f.value = note.folder_path || '';
if (g) g.value = (note.tags || []).join(', ');
this._setNoteEditorContent(note.content || '');
},
_clearEditFields() {
const t = this.$('#noteEditorTitle');
const f = this.$('#noteEditorFolder');
const g = this.$('#noteEditorTags');
if (t) t.value = '';
if (f) f.value = '';
if (g) g.value = '';
this._setNoteEditorContent('');
},
_getNoteEditorContent() {
if (this._noteEditor) return this._noteEditor.getValue();
const ta = this.container.querySelector('#noteEditorContentContainer textarea');
return ta ? ta.value : '';
},
_setNoteEditorContent(text) {
if (this._noteEditor) {
this._noteEditor.setValue(text);
return;
}
const container = this.$('#noteEditorContentContainer');
if (!container) return;
if (window.CM?.noteEditor) {
container.innerHTML = '';
this._noteEditor = CM.noteEditor(container, {
value: text,
darkMode: document.documentElement.getAttribute('data-theme') !== 'light',
onChange: null,
onLink: (title) => {
this._navigateToLinkedNote(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 []; }
},
});
} else {
if (!container.querySelector('textarea')) {
container.innerHTML = '<textarea id="noteEditorContent" rows="12" class="notes-content-input" placeholder="Write your note in markdown\u2026"></textarea>';
}
const ta = container.querySelector('textarea');
if (ta) ta.value = text;
}
},
async _navigateToLinkedNote(title) {
try {
const resp = await API.searchNoteTitles(title, 1);
const match = (resp.data || []).find(n => n.title.toLowerCase() === title.toLowerCase());
if (match) {
await this.openNoteEditor(match.id);
} else {
if (await showConfirm(`Note "${title}" not found. Create it?`)) {
const created = await API.createNote(title, `# ${title}\n\n`, '/', []);
await this.openNoteEditor(created.id);
}
}
} catch (e) { UI.toast('Failed to navigate: ' + e.message, 'error'); }
},
_destroyNoteEditor() {
if (this._noteEditor) {
this._noteEditor.destroy();
this._noteEditor = null;
}
},
_showNoteReadMode() {
if (!this._currentNote) return;
const n = this._currentNote;
const titleEl = this.$('#noteReadTitle');
if (titleEl) titleEl.textContent = n.title || '';
const parts = [];
if (n.folder_path && n.folder_path !== '/') parts.push(`<span class="note-folder">${esc(n.folder_path)}</span>`);
(n.tags || []).forEach(t => parts.push(`<span class="note-tag">${esc(t)}</span>`));
const metaEl = this.$('#noteReadMeta');
if (metaEl) metaEl.innerHTML = parts.join(' ');
const noteReadEl = this.$('#noteReadContent');
if (noteReadEl) {
noteReadEl.innerHTML = formatMessage(n.content || '');
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(noteReadEl);
this._renderWikilinksInReadMode(noteReadEl);
}
const delBtn = this.$('#noteDeleteBtn2');
if (delBtn) delBtn.style.display = this._editingNoteId ? '' : 'none';
const rm = this.$('#noteReadMode');
const em = this.$('#noteEditMode');
const eb = this.$('#noteEditBtn');
const pb = this.$('#notePreviewBtn');
if (rm) rm.style.display = '';
if (em) em.style.display = 'none';
if (eb) eb.style.display = '';
if (pb) pb.style.display = 'none';
if (this._editingNoteId) this._loadBacklinks(this._editingNoteId);
this._updateDailyNav(this._currentNote);
},
_showNoteEditMode() {
const rm = this.$('#noteReadMode');
const em = this.$('#noteEditMode');
const db = this.$('#noteDeleteBtn');
const cb = this.$('#noteCancelEditBtn');
const eb = this.$('#noteEditBtn');
const pb = this.$('#notePreviewBtn');
if (rm) rm.style.display = 'none';
if (em) em.style.display = '';
if (db) db.style.display = this._editingNoteId ? '' : 'none';
if (cb) cb.style.display = this._editingNoteId ? '' : 'none';
if (eb) eb.style.display = 'none';
if (pb) pb.style.display = '';
},
_showNotePreview() {
const title = this.$('#noteEditorTitle')?.value.trim() || '';
const content = this._getNoteEditorContent();
const folderVal = this.$('#noteEditorFolder')?.value.trim() || '';
const tagsStr = this.$('#noteEditorTags')?.value.trim() || '';
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
const titleEl = this.$('#noteReadTitle');
if (titleEl) titleEl.textContent = title || 'Untitled';
const parts = [];
if (folderVal) parts.push(`<span class="note-folder">${esc(folderVal)}</span>`);
tags.forEach(t => parts.push(`<span class="note-tag">${esc(t)}</span>`));
const metaEl = this.$('#noteReadMeta');
if (metaEl) metaEl.innerHTML = parts.join(' ');
const readEl = this.$('#noteReadContent');
if (readEl) {
readEl.innerHTML = formatMessage(content || '');
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(readEl);
this._renderWikilinksInReadMode(readEl);
}
const rm = this.$('#noteReadMode');
const em = this.$('#noteEditMode');
const eb = this.$('#noteEditBtn');
const pb = this.$('#notePreviewBtn');
if (rm) rm.style.display = '';
if (em) em.style.display = 'none';
if (eb) eb.style.display = '';
if (pb) pb.style.display = 'none';
const delBtn = this.$('#noteDeleteBtn2');
if (delBtn) delBtn.style.display = 'none';
},
// ── CRUD ──
async saveNote() {
const title = this.$('#noteEditorTitle')?.value.trim() || '';
const content = this._getNoteEditorContent();
const folder = this.$('#noteEditorFolder')?.value.trim() || '';
const tagsStr = this.$('#noteEditorTags')?.value.trim() || '';
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
if (!title) { UI.toast('Title is required', 'warning'); return; }
if (!content) { UI.toast('Content is required', 'warning'); return; }
try {
if (this._editingNoteId) {
const updated = await API.updateNote(this._editingNoteId, { title, content, folder_path: folder, tags, mode: 'replace' });
this._currentNote = updated;
UI.toast('Note updated', 'success');
this._showNoteReadMode();
} else {
const created = await API.createNote(title, content, folder, tags);
this._editingNoteId = created.id;
this._currentNote = created;
UI.toast('Note created', 'success');
this._showNoteReadMode();
}
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
} catch (e) { UI.toast(e.message, 'error'); }
},
async deleteNote() {
if (!this._editingNoteId) return;
if (!await showConfirm('Delete this note?')) return;
try {
await API.deleteNote(this._editingNoteId);
UI.toast('Note deleted', 'success');
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
this.showNotesList();
await this.loadNotesList();
await this.loadNoteFolders();
} catch (e) { UI.toast(e.message, 'error'); }
},
// ── Backlinks ──
async _loadBacklinks(noteId) {
const container = this.$('#noteBacklinks');
const listEl = this.$('#noteBacklinksList');
const countEl = this.$('#noteBacklinksCount');
if (!container || !listEl || !countEl) return;
try {
const resp = await API.getNoteBacklinks(noteId);
const links = resp.data || [];
countEl.textContent = links.length;
container.style.display = links.length > 0 ? '' : 'none';
listEl.innerHTML = links.map(n => `
<div class="note-backlink-item" data-action="openNoteEditor" data-args='${JSON.stringify([n.id])}'">
<span class="note-backlink-title">${esc(n.title)}</span>
<span class="note-backlink-folder text-muted">${esc(n.folder_path)}</span>
</div>
`).join('');
} catch {
container.style.display = 'none';
}
},
toggleBacklinks() {
const list = this.$('#noteBacklinksList');
if (list) list.style.display = list.style.display === 'none' ? '' : 'none';
},
// ── Wikilink Read-Mode Rendering ──
_renderWikilinksInReadMode(containerEl) {
if (!containerEl) return;
const html = containerEl.innerHTML;
let transclusionId = 0;
const rendered = html.replace(
/(!?)\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g,
(match, bang, title, display) => {
const titleTrimmed = title.trim();
const displayText = display ? display.trim() : titleTrimmed;
if (bang === '!') {
const tid = `transclusion-${++transclusionId}`;
return `<div class="transclusion-embed" id="${tid}" data-title="${esc(titleTrimmed)}">
<div class="transclusion-header">
<span class="wikilink-chip wikilink-transclusion" data-action="_navigateToLinkedNote" data-args='${JSON.stringify([esc(titleTrimmed)])}'" title="Open: ${esc(titleTrimmed)}">↗ ${esc(displayText)}</span>
</div>
<div class="transclusion-content"><span class="text-muted">Loading\u2026</span></div>
</div>`;
}
return `<span class="wikilink-chip" data-action="_navigateToLinkedNote" data-args='${JSON.stringify([esc(titleTrimmed)])}'" title="Link: ${esc(titleTrimmed)}">${esc(displayText)}</span>`;
}
);
containerEl.innerHTML = rendered;
this._resolveTransclusions(containerEl);
},
async _resolveTransclusions(containerEl) {
const embeds = containerEl.querySelectorAll('.transclusion-embed');
if (!embeds.length) return;
const cache = {};
for (const el of embeds) {
const title = el.dataset.title;
const contentEl = el.querySelector('.transclusion-content');
if (!title || !contentEl) continue;
try {
let note = cache[title.toLowerCase()];
if (!note) {
const resp = await API.searchNoteTitles(title, 1);
const match = (resp.data || []).find(n => n.title.toLowerCase() === title.toLowerCase());
if (!match) {
contentEl.innerHTML = '<span class="text-muted">Note not found</span>';
continue;
}
note = await API.getNote(match.id);
cache[title.toLowerCase()] = note;
}
const safeContent = (note.content || '').replace(/!\[\[[^\]]+\]\]/g, (m) => {
const inner = m.match(/!\[\[([^\]|]+)/)?.[1]?.trim() || '';
return `[Embedded: ${inner}]`;
});
contentEl.innerHTML = formatMessage(safeContent);
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(contentEl);
} catch {
contentEl.innerHTML = '<span class="text-muted">Failed to load</span>';
}
}
},
// ── Daily Notes ──
async openDailyNote() {
const today = new Date().toISOString().slice(0, 10);
const title = `Daily \u2014 ${today}`;
const folder = '/daily/';
try {
const resp = await API.searchNoteTitles(title, 1);
const existing = (resp.data || []).find(
n => n.title.toLowerCase() === title.toLowerCase()
);
if (existing) {
await this.openNoteEditor(existing.id);
} else {
const content = `# ${today}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
const created = await API.createNote(title, content, folder, ['daily']);
await this.openNoteEditor(created.id);
}
} catch (e) { UI.toast('Failed to open daily note: ' + e.message, 'error'); }
},
_isDailyNote(note) {
return note && /^Daily \u2014 \d{4}-\d{2}-\d{2}$/.test(note.title);
},
_updateDailyNav(note) {
let nav = this.$('#noteDailyNav');
if (!this._isDailyNote(note)) {
if (nav) nav.style.display = 'none';
return;
}
if (!nav) {
const toolbar = this.container.querySelector('.notes-editor-toolbar');
if (!toolbar) return;
nav = document.createElement('div');
nav.id = 'noteDailyNav';
nav.className = 'note-daily-nav';
nav.innerHTML = `
<button class="btn-small" id="dailyPrevBtn" title="Previous day">\u2039 Prev</button>
<span class="note-daily-date" id="dailyDateLabel"></span>
<button class="btn-small" id="dailyNextBtn" title="Next day">Next \u203a</button>
`;
toolbar.after(nav);
nav.querySelector('#dailyPrevBtn')?.addEventListener('click', () => this._navigateDaily(-1));
nav.querySelector('#dailyNextBtn')?.addEventListener('click', () => this._navigateDaily(1));
}
nav.style.display = '';
const dateStr = note.title.replace('Daily \u2014 ', '');
const label = nav.querySelector('#dailyDateLabel');
if (label) label.textContent = dateStr;
const today = new Date().toISOString().slice(0, 10);
const nextBtn = nav.querySelector('#dailyNextBtn');
if (nextBtn) nextBtn.disabled = dateStr >= today;
},
async _navigateDaily(offset) {
if (!this._currentNote || !this._isDailyNote(this._currentNote)) return;
const dateStr = this._currentNote.title.replace('Daily \u2014 ', '');
const d = new Date(dateStr + 'T12:00:00');
d.setDate(d.getDate() + offset);
const newDateStr = d.toISOString().slice(0, 10);
const title = `Daily \u2014 ${newDateStr}`;
try {
const resp = await API.searchNoteTitles(title, 1);
const existing = (resp.data || []).find(
n => n.title.toLowerCase() === title.toLowerCase()
);
if (existing) {
await this.openNoteEditor(existing.id);
} else {
const content = `# ${newDateStr}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
const created = await API.createNote(title, content, '/daily/', ['daily']);
await this.openNoteEditor(created.id);
}
} catch (e) { UI.toast('Failed to navigate: ' + e.message, 'error'); }
},
// ── Save-to-Note (from chat messages) ──
async saveMessageToNote(msgIndex) {
const chat = App.chats?.find(c => c.id === App.activeId);
const msg = chat?.messages?.[msgIndex];
if (!msg) return;
const sel = window.getSelection();
let content = msg.content;
content = content.replace(/<(?:thinking|think)>[\s\S]*?<\/(?:thinking|think)>/gi, '').trim();
const msgId = msg.id || '';
const msgEl = msgId ? document.querySelector(`.message[data-msg-id="${msgId}"] .msg-text`) : null;
if (sel && sel.rangeCount > 0 && msgEl?.contains(sel.anchorNode)) {
const selected = sel.toString().trim();
if (selected) content = selected;
}
const firstLine = content.split('\n')[0].replace(/^#+\s*/, '').trim();
const defaultTitle = firstLine.slice(0, 60) || 'Chat excerpt';
this._showSaveToNoteModal({
content,
sourceChannelId: App.activeId || '',
sourceMessageId: msg.id || '',
defaultTitle,
});
},
_showSaveToNoteModal(opts) {
document.getElementById('saveToNoteModal')?.remove();
const modal = document.createElement('div');
modal.id = 'saveToNoteModal';
modal.className = 'modal-overlay active';
modal.innerHTML = `
<div class="modal save-to-note-modal">
<h3>Save to Note</h3>
<div class="save-note-mode-toggle">
<label><input type="radio" name="saveNoteMode" value="create" checked> Create new note</label>
<label><input type="radio" name="saveNoteMode" value="append"> Append to existing</label>
</div>
<div id="saveNoteCreateFields">
<div class="form-group">
<input type="text" id="saveNoteTitle" placeholder="Title" value="${esc(opts.defaultTitle)}" class="notes-title-input">
</div>
<div class="form-row">
<div class="form-group"><input type="text" id="saveNoteFolder" placeholder="Folder (e.g. /work)" class="notes-folder-input"></div>
<div class="form-group"><input type="text" id="saveNoteTags" placeholder="Tags (comma-separated)" class="notes-tags-input"></div>
</div>
</div>
<div id="saveNoteAppendFields" style="display:none">
<div class="form-group">
<input type="text" id="saveNoteSearch" placeholder="Search notes to append to..." class="notes-title-input" autocomplete="off">
<div id="saveNoteSearchResults" class="save-note-search-results"></div>
</div>
</div>
<div class="form-group">
<label class="text-muted" style="font-size:12px">Preview (${opts.content.length} chars)</label>
<div class="save-note-preview msg-text">${formatMessage(opts.content.slice(0, 300))}${opts.content.length > 300 ? '\u2026' : ''}</div>
</div>
<div class="modal-actions">
<button class="btn-small" id="saveNoteCancelBtn">Cancel</button>
<button class="btn-small btn-primary" id="saveNoteConfirmBtn">Create</button>
</div>
</div>
`;
document.body.appendChild(modal);
let selectedAppendNote = null;
modal.querySelectorAll('input[name="saveNoteMode"]').forEach(radio => {
radio.addEventListener('change', (e) => {
const isAppend = e.target.value === 'append';
document.getElementById('saveNoteCreateFields').style.display = isAppend ? 'none' : '';
document.getElementById('saveNoteAppendFields').style.display = isAppend ? '' : 'none';
document.getElementById('saveNoteConfirmBtn').textContent = isAppend ? 'Append' : 'Create';
selectedAppendNote = null;
document.getElementById('saveNoteSearchResults').innerHTML = '';
});
});
let _searchTimer;
document.getElementById('saveNoteSearch')?.addEventListener('input', (e) => {
clearTimeout(_searchTimer);
const q = e.target.value.trim();
selectedAppendNote = null;
_searchTimer = setTimeout(async () => {
const resultsEl = document.getElementById('saveNoteSearchResults');
if (q.length < 2) { resultsEl.innerHTML = ''; return; }
try {
const resp = await API.searchNoteTitles(q, 8);
const notes = resp.data || [];
resultsEl.innerHTML = notes.map(n =>
`<div class="save-note-result" data-id="${n.id}" data-title="${esc(n.title)}">${esc(n.title)}</div>`
).join('') || '<div class="text-muted" style="padding:6px">No results</div>';
resultsEl.querySelectorAll('.save-note-result').forEach(el => {
el.addEventListener('click', () => {
resultsEl.querySelectorAll('.save-note-result').forEach(r => r.classList.remove('selected'));
el.classList.add('selected');
selectedAppendNote = { id: el.dataset.id, title: el.dataset.title };
document.getElementById('saveNoteSearch').value = el.dataset.title;
});
});
} catch { resultsEl.innerHTML = '<div class="text-muted" style="padding:6px">Search failed</div>'; }
}, 250);
});
document.getElementById('saveNoteCancelBtn').addEventListener('click', () => modal.remove());
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
document.getElementById('saveNoteConfirmBtn').addEventListener('click', async () => {
const isAppend = modal.querySelector('input[name="saveNoteMode"]:checked')?.value === 'append';
try {
if (isAppend) {
if (!selectedAppendNote) { UI.toast('Select a note to append to', 'warning'); return; }
await API.updateNote(selectedAppendNote.id, {
content: '\n\n---\n\n' + opts.content,
mode: 'append',
});
UI.toast(`Appended to "${selectedAppendNote.title}"`, 'success');
} else {
const title = document.getElementById('saveNoteTitle').value.trim();
if (!title) { UI.toast('Title is required', 'warning'); return; }
const folder = document.getElementById('saveNoteFolder').value.trim();
const tagsStr = document.getElementById('saveNoteTags').value.trim();
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
await API.createNote(title, opts.content, folder, tags, opts.sourceChannelId, opts.sourceMessageId);
UI.toast('Note created', 'success');
}
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
modal.remove();
} catch (e) { UI.toast(e.message, 'error'); }
});
document.getElementById('saveNoteTitle')?.focus();
document.getElementById('saveNoteTitle')?.select();
},
// ── Compound onclick wrappers (for data-action dispatch) ──
_toggleNoteSelectCb(el, noteId) {
this._toggleNoteSelect(noteId, el.checked);
},
_noteItemClick(noteId) {
if (this._notesSelectMode) {
this._toggleNoteSelect(noteId);
} else {
this.openNoteEditor(noteId);
}
},
// ── Lifecycle ──
_cleanup() {
this._destroyNoteEditor();
clearTimeout(this._searchTimer);
},
}, NotePanel);
NotePanel._register(id, instance);
return instance;
},
};
// ── Exports ─────────────────────────────────
sb.ns('NotePanel', NotePanel);

View File

@@ -1,754 +1,90 @@
// ==========================================
// Chat Switchboard Notes Panel
// Chat Switchboard Notes Panel (Shim)
// ==========================================
// Notes panel: editor, multi-select, folders, CRUD.
// v0.30.1: Thin backward-compat layer over NotePanel component.
// All state and logic now lives in note-panel.js.
// This file creates the singleton (NotePanel.primary) and wires
// sb.register() calls so data-action attributes continue to work.
var _editingNoteId = null;
var _notesSelectMode = false;
var _selectedNoteIds = new Set();
var _notesSort = 'updated_desc';
var _noteEditor = null; // CM6 noteEditor instance
// ── Singleton accessor ──
function _np() { return NotePanel.primary; }
// ── Notes ─────────────────────────────────────
// ── Public API (delegated to NotePanel.primary) ──
async function openNotes() {
// Toggle: if notes is active, close it; otherwise open it
if (PanelRegistry.isOpen('notes')) {
PanelRegistry.close('notes');
return;
}
PanelRegistry.open('notes');
_exitSelectMode();
_np()?._exitSelectMode();
}
async function loadNotesList(folder, searchQuery) {
const list = document.getElementById('notesList');
list.innerHTML = '<div class="loading">Loading…</div>';
try {
let data;
if (searchQuery) {
data = await API.searchNotes(searchQuery);
const results = data.data || [];
if (results.length === 0) {
list.innerHTML = '<div class="empty-hint">No results found</div>';
return;
}
list.innerHTML = results.map(n => _noteListItem(n, true)).join('');
} else {
const folderVal = folder || document.getElementById('notesFolderFilter')?.value || '';
data = await API.listNotes(100, 0, folderVal, '', _notesSort);
const notes = data.data || [];
if (notes.length === 0) {
list.innerHTML = `<div class="empty-hint">${folderVal ? 'No notes in this folder' : 'No notes yet. Create one or ask the AI to save a note.'}</div>`;
return;
}
list.innerHTML = notes.map(n => _noteListItem(n, false)).join('');
}
} catch (e) {
list.innerHTML = `<div class="empty-hint">Failed to load: ${esc(e.message)}</div>`;
}
}
function _noteListItem(note, isSearch) {
const tags = (note.tags || []).map(t => `<span class="note-tag">${esc(t)}</span>`).join('');
const folder = note.folder_path && note.folder_path !== '/' ? `<span class="note-folder">${esc(note.folder_path)}</span>` : '';
const preview = isSearch && note.headline
? `<div class="note-headline">${_highlightHeadline(note.headline)}</div>`
: `<div class="note-preview">${esc((note.preview || note.content || '').slice(0, 120))}</div>`;
const time = _relativeTime(note.updated_at);
const checked = _selectedNoteIds.has(note.id) ? 'checked' : '';
return `
<div class="note-item ${_selectedNoteIds.has(note.id) ? 'selected' : ''}" data-note-id="${note.id}">
<div class="note-select-col" style="display:${_notesSelectMode ? '' : 'none'}">
<input type="checkbox" class="note-checkbox" data-id="${note.id}" ${checked}
data-action="_toggleNoteSelectCb" data-args='${JSON.stringify([note.id])}' data-pass-el">
</div>
<div class="note-content-col" data-action="_noteItemClick" data-args='${JSON.stringify([note.id])}'">
<div class="note-item-header">
<span class="note-item-title">${esc(note.title)}</span>
<span class="note-item-time">${time}</span>
</div>
${preview}
<div class="note-item-meta">${folder}${tags}</div>
</div>
</div>`;
}
function _highlightHeadline(headline) {
const escaped = esc(headline);
return escaped.replace(/\*\*(.+?)\*\*/g, '<mark>$1</mark>');
}
// ── Multi-select ────────────────
function _enterSelectMode() {
_notesSelectMode = true;
_selectedNoteIds.clear();
document.getElementById('notesSelectionBar').style.display = '';
document.querySelectorAll('.note-select-col').forEach(el => el.style.display = '');
_updateSelectedCount();
}
function _exitSelectMode() {
_notesSelectMode = false;
_selectedNoteIds.clear();
document.getElementById('notesSelectionBar').style.display = 'none';
document.querySelectorAll('.note-select-col').forEach(el => el.style.display = 'none');
document.querySelectorAll('.note-item.selected').forEach(el => el.classList.remove('selected'));
document.querySelectorAll('.note-checkbox').forEach(cb => cb.checked = false);
const sa = document.getElementById('notesSelectAll');
if (sa) sa.checked = false;
}
function _toggleNoteSelect(id, forceState) {
if (!_notesSelectMode) {
_enterSelectMode();
}
const has = _selectedNoteIds.has(id);
const newState = forceState !== undefined ? forceState : !has;
if (newState) _selectedNoteIds.add(id);
else _selectedNoteIds.delete(id);
const item = document.querySelector(`.note-item[data-note-id="${id}"]`);
if (item) {
item.classList.toggle('selected', newState);
const cb = item.querySelector('.note-checkbox');
if (cb) cb.checked = newState;
}
_updateSelectedCount();
}
function _toggleSelectAll(checked) {
document.querySelectorAll('.note-checkbox').forEach(cb => {
const id = cb.dataset.id;
if (checked) _selectedNoteIds.add(id);
else _selectedNoteIds.delete(id);
cb.checked = checked;
cb.closest('.note-item')?.classList.toggle('selected', checked);
});
_updateSelectedCount();
}
function _updateSelectedCount() {
const el = document.getElementById('notesSelectedCount');
if (el) el.textContent = _selectedNoteIds.size;
}
async function _bulkDeleteSelected() {
if (_selectedNoteIds.size === 0) return;
const count = _selectedNoteIds.size;
if (!await showConfirm(`Delete ${count} note${count > 1 ? 's' : ''}?`)) return;
try {
const resp = await API.bulkDeleteNotes([..._selectedNoteIds]);
UI.toast(`Deleted ${resp.deleted} note${resp.deleted !== 1 ? 's' : ''}`, 'success');
_exitSelectMode();
await loadNotesList();
await loadNoteFolders();
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── Folders / Editor / CRUD ─────
async function loadNoteFolders() {
const sel = document.getElementById('notesFolderFilter');
if (!sel) return;
try {
const data = await API.listNoteFolders();
const folders = data.folders || [];
sel.innerHTML = '<option value="">All folders</option>';
folders.forEach(f => {
sel.innerHTML += `<option value="${esc(f.path)}">${esc(f.path)} (${f.count})</option>`;
});
} catch (e) { /* non-critical */ }
return _np()?.loadNotesList(folder, searchQuery);
}
function showNotesList() {
document.getElementById('notesListView').style.display = '';
document.getElementById('notesEditorView').style.display = 'none';
const graphView = document.getElementById('notesGraphView');
if (graphView) graphView.style.display = 'none';
_editingNoteId = null;
_destroyNoteEditor();
// Reset list to loading state to prevent stale content flash
const list = document.getElementById('notesList');
if (list) list.innerHTML = '<div class="loading">Loading…</div>';
return _np()?.showNotesList();
}
var _currentNote = null; // cached note for read mode
function copyNoteContent() {
if (!_currentNote) return;
const text = `# ${_currentNote.title || ''}\n\n${_currentNote.content || ''}`;
navigator.clipboard.writeText(text).then(() => UI.toast('Copied', 'success'));
async function loadNoteFolders() {
return _np()?.loadNoteFolders();
}
async function openNoteEditor(noteId) {
document.getElementById('notesListView').style.display = 'none';
document.getElementById('notesEditorView').style.display = '';
if (noteId) {
_editingNoteId = noteId;
try {
_currentNote = await API.getNote(noteId);
_populateEditFields(_currentNote);
_showNoteReadMode();
} catch (e) {
UI.toast('Failed to load note: ' + e.message, 'error');
showNotesList();
}
} else {
// New note — straight to edit mode
_editingNoteId = null;
_currentNote = null;
_clearEditFields();
_showNoteEditMode();
document.getElementById('noteEditorTitle').focus();
}
}
function _populateEditFields(note) {
document.getElementById('noteEditorTitle').value = note.title || '';
document.getElementById('noteEditorFolder').value = note.folder_path || '';
document.getElementById('noteEditorTags').value = (note.tags || []).join(', ');
_setNoteEditorContent(note.content || '');
}
function _clearEditFields() {
document.getElementById('noteEditorTitle').value = '';
document.getElementById('noteEditorFolder').value = '';
document.getElementById('noteEditorTags').value = '';
_setNoteEditorContent('');
}
/** Get content from CM6 editor or fallback textarea */
function _getNoteEditorContent() {
if (_noteEditor) return _noteEditor.getValue();
const ta = document.querySelector('#noteEditorContentContainer textarea');
return ta ? ta.value : '';
}
/** Set content in CM6 editor or fallback textarea */
function _setNoteEditorContent(text) {
if (_noteEditor) {
_noteEditor.setValue(text);
return;
}
// Lazy-init CM6 note editor
const container = document.getElementById('noteEditorContentContainer');
if (!container) return;
if (window.CM?.noteEditor) {
container.innerHTML = ''; // clear any previous
_noteEditor = CM.noteEditor(container, {
value: text,
darkMode: document.documentElement.getAttribute('data-theme') !== 'light',
onChange: null, // could wire auto-save later
onLink: (title) => {
_navigateToLinkedNote(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 []; }
},
});
} else {
// Fallback: plain textarea
if (!container.querySelector('textarea')) {
container.innerHTML = '<textarea id="noteEditorContent" rows="12" class="notes-content-input" placeholder="Write your note in markdown..."></textarea>';
}
const ta = container.querySelector('textarea');
if (ta) ta.value = text;
}
}
/** Navigate to a note by title (from wikilink click) */
async function _navigateToLinkedNote(title) {
try {
const resp = await API.searchNoteTitles(title, 1);
const match = (resp.data || []).find(n => n.title.toLowerCase() === title.toLowerCase());
if (match) {
await openNoteEditor(match.id);
} else {
// Offer to create
if (await showConfirm(`Note "${title}" not found. Create it?`)) {
const created = await API.createNote(title, `# ${title}\n\n`, '/', []);
await openNoteEditor(created.id);
}
}
} catch (e) { UI.toast('Failed to navigate: ' + e.message, 'error'); }
}
/** Destroy CM6 editor on panel close */
function _destroyNoteEditor() {
if (_noteEditor) {
_noteEditor.destroy();
_noteEditor = null;
}
}
function _showNoteReadMode() {
if (!_currentNote) return;
const n = _currentNote;
// Title
document.getElementById('noteReadTitle').textContent = n.title || '';
// Meta: folder + tags
const parts = [];
if (n.folder_path && n.folder_path !== '/') parts.push(`<span class="note-folder">${esc(n.folder_path)}</span>`);
(n.tags || []).forEach(t => parts.push(`<span class="note-tag">${esc(t)}</span>`));
document.getElementById('noteReadMeta').innerHTML = parts.join(' ');
// Rendered markdown content — reuse the chat formatter
const noteReadEl = document.getElementById('noteReadContent');
noteReadEl.innerHTML = formatMessage(n.content || '');
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(noteReadEl);
// Render [[wikilinks]] as clickable chips in read mode
_renderWikilinksInReadMode(noteReadEl);
// Show/hide delete
document.getElementById('noteDeleteBtn2').style.display = _editingNoteId ? '' : 'none';
// Toggle visibility
document.getElementById('noteReadMode').style.display = '';
document.getElementById('noteEditMode').style.display = 'none';
document.getElementById('noteEditBtn').style.display = '';
document.getElementById('notePreviewBtn').style.display = 'none';
// Load backlinks
if (_editingNoteId) _loadBacklinks(_editingNoteId);
// Show daily note navigation if applicable
_updateDailyNav(_currentNote);
}
function _showNoteEditMode() {
document.getElementById('noteReadMode').style.display = 'none';
document.getElementById('noteEditMode').style.display = '';
document.getElementById('noteDeleteBtn').style.display = _editingNoteId ? '' : 'none';
document.getElementById('noteCancelEditBtn').style.display = _editingNoteId ? '' : 'none';
document.getElementById('noteEditBtn').style.display = 'none';
document.getElementById('notePreviewBtn').style.display = '';
}
function _showNotePreview() {
// Live preview from editor without saving
const title = document.getElementById('noteEditorTitle').value.trim();
const content = _getNoteEditorContent();
const folderVal = document.getElementById('noteEditorFolder').value.trim();
const tagsStr = document.getElementById('noteEditorTags').value.trim();
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
document.getElementById('noteReadTitle').textContent = title || 'Untitled';
const parts = [];
if (folderVal) parts.push(`<span class="note-folder">${esc(folderVal)}</span>`);
tags.forEach(t => parts.push(`<span class="note-tag">${esc(t)}</span>`));
document.getElementById('noteReadMeta').innerHTML = parts.join(' ');
document.getElementById('noteReadContent').innerHTML = formatMessage(content || '');
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(document.getElementById('noteReadContent'));
_renderWikilinksInReadMode(document.getElementById('noteReadContent'));
document.getElementById('noteReadMode').style.display = '';
document.getElementById('noteEditMode').style.display = 'none';
document.getElementById('noteEditBtn').style.display = '';
document.getElementById('notePreviewBtn').style.display = 'none';
// Keep delete hidden in preview-from-edit
document.getElementById('noteDeleteBtn2').style.display = 'none';
return _np()?.openNoteEditor(noteId);
}
async function saveNote() {
const title = document.getElementById('noteEditorTitle').value.trim();
const content = _getNoteEditorContent();
const folder = document.getElementById('noteEditorFolder').value.trim();
const tagsStr = document.getElementById('noteEditorTags').value.trim();
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
if (!title) { UI.toast('Title is required', 'warning'); return; }
if (!content) { UI.toast('Content is required', 'warning'); return; }
try {
if (_editingNoteId) {
const updated = await API.updateNote(_editingNoteId, { title, content, folder_path: folder, tags, mode: 'replace' });
_currentNote = updated;
UI.toast('Note updated', 'success');
_showNoteReadMode();
} else {
const created = await API.createNote(title, content, folder, tags);
_editingNoteId = created.id;
_currentNote = created;
UI.toast('Note created', 'success');
_showNoteReadMode();
}
// Invalidate graph cache
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
} catch (e) { UI.toast(e.message, 'error'); }
return _np()?.saveNote();
}
async function deleteNote() {
if (!_editingNoteId) return;
if (!await showConfirm('Delete this note?')) return;
try {
await API.deleteNote(_editingNoteId);
UI.toast('Note deleted', 'success');
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
showNotesList();
await loadNotesList();
await loadNoteFolders();
} catch (e) { UI.toast(e.message, 'error'); }
return _np()?.deleteNote();
}
// ── Backlinks ───────────────────
async function openDailyNote() {
return _np()?.openDailyNote();
}
async function _loadBacklinks(noteId) {
const container = document.getElementById('noteBacklinks');
const listEl = document.getElementById('noteBacklinksList');
const countEl = document.getElementById('noteBacklinksCount');
if (!container || !listEl || !countEl) return;
async function saveMessageToNote(msgIndex) {
return _np()?.saveMessageToNote(msgIndex);
}
try {
const resp = await API.getNoteBacklinks(noteId);
const links = resp.data || [];
countEl.textContent = links.length;
container.style.display = links.length > 0 ? '' : 'none';
listEl.innerHTML = links.map(n => `
<div class="note-backlink-item" data-action="openNoteEditor" data-args='${JSON.stringify([n.id])}'">
<span class="note-backlink-title">${esc(n.title)}</span>
<span class="note-backlink-folder text-muted">${esc(n.folder_path)}</span>
</div>
`).join('');
} catch {
container.style.display = 'none';
}
function _toggleNoteSelect(id, forceState) {
return _np()?._toggleNoteSelect(id, forceState);
}
function _navigateToLinkedNote(title) {
return _np()?._navigateToLinkedNote(title);
}
function toggleBacklinks() {
const list = document.getElementById('noteBacklinksList');
if (list) list.style.display = list.style.display === 'none' ? '' : 'none';
return _np()?.toggleBacklinks();
}
// ── Wikilink Read-Mode Rendering ──
function _renderWikilinksInReadMode(containerEl) {
if (!containerEl) return;
const html = containerEl.innerHTML;
let transclusionId = 0;
// Replace [[Title|display]] and [[Title]] with clickable spans
// For ![[transclusion]], render a placeholder that gets filled async
const rendered = html.replace(
/(!?)\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g,
(match, bang, title, display) => {
const titleTrimmed = title.trim();
const displayText = display ? display.trim() : titleTrimmed;
if (bang === '!') {
const tid = `transclusion-${++transclusionId}`;
return `<div class="transclusion-embed" id="${tid}" data-title="${esc(titleTrimmed)}">
<div class="transclusion-header">
<span class="wikilink-chip wikilink-transclusion" data-action="_navigateToLinkedNote" data-args='${JSON.stringify([esc(titleTrimmed)])}'" title="Open: ${esc(titleTrimmed)}">↗ ${esc(displayText)}</span>
</div>
<div class="transclusion-content"><span class="text-muted">Loading…</span></div>
</div>`;
}
return `<span class="wikilink-chip" data-action="_navigateToLinkedNote" data-args='${JSON.stringify([esc(titleTrimmed)])}'" title="Link: ${esc(titleTrimmed)}">${esc(displayText)}</span>`;
}
);
containerEl.innerHTML = rendered;
// Async: resolve and embed transclusion content
_resolveTransclusions(containerEl);
function _toggleNoteSelectCb(el, noteId) {
return _np()?._toggleNoteSelectCb(el, noteId);
}
/** Fetch content for each ![[embed]] placeholder — max depth 1 (no recursive transclusion) */
async function _resolveTransclusions(containerEl) {
const embeds = containerEl.querySelectorAll('.transclusion-embed');
if (!embeds.length) return;
// Cache to avoid duplicate fetches in same note
const cache = {};
for (const el of embeds) {
const title = el.dataset.title;
const contentEl = el.querySelector('.transclusion-content');
if (!title || !contentEl) continue;
try {
let note = cache[title.toLowerCase()];
if (!note) {
const resp = await API.searchNoteTitles(title, 1);
const match = (resp.data || []).find(n => n.title.toLowerCase() === title.toLowerCase());
if (!match) {
contentEl.innerHTML = '<span class="text-muted">Note not found</span>';
continue;
}
note = await API.getNote(match.id);
cache[title.toLowerCase()] = note;
}
// Render embedded content (no recursive transclusion — strip ![[]] )
const safeContent = (note.content || '').replace(/!\[\[[^\]]+\]\]/g, (m) => {
const inner = m.match(/!\[\[([^\]|]+)/)?.[1]?.trim() || '';
return `[Embedded: ${inner}]`;
});
contentEl.innerHTML = formatMessage(safeContent);
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(contentEl);
} catch {
contentEl.innerHTML = '<span class="text-muted">Failed to load</span>';
}
}
function _noteItemClick(noteId) {
return _np()?._noteItemClick(noteId);
}
// ── Daily Notes ─────────────────
// Read-only accessors for backward compat (sb.register registered these as values)
function _currentNote() { return _np()?._currentNote; }
function _editingNoteId() { return _np()?._editingNoteId; }
async function openDailyNote() {
const today = new Date().toISOString().slice(0, 10);
const title = `Daily — ${today}`;
const folder = '/daily/';
try {
const resp = await API.searchNoteTitles(title, 1);
const existing = (resp.data || []).find(
n => n.title.toLowerCase() === title.toLowerCase()
);
if (existing) {
await openNoteEditor(existing.id);
} else {
const content = `# ${today}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
const created = await API.createNote(title, content, folder, ['daily']);
await openNoteEditor(created.id);
}
} catch (e) { UI.toast('Failed to open daily note: ' + e.message, 'error'); }
}
/** Detect if current note is a daily note */
function _isDailyNote(note) {
return note && /^Daily — \d{4}-\d{2}-\d{2}$/.test(note.title);
}
/** Show prev/next nav for daily notes */
function _updateDailyNav(note) {
let nav = document.getElementById('noteDailyNav');
if (!_isDailyNote(note)) {
if (nav) nav.style.display = 'none';
return;
}
// Ensure nav element exists
if (!nav) {
const toolbar = document.querySelector('.notes-editor-toolbar');
if (!toolbar) return;
nav = document.createElement('div');
nav.id = 'noteDailyNav';
nav.className = 'note-daily-nav';
nav.innerHTML = `
<button class="btn-small" id="dailyPrevBtn" title="Previous day"> Prev</button>
<span class="note-daily-date" id="dailyDateLabel"></span>
<button class="btn-small" id="dailyNextBtn" title="Next day">Next </button>
`;
toolbar.after(nav);
document.getElementById('dailyPrevBtn')?.addEventListener('click', () => _navigateDaily(-1));
document.getElementById('dailyNextBtn')?.addEventListener('click', () => _navigateDaily(1));
}
nav.style.display = '';
const dateStr = note.title.replace('Daily — ', '');
document.getElementById('dailyDateLabel').textContent = dateStr;
// Disable "Next" if it's today or future
const today = new Date().toISOString().slice(0, 10);
document.getElementById('dailyNextBtn').disabled = dateStr >= today;
}
/** Navigate to prev/next daily note */
async function _navigateDaily(offset) {
if (!_currentNote || !_isDailyNote(_currentNote)) return;
const dateStr = _currentNote.title.replace('Daily — ', '');
const d = new Date(dateStr + 'T12:00:00'); // noon to avoid DST edge
d.setDate(d.getDate() + offset);
const newDateStr = d.toISOString().slice(0, 10);
const title = `Daily — ${newDateStr}`;
try {
const resp = await API.searchNoteTitles(title, 1);
const existing = (resp.data || []).find(
n => n.title.toLowerCase() === title.toLowerCase()
);
if (existing) {
await openNoteEditor(existing.id);
} else {
// Create the daily note for that day
const content = `# ${newDateStr}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
const created = await API.createNote(title, content, '/daily/', ['daily']);
await openNoteEditor(created.id);
}
} catch (e) { UI.toast('Failed to navigate: ' + e.message, 'error'); }
}
// ── Save-to-Note (from chat messages) ──
async function saveMessageToNote(msgIndex) {
const chat = App.chats?.find(c => c.id === App.activeId);
const msg = chat?.messages?.[msgIndex];
if (!msg) return;
// Check for text selection within the message element
const sel = window.getSelection();
let content = msg.content;
// Strip thinking/reasoning blocks — only the response matters in notes
content = content.replace(/<(?:thinking|think)>[\s\S]*?<\/(?:thinking|think)>/gi, '').trim();
const msgId = msg.id || '';
const msgEl = msgId ? document.querySelector(`.message[data-msg-id="${msgId}"] .msg-text`) : null;
if (sel && sel.rangeCount > 0 && msgEl?.contains(sel.anchorNode)) {
const selected = sel.toString().trim();
if (selected) content = selected;
}
// Extract title from first line
const firstLine = content.split('\n')[0].replace(/^#+\s*/, '').trim();
const defaultTitle = firstLine.slice(0, 60) || 'Chat excerpt';
// Show save-to-note modal
_showSaveToNoteModal({
content,
sourceChannelId: App.activeId || '',
sourceMessageId: msg.id || '',
defaultTitle,
});
}
/** Modal for save-to-note with create/append options */
function _showSaveToNoteModal(opts) {
// Remove existing modal if any
document.getElementById('saveToNoteModal')?.remove();
const modal = document.createElement('div');
modal.id = 'saveToNoteModal';
modal.className = 'modal-overlay active';
modal.innerHTML = `
<div class="modal save-to-note-modal">
<h3>Save to Note</h3>
<div class="save-note-mode-toggle">
<label><input type="radio" name="saveNoteMode" value="create" checked> Create new note</label>
<label><input type="radio" name="saveNoteMode" value="append"> Append to existing</label>
</div>
<div id="saveNoteCreateFields">
<div class="form-group">
<input type="text" id="saveNoteTitle" placeholder="Title" value="${esc(opts.defaultTitle)}" class="notes-title-input">
</div>
<div class="form-row">
<div class="form-group"><input type="text" id="saveNoteFolder" placeholder="Folder (e.g. /work)" class="notes-folder-input"></div>
<div class="form-group"><input type="text" id="saveNoteTags" placeholder="Tags (comma-separated)" class="notes-tags-input"></div>
</div>
</div>
<div id="saveNoteAppendFields" style="display:none">
<div class="form-group">
<input type="text" id="saveNoteSearch" placeholder="Search notes to append to..." class="notes-title-input" autocomplete="off">
<div id="saveNoteSearchResults" class="save-note-search-results"></div>
</div>
</div>
<div class="form-group">
<label class="text-muted" style="font-size:12px">Preview (${opts.content.length} chars)</label>
<div class="save-note-preview msg-text">${formatMessage(opts.content.slice(0, 300))}${opts.content.length > 300 ? '…' : ''}</div>
</div>
<div class="modal-actions">
<button class="btn-small" id="saveNoteCancelBtn">Cancel</button>
<button class="btn-small btn-primary" id="saveNoteConfirmBtn">Create</button>
</div>
</div>
`;
document.body.appendChild(modal);
let selectedAppendNote = null;
// Toggle create vs append
modal.querySelectorAll('input[name="saveNoteMode"]').forEach(radio => {
radio.addEventListener('change', (e) => {
const isAppend = e.target.value === 'append';
document.getElementById('saveNoteCreateFields').style.display = isAppend ? 'none' : '';
document.getElementById('saveNoteAppendFields').style.display = isAppend ? '' : 'none';
document.getElementById('saveNoteConfirmBtn').textContent = isAppend ? 'Append' : 'Create';
selectedAppendNote = null;
document.getElementById('saveNoteSearchResults').innerHTML = '';
});
});
// Append search with debounce
let _searchTimer;
document.getElementById('saveNoteSearch')?.addEventListener('input', (e) => {
clearTimeout(_searchTimer);
const q = e.target.value.trim();
selectedAppendNote = null;
_searchTimer = setTimeout(async () => {
const resultsEl = document.getElementById('saveNoteSearchResults');
if (q.length < 2) { resultsEl.innerHTML = ''; return; }
try {
const resp = await API.searchNoteTitles(q, 8);
const notes = resp.data || [];
resultsEl.innerHTML = notes.map(n =>
`<div class="save-note-result" data-id="${n.id}" data-title="${esc(n.title)}">${esc(n.title)}</div>`
).join('') || '<div class="text-muted" style="padding:6px">No results</div>';
resultsEl.querySelectorAll('.save-note-result').forEach(el => {
el.addEventListener('click', () => {
resultsEl.querySelectorAll('.save-note-result').forEach(r => r.classList.remove('selected'));
el.classList.add('selected');
selectedAppendNote = { id: el.dataset.id, title: el.dataset.title };
document.getElementById('saveNoteSearch').value = el.dataset.title;
});
});
} catch { resultsEl.innerHTML = '<div class="text-muted" style="padding:6px">Search failed</div>'; }
}, 250);
});
// Cancel
document.getElementById('saveNoteCancelBtn').addEventListener('click', () => modal.remove());
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
// Confirm
document.getElementById('saveNoteConfirmBtn').addEventListener('click', async () => {
const isAppend = modal.querySelector('input[name="saveNoteMode"]:checked')?.value === 'append';
try {
if (isAppend) {
if (!selectedAppendNote) { UI.toast('Select a note to append to', 'warning'); return; }
await API.updateNote(selectedAppendNote.id, {
content: '\n\n---\n\n' + opts.content,
mode: 'append',
});
UI.toast(`Appended to "${selectedAppendNote.title}"`, 'success');
} else {
const title = document.getElementById('saveNoteTitle').value.trim();
if (!title) { UI.toast('Title is required', 'warning'); return; }
const folder = document.getElementById('saveNoteFolder').value.trim();
const tagsStr = document.getElementById('saveNoteTags').value.trim();
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
await API.createNote(title, opts.content, folder, tags, opts.sourceChannelId, opts.sourceMessageId);
UI.toast('Note created', 'success');
}
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
modal.remove();
} catch (e) { UI.toast(e.message, 'error'); }
});
// Focus title
document.getElementById('saveNoteTitle')?.focus();
document.getElementById('saveNoteTitle')?.select();
}
// ── Notes Listeners (extracted from initListeners) ──
// ── Panel Registration ──
// Creates the singleton NotePanel.primary and registers with PanelRegistry.
function _registerNotesPanel() {
const body = document.getElementById('sidePanelBody');
if (!body || typeof PanelRegistry === 'undefined') return;
if (!body || typeof PanelRegistry === 'undefined' || typeof NotePanel === 'undefined') return;
// ── Create the panel element dynamically ──
const el = document.createElement('div');
@@ -760,12 +96,12 @@ function _registerNotesPanel() {
<div id="notesListView">
<div class="notes-toolbar">
<button id="notesNewBtn" class="btn-small" title="New note">+ New</button>
<button id="notesTodayBtn" class="btn-small" title="Open daily note">📅 Today</button>
<button id="notesGraphBtn" class="btn-small" title="Note graph">🕸 Graph</button>
<button id="notesSelectModeBtn" class="btn-small" title="Select mode"> Select</button>
<button id="notesTodayBtn" class="btn-small" title="Open daily note">\ud83d\udcc5 Today</button>
<button id="notesGraphBtn" class="btn-small" title="Note graph">\ud83d\udd78 Graph</button>
<button id="notesSelectModeBtn" class="btn-small" title="Select mode">\u2611 Select</button>
</div>
<div class="notes-search-row">
<input type="text" id="notesSearchInput" class="notes-search-input" placeholder="Search notes" autocomplete="off">
<input type="text" id="notesSearchInput" class="notes-search-input" placeholder="Search notes\u2026" autocomplete="off">
</div>
<div class="notes-filter-row">
<select id="notesFolderFilter" class="notes-filter-select"><option value="">All folders</option></select>
@@ -786,7 +122,7 @@ function _registerNotesPanel() {
<!-- Editor view -->
<div id="notesEditorView" style="display:none;">
<div class="notes-editor-header">
<button id="notesBackBtn" class="btn-small" title="Back to list"> Back</button>
<button id="notesBackBtn" class="btn-small" title="Back to list">\u2190 Back</button>
<div style="flex:1"></div>
<button id="noteEditBtn" class="btn-small" style="display:none;">Edit</button>
<button id="notePreviewBtn" class="btn-small" style="display:none;">Preview</button>
@@ -802,7 +138,7 @@ function _registerNotesPanel() {
<input type="text" id="noteEditorTags" class="notes-tags-input" placeholder="Tags (comma-separated)">
</div>
<div id="noteEditorContentContainer" class="notes-content-container">
<textarea id="noteEditorContent" rows="12" class="notes-content-input" placeholder="Write your note in markdown"></textarea>
<textarea id="noteEditorContent" rows="12" class="notes-content-input" placeholder="Write your note in markdown\u2026"></textarea>
</div>
</div>
<!-- Read mode -->
@@ -825,9 +161,9 @@ function _registerNotesPanel() {
<!-- Graph view -->
<div id="notesGraphView" class="notes-graph-view" style="display:none;">
<div class="notes-graph-toolbar">
<button class="btn-small" id="notesGraphBackBtn" title="Back to list"> Back</button>
<button class="btn-small" id="notesGraphBackBtn" title="Back to list">\u2190 Back</button>
<span class="notes-graph-stats">
<span id="graphNodeCount">0</span> notes · <span id="graphEdgeCount">0</span> links
<span id="graphNodeCount">0</span> notes \u00b7 <span id="graphEdgeCount">0</span> links
</span>
<div style="flex:1"></div>
<button class="btn-small" data-action="_graphToggleOrphans" title="Toggle orphan nodes">Orphans</button>
@@ -837,67 +173,65 @@ function _registerNotesPanel() {
</div>`;
body.appendChild(el);
// ── Create NotePanel singleton ──
const np = NotePanel.create({ container: el });
NotePanel.primary = np;
// ── Wire listeners ──
// Sidebar trigger (lives outside this panel)
document.getElementById('notesBtn')?.addEventListener('click', openNotes);
// Toolbar buttons
el.querySelector('#notesNewBtn').addEventListener('click', () => openNoteEditor(null));
el.querySelector('#notesNewBtn').addEventListener('click', () => np.openNoteEditor(null));
el.querySelector('#notesGraphBtn').addEventListener('click', () => {
if (typeof openNoteGraph === 'function') openNoteGraph();
});
el.querySelector('#notesTodayBtn').addEventListener('click', openDailyNote);
el.querySelector('#notesTodayBtn').addEventListener('click', () => np.openDailyNote());
el.querySelector('#notesGraphBackBtn').addEventListener('click', () => {
if (typeof closeNoteGraph === 'function') closeNoteGraph();
});
el.querySelector('#notesSelectModeBtn').addEventListener('click', () => {
if (_notesSelectMode) _exitSelectMode();
else _enterSelectMode();
if (np._notesSelectMode) np._exitSelectMode();
else np._enterSelectMode();
});
// Editor header buttons
el.querySelector('#notesBackBtn').addEventListener('click', () => { showNotesList(); loadNotesList(); loadNoteFolders(); });
el.querySelector('#noteSaveBtn').addEventListener('click', saveNote);
el.querySelector('#noteDeleteBtn').addEventListener('click', deleteNote);
el.querySelector('#noteDeleteBtn2').addEventListener('click', deleteNote);
el.querySelector('#noteEditBtn').addEventListener('click', _showNoteEditMode);
el.querySelector('#notePreviewBtn').addEventListener('click', _showNotePreview);
el.querySelector('#notesBackBtn').addEventListener('click', () => { np.showNotesList(); np.loadNotesList(); np.loadNoteFolders(); });
el.querySelector('#noteSaveBtn').addEventListener('click', () => np.saveNote());
el.querySelector('#noteDeleteBtn').addEventListener('click', () => np.deleteNote());
el.querySelector('#noteDeleteBtn2').addEventListener('click', () => np.deleteNote());
el.querySelector('#noteEditBtn').addEventListener('click', () => np._showNoteEditMode());
el.querySelector('#notePreviewBtn').addEventListener('click', () => np._showNotePreview());
el.querySelector('#noteCancelEditBtn').addEventListener('click', () => {
if (_currentNote) { _populateEditFields(_currentNote); _showNoteReadMode(); }
else showNotesList();
if (np._currentNote) { np._populateEditFields(np._currentNote); np._showNoteReadMode(); }
else np.showNotesList();
});
// Filters
el.querySelector('#notesFolderFilter').addEventListener('change', (e) => {
document.getElementById('notesSearchInput').value = '';
_exitSelectMode();
loadNotesList(e.target.value);
np.$('#notesSearchInput').value = '';
np._exitSelectMode();
np.loadNotesList(e.target.value);
});
el.querySelector('#notesSortSelect').addEventListener('change', (e) => {
_notesSort = e.target.value;
_exitSelectMode();
loadNotesList();
np._notesSort = e.target.value;
np._exitSelectMode();
np.loadNotesList();
});
// Multi-select controls
el.querySelector('#notesSelectAll').addEventListener('change', (e) => {
_toggleSelectAll(e.target.checked);
np._toggleSelectAll(e.target.checked);
});
el.querySelector('#notesDeleteSelectedBtn').addEventListener('click', _bulkDeleteSelected);
el.querySelector('#notesCancelSelectBtn').addEventListener('click', _exitSelectMode);
el.querySelector('#notesDeleteSelectedBtn').addEventListener('click', () => np._bulkDeleteSelected());
el.querySelector('#notesCancelSelectBtn').addEventListener('click', () => np._exitSelectMode());
// Search with debounce
let _notesSearchTimer;
el.querySelector('#notesSearchInput').addEventListener('input', (e) => {
clearTimeout(_notesSearchTimer);
const q = e.target.value.trim();
_notesSearchTimer = setTimeout(() => {
_exitSelectMode();
np._exitSelectMode();
if (q.length >= 2) {
document.getElementById('notesFolderFilter').value = '';
loadNotesList(null, q);
np.$('#notesFolderFilter').value = '';
np.loadNotesList(null, q);
} else if (q.length === 0) {
loadNotesList();
np.loadNotesList();
}
}, 300);
});
@@ -907,29 +241,28 @@ function _registerNotesPanel() {
element: el,
label: 'Notes',
onOpen() {
// Ensure notes are loaded whenever the panel opens
loadNotesList();
loadNoteFolders();
np.loadNotesList();
np.loadNoteFolders();
},
onClose() {
_destroyNoteEditor();
np._destroyNoteEditor();
},
saveState() {
const listView = document.getElementById('notesListView');
const editorView = document.getElementById('notesEditorView');
const graphView = document.getElementById('notesGraphView');
const listView = np.$('#notesListView');
const editorView = np.$('#notesEditorView');
const graphView = np.$('#notesGraphView');
let viewMode = 'list';
if (editorView && editorView.style.display !== 'none') viewMode = 'editor';
if (graphView && graphView.style.display !== 'none') viewMode = 'graph';
return {
scrollTop: listView?.scrollTop || 0,
editingNoteId: _editingNoteId,
editingNoteId: np._editingNoteId,
viewMode,
};
},
restoreState(state) {
if (state.scrollTop) {
const listView = document.getElementById('notesListView');
const listView = np.$('#notesListView');
if (listView) listView.scrollTop = state.scrollTop;
}
},
@@ -939,21 +272,6 @@ function _registerNotesPanel() {
/** @deprecated — listeners are now wired in _registerNotesPanel() */
function _initNotesListeners() {
// No-op: kept for backward compatibility with app.js calling sequence.
// All wiring moved into _registerNotesPanel().
}
// ── Wrappers for compound onclick expressions ──
// Checkbox toggle: reads .checked from the clicked element
function _toggleNoteSelectCb(el, noteId) {
_toggleNoteSelect(noteId, el.checked);
}
// Note list item click: mode-dependent dispatch
function _noteItemClick(noteId) {
if (_notesSelectMode) {
_toggleNoteSelect(noteId);
} else {
openNoteEditor(noteId);
}
}
// ── Exports ─────────────────────────────────

View File

@@ -25,8 +25,6 @@
//
// Exports: window.sb
'use strict';
const sb = {
_actions: new Map(),
_namespaces: new Map(),

View File

@@ -255,16 +255,45 @@ const Switchboard = {
};
/**
* Initialize notes in a container element.
* NOTE: v0.28.5 stub — notes lacks a clean create() factory.
* Full component extraction is pre-1.0 tech debt.
* Create a NotePanel instance in a container element.
* Wraps NotePanel.create() with SDK-aware defaults.
*
* @param {HTMLElement} container
* @param {HTMLElement} container — mount target
* @param {object} opts — { projectId }
* @returns {object} NotePanel instance (loadNotesList, openNoteEditor, destroy, etc.)
*/
sw.notes = function (container, opts) {
console.warn('[Switchboard] sw.notes() not yet available — notes component needs refactor (pre-1.0)');
return null;
if (typeof NotePanel === 'undefined') {
console.error('[Switchboard] NotePanel not available');
return null;
}
const _opts = opts || {};
return NotePanel.create({
container,
projectId: _opts.projectId || null,
});
};
/**
* Access the PanelRegistry through the SDK.
* Returns a thin wrapper with open/close/toggle/isOpen/active/cycle/register.
*
* @returns {object|null} Panel control object
*/
sw.panels = function () {
if (typeof PanelRegistry === 'undefined') {
console.warn('[Switchboard] PanelRegistry not available');
return null;
}
return {
open(name) { PanelRegistry.open(name); },
close(name) { PanelRegistry.close(name); },
toggle(name) { PanelRegistry.toggle(name); },
isOpen(name) { return PanelRegistry.isOpen(name); },
active() { return PanelRegistry.active(); },
cycle() { PanelRegistry.cycle(); },
register(name, opts) { PanelRegistry.register(name, opts); },
};
};
// ── Pipe/Filter Pipeline ─────────────────────────────

View File

@@ -223,8 +223,8 @@ Object.assign(UI, {
p.editorKeymap = mode;
localStorage.setItem('cs-appearance', JSON.stringify(p));
// Notify open editors
if (typeof Events !== 'undefined' && Events.emit) {
Events.emit('keymap.changed', { mode });
if (typeof sw !== 'undefined') {
sw.emit('keymap.changed', { mode });
}
});
});
@@ -257,9 +257,9 @@ Object.assign(UI, {
document.documentElement.setAttribute('data-theme', resolved);
}
// Publish event for CM6 editors and extensions
if (typeof Events !== 'undefined' && Events.emit) {
const resolved = typeof Theme !== 'undefined' ? Theme.resolved() : mode;
Events.emit('theme.changed', { mode, resolved });
if (typeof sw !== 'undefined') {
const resolved = sw.theme?.current || mode;
sw.emit('theme.changed', { mode, resolved });
}
},