Created
March 26, 2026 10:26
-
-
Save tkanarsky/b068bf0abbc37e3b8dbe0cb596667779 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name HBUI Margin Notes for Claude.ai | |
| // @namespace hbui | |
| // @version 3.0 | |
| // @description Adds margin notes (reactions, inline replies) to Claude.ai chat with auto-sync | |
| // @match https://claude.ai/* | |
| // @grant none | |
| // ==/UserScript== | |
| (function () { | |
| "use strict"; | |
| // --- All state on window.H for patchability --- | |
| const H = (window.H = {}); | |
| H.ann = new Map(); // msgIndex -> [{type, reaction?, quote, comment?, _offsetY}] | |
| H.tb = null; // toolbar element | |
| H.sel = null; // current selection context | |
| H.reconstituted = false; | |
| H.chatUrl = location.pathname; // track current chat for navigation detection | |
| H.reset = () => { | |
| H.ann.clear(); | |
| H.reconstituted = false; | |
| H.hideTB(); | |
| document.querySelectorAll(".hbui-gutter").forEach((g) => g.remove()); | |
| H.chatUrl = location.pathname; | |
| }; | |
| H.checkNav = () => { | |
| if (location.pathname !== H.chatUrl) { | |
| H.reset(); | |
| // Re-reconstitute for the new chat after DOM settles | |
| setTimeout(H.reconstitute, 1000); | |
| } | |
| }; | |
| const NOTE_HEIGHT = 32; | |
| H.R = [ | |
| { n: "agree", l: "Agree", e: "\u{1F44D}", c: "#4ade80" }, | |
| { n: "disagree", l: "Disagree", e: "\u{1F44E}", c: "#f87171" }, | |
| { n: "define", l: "Define", e: "\u{1F4D6}", c: "#67e8f9" }, | |
| { n: "exemplify", l: "Exemplify", e: "\u{1F4A1}", c: "#a78bfa" }, | |
| { n: "simplify", l: "Simplify", e: "\u2728", c: "#fbbf24" }, | |
| { n: "wrong", l: "Wrong", e: "\u274C", c: "#f87171" }, | |
| { n: "skip", l: "Skip", e: "\u23ED", c: "#9b9991" }, | |
| ]; | |
| // --- CSS --- | |
| const style = document.createElement("style"); | |
| style.textContent = [ | |
| ".hbui-toolbar {", | |
| " position: fixed; z-index: 10000;", | |
| " background: hsl(60, 2.1%, 18.4%);", | |
| " border: 1px solid hsl(51, 16.5%, 84.5%, 0.15);", | |
| " border-radius: 12px;", | |
| " box-shadow: 0 8px 32px rgba(0,0,0,0.5);", | |
| " display: flex; align-items: center; overflow: hidden;", | |
| " transform: translate(-50%, -100%); margin-top: -8px;", | |
| " animation: hbui-fade-in 0.15s ease-out;", | |
| "}", | |
| "@keyframes hbui-fade-in {", | |
| " from { opacity: 0; transform: translate(-50%, -100%) scale(0.95); }", | |
| " to { opacity: 1; transform: translate(-50%, -100%) scale(1); }", | |
| "}", | |
| ".hbui-toolbar-btn {", | |
| " display: flex; flex-direction: column; align-items: center; gap: 1px;", | |
| " padding: 6px 10px; border: none; background: none;", | |
| " color: hsl(50, 9%, 73.7%); cursor: pointer;", | |
| " font-size: 16px; line-height: 1; transition: background 0.15s;", | |
| " white-space: nowrap;", | |
| "}", | |
| ".hbui-toolbar-btn:hover {", | |
| " background: hsl(60, 2.7%, 14.5%); color: hsl(48, 33.3%, 97.1%);", | |
| "}", | |
| ".hbui-toolbar-btn span.hbui-btn-label {", | |
| " font-size: 9px; font-family: system-ui, sans-serif;", | |
| "}", | |
| ".hbui-toolbar-reply { color: hsl(210, 70.9%, 51.6%); }", | |
| ".hbui-toolbar-reply:hover { color: hsl(210, 65.5%, 67.1%); }", | |
| ".hbui-toolbar-hint {", | |
| " font-size: 9px; color: hsl(48, 4.8%, 59.2%);", | |
| " padding-right: 10px; font-style: italic; white-space: nowrap;", | |
| "}", | |
| ".hbui-reply-input { display: flex; align-items: center; padding: 4px 8px; gap: 6px; }", | |
| ".hbui-reply-input input {", | |
| " background: hsl(60, 2.7%, 14.5%);", | |
| " border: 1px solid hsl(51, 16.5%, 84.5%, 0.15);", | |
| " border-radius: 6px; color: hsl(48, 33.3%, 97.1%);", | |
| " padding: 4px 8px; font-size: 13px; width: 200px; outline: none;", | |
| " font-family: system-ui, sans-serif;", | |
| "}", | |
| ".hbui-reply-input input:focus { border-color: hsl(210, 70.9%, 51.6%); }", | |
| ".hbui-reply-input button {", | |
| " background: hsl(15, 63.1%, 59.6%); color: white; border: none;", | |
| " border-radius: 6px; padding: 4px 10px; cursor: pointer;", | |
| " font-size: 12px; font-family: system-ui, sans-serif;", | |
| "}", | |
| ".hbui-gutter {", | |
| " position: absolute; left: -180px; top: 0; width: 170px; pointer-events: auto;", | |
| "}", | |
| ".hbui-note {", | |
| " position: absolute; right: 0; display: flex; align-items: center;", | |
| " gap: 4px; padding: 2px 8px; border-radius: 6px;", | |
| " font-size: 11px; font-family: system-ui, sans-serif;", | |
| " white-space: nowrap; overflow: hidden; text-overflow: ellipsis;", | |
| " max-width: 170px; cursor: default; transition: background 0.15s;", | |
| " height: 28px;", | |
| "}", | |
| ".hbui-note:hover { background: hsl(60, 2.7%, 14.5%); }", | |
| ".hbui-note-text { overflow: hidden; text-overflow: ellipsis; }", | |
| ".hbui-note-remove {", | |
| " opacity: 0; cursor: pointer; margin-left: auto;", | |
| " padding: 0 2px; font-size: 10px; color: hsl(48, 4.8%, 59.2%);", | |
| "}", | |
| ".hbui-note:hover .hbui-note-remove { opacity: 1; }", | |
| ].join("\n"); | |
| document.head.appendChild(style); | |
| // --- Helpers --- | |
| H.esc = (str) => { | |
| const d = document.createElement("div"); | |
| d.textContent = str; | |
| return d.innerHTML; | |
| }; | |
| H.msgs = () => Array.from(document.querySelectorAll(".font-claude-response")); | |
| H.msgIdx = (el) => H.msgs().indexOf(el); | |
| H.findMsg = (node) => { | |
| let el = node; | |
| while (el) { | |
| if (el.classList && el.classList.contains("font-claude-response")) return el; | |
| el = el.parentElement; | |
| } | |
| return null; | |
| }; | |
| H.gutterParent = (msgEl) => { | |
| let el = msgEl.parentElement; | |
| if (el) { | |
| const pos = getComputedStyle(el).position; | |
| if (pos === "relative" || pos === "absolute") return el; | |
| } | |
| el = msgEl; | |
| for (let i = 0; i < 6 && el; i++) { | |
| el = el.parentElement; | |
| if (!el) break; | |
| const pos = getComputedStyle(el).position; | |
| if (pos === "relative" || pos === "absolute") return el; | |
| } | |
| return msgEl; | |
| }; | |
| H.total = () => { | |
| let n = 0; | |
| for (const [, arr] of H.ann) n += arr.length; | |
| return n; | |
| }; | |
| H.lastMsgIdx = () => H.msgs().length - 1; | |
| H.json = () => { | |
| const last = H.lastMsgIdx(); | |
| const payload = []; | |
| const notes = H.ann.get(last); | |
| if (notes) { | |
| for (const a of notes) { | |
| const obj = { type: a.type, quote: a.quote }; | |
| if (a.reaction) obj.reaction = a.reaction; | |
| if (a.comment) obj.comment = a.comment; | |
| payload.push(obj); | |
| } | |
| } | |
| return JSON.stringify({ hbui_annotations: payload }); | |
| }; | |
| H.currentTotal = () => { | |
| const notes = H.ann.get(H.lastMsgIdx()); | |
| return notes ? notes.length : 0; | |
| }; | |
| // --- Core: add / remove / render / sync --- | |
| H.add = (mi, a) => { | |
| if (!H.ann.has(mi)) H.ann.set(mi, []); | |
| H.ann.get(mi).push(a); | |
| H.render(); | |
| H.sync(); | |
| }; | |
| H.rm = (mi, idx) => { | |
| const arr = H.ann.get(mi); | |
| if (arr) { | |
| arr.splice(idx, 1); | |
| if (arr.length === 0) H.ann.delete(mi); | |
| } | |
| H.render(); | |
| H.sync(); | |
| }; | |
| H.render = () => { | |
| document.querySelectorAll(".hbui-gutter").forEach((g) => g.remove()); | |
| const msgs = H.msgs(); | |
| for (const [mi, notes] of H.ann) { | |
| const msgEl = msgs[mi]; | |
| if (!msgEl) continue; | |
| const parent = H.gutterParent(msgEl); | |
| const gutter = document.createElement("div"); | |
| gutter.className = "hbui-gutter"; | |
| const sorted = notes | |
| .map((a, i) => ({ a, i })) | |
| .sort((x, y) => x.a._offsetY - y.a._offsetY); | |
| let lastBottom = -Infinity; | |
| for (const { a, i } of sorted) { | |
| const y = Math.max(a._offsetY, lastBottom); | |
| lastBottom = y + NOTE_HEIGHT; | |
| const r = H.R.find((r) => r.n === a.reaction); | |
| const emoji = a.type === "reply" ? "\u{1F4AC}" : r ? r.e : "\u2022"; | |
| const color = a.type === "reply" ? "hsl(210, 70.9%, 51.6%)" : r ? r.c : "#9b9991"; | |
| const label = a.type === "reply" ? a.comment : "\u201C" + (a.quote || "").slice(0, 20) + "\u2026\u201D"; | |
| const note = document.createElement("div"); | |
| note.className = "hbui-note"; | |
| note.style.top = y + "px"; | |
| note.style.color = color; | |
| note.innerHTML = | |
| "<span>" + emoji + "</span>" + | |
| '<span class="hbui-note-text">' + H.esc(label) + "</span>" + | |
| '<span class="hbui-note-remove" data-msg="' + mi + '" data-idx="' + i + '">\u2715</span>'; | |
| note.querySelector(".hbui-note-remove").onclick = (e) => { | |
| e.stopPropagation(); | |
| H.rm(mi, i); | |
| }; | |
| gutter.appendChild(note); | |
| } | |
| parent.style.overflow = "visible"; | |
| parent.appendChild(gutter); | |
| } | |
| }; | |
| H.sync = () => { | |
| const pm = document.querySelector(".ProseMirror[data-testid='chat-input']") || | |
| document.querySelector(".ProseMirror"); | |
| if (!pm) return; | |
| // Preserve user-typed text outside <hbui> block | |
| const paras = Array.from(pm.querySelectorAll("p")); | |
| let inBlock = false; | |
| const kept = []; | |
| for (const p of paras) { | |
| const t = p.textContent.trim(); | |
| // Single-line format: <hbui>...</hbui> all in one paragraph | |
| if (t.startsWith("<hbui>") && t.endsWith("</hbui>")) continue; | |
| // Multi-line format (legacy) | |
| if (t === "<hbui>") { inBlock = true; continue; } | |
| if (inBlock) { if (t === "</hbui>") { inBlock = false; continue; } continue; } | |
| kept.push(p.outerHTML); | |
| } | |
| const userHtml = kept.join(""); | |
| const c = H.currentTotal(); | |
| if (c > 0) { | |
| const hbuiHtml = "<p>" + H.esc("<hbui>" + H.json() + "</hbui>") + "</p>"; | |
| pm.innerHTML = hbuiHtml + (userHtml || '<p><br class="ProseMirror-trailingBreak"></p>'); | |
| } else { | |
| pm.innerHTML = userHtml || '<p data-placeholder="Reply..." class="is-empty is-editor-empty"><br class="ProseMirror-trailingBreak"></p>'; | |
| } | |
| pm.dispatchEvent(new Event("input", { bubbles: true })); | |
| }; | |
| // --- Selection Toolbar --- | |
| H.showTB = (x, y, msgEl, quote, offsetY) => { | |
| if (H.tb) H.tb.remove(); | |
| H.tb = document.createElement("div"); | |
| H.tb.className = "hbui-toolbar"; | |
| document.body.appendChild(H.tb); | |
| const mi = H.msgIdx(msgEl); | |
| for (const r of H.R) { | |
| const btn = document.createElement("button"); | |
| btn.className = "hbui-toolbar-btn"; | |
| btn.innerHTML = "<span>" + r.e + '</span><span class="hbui-btn-label">' + r.l + "</span>"; | |
| btn.onclick = (e) => { | |
| e.stopPropagation(); | |
| H.add(mi, { | |
| type: "reaction", | |
| reaction: r.n, | |
| quote: quote, | |
| _offsetY: offsetY, | |
| }); | |
| H.hideTB(); | |
| window.getSelection()?.removeAllRanges(); | |
| }; | |
| H.tb.appendChild(btn); | |
| } | |
| const replyBtn = document.createElement("button"); | |
| replyBtn.className = "hbui-toolbar-btn hbui-toolbar-reply"; | |
| replyBtn.innerHTML = '<span>\u21A9</span><span class="hbui-btn-label">Reply</span>'; | |
| replyBtn.onclick = (e) => { | |
| e.stopPropagation(); | |
| H.showReply(H.tb, mi, quote, offsetY); | |
| }; | |
| H.tb.appendChild(replyBtn); | |
| const hint = document.createElement("span"); | |
| hint.className = "hbui-toolbar-hint"; | |
| hint.textContent = "or type to reply"; | |
| H.tb.appendChild(hint); | |
| H.tb.style.left = x + "px"; | |
| H.tb.style.top = y + "px"; | |
| H.sel = { mi, quote, offsetY }; | |
| }; | |
| H.hideTB = () => { | |
| if (H.tb) { H.tb.remove(); H.tb = null; } | |
| H.sel = null; | |
| }; | |
| H.showReply = (tb, mi, quote, offsetY, initialText) => { | |
| tb.innerHTML = ""; | |
| const wrapper = document.createElement("div"); | |
| wrapper.className = "hbui-reply-input"; | |
| const input = document.createElement("input"); | |
| input.type = "text"; | |
| input.placeholder = "Your reply\u2026"; | |
| input.value = initialText || ""; | |
| const sendBtn = document.createElement("button"); | |
| sendBtn.textContent = "Add"; | |
| const submit = () => { | |
| if (input.value.trim()) { | |
| H.add(mi, { | |
| type: "reply", | |
| quote: quote, | |
| comment: input.value.trim(), | |
| _offsetY: offsetY, | |
| }); | |
| } | |
| H.hideTB(); | |
| window.getSelection()?.removeAllRanges(); | |
| }; | |
| input.onkeydown = (e) => { | |
| if (e.key === "Enter") submit(); | |
| if (e.key === "Escape") H.hideTB(); | |
| e.stopPropagation(); | |
| }; | |
| sendBtn.onclick = submit; | |
| wrapper.appendChild(input); | |
| wrapper.appendChild(sendBtn); | |
| tb.appendChild(wrapper); | |
| requestAnimationFrame(() => input.focus()); | |
| }; | |
| // --- Type-to-reply (capture phase to beat ProseMirror) --- | |
| document.addEventListener("keydown", (e) => { | |
| if (!H.sel || !H.tb) return; | |
| if (e.metaKey || e.ctrlKey || e.altKey) return; | |
| if (e.key.length !== 1) return; | |
| // Allow if already typing in the reply input | |
| const a = document.activeElement; | |
| if (a && H.tb.contains(a)) return; | |
| e.preventDefault(); | |
| e.stopImmediatePropagation(); | |
| H.showReply(H.tb, H.sel.mi, H.sel.quote, H.sel.offsetY, e.key); | |
| }, true); | |
| // --- Selection change --- | |
| let selTimeout = null; | |
| document.addEventListener("selectionchange", () => { | |
| clearTimeout(selTimeout); | |
| selTimeout = setTimeout(() => { | |
| const sel = window.getSelection(); | |
| if (!sel || sel.isCollapsed || !sel.toString().trim()) { | |
| if (H.tb && !H.tb.contains(document.activeElement)) H.hideTB(); | |
| return; | |
| } | |
| const quote = sel.toString().trim(); | |
| if (quote.length < 2) return; | |
| const range = sel.getRangeAt(0); | |
| const msgEl = H.findMsg(range.startContainer); | |
| if (!msgEl) return; | |
| const rects = range.getClientRects(); | |
| if (!rects.length) return; | |
| const firstRect = rects[0]; | |
| const msgRect = msgEl.getBoundingClientRect(); | |
| const offsetY = firstRect.top - msgRect.top; | |
| const x = firstRect.left + firstRect.width / 2; | |
| const y = firstRect.top; | |
| H.showTB(x, y, msgEl, quote, offsetY); | |
| }, 150); | |
| }); | |
| document.addEventListener("mousedown", (e) => { | |
| if (H.tb && !H.tb.contains(e.target)) H.hideTB(); | |
| }); | |
| // --- History Reconstitution --- | |
| H.reconstitute = () => { | |
| if (H.reconstituted) return; | |
| const userMsgs = document.querySelectorAll('[data-testid="user-message"]'); | |
| const assistantMsgs = H.msgs(); | |
| if (assistantMsgs.length === 0) return; | |
| userMsgs.forEach((userMsg) => { | |
| const text = userMsg.textContent || ""; | |
| // Look for <hbui>...</hbui> blocks | |
| const hbuiMatch = text.match(/<hbui>([\s\S]*?)<\/hbui>/); | |
| // Also try raw JSON for backwards compat | |
| const jsonMatch = !hbuiMatch && text.match(/\{[\s\S]*?"hbui_annotations"\s*:\s*\[[\s\S]*?\]\s*\}/); | |
| const raw = hbuiMatch ? hbuiMatch[1].trim() : jsonMatch ? jsonMatch[0] : null; | |
| if (!raw) return; | |
| let parsed; | |
| try { | |
| parsed = JSON.parse(raw); | |
| } catch { | |
| return; | |
| } | |
| if (!Array.isArray(parsed.hbui_annotations)) return; | |
| for (const ann of parsed.hbui_annotations) { | |
| const mi = ann.message_index ?? 0; | |
| if (!H.ann.has(mi)) H.ann.set(mi, []); | |
| let offsetY = 20 + H.ann.get(mi).length * NOTE_HEIGHT; | |
| const assistantMsg = assistantMsgs[mi]; | |
| if (assistantMsg && ann.quote) { | |
| const walker = document.createTreeWalker(assistantMsg, NodeFilter.SHOW_TEXT); | |
| const needle = ann.quote.slice(0, 30); | |
| while (walker.nextNode()) { | |
| if (walker.currentNode.textContent.includes(needle)) { | |
| const r = document.createRange(); | |
| r.selectNodeContents(walker.currentNode); | |
| const rect = r.getBoundingClientRect(); | |
| const msgRect = assistantMsg.getBoundingClientRect(); | |
| offsetY = rect.top - msgRect.top; | |
| break; | |
| } | |
| } | |
| } | |
| H.ann.get(mi).push({ | |
| type: ann.type, | |
| reaction: ann.reaction, | |
| quote: ann.quote, | |
| comment: ann.comment, | |
| _offsetY: offsetY, | |
| }); | |
| } | |
| }); | |
| if (H.ann.size > 0) { | |
| H.reconstituted = true; | |
| H.render(); | |
| } | |
| }; | |
| // --- Init --- | |
| function init() { | |
| let debounce = null; | |
| const observer = new MutationObserver(() => { | |
| clearTimeout(debounce); | |
| debounce = setTimeout(() => { | |
| H.checkNav(); | |
| if (!H.reconstituted) H.reconstitute(); | |
| if (H.ann.size > 0) H.render(); | |
| }, 500); | |
| }); | |
| const target = | |
| document.querySelector(".overflow-y-auto.overflow-x-hidden") || | |
| document.body; | |
| observer.observe(target, { childList: true, subtree: true }); | |
| // Detect SPA navigation (pushState/popstate) | |
| window.addEventListener("popstate", () => H.checkNav()); | |
| const origPush = history.pushState.bind(history); | |
| history.pushState = function () { | |
| origPush.apply(this, arguments); | |
| setTimeout(() => H.checkNav(), 100); | |
| }; | |
| setTimeout(H.reconstitute, 1500); | |
| console.log("[HBUI] Margin notes v3.1 (auto-sync + nav reset) loaded"); | |
| } | |
| if (document.readyState === "loading") { | |
| document.addEventListener("DOMContentLoaded", init); | |
| } else { | |
| init(); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment