Skip to content

How to save AI chat conversations

Gildas edited this page Sep 24, 2026 · 7 revisions

Pages like ChatGPT, Perplexity or DeepSeek do not keep the whole conversation in the page. They remove the messages that are scrolled out of view, and they add them back when you scroll to them again. This technique is called virtualization.

SingleFile saves the page as it is. The messages that are not in the page cannot be saved. This is why the saved page is blank where these messages were, and why the result changes depending on where you scrolled before saving.

This is not something SingleFile can fix on its own. The missing messages are not hidden, they no longer exist in the page. Bringing them back means interacting with the site itself.

The solutions with a user script need one setting. In the extension, the user script API is disabled by default. Open the options page and click the icon next to the title to open the Options editor. If two icons are displayed, the editor is the second one. Replace "userScriptEnabled": false with "userScriptEnabled": true, then click Save. You also need an extension to store and run the scripts, search "user script" on the store of your browser. With the CLI there is nothing to enable, pass the script with the option --browser-script.

Solution without a user script

Make the window tall enough to display the whole conversation. The site then keeps all the messages in the page.

  • With the extension, zoom out with Ctrl - (or Cmd - on macOS) until the whole conversation is visible, then save the page.
  • With the CLI, use the options --browser-device-width and --browser-device-height, for example --browser-device-height=60000.

Be careful, a value that is too small is not a partial solution. It saves a different part of the conversation, and it can drop messages that would have been saved otherwise. The value must be large enough for the whole conversation. Count around 1500 pixels per message.

This solution also works on the pages which scroll the whole window, like a discussion thread on X. Increase the height until the number of saved messages stops growing. On a thread of 252 replies, I saved 19 replies with the default window, 141 with --browser-device-height=4000, 249 with 8000 and 252 with 16000. The file was 7.6 MB with 8000.

Solution with a user script

This script expands the scrollable areas of the page just before saving it, waits for the site to display all the content, and restores the page afterwards. It is not written for a particular website.

It only helps when the conversation is displayed in a scrollable area inside the page, which is the case of ChatGPT and DeepSeek. It does nothing on a page which scrolls the whole window, like X. For these pages, see the second script below.

Here are the results I measured, with the same page saved twice, and the number of messages counted in the saved file.

page without the script with the script
ChatGPT conversation of 42 messages 5 42
DeepSeek conversation of 14 messages 3 14
X profile timeline 9 9

See How to execute a user script before a page is saved to install it.

// ==UserScript==
// @name         Expand virtualized lists
// @namespace    https://github.com/gildas-lormeau/SingleFile
// @version      1.0
// @description  [SingleFile] Expand scrollable areas to save conversations and lists entirely
// @author       Gildas Lormeau
// @match        *://*/*
// @grant        none
// ==/UserScript==


(() => {

  const MOUNT_DELAY = 500;
  const STABLE_ROUNDS = 4;
  const HEIGHT_INCREMENT = 40000;
  const MAX_HEIGHT = 400000;
  const MAX_DURATION = 60000;
  const MIN_SCROLL_OVERFLOW = 100;

  const savedStyles = new Map();
  dispatchEvent(new CustomEvent("single-file-user-script-init"));

  addEventListener("single-file-on-before-capture-request", event => {
    event.preventDefault();
    expandAll()
      .catch(() => restore())
      .finally(() => dispatchEvent(new CustomEvent("single-file-on-before-capture-response")));
  });

  addEventListener("single-file-on-after-capture-request", () => restore());

  async function expandAll() {
    const deadline = Date.now() + MAX_DURATION;
    let height = HEIGHT_INCREMENT;
    let stableRounds = 0;
    let previousCount = -1;
    while (stableRounds < STABLE_ROUNDS && Date.now() < deadline) {
      findScrollers().forEach(element => expand(element, height));
      await settle();
      const count = document.querySelectorAll("*").length;
      if (count == previousCount) {
        stableRounds++;
      } else {
        stableRounds = 0;
        height = Math.min(height + HEIGHT_INCREMENT, MAX_HEIGHT);
      }
      previousCount = count;
    }
  }

  function findScrollers() {
    return Array.from(document.querySelectorAll("*")).filter(element => {
      if (savedStyles.has(element)) {
        return true;
      }
      if (element == document.documentElement || element == document.body) {
        return false;
      }
      const style = getComputedStyle(element);
      return (style.overflowY == "auto" || style.overflowY == "scroll") &&
        element.scrollHeight > element.clientHeight + MIN_SCROLL_OVERFLOW;
    });
  }

  function expand(element, height) {
    if (!savedStyles.has(element)) {
      savedStyles.set(element, element.getAttribute("style"));
    }
    element.style.setProperty("height", height + "px", "important");
    element.style.setProperty("min-height", height + "px", "important");
    element.style.setProperty("max-height", "none", "important");
    element.scrollTop = 0;
  }

  function restore() {
    savedStyles.forEach((style, element) => {
      if (style == null) {
        element.removeAttribute("style");
      } else {
        element.setAttribute("style", style);
      }
    });
    savedStyles.clear();
  }

  function settle() {
    return new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
      .then(() => new Promise(resolve => setTimeout(resolve, MOUNT_DELAY)));
  }

})();

Solution with a user script, for the pages which scroll the whole window

On a page like X, the messages are not in a scrollable area, they are removed from the page while you scroll. The script below replaces the methods used by the page to remove them, scrolls to the end so that all the messages are displayed, and restores the methods afterwards.

This script only works if it is executed in the page itself. Install it with a user script manager, with @grant none. It does not work with the option --browser-script of the CLI, because these scripts are executed in a separate environment where the modifications are invisible to the page.

I saved a discussion thread of 252 replies on X with the default size of the window. 5 messages were saved without the script, and 129 with it.

// ==UserScript==
// @name         Keep the messages removed while scrolling
// @namespace    https://github.com/gildas-lormeau/SingleFile
// @version      1.0
// @description  [SingleFile] Prevent the page from removing the messages, and scroll to display them all
// @author       Gildas Lormeau
// @match        *://x.com/*
// @grant        none
// ==/UserScript==


(() => {

  const ITEM_SELECTOR = "article";
  const SCROLL_STEP_RATIO = 0.8;
  const SCROLL_DELAY = 800;
  const STABLE_ROUNDS = 15;
  const MAX_STEPS = 600;
  const MAX_DURATION = 120000;

  const ORIGINALS_PROPERTY = "_singleFile_keptMethods";
  dispatchEvent(new CustomEvent("single-file-user-script-init"));

  addEventListener("single-file-on-before-capture-request", event => {
    event.preventDefault();
    expandPage()
      .catch(() => restore())
      .finally(() => dispatchEvent(new CustomEvent("single-file-on-before-capture-response")));
  });

  addEventListener("single-file-on-after-capture-request", () => restore());

  function isMessage(node) {
    return node && node.nodeType == 1 &&
      (node.matches(ITEM_SELECTOR) || node.querySelector(ITEM_SELECTOR));
  }

  function patch() {
    if (globalThis[ORIGINALS_PROPERTY]) {
      return;
    }
    const originals = globalThis[ORIGINALS_PROPERTY] = {
      removeChild: Node.prototype.removeChild,
      remove: Element.prototype.remove,
      replaceChild: Node.prototype.replaceChild
    };
    Node.prototype.removeChild = function (child) {
      return isMessage(child) ? child : originals.removeChild.call(this, child);
    };
    Element.prototype.remove = function () {
      if (!isMessage(this)) {
        originals.remove.call(this);
      }
    };
    Node.prototype.replaceChild = function (newChild, oldChild) {
      return isMessage(oldChild) ? oldChild : originals.replaceChild.call(this, newChild, oldChild);
    };
  }

  function restore() {
    const originals = globalThis[ORIGINALS_PROPERTY];
    if (originals) {
      Node.prototype.removeChild = originals.removeChild;
      Element.prototype.remove = originals.remove;
      Node.prototype.replaceChild = originals.replaceChild;
      delete globalThis[ORIGINALS_PROPERTY];
    }
  }

  function wait(delay) {
    return new Promise(resolve => setTimeout(resolve, delay));
  }

  async function expandPage() {
    patch();
    const deadline = Date.now() + MAX_DURATION;
    let stableRounds = 0;
    let previousCount = -1;
    scrollTo(0, 0);
    await wait(SCROLL_DELAY);
    for (let step = 0; step < MAX_STEPS && stableRounds < STABLE_ROUNDS && Date.now() < deadline; step++) {
      scrollBy(0, innerHeight * SCROLL_STEP_RATIO);
      await wait(SCROLL_DELAY);
      const count = document.querySelectorAll(ITEM_SELECTOR).length;
      stableRounds = count == previousCount ? stableRounds + 1 : 0;
      previousCount = count;
    }
    scrollTo(0, 0);
    await wait(SCROLL_DELAY);
  }

})();

Adapt ITEM_SELECTOR to the website. It must select the messages, and nothing else. Do not remove the selector to protect everything: I tried it on X and the page stopped working, only 5 messages were saved.

After the page is saved, the messages are still all displayed. Reload the page to display it normally again.

Solution for the messages clipped to a few lines

Some sites do not remove the messages, they clip them. Google AI Mode does this with your own questions. A long question is displayed on two lines, with a button to expand it. The saved page keeps the text, but it stays clipped, and the button no longer works.

Nothing is lost here. The text is in the file. It is only hidden by CSS.

This script removes the clipping just before the page is saved, and restores it afterwards. Unlike the script above, it works with the option --browser-script of the CLI.

I measured it on a Google AI Mode conversation of ten questions. Two of them were clipped to two lines. With the script they are displayed in full, on four and six lines. Nothing else in the page moves.

See How to execute a user script before a page is saved to install it.

// ==UserScript==
// @name         Expand the clipped messages
// @namespace    https://github.com/gildas-lormeau/SingleFile
// @version      1.0
// @description  [SingleFile] Display in full the text that the page clips to a few lines
// @author       Gildas Lormeau
// @match        *://*/*
// @grant        none
// ==/UserScript==


(() => {

  const SELECTOR = "*";

  const savedStyles = new Map();
  dispatchEvent(new CustomEvent("single-file-user-script-init"));

  addEventListener("single-file-on-before-capture-request", () => expand());

  addEventListener("single-file-on-after-capture-request", () => restore());

  function expand() {
    document.querySelectorAll(SELECTOR).forEach(element => {
      if (getComputedStyle(element).webkitLineClamp != "none") {
        savedStyles.set(element, element.getAttribute("style"));
        element.style.setProperty("-webkit-line-clamp", "unset", "important");
        element.style.setProperty("display", "block", "important");
        element.style.setProperty("overflow", "visible", "important");
      }
    });
  }

  function restore() {
    savedStyles.forEach((style, element) => {
      if (style == null) {
        element.removeAttribute("style");
      } else {
        element.setAttribute("style", style);
      }
    });
    savedStyles.clear();
  }

})();

The script removes the clipping everywhere in the page, not only on the messages. On a page with a grid of cards, the titles clipped to two lines become longer, and the layout can move. Change SELECTOR to limit it to the part of the page you want.

Google Gemini

Google Gemini also folds your long prompts, with an "Expand" button. The script above does not work there. Gemini does not clip the text to a number of lines, it limits the height of the prompt and hides what overflows, when the prompt has the class collapsed. The script below removes this class just before the page is saved, and adds it back afterwards. It works with the option --browser-script of the CLI too.

I measured it on a saved Gemini conversation. The folded prompt was 96 pixels high. Without the class, it is displayed in full, on 1320 pixels. The script above did not find any element to expand in this page.

Gemini loads the oldest messages when you scroll up. Scroll to the top of the conversation before saving it (see #2000).

// ==UserScript==
// @name         Expand Gemini prompts
// @namespace    https://github.com/gildas-lormeau/SingleFile
// @version      1.0
// @description  [SingleFile] Expand the folded prompts of a Gemini conversation
// @author       Gildas Lormeau
// @match        https://gemini.google.com/*
// @noframes
// @grant        none
// ==/UserScript==


(() => {

  const FOLDED_CLASS_NAME = "collapsed";
  const expandedElements = [];
  dispatchEvent(new CustomEvent("single-file-user-script-init"));

  addEventListener("single-file-on-before-capture-request", () => {
    document.querySelectorAll(".query-text." + FOLDED_CLASS_NAME).forEach(element => {
      element.classList.remove(FOLDED_CLASS_NAME);
      expandedElements.push(element);
    });
  });

  addEventListener("single-file-on-after-capture-request", () => {
    expandedElements.forEach(element => element.classList.add(FOLDED_CLASS_NAME));
    expandedElements.length = 0;
  });

})();

Notes

  • The script stops as soon as the number of elements in the page stops changing. It also stops after 60 seconds. You can raise MAX_DURATION for very long conversations.
  • A single wait is not enough. On a conversation of 42 messages, the messages appeared progressively, 10 then 20 then 30 then 40 then 42, over about ten rounds.
  • The script expands every scrollable area it finds, not only the conversation. On a page with an infinite list, it will keep loading content until the timeout is reached.
  • The styles are restored when the page is processed. The page is left in the state it had before saving.
  • A conversation and a thread have an end, an infinite list does not. On a discussion thread, the number of saved messages stops growing and the file stays reasonable. On a timeline, the site keeps loading as long as the window is tall. Saving a profile timeline on X with --browser-device-height=60000 produced 683 messages in a file of 71 MB, which is difficult to open. Increase the height progressively instead of using a very large value directly.