Skip to content

Instantly share code, notes, and snippets.

@tkanarsky
Created March 26, 2026 10:26
Show Gist options
  • Select an option

  • Save tkanarsky/b068bf0abbc37e3b8dbe0cb596667779 to your computer and use it in GitHub Desktop.

Select an option

Save tkanarsky/b068bf0abbc37e3b8dbe0cb596667779 to your computer and use it in GitHub Desktop.
// ==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