4 How to save AI chat conversations
Gildas Lormeau edited this page 2026-08-27 17:26:36 +02:00

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.

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.

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.