<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mizael Paredes Vielma</title>
    <description>The latest articles on DEV Community by Mizael Paredes Vielma (@mizaelpv).</description>
    <link>https://dev.to/mizaelpv</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F327099%2F7f64cb1c-1a18-4231-9a4d-89304bb8208a.jpeg</url>
      <title>DEV Community: Mizael Paredes Vielma</title>
      <link>https://dev.to/mizaelpv</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mizaelpv"/>
    <language>en</language>
    <item>
      <title>What I learned building a Chrome extension for Claude.ai</title>
      <dc:creator>Mizael Paredes Vielma</dc:creator>
      <pubDate>Thu, 16 Apr 2026 20:55:49 +0000</pubDate>
      <link>https://dev.to/mizaelpv/what-i-learned-building-a-chrome-extension-for-claudeai-1cn0</link>
      <guid>https://dev.to/mizaelpv/what-i-learned-building-a-chrome-extension-for-claudeai-1cn0</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F54b91wtnu3ykpr15jfsw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F54b91wtnu3ykpr15jfsw.png" alt=" " width="800" height="324"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Five minutes searching the Chrome Web Store confirmed nobody had built a solution for Claude specifically. So I built it myself over a weekend.&lt;/p&gt;

&lt;p&gt;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.&lt;br&gt;
Here's what I learned building it.&lt;/p&gt;
&lt;h2&gt;
  
  
  &lt;strong&gt;The DOM had no stable selectors — until it did&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The first version of the pin button injection relied on &lt;br&gt;
&lt;code&gt;[data-testid^="ai-turn"]&lt;/code&gt; to identify assistant response containers. That matched nothing.&lt;/p&gt;

&lt;p&gt;Claude's actual DOM has no &lt;code&gt;ai-turn&lt;/code&gt;testid at all. After inspecting the live page, the real identifiers turned out to be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[data-testid="user-message"] for human turns&lt;/li&gt;
&lt;li&gt;[role="group"][aria-label="Message actions"] for the action bar&lt;/li&gt;
&lt;li&gt;[data-testid="action-bar-copy"] as a reliable anchor inside that bar&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[data-testid]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data-testid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;===&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That output is what unblocked the whole injection strategy.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. Finding the right turn container without grabbing the whole chat
&lt;/h2&gt;

&lt;p&gt;Once the action bar was found, injecting a button was easy. The hard part was figuring out which text to pin.&lt;br&gt;
The naive approach — &lt;code&gt;actionBar.closest('div').innerText&lt;/code&gt; — 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 &lt;code&gt;[data-testid="user-message"]&lt;/code&gt; descendant:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;findTurnContainer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;actionBarEl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;actionBarEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parentElement&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;lastGood&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[data-testid="user-message"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;lastGood&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;lastGood&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parentElement&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;lastGood&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The key insight: the moment an ancestor contains a user-message, you've gone one level too high. Return the previous level.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. Why MutationObserver needs a debounce
&lt;/h2&gt;

&lt;p&gt;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.&lt;br&gt;
The fix is a 300ms debounce:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;debounceTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;observer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MutationObserver&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;debounceTimer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;debounceTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scanTurns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;observer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;childList&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;subtree&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This waits for DOM mutations to settle before running the injection scan. No duplicate buttons, no race conditions.&lt;br&gt;
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.&lt;/p&gt;
&lt;h2&gt;
  
  
  4. Claude's CSP blocks all external requests from content scripts
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The fix: all external API calls — Notion, any future integrations — must go through &lt;code&gt;background.js&lt;/code&gt;, which isn't subject to the page's CSP:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;// content.js — DON'T do this&lt;br&gt;
fetch('https://api.notion.com/v1/pages', { ... }) // blocked by CSP&lt;br&gt;
// content.js — DO this instead&lt;br&gt;
chrome.runtime.sendMessage({ type: 'NOTION_CREATE', data: pinObject });&lt;br&gt;
// background.js — this works fine&lt;br&gt;
chrome.runtime.onMessage.addListener((message) =&amp;gt; {&lt;br&gt;
  if (message.type === 'NOTION_CREATE') {&lt;br&gt;
    fetch('https://api.notion.com/v1/pages', { ... }) // works&lt;br&gt;
  }&lt;br&gt;
});&lt;/code&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  5. &lt;code&gt;display: flex&lt;/code&gt; silently wins against [hidden]
&lt;/h2&gt;

&lt;p&gt;The empty state div had hidden set correctly in JS but kept showing up alongside pin cards. The bug: the CSS set &lt;code&gt;display: flex&lt;/code&gt; on &lt;code&gt;.empty-state&lt;/code&gt;, which has equal specificity to the browser's user-agent rule [hidden] &lt;code&gt;{ display: none }&lt;/code&gt;. Author styles beat user-agent styles, so display: flex won every time.&lt;/p&gt;

&lt;p&gt;Fix — one line at the top of the CSS file:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;hidden&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt; &lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That's the kind of bug that takes way too long to find because nothing looks wrong in the code.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fduj0o81ppuir6uiw2xni.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fduj0o81ppuir6uiw2xni.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  6. Closing the sidebar without a close API
&lt;/h2&gt;

&lt;p&gt;Chrome's sidePanel API has &lt;code&gt;open()&lt;/code&gt; but no &lt;code&gt;close()&lt;/code&gt;. The documented workaround — &lt;code&gt;setOptions({ enabled: false })&lt;/code&gt;immediately followed by &lt;code&gt;setOptions({ enabled: true })&lt;/code&gt; — didn't work reliably.&lt;/p&gt;

&lt;p&gt;What worked: recognizing that the sidebar is just an extension page, and extension pages can call &lt;code&gt;window.close()&lt;/code&gt;. When &lt;code&gt;chrome.tabs.onActivated&lt;/code&gt;fires in the background service worker (tab switched), it broadcasts &lt;code&gt;{ type: 'CLOSE_SIDEBAR' }&lt;/code&gt; to all extension pages, and &lt;code&gt;sidebar.js&lt;/code&gt; listens and calls &lt;code&gt;window.close()&lt;/code&gt;. Zero API gymnastics needed.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;The turn-container heuristic is fragile. Walking 20 levels up the DOM and checking for &lt;code&gt;[data-testid="user-message"]&lt;/code&gt; descendants works today but will break the moment Anthropic restructures their layout. &lt;/p&gt;

&lt;p&gt;The right fix is a small test harness that verifies selectors on extension load and surfaces a warning if they stop resolving.&lt;br&gt;
&lt;code&gt;chrome.storage.local&lt;/code&gt; 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.&lt;br&gt;
The MV3 service worker can die between events. Any in-memory state (like &lt;code&gt;previousTabId&lt;/code&gt;) resets to null every time Chrome puts the service worker to sleep. For anything that needs to survive across events, use &lt;code&gt;chrome.storage.session&lt;/code&gt; instead of plain variables.&lt;/p&gt;
&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;PinIt v1 is live on the Chrome Web Store — Claude.ai only for now. v2 adds ChatGPT support, Notion export, and better selector resilience.&lt;br&gt;
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.&lt;/p&gt;

&lt;p&gt;I'm building in public — documenting every project, every technical decision, every lesson learned. Follow along if that sounds interesting.&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://chromewebstore.google.com/detail/pinit/adkmkoemjmfhnccfbmgcffajpnndgjlc" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Flh3.googleusercontent.com%2FqrgWESQGiObBmOax2qPqqBSflLCrRHXl4tdolsjyiw7neLxDApKjCE1K50ImrIxZe36X_TA1OGOIs7DPCNq266Zu7A%3Ds128-rj-sc0x00ffffff" height="128" class="m-0" width="128"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://chromewebstore.google.com/detail/pinit/adkmkoemjmfhnccfbmgcffajpnndgjlc" rel="noopener noreferrer" class="c-link"&gt;
            PinIt - Chrome Web Store
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Pin Claude responses and prompts for quick reference in a sidebar.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fssl.gstatic.com%2Fchrome%2Fwebstore%2Fimages%2Ficon_48px.png" width="48" height="48"&gt;
          chromewebstore.google.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;



</description>
      <category>ai</category>
      <category>claude</category>
      <category>productivity</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
