Save3Clicks (M365 Copilot)

On matching URLs, open the mode switcher, optionally click More, and select a configured mode.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey to install this script.

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         Save3Clicks (M365 Copilot)
// @namespace    anon.local.Save3Clicks
// @version      1.1.1
// @description  On matching URLs, open the mode switcher, optionally click More, and select a configured mode.
// @match        *://*/*
// @run-at       document-idle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @license MIT
// ==/UserScript==

(function () {
  'use strict';

  // CHANGE DEFAULT MODE HERE IF THERE ARE UPDATES TO M365 COPILOT
  const DEFAULT_MODE = 'GPT-5.4 Think deeper';
  // SOME M365 HAVE UNIQUE URLS BASED ON ORGANIZATION
  // THESE ARE MY DEFAULTS SO JUST ADD MORE
  const DEFAULT_URL_RULES = [
    'https://m365.cloud.microsoft/chat?fromCode=CsrToSSR',
    'https://m365.cloud.microsoft/chat'
  ];

  const KEY_MODE = 'targetMode';
  const KEY_URL_RULES = 'urlRules';
  const KEY_ALWAYS_CLICK_MORE = 'alwaysClickMore';

  let lastProcessedUrl = null;
  let runInProgress = false;

  function getMode() {
    return GM_getValue(KEY_MODE, DEFAULT_MODE);
  }

  function setMode(value) {
    GM_setValue(KEY_MODE, String(value || '').trim() || DEFAULT_MODE);
  }

  function getUrlRules() {
    const saved = GM_getValue(KEY_URL_RULES, DEFAULT_URL_RULES);
    return Array.isArray(saved) ? saved : DEFAULT_URL_RULES;
  }

  function setUrlRules(lines) {
    const rules = String(lines || '')
      .split('\n')
      .map(s => s.trim())
      .filter(Boolean);
    GM_setValue(KEY_URL_RULES, rules);
  }

  function getAlwaysClickMore() {
    return GM_getValue(KEY_ALWAYS_CLICK_MORE, true);
  }

  function setAlwaysClickMore(value) {
    GM_setValue(KEY_ALWAYS_CLICK_MORE, !!value);
  }

  function normalizeText(s) {
    return String(s || '')
      .replace(/\s+/g, ' ')
      .trim()
      .toLowerCase();
  }

  function ruleMatchesUrl(rule, url) {
    if (!rule) return false;

    if (rule.startsWith('/') && rule.endsWith('/')) {
      try {
        const rx = new RegExp(rule.slice(1, -1));
        return rx.test(url);
      } catch {
        return false;
      }
    }

    return url.includes(rule);
  }

  function currentUrlMatches() {
    const rules = getUrlRules();
    if (!rules.length) return false;
    const url = location.href;
    return rules.some(rule => ruleMatchesUrl(rule, url));
  }

  function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

  function isVisible(el) {
    if (!el) return false;
    const style = getComputedStyle(el);
    const rect = el.getBoundingClientRect();
    return (
      style.display !== 'none' &&
      style.visibility !== 'hidden' &&
      style.opacity !== '0' &&
      rect.width > 0 &&
      rect.height > 0
    );
  }

  async function waitForFinder(findFn, { timeout = 8000, interval = 100 } = {}) {
    const immediate = findFn();
    if (immediate) return immediate;

    return new Promise((resolve, reject) => {
      const started = Date.now();

      const timer = setInterval(() => {
        const found = findFn();
        if (found) {
          cleanup();
          resolve(found);
          return;
        }
        if (Date.now() - started > timeout) {
          cleanup();
          reject(new Error('Timeout waiting for element'));
        }
      }, interval);

      const observer = new MutationObserver(() => {
        const found = findFn();
        if (found) {
          cleanup();
          resolve(found);
        }
      });

      function cleanup() {
        clearInterval(timer);
        observer.disconnect();
      }

      observer.observe(document.documentElement || document.body, {
        childList: true,
        subtree: true,
        attributes: true,
      });
    });
  }

  // clicker
  function safeClick(el) {
    if (!el) return false;
    try {
      el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
      el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
      el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
      el.click();
      return true;
    } catch (err) {
      console.warn('[AutoMode] Click failed:', err);
      return false;
    }
  }

  // hard coded id name for the element, ill update this if it changes in the future
  function getSwitcherButton() {
    return document.getElementById('gptModeSwitcher');
  }

  function getOpenMenus() {
    return [...document.querySelectorAll('div[role="menu"]')];
  }

  function getMenuItems() {
    const menus = getOpenMenus();
    const items = menus.flatMap(menu => [...menu.querySelectorAll('[role="menuitem"], [role="button"]')]);
    return items.filter(isVisible);
  }

  function findItemByLabel(label) {
    const target = normalizeText(label);
    return getMenuItems().find(el => normalizeText(el.textContent).includes(target)) || null;
  }

  function isItemSelected(item) {
    if (!item) return false;

    const visibleCheckmark = item.querySelector(
      'span[style*="visibility: visible"] svg[data-testid^="checkmark"]'
    );
    if (visibleCheckmark) return true;

    const anyVisibleCheck = [...item.querySelectorAll('svg[data-testid^="checkmark"]')]
      .some(svg => {
        const parent = svg.closest('span') || svg;
        return isVisible(parent);
      });

    return anyVisibleCheck;
  }

  async function openSwitcherMenu() {
    // if the menu = open then do nothing, otherwise open
    if (getMenuItems().length) return true;

    const btn = await waitForFinder(() => getSwitcherButton(), { timeout: 12000 });
    if (!btn) throw new Error('gptModeSwitcher not found');

    safeClick(btn);

    await waitForFinder(() => getMenuItems().length > 0 ? getMenuItems()[0] : null, {
      timeout: 6000
    });

    return true;
  }

  async function clickMoreIfPresent() {
    if (!getAlwaysClickMore()) return 'skipped-disabled';

    // find the more button by label
    let moreItem = findItemByLabel('More');

    // retry in case your webpage is slow to load
    if (!moreItem) {
      await sleep(250);
      moreItem = findItemByLabel('More');
    }

    if (!moreItem) {
      return 'not-found';
    }

    // if it looks like a submenu then click it
    safeClick(moreItem);

    // even more time for slow ui
    await sleep(250);
    return 'clicked';
  }

  async function selectMode(modeLabel) {
    await openSwitcherMenu();
    await clickMoreIfPresent();

    let item = findItemByLabel(modeLabel);

    // even more time for slow ur but for finindng the item this time
    if (!item) {
      await sleep(300);
      item = findItemByLabel(modeLabel);
    }

    if (!item) {
      throw new Error(`Mode not found in menu: ${modeLabel}`);
    }

    if (isItemSelected(item)) {
      console.log('[AutoMode] Already selected:', modeLabel);
      return 'already-selected';
    }

    safeClick(item);
    console.log('[AutoMode] Clicked mode:', modeLabel);
    return 'clicked';
  }

  async function runOnceForCurrentUrl() {
    if (runInProgress) return;
    if (!currentUrlMatches()) return;
    if (lastProcessedUrl === location.href) return;

    runInProgress = true;
    try {
      const mode = getMode();
      await selectMode(mode);
      lastProcessedUrl = location.href;
    } catch (err) {
      console.warn('[AutoMode] Failed:', err);
    } finally {
      runInProgress = false;
    }
  }

  function registerMenu() {
    GM_registerMenuCommand(`Set target mode (current: ${getMode()})`, () => {
      const next = prompt('Enter the exact menu label to click:', getMode());
      if (next != null) {
        setMode(next);
        alert(`Saved target mode:\n${getMode()}`);
      }
    });

    GM_registerMenuCommand(
      `Toggle "click More first" (currently: ${getAlwaysClickMore() ? 'ON' : 'OFF'})`,
      () => {
        setAlwaysClickMore(!getAlwaysClickMore());
        alert(`click More first = ${getAlwaysClickMore() ? 'ON' : 'OFF'}`);
      }
    );

    GM_registerMenuCommand('Set URL rules (one per line; substring or /regex/)', () => {
      const current = getUrlRules().join('\n');
      const next = prompt(
        'Enter URL rules, one per line.\n\nExamples:\nhttps://copilot.microsoft.com/\n/copilot\\.microsoft\\.com\\/.*/',
        current
      );
      if (next != null) {
        setUrlRules(next);
        alert(`Saved ${getUrlRules().length} URL rule(s).`);
      }
    });

    GM_registerMenuCommand('Run now on this page', async () => {
      try {
        await selectMode(getMode());
        alert(`Done: attempted to select "${getMode()}"`);
      } catch (err) {
        alert(`Failed: ${err.message}`);
      }
    });
  }

  function initUrlWatcher() {
    let lastUrl = location.href;

    setInterval(() => {
      if (location.href !== lastUrl) {
        lastUrl = location.href;
        lastProcessedUrl = null;
        runOnceForCurrentUrl();
      }
    }, 500);

    // runs it once but retries if fails
    runOnceForCurrentUrl();
    setTimeout(runOnceForCurrentUrl, 1500);
    setTimeout(runOnceForCurrentUrl, 3500);
  }

  registerMenu();
  initUrlWatcher();
})();