I use Claude constantly during work — debugging, writing, planning. And I kept running into the same annoying pattern: I'd get a brilliant response buried somewhere in a long conversation, need to reference it again 20 minutes later, and have to scroll through dozens of turns to find it.
Five minutes searching the Chrome Web Store confirmed nobody had built a solution for Claude specifically. So I built it myself over a weekend.
PinIt lets you pin any Claude response — together with the prompt that generated it — and access it instantly from a sidebar. Click a pin and it scrolls you back to that exact moment in the conversation.
Here's what I learned building it.
The DOM had no stable selectors — until it did
The first version of the pin button injection relied on
[data-testid^="ai-turn"] to identify assistant response containers. That matched nothing.
Claude's actual DOM has no ai-turntestid at all. After inspecting the live page, the real identifiers turned out to be:
- [data-testid="user-message"] for human turns
- [role="group"][aria-label="Message actions"] for the action bar
- [data-testid="action-bar-copy"] as a reliable anchor inside that bar
None of this is documented anywhere — Claude doesn't publish its DOM structure. Finding the real selectors required live DevTools inspection and a round of console queries:
[...document.querySelectorAll('[data-testid]')]
.map(el => el.getAttribute('data-testid'))
.filter((v,i,a) => a.indexOf(v)===i)
.sort()
That output is what unblocked the whole injection strategy.
2. Finding the right turn container without grabbing the whole chat
Once the action bar was found, injecting a button was easy. The hard part was figuring out which text to pin.
The naive approach — actionBar.closest('div').innerText — grabbed the entire conversation. The DOM has no obvious turn-level wrapper. The solution was to walk up the ancestor chain from the action bar and stop at the first element that, when queried, contains a [data-testid="user-message"] descendant:
function findTurnContainer(actionBarEl) {
let el = actionBarEl.parentElement;
let lastGood = el;
for (let i = 0; i < 20 && el && el !== document.body; i++) {
if (el.querySelector('[data-testid="user-message"]')) return lastGood;
lastGood = el;
el = el.parentElement;
}
return lastGood;
}
The key insight: the moment an ancestor contains a user-message, you've gone one level too high. Return the previous level.
3. Why MutationObserver needs a debounce
Claude streams its responses token by token. If you inject your pin button the moment a new node appears, you'll inject it dozens of times during a single response — once per token batch.
The fix is a 300ms debounce:
let debounceTimer = null;
const observer = new MutationObserver(() => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(scanTurns, 300);
});
observer.observe(document.body, { childList: true, subtree: true });
This waits for DOM mutations to settle before running the injection scan. No duplicate buttons, no race conditions.
The secondary guard is stamping each action bar with a data-pinit-injected attribute after injection, so scanTurns skips already-processed elements even if it fires multiple times.
4. Claude's CSP blocks all external requests from content scripts
Any fetch or XHR from content.js targeting an external URL will be rejected by Claude's Content Security Policy. The error in the console just says "blocked" with no obvious pointer to why.
The fix: all external API calls — Notion, any future integrations — must go through background.js, which isn't subject to the page's CSP:
// content.js — DON'T do this
fetch('https://api.notion.com/v1/pages', { ... }) // blocked by CSP
// content.js — DO this instead
chrome.runtime.sendMessage({ type: 'NOTION_CREATE', data: pinObject });
// background.js — this works fine
chrome.runtime.onMessage.addListener((message) => {
if (message.type === 'NOTION_CREATE') {
fetch('https://api.notion.com/v1/pages', { ... }) // works
}
});
5. display: flex silently wins against [hidden]
The empty state div had hidden set correctly in JS but kept showing up alongside pin cards. The bug: the CSS set display: flex on .empty-state, which has equal specificity to the browser's user-agent rule [hidden] { display: none }. Author styles beat user-agent styles, so display: flex won every time.
Fix — one line at the top of the CSS file:
[hidden] { display: none !important; }
That's the kind of bug that takes way too long to find because nothing looks wrong in the code.
6. Closing the sidebar without a close API
Chrome's sidePanel API has open() but no close(). The documented workaround — setOptions({ enabled: false })immediately followed by setOptions({ enabled: true }) — didn't work reliably.
What worked: recognizing that the sidebar is just an extension page, and extension pages can call window.close(). When chrome.tabs.onActivatedfires in the background service worker (tab switched), it broadcasts { type: 'CLOSE_SIDEBAR' } to all extension pages, and sidebar.js listens and calls window.close(). Zero API gymnastics needed.
What I'd do differently
The turn-container heuristic is fragile. Walking 20 levels up the DOM and checking for [data-testid="user-message"] descendants works today but will break the moment Anthropic restructures their layout.
The right fix is a small test harness that verifies selectors on extension load and surfaces a warning if they stop resolving.
chrome.storage.local has a 5MB cap. Every pin stores the full response text. A heavy user could hit the limit in weeks. The next version moves to IndexedDB with a short preview stored and full text fetched on demand.
The MV3 service worker can die between events. Any in-memory state (like previousTabId) resets to null every time Chrome puts the service worker to sleep. For anything that needs to survive across events, use chrome.storage.session instead of plain variables.
What's next
PinIt v1 is live on the Chrome Web Store — Claude.ai only for now. v2 adds ChatGPT support, Notion export, and better selector resilience.
If you use Claude heavily, give it a try. And if you've ever built a Chrome extension and fought with DOM selectors — you know exactly what I went through.
I'm building in public — documenting every project, every technical decision, every lesson learned. Follow along if that sounds interesting.


Top comments (0)