Skip to content

[userscript] ?chatgpt-memories-copy?: Add 'Copy' button next to trash icon in saved memories modal on ChatGPT #2

@0xdevalias

Description

@0xdevalias

Overview

Add a userscript that enhances the ChatGPT "Saved memories" modal by inserting a "Copy" button next to the existing trash (Remove) icon for each memory row. This allows quickly copying the memory text into the clipboard without manually selecting it.

Implementation Details

  • Scope: Only run inside the Saved memories modal ([data-testid="modal-memories"]) on the Personalization settings tab (#settings/Personalization).
  • Row targeting:
    • Select rows with [data-testid="modal-memories"] table tbody tr.
    • Memory text: .text-left
    • Action area: .text-right
  • Copy button:
    • Implemented as a <button> with the same styling classes as the trash button:
      • text-token-text-tertiary hover:text-token-text-secondary
    • Inserted immediately before the trash button (button[aria-label="Remove"]).
    • If no trash button exists, prepend the copy button to the action cell.
    • SVG icon: document-style “copy” icon.
  • Click handler:
    • Uses navigator.clipboard.writeText() to copy the .text-left text.
    • Provides visual feedback by temporarily toggling text-token-text-secondary class.
    • Click handler defined as a standalone handleCopyClick function.
  • MutationObserver:
    • Scoped to the <tbody> inside the modal table.
    • Config: { childList: true } (no subtree).
    • Filters mutations to only react when <tr> nodes are added/removed.
    • Efficient "skip fast" if changes are irrelevant.
  • URL targeting:
    • Restricted to https://chatgpt.com/c/*#settings/Personalization*
    • Restricted to https://chat.openai.com/c/*#settings/Personalization*

Notes

We’ve been iterating on a WIP PoC in:

The current PoC WIP script (may not even work currently) is:

// ==UserScript==
// @name         ChatGPT Memories Copy Button
// @namespace    https://www.devalias.net/
// @version      0.4
// @description  Add a "copy" button next to the trash icon for ChatGPT user memories to copy memory text easily.
// @author       Glenn 'devalias' Grant
// @match        https://chatgpt.com/c/*#settings/Personalization*
// @match        https://chat.openai.com/c/*#settings/Personalization*
// @icon         https://cdn.oaistatic.com/assets/favicon-eex17e9e.ico
// ==/UserScript==

(function() {
  'use strict';

  function handleCopyClick(textCell, btn) {
    const text = textCell.textContent.trim();
    navigator.clipboard.writeText(text).then(() => {
      // Visual feedback by temporarily changing color
      btn.classList.add('text-token-text-secondary');
      setTimeout(() => btn.classList.remove('text-token-text-secondary'), 800);
    });
  }

  function createCopyButton(textCell) {
    const btn = document.createElement('button');
    btn.type = 'button';
    btn.ariaLabel = 'Copy';
    btn.className = 'text-token-text-tertiary hover:text-token-text-secondary';
    btn.innerHTML = `
      <span class="leading-none">
        <svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor" class="icon-sm">
          <path d="M6 2a2 2 0 0 0-2 2v10h2V4h8V2H6zm2 4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2H8zm0 2h8v10H8V8z"/>
        </svg>
      </span>`;
    btn.addEventListener('click', () => handleCopyClick(textCell, btn)); // explicit btn arg
    return btn;
  }

  function injectCopyButtons() {
    document.querySelectorAll('[data-testid="modal-memories"] table tbody tr').forEach(row => {
      const textCell = row.querySelector('.text-left');
      const actionCell = row.querySelector('.text-right');
      if (!textCell || !actionCell) return;
      if (actionCell.querySelector('button[aria-label="Copy"]')) return; // already added

      const copyBtn = createCopyButton(textCell);

      const trashBtn = actionCell.querySelector('button[aria-label="Remove"]');
      if (trashBtn) {
        actionCell.insertBefore(copyBtn, trashBtn);
      } else {
        // fallback: put it first so it precedes any other future actions
        actionCell.prepend(copyBtn);
      }
    });
  }

  const memoriesModalTbody = document.querySelector('[data-testid="modal-memories"] table tbody');

  if (memoriesModalTbody) {
    const observer = new MutationObserver(mutations => {
      // MutationRecord structure reference:
      // {
      //   type: "childList" | "attributes" | "characterData",
      //   target: Node,                  // node that changed
      //   addedNodes: NodeList,          // newly added children
      //   removedNodes: NodeList,        // removed children
      //   previousSibling: Node | null,
      //   nextSibling: Node | null,
      //   attributeName: string | null,
      //   attributeNamespace: string | null,
      //   oldValue: string | null
      // }

      for (const m of mutations) {
        // NOTE: could also use mutations.find(...) for conciseness, but loop is clearer
        if (
          m.type === "childList" &&
          ([...m.addedNodes, ...m.removedNodes].some(n => n.nodeName === "TR"))
        ) {
          // Debug logging to help refine filtering:
          // console.debug("Relevant mutation:", m);

          injectCopyButtons();
          break; // stop after first relevant
        }
      }
    });

    observer.observe(memoriesModalTbody, { childList: true });
    // ⚠️ If the modal isn't yet present when this script runs,
    // we may need to observe a higher-level container until it appears.
  } else {
    // Fallback: tbody not found at script init.
    // Might need to retry later or observe the modal root itself.
    // console.warn("Memories modal tbody not found; observer not started");
  }

  injectCopyButtons();
})();

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions