diff --git a/changelogs/CHANGELOG-reading-companion-threads.md b/changelogs/CHANGELOG-reading-companion-threads.md new file mode 100644 index 0000000..16c7949 --- /dev/null +++ b/changelogs/CHANGELOG-reading-companion-threads.md @@ -0,0 +1,39 @@ +# Reading Companion — Non-Modal, Draggable, Multi-Thread Annotation Q&A + +- The annotation "Ask AI" thread panel is now **non-modal** — no full-screen backdrop, so the document stays fully readable and scrollable while a conversation is open +- **Multiple threads open in parallel** — each annotation gets its own panel; opening a second no longer closes the first. New panels cascade (28px offset) and a click brings a panel to the front +- **Draggable** — float panels move by their header (new grip handle); a single shared drag controller avoids per-panel listener accumulation +- **Right-side Threads dock** — a panel can dock into a right rail that the document column reflows away from (mirrors the AI-panel reflow), so you can read in the main column and ask in the rail in parallel. Dock shows a live thread count; collapses when empty +- **Dock ↔ float** — any thread toggles between a floating window and a docked card via a header button +- Per-conversation state (streaming, web-search toggle, attachments, model) is now stored **per panel** instead of in module globals, so parallel threads don't clobber each other + +--- + +## Summary + +The annotation Q&A panel was a single, anchored modal: it blocked the text behind it, only one could be open at a time, and it couldn't be moved — fighting the reader's task during dense research. This reworks it into a "reading companion": non-modal floating panels you can drag, several at once, plus an optional right-side dock the document reflows around so reading and asking happen in parallel. + +--- + +## 1. Per-Panel State (multi-thread foundation) +**Files:** `js/ai-tags.js` +**What:** Replaced the module-level singletons (`activeThreadPanel`, `threadPanelTagData`, `threadPanelStreaming`, `threadSearchEnabled`, `threadAttachments`) with state stored on each panel element (`panel._tagData`, `_streaming`, `_search`, `_attachments`, `_docked`) and an `openPanels` registry keyed by tag id. `sendThreadMessage(textarea, panel)` and the toolbar handlers now operate on the specific panel. +**Impact:** Many annotation conversations can stream concurrently without interfering. (Note: they share the globally-selected AI model — switching a panel's model switches the active model, since the local inference backend runs one model at a time.) + +## 2. Non-Modal Floating Panels + Drag +**Files:** `js/ai-tags.js`, `css/ai-tags.css` +**What:** Removed the `createOverlay()` modal backdrop from the Q&A path (kept only for the legacy non-Q&A info popup). Added a header drag grip, cascade positioning, z-index bring-to-front, and a single shared `mousemove`/`mouseup` drag controller (bound once, not per panel) with an idempotent `makeDraggable` guard. +**Impact:** The document stays readable; panels can be repositioned out of the way; opening a second annotation keeps the first. + +## 3. Right-Side Threads Dock +**Files:** `js/ai-tags.js`, `css/ai-tags.css` +**What:** A lazily-created `aside.ai-tag-thread-dock` holds docked panels as stacked cards. `body.ai-tag-dock-open .app-container { width: calc(100% - var(--ai-tag-dock-width)) }` reflows the document so the dock never overlaps text. Header dock-toggle moves a panel between floating and docked; the dock shows a live count and collapses/hides when empty. Full-width on mobile (no reflow). +**Impact:** Read in the main column, ask in the rail — true parallel reading and questioning. + +--- + +## Testing + +- Verified live in the browser preview: two annotations → two parallel panels (0 modal overlays); cascade + z-index bring-to-front; header drag moves a panel; dock toggles in/out and reflows the document; close cleans up and restores width. +- Vite build clean; smoke suite 22/22 pass. +- ESLint: no new errors on `ai-tags.js` (the pre-existing `no-useless-assignment` warnings are untouched code). diff --git a/css/ai-tags.css b/css/ai-tags.css index 1449dbe..69a803d 100644 --- a/css/ai-tags.css +++ b/css/ai-tags.css @@ -511,13 +511,123 @@ body.ai-tags-hidden .ai-tag-pill-anchor { color: #a78bfa; } -/* --- Thread Panel Overlay (captures clicks outside) --- */ +/* --- Thread Panel Overlay (legacy info popup only — Q&A threads are non-modal) --- */ .ai-tag-thread-overlay { position: fixed; inset: 0; z-index: 10000; } +/* --- Drag grip + dock toggle in the panel header --- */ +.ai-tag-thread-grip { + cursor: grab; + color: #6e7681; + font-size: 13px; + padding: 0 4px 0 0; + display: flex; + align-items: center; + user-select: none; +} +.ai-tag-thread-panel--dragging, +.ai-tag-thread-panel--dragging .ai-tag-thread-grip { + cursor: grabbing; +} +.ai-tag-thread-panel--dragging { + animation: none; + opacity: 0.96; +} +.ai-tag-thread-dock { + background: none; + border: none; + color: #8b949e; + font-size: 13px; + cursor: pointer; + padding: 2px 4px; + margin-right: 2px; + border-radius: 6px; +} +.ai-tag-thread-dock:hover { color: #c9d1d9; background: rgba(110, 118, 129, 0.15); } + +/* --- Right-side Threads dock (Stage 2) --- */ +:root { --ai-tag-dock-width: 320px; } + +.ai-tag-thread-dock { + position: fixed; + top: 0; + right: 0; + width: var(--ai-tag-dock-width); + height: 100vh; + z-index: 9000; + display: flex; + flex-direction: column; + background: rgba(13, 17, 23, 0.98); + border-left: 1px solid rgba(99, 110, 123, 0.3); + box-shadow: -8px 0 32px rgba(0, 0, 0, 0.35); +} +[data-theme="light"] .ai-tag-thread-dock { + background: rgba(250, 251, 252, 0.99); + border-left-color: rgba(0, 0, 0, 0.1); + box-shadow: -8px 0 32px rgba(0, 0, 0, 0.1); +} +.ai-tag-dock-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border-bottom: 1px solid rgba(99, 110, 123, 0.2); + flex-shrink: 0; +} +.ai-tag-dock-title { font-size: 13px; font-weight: 500; color: #c9d1d9; } +[data-theme="light"] .ai-tag-dock-title { color: #24292f; } +.ai-tag-dock-count { + display: inline-block; + min-width: 16px; + text-align: center; + font-size: 11px; + background: rgba(139, 92, 246, 0.25); + color: #a78bfa; + border-radius: 8px; + padding: 0 5px; + margin-left: 4px; +} +.ai-tag-dock-collapse { + background: none; + border: none; + color: #8b949e; + cursor: pointer; + font-size: 14px; + padding: 2px 6px; + border-radius: 6px; +} +.ai-tag-dock-collapse:hover { color: #c9d1d9; background: rgba(110, 118, 129, 0.15); } +.ai-tag-dock-body { + flex: 1; + overflow-y: auto; + padding: 10px; + display: flex; + flex-direction: column; + gap: 10px; +} + +/* Docked panels lose their fixed positioning and float-window chrome — + they become stacked cards inside the rail. */ +.ai-tag-thread-dock .ai-tag-thread-panel { + position: static !important; + width: 100% !important; + max-height: 420px; + z-index: auto !important; + box-shadow: none; + backdrop-filter: none; + animation: none; + border: 1px solid rgba(99, 110, 123, 0.25); +} +.ai-tag-thread-dock .ai-tag-thread-grip { display: none; } + +/* Reflow the document so the dock never overlaps the text (mirrors ai-panel-active) */ +body.ai-tag-dock-open .app-container { + width: calc(100% - var(--ai-tag-dock-width)); +} + /* --- Responsive: mobile --- */ @media (max-width: 640px) { .ai-tag-thread-panel { @@ -537,4 +647,11 @@ body.ai-tags-hidden .ai-tag-pill-anchor { .ai-tag-prompt-card { width: 90vw; } + /* Dock takes the full width on phones; don't reflow the (already narrow) doc. */ + .ai-tag-thread-dock { width: 100vw; } + body.ai-tag-dock-open .app-container { width: 100%; } + .ai-tag-thread-dock .ai-tag-thread-panel { + width: 100% !important; + max-height: none; + } } diff --git a/js/ai-tags.js b/js/ai-tags.js index 30f2201..9609b41 100644 --- a/js/ai-tags.js +++ b/js/ai-tags.js @@ -19,13 +19,16 @@ var LABEL_OPTIONS = ['key concept', 'review later', 'exam', 'confusing', 'important', 'todo']; // --- State --- + // Legacy single-panel refs kept for the non-Q&A info popups (showTagInfo). var activeThreadPanel = null; var activeThreadOverlay = null; var activePromptOverlay = null; - var threadPanelTagData = null; - var threadPanelStreaming = false; - var threadSearchEnabled = false; - var threadAttachments = []; + // Multi-panel Q&A threads: each open thread is a non-modal, draggable panel + // (or a card docked in the right rail). State lives on the panel element so + // many conversations can stream in parallel. Keyed by tag id. + var openPanels = {}; // id -> panel element + var panelZ = 10010; // running z-index for bring-to-front + var threadDock = null; // right-rail dock element (lazy-created) // ======================================== // PARSING & SERIALIZATION @@ -748,7 +751,6 @@ createOverlay(); document.body.appendChild(panel); activeThreadPanel = panel; - threadPanelTagData = tagData; panel.querySelector('.ai-tag-thread-close').addEventListener('click', closeThreadPanel); panel.querySelector('.ai-tag-thread-delete').addEventListener('click', function () { @@ -762,14 +764,22 @@ // THREAD PANEL (Deep Dive Q&A) // ======================================== - function openThreadPanel(tagData, anchorEl) { - closeThreadPanel(); - threadPanelTagData = tagData; - threadSearchEnabled = false; - threadAttachments = []; + function openThreadPanel(tagData, anchorEl, opts) { + opts = opts || {}; + // If this annotation's thread is already open, just focus it (don't duplicate). + if (openPanels[tagData.id]) { + focusPanel(openPanels[tagData.id]); + return openPanels[tagData.id]; + } var panel = document.createElement('div'); panel.className = 'ai-tag-thread-panel'; + // Per-panel state — lets many threads stream in parallel without clobbering. + panel._tagData = tagData; + panel._streaming = false; + panel._search = false; + panel._attachments = []; + panel._docked = !!opts.docked; // Build model options var models = window.AI_MODELS || {}; @@ -794,9 +804,14 @@ messagesHtml = '
Ask anything about this passage
'; } + // Header carries a drag grip + a dock/pop-out toggle alongside close. + var dockIcon = panel._docked ? 'bi-box-arrow-up-right' : 'bi-layout-sidebar-reverse'; + var dockTitle = panel._docked ? 'Pop out to floating window' : 'Dock to side panel'; panel.innerHTML = '
' + + '' + '
' + escapeHtml((tagData.anchor || '').substring(0, 120)) + '
' + + '' + '' + '
' + '
' + @@ -814,10 +829,20 @@ '' + '
'; - positionPanel(panel, anchorEl); - createOverlay(); - document.body.appendChild(panel); - activeThreadPanel = panel; + // Register and place: either into the side dock, or floating (cascaded). + openPanels[tagData.id] = panel; + if (panel._docked) { + appendToDock(panel); + } else { + positionFloatingPanel(panel, anchorEl); + document.body.appendChild(panel); + makeDraggable(panel); + } + focusPanel(panel); + updateDockBadge(); + + // Bring-to-front on any interaction with a floating panel. + panel.addEventListener('mousedown', function () { if (!panel._docked) focusPanel(panel); }); // Scroll to bottom var messagesArea = panel.querySelector('.ai-tag-thread-messages'); @@ -825,11 +850,14 @@ // Event handlers panel.querySelector('.ai-tag-thread-close').addEventListener('click', function () { - saveAndCloseThread(); + closePanel(panel); + }); + panel.querySelector('.ai-tag-thread-dock').addEventListener('click', function () { + togglePanelDock(panel); }); panel.querySelector('.ai-tag-thread-delete').addEventListener('click', function () { removeTagFromEditor(tagData.id); - closeThreadPanel(); + closePanel(panel); if (M.showToast) M.showToast('🗑️ Annotation removed', 'success'); }); @@ -839,7 +867,7 @@ if (M.switchToModel) M.switchToModel(this.value); }); - // Search toggle + // Search toggle (per-panel) var searchBtn = panel.querySelector('.ai-tag-search-toggle'); searchBtn.addEventListener('click', function () { // Check if web search is actually configured @@ -847,21 +875,21 @@ if (M.showToast) M.showToast('🔍 Web Search not configured — set up a search API key in the AI panel first', 'warning'); return; } - threadSearchEnabled = !threadSearchEnabled; - searchBtn.classList.toggle('active', threadSearchEnabled); - if (M.showToast) M.showToast(threadSearchEnabled ? '🌐 Web Search enabled for this conversation' : '🌐 Web Search disabled', 'info'); + panel._search = !panel._search; + searchBtn.classList.toggle('active', panel._search); + if (M.showToast) M.showToast(panel._search ? '🌐 Web Search enabled for this conversation' : '🌐 Web Search disabled', 'info'); }); - // Attach + // Attach (per-panel) var attachBtn = panel.querySelector('.ai-tag-attach-btn'); var attachInput = panel.querySelector('.ai-tag-attach-input'); attachBtn.addEventListener('click', function () { attachInput.click(); }); attachInput.addEventListener('change', function () { - threadAttachments = []; + panel._attachments = []; Array.from(this.files).forEach(function (file) { var reader = new FileReader(); reader.onload = function (e) { - threadAttachments.push({ + panel._attachments.push({ type: file.type.startsWith('image/') ? 'image' : 'file', mimeType: file.type, name: file.name, data: e.target.result.split(',')[1] || '' @@ -876,11 +904,11 @@ var textarea = panel.querySelector('.ai-tag-thread-input textarea'); var sendBtn = panel.querySelector('.ai-tag-thread-send'); - sendBtn.addEventListener('click', function () { sendThreadMessage(textarea); }); + sendBtn.addEventListener('click', function () { sendThreadMessage(textarea, panel); }); textarea.addEventListener('keydown', function (e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); - sendThreadMessage(textarea); + sendThreadMessage(textarea, panel); } }); textarea.addEventListener('input', function () { @@ -890,11 +918,13 @@ }); setTimeout(function () { textarea.focus(); }, 150); + return panel; } - function sendThreadMessage(textarea) { + function sendThreadMessage(textarea, panel) { + if (!panel) return; var text = textarea.value.trim(); - if (!text || threadPanelStreaming) return; + if (!text || panel._streaming) return; if (!M.isCurrentModelReady || !M.isCurrentModelReady()) { // Auto-load the selected model instead of just showing a toast var modelId = M.getCurrentAiModel ? M.getCurrentAiModel() : ''; @@ -910,7 +940,7 @@ clearInterval(retryTimer); // Re-set the text in case user typed more while waiting if (!textarea.value.trim()) textarea.value = text; - sendThreadMessage(textarea); + sendThreadMessage(textarea, panel); } else if (retryCount >= maxRetries) { clearInterval(retryTimer); if (M.showToast) M.showToast('❌ Model failed to load. Try switching models.', 'error'); @@ -922,13 +952,13 @@ return; } - var tagData = threadPanelTagData; + var tagData = panel._tagData; if (!tagData) return; textarea.value = ''; textarea.style.height = 'auto'; - var messagesArea = activeThreadPanel.querySelector('.ai-tag-thread-messages'); + var messagesArea = panel.querySelector('.ai-tag-thread-messages'); // Remove welcome message if present var welcome = messagesArea.querySelector('div[style*="text-align:center"]'); @@ -947,13 +977,13 @@ messagesArea.appendChild(aiMsg); messagesArea.scrollTop = messagesArea.scrollHeight; - threadPanelStreaming = true; - var sendBtn = activeThreadPanel.querySelector('.ai-tag-thread-send'); + panel._streaming = true; + var sendBtn = panel.querySelector('.ai-tag-thread-send'); if (sendBtn) sendBtn.disabled = true; // Build context var contextPromise; - if (threadSearchEnabled && M.webSearch && M.webSearch.isSearchEnabled && M.webSearch.isSearchEnabled()) { + if (panel._search && M.webSearch && M.webSearch.isSearchEnabled && M.webSearch.isSearchEnabled()) { contextPromise = M.webSearch.performMultiSearch(text).then(function (results) { return buildTagContext(tagData, results); }).catch(function () { @@ -970,14 +1000,15 @@ userPrompt: text, enableThinking: false, onToken: function (token, accumulated) { - if (!activeThreadPanel) return; + // Panel may have been closed mid-stream — guard against a stale ref. + if (!openPanels[tagData.id]) return; aiMsg.innerHTML = 'AI' + escapeHtml(accumulated); messagesArea.scrollTop = messagesArea.scrollHeight; }, - attachments: threadAttachments + attachments: panel._attachments }); }).then(function (fullResponse) { - threadPanelStreaming = false; + panel._streaming = false; if (sendBtn) sendBtn.disabled = false; aiMsg.classList.remove('ai-tag-thread-msg--streaming'); aiMsg.innerHTML = 'AI' + escapeHtml(fullResponse); @@ -993,9 +1024,10 @@ } else { updateTagInEditor(tagData.id, serializeAiTag(tagData)); } + updateDockBadge(); // Don't re-render here — it would destroy the panel }).catch(function (err) { - threadPanelStreaming = false; + panel._streaming = false; if (sendBtn) sendBtn.disabled = false; aiMsg.classList.remove('ai-tag-thread-msg--streaming'); aiMsg.innerHTML = 'AIError: ' + escapeHtml(err.message) + ''; @@ -1057,18 +1089,171 @@ return context; } - function saveAndCloseThread() { - if (threadPanelTagData && threadPanelTagData.thread && threadPanelTagData.thread.length > 0) { - // Already saved incrementally in sendThreadMessage + // ======================================== + // MULTI-PANEL: floating windows + side dock + // ======================================== + + // Bring a floating panel to the front (no-op for docked panels). + function focusPanel(panel) { + if (!panel || panel._docked) { + if (panel && panel._docked) { panel.scrollIntoView({ block: 'nearest' }); } + return; } - closeThreadPanel(); + panelZ += 1; + panel.style.zIndex = panelZ; + } + + // Close a single thread panel (and tear down the dock if it empties). + function closePanel(panel) { + if (!panel) return; + var id = panel._tagData && panel._tagData.id; + panel.remove(); + if (id) delete openPanels[id]; + updateDockBadge(); + maybeCollapseDock(); if (M.renderMarkdown) M.renderMarkdown(); } - // ======================================== - // PANEL POSITIONING & OVERLAY - // ======================================== + // Place a floating panel near its anchor, cascading so multiples don't stack + // exactly on top of each other. + function positionFloatingPanel(panel, anchorEl) { + var n = Object.keys(openPanels).length; + var offset = ((n - 1) % 6) * 28; + var panelWidth = 400, panelHeight = 520; + var left, top; + if (anchorEl) { + var rect = anchorEl.getBoundingClientRect(); + left = Math.min(rect.right + 8, window.innerWidth - panelWidth - 16); + top = Math.min(rect.top - 20, window.innerHeight - panelHeight - 16); + } else { + left = (window.innerWidth - panelWidth) / 2; + top = (window.innerHeight - panelHeight) / 2; + } + left = Math.max(16, left - offset); + top = Math.max(16, top + offset); + panel.style.position = 'fixed'; + panel.style.left = left + 'px'; + panel.style.top = top + 'px'; + } + + // Single shared drag controller — the move/up listeners live on document once + // for the whole module (not per panel) so they never accumulate. + var dragState = null; // { panel, startX, startY, startLeft, startTop } + document.addEventListener('mousemove', function (e) { + if (!dragState) return; + var nl = dragState.startLeft + (e.clientX - dragState.startX); + var nt = dragState.startTop + (e.clientY - dragState.startY); + nl = Math.max(0, Math.min(nl, window.innerWidth - 80)); + nt = Math.max(0, Math.min(nt, window.innerHeight - 40)); + dragState.panel.style.left = nl + 'px'; + dragState.panel.style.top = nt + 'px'; + }); + document.addEventListener('mouseup', function () { + if (dragState) { + dragState.panel.classList.remove('ai-tag-thread-panel--dragging'); + dragState = null; + } + }); + + // Bind a panel's header as a drag handle (once per panel — guarded so a + // dock↔float round-trip doesn't double-bind). + function makeDraggable(panel) { + if (panel._dragBound) return; + panel._dragBound = true; + var header = panel.querySelector('.ai-tag-thread-header'); + if (!header) return; + header.addEventListener('mousedown', function (e) { + if (e.target.closest('button')) return; // let close/dock buttons work + if (panel._docked) return; + focusPanel(panel); + var r = panel.getBoundingClientRect(); + dragState = { panel: panel, startX: e.clientX, startY: e.clientY, startLeft: r.left, startTop: r.top }; + panel.classList.add('ai-tag-thread-panel--dragging'); + e.preventDefault(); + }); + } + + // Move a panel between floating and docked states. + function togglePanelDock(panel) { + if (panel._docked) { + panel._docked = false; + document.body.appendChild(panel); + positionFloatingPanel(panel, null); + makeDraggable(panel); + setDockToggleIcon(panel); + focusPanel(panel); + maybeCollapseDock(); + } else { + panel._docked = true; + panel.style.left = ''; + panel.style.top = ''; + panel.style.zIndex = ''; + appendToDock(panel); + setDockToggleIcon(panel); + } + updateDockBadge(); + } + + function setDockToggleIcon(panel) { + var btn = panel.querySelector('.ai-tag-thread-dock i'); + if (!btn) return; + if (panel._docked) { + btn.className = 'bi bi-box-arrow-up-right'; + btn.parentNode.title = 'Pop out to floating window'; + } else { + btn.className = 'bi bi-layout-sidebar-reverse'; + btn.parentNode.title = 'Dock to side panel'; + } + } + + // ── Right-side Threads dock ── + function ensureDock() { + if (threadDock) return threadDock; + var dock = document.createElement('aside'); + dock.className = 'ai-tag-thread-dock'; + dock.innerHTML = + '
' + + ' Threads 0' + + '' + + '
' + + '
'; + document.body.appendChild(dock); + dock.querySelector('.ai-tag-dock-collapse').addEventListener('click', function () { + document.body.classList.remove('ai-tag-dock-open'); + maybeCollapseDock(true); + }); + threadDock = dock; + return dock; + } + + function appendToDock(panel) { + var dock = ensureDock(); + dock.querySelector('.ai-tag-dock-body').appendChild(panel); + document.body.classList.add('ai-tag-dock-open'); + } + function updateDockBadge() { + if (!threadDock) return; + var docked = threadDock.querySelectorAll('.ai-tag-thread-panel').length; + var total = Object.keys(openPanels).length; + var countEl = threadDock.querySelector('.ai-tag-dock-count'); + if (countEl) countEl.textContent = total; + // hide the dock chrome when nothing is docked + threadDock.style.display = docked > 0 ? '' : 'none'; + if (docked > 0) document.body.classList.add('ai-tag-dock-open'); + } + + // Remove the dock (and un-reflow the document) once it has no docked panels. + function maybeCollapseDock(force) { + if (!threadDock) return; + var docked = threadDock.querySelectorAll('.ai-tag-thread-panel').length; + if (docked === 0 || force) { + document.body.classList.remove('ai-tag-dock-open'); + if (docked === 0) { threadDock.style.display = 'none'; } + } + } + + // ── Legacy single-panel popup (used by showTagInfo for non-Q&A tags) ── function positionPanel(panel, anchorEl) { if (anchorEl) { var rect = anchorEl.getBoundingClientRect(); @@ -1081,7 +1266,6 @@ panel.style.left = left + 'px'; panel.style.top = top + 'px'; } else { - // Center on screen panel.style.left = Math.max(16, (window.innerWidth - 400) / 2) + 'px'; panel.style.top = Math.max(16, (window.innerHeight - 520) / 2) + 'px'; } @@ -1091,9 +1275,7 @@ closeOverlay(); var overlay = document.createElement('div'); overlay.className = 'ai-tag-thread-overlay'; - overlay.addEventListener('click', function () { - saveAndCloseThread(); - }); + overlay.addEventListener('click', function () { closeThreadPanel(); }); document.body.appendChild(overlay); activeThreadOverlay = overlay; } @@ -1111,9 +1293,6 @@ activeThreadPanel = null; } closeOverlay(); - threadPanelTagData = null; - threadPanelStreaming = false; - threadAttachments = []; } // ========================================