diff --git a/VERSION b/VERSION
index 5540b6e..1a44cad 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.29.3
+0.30.1
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index e9f54de..fdb146f 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -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
diff --git a/server/handlers/memory.go b/server/handlers/memory.go
index 2286e30..a675631 100644
--- a/server/handlers/memory.go
+++ b/server/handlers/memory.go
@@ -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,
+ })
+}
diff --git a/server/main.go b/server/main.go
index 0dc5489..ff808e4 100644
--- a/server/main.go
+++ b/server/main.go
@@ -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)
diff --git a/server/memory/compactor.go b/server/memory/compactor.go
new file mode 100644
index 0000000..6706925
--- /dev/null
+++ b/server/memory/compactor.go
@@ -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
+}
diff --git a/server/memory/scanner.go b/server/memory/scanner.go
index a5f7b71..3bd3caf 100644
--- a/server/memory/scanner.go
+++ b/server/memory/scanner.go
@@ -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.
diff --git a/server/pages/templates/base.html b/server/pages/templates/base.html
index 2faab9d..a06e192 100644
--- a/server/pages/templates/base.html
+++ b/server/pages/templates/base.html
@@ -91,7 +91,7 @@
{{if .Manifest}}window.__MANIFEST__ = {{.Manifest | toJSON}};{{end}}
-
+
-
+
diff --git a/server/pages/templates/surfaces/chat.html b/server/pages/templates/surfaces/chat.html
index 0b9223c..eaa4410 100644
--- a/server/pages/templates/surfaces/chat.html
+++ b/server/pages/templates/surfaces/chat.html
@@ -515,6 +515,7 @@ window.addEventListener('unhandledrejection', function(e) {
+
@@ -522,12 +523,12 @@ window.addEventListener('unhandledrejection', function(e) {
-
+
-
+
diff --git a/server/pages/templates/surfaces/notes.html b/server/pages/templates/surfaces/notes.html
index 6539355..0b88248 100644
--- a/server/pages/templates/surfaces/notes.html
+++ b/server/pages/templates/surfaces/notes.html
@@ -29,6 +29,7 @@
+
@@ -37,7 +38,7 @@
{{/* debug.js now loaded in base.html for all surfaces */}}
-
+
-
+
diff --git a/server/store/postgres/memory.go b/server/store/postgres/memory.go
index c8224a3..17b21ee 100644
--- a/server/store/postgres/memory.go
+++ b/server/store/postgres/memory.go
@@ -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
+}
diff --git a/server/store/sqlite/memory.go b/server/store/sqlite/memory.go
index 80e3bca..6fb8f5b 100644
--- a/server/store/sqlite/memory.go
+++ b/server/store/sqlite/memory.go
@@ -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
+}
diff --git a/server/store/store_memory.go b/server/store/store_memory.go
index 37860e8..9f9841d 100644
--- a/server/store/store_memory.go
+++ b/server/store/store_memory.go
@@ -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)
}
diff --git a/src/js/admin-handlers.js b/src/js/admin-handlers.js
index e95b602..1a39535 100644
--- a/src/js/admin-handlers.js
+++ b/src/js/admin-handlers.js
@@ -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);
diff --git a/src/js/app.js b/src/js/app.js
index 559a4c1..6973862 100644
--- a/src/js/app.js
+++ b/src/js/app.js
@@ -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);
});
diff --git a/src/js/chat.js b/src/js/chat.js
index 65a98ac..28ac8f0 100644
--- a/src/js/chat.js
+++ b/src/js/chat.js
@@ -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();
diff --git a/src/js/editor-surface.js b/src/js/editor-surface.js
index e083342..5b55ab8 100644
--- a/src/js/editor-surface.js
+++ b/src/js/editor-surface.js
@@ -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;
diff --git a/src/js/note-panel.js b/src/js/note-panel.js
new file mode 100644
index 0000000..ad6cc62
--- /dev/null
+++ b/src/js/note-panel.js
@@ -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 = '
Loading\u2026
';
+
+ try {
+ let data;
+ if (searchQuery) {
+ data = await API.searchNotes(searchQuery);
+ const results = data.data || [];
+ if (results.length === 0) {
+ list.innerHTML = 'No results found
';
+ 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 = `${folderVal ? 'No notes in this folder' : 'No notes yet. Create one or ask the AI to save a note.'}
`;
+ return;
+ }
+ list.innerHTML = notes.map(n => this._noteListItem(n, false)).join('');
+ }
+ } catch (e) {
+ list.innerHTML = `Failed to load: ${esc(e.message)}
`;
+ }
+ },
+
+ _noteListItem(note, isSearch) {
+ const tags = (note.tags || []).map(t => `${esc(t)}`).join('');
+ const folder = note.folder_path && note.folder_path !== '/' ? `${esc(note.folder_path)}` : '';
+ const preview = isSearch && note.headline
+ ? `${this._highlightHeadline(note.headline)}
`
+ : `${esc((note.preview || note.content || '').slice(0, 120))}
`;
+ const time = _relativeTime(note.updated_at);
+ const checked = this._selectedNoteIds.has(note.id) ? 'checked' : '';
+
+ return `
+
+
+
+
+
+
+ ${preview}
+
${folder}${tags}
+
+
`;
+ },
+
+ _highlightHeadline(headline) {
+ const escaped = esc(headline);
+ return escaped.replace(/\*\*(.+?)\*\*/g, '$1');
+ },
+
+ // ββ 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 = '';
+ folders.forEach(f => {
+ sel.innerHTML += ``;
+ });
+ } 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 = 'Loading\u2026
';
+ },
+
+ // ββ 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 = '';
+ }
+ 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(`${esc(n.folder_path)}`);
+ (n.tags || []).forEach(t => parts.push(`${esc(t)}`));
+ 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(`${esc(folderVal)}`);
+ tags.forEach(t => parts.push(`${esc(t)}`));
+ 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 => `
+
+ ${esc(n.title)}
+ ${esc(n.folder_path)}
+
+ `).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 ``;
+ }
+ return `${esc(displayText)}`;
+ }
+ );
+ 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 = 'Note not found';
+ 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 = 'Failed to load';
+ }
+ }
+ },
+
+ // ββ 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 = `
+
+
+
+ `;
+ 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 = `
+
+
Save to Note
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ 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 =>
+ `${esc(n.title)}
`
+ ).join('') || 'No results
';
+ 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 = 'Search failed
'; }
+ }, 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);
diff --git a/src/js/notes.js b/src/js/notes.js
index 442056e..ceddb02 100644
--- a/src/js/notes.js
+++ b/src/js/notes.js
@@ -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 = 'Loadingβ¦
';
-
- try {
- let data;
- if (searchQuery) {
- data = await API.searchNotes(searchQuery);
- const results = data.data || [];
- if (results.length === 0) {
- list.innerHTML = 'No results found
';
- 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 = `${folderVal ? 'No notes in this folder' : 'No notes yet. Create one or ask the AI to save a note.'}
`;
- return;
- }
- list.innerHTML = notes.map(n => _noteListItem(n, false)).join('');
- }
- } catch (e) {
- list.innerHTML = `Failed to load: ${esc(e.message)}
`;
- }
-}
-
-function _noteListItem(note, isSearch) {
- const tags = (note.tags || []).map(t => `${esc(t)}`).join('');
- const folder = note.folder_path && note.folder_path !== '/' ? `${esc(note.folder_path)}` : '';
- const preview = isSearch && note.headline
- ? `${_highlightHeadline(note.headline)}
`
- : `${esc((note.preview || note.content || '').slice(0, 120))}
`;
- const time = _relativeTime(note.updated_at);
- const checked = _selectedNoteIds.has(note.id) ? 'checked' : '';
-
- return `
-
-
-
-
-
-
- ${preview}
-
${folder}${tags}
-
-
`;
-}
-
-function _highlightHeadline(headline) {
- const escaped = esc(headline);
- return escaped.replace(/\*\*(.+?)\*\*/g, '$1');
-}
-
-// ββ 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 = '';
- folders.forEach(f => {
- sel.innerHTML += ``;
- });
- } 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 = 'Loadingβ¦
';
+ 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 = '';
- }
- 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(`${esc(n.folder_path)}`);
- (n.tags || []).forEach(t => parts.push(`${esc(t)}`));
- 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(`${esc(folderVal)}`);
- tags.forEach(t => parts.push(`${esc(t)}`));
- 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 => `
-
- ${esc(n.title)}
- ${esc(n.folder_path)}
-
- `).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 ``;
- }
- return `${esc(displayText)}`;
- }
- );
- 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 = 'Note not found';
- 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 = 'Failed to load';
- }
- }
+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 = `
-
-
-
- `;
- 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 = `
-
-
Save to Note
-
-
-
-
-
-
-
-
-
-
-
-
- `;
- 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 =>
- `${esc(n.title)}
`
- ).join('') || 'No results
';
- 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 = 'Search failed
'; }
- }, 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() {
-
-
-
+
+
+
-
+
@@ -786,7 +122,7 @@ function _registerNotesPanel() {
@@ -825,9 +161,9 @@ function _registerNotesPanel() {
`;
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 βββββββββββββββββββββββββββββββββ
diff --git a/src/js/sb.js b/src/js/sb.js
index 82c675b..0dac4f2 100644
--- a/src/js/sb.js
+++ b/src/js/sb.js
@@ -25,8 +25,6 @@
//
// Exports: window.sb
-'use strict';
-
const sb = {
_actions: new Map(),
_namespaces: new Map(),
diff --git a/src/js/switchboard-sdk.js b/src/js/switchboard-sdk.js
index a064ae0..46a660c 100644
--- a/src/js/switchboard-sdk.js
+++ b/src/js/switchboard-sdk.js
@@ -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 βββββββββββββββββββββββββββββ
diff --git a/src/js/ui-settings.js b/src/js/ui-settings.js
index f9fe0f4..7b4d077 100644
--- a/src/js/ui-settings.js
+++ b/src/js/ui-settings.js
@@ -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 });
}
},