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 =
'