From 8fee53e44030529ed5c9e98f6aab8409ffbdc5c3 Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Wed, 18 Mar 2026 13:22:15 +0000 Subject: [PATCH] Changeset 0.30.1 (#200) Co-authored-by: Jeffrey Smith Co-committed-by: Jeffrey Smith --- VERSION | 2 +- docs/ROADMAP.md | 8 +- server/handlers/memory.go | 43 +- server/main.go | 3 + server/memory/compactor.go | 157 ++++ server/memory/scanner.go | 90 +- server/pages/templates/base.html | 2 +- server/pages/templates/surfaces/admin.html | 2 +- server/pages/templates/surfaces/chat.html | 5 +- server/pages/templates/surfaces/notes.html | 3 +- server/pages/templates/surfaces/settings.html | 2 +- server/store/postgres/memory.go | 33 + server/store/sqlite/memory.go | 33 + server/store/store_memory.go | 11 + src/js/admin-handlers.js | 11 +- src/js/app.js | 8 +- src/js/chat.js | 30 +- src/js/editor-surface.js | 4 +- src/js/note-panel.js | 788 ++++++++++++++++ src/js/notes.js | 852 ++---------------- src/js/sb.js | 2 - src/js/switchboard-sdk.js | 41 +- src/js/ui-settings.js | 10 +- 23 files changed, 1294 insertions(+), 846 deletions(-) create mode 100644 server/memory/compactor.go create mode 100644 src/js/note-panel.js 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 ` +
+
+ +
+
+
+ ${esc(note.title)} + ${time} +
+ ${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 => ` + + `).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 `
+
+ β†— ${esc(displayText)} +
+
Loading\u2026
+
`; + } + 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 = ` + + `; + 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 ` -
-
- -
-
-
- ${esc(note.title)} - ${time} -
- ${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 => ` - - `).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 `
-
- β†— ${esc(displayText)} -
-
Loading…
-
`; - } - 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 = ` - - `; - 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() {