ChatGPT Response Complete Notifier

Sends a desktop notification with text preview when ChatGPT finishes a response. Robustly handles 'New Chat' detection and prevents notification spam.

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

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

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==UserScript==
// @name         ChatGPT Response Complete Notifier
// @namespace    http://tampermonkey.net/
// @version      1.0.1
// @author       ramhaidar
// @description  Sends a desktop notification with text preview when ChatGPT finishes a response. Robustly handles 'New Chat' detection and prevents notification spam.
// @homepage     https://github.com/ramhaidar/ChatGPT-Response-Complete-Notifier
// @source       https://github.com/ramhaidar/ChatGPT-Response-Complete-Notifier/raw/refs/heads/main/chatgpt-response-complete-notifier.user.js
// @license      GPL-3.0
// @match        https://chatgpt.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_log
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  // [CRN] Configuration
  const CONFIG = {
    DEBUG_MODE: false,
    NOTIFICATION_COOLDOWN: 3000,
    PERMISSION_KEY: 'crn_notification_permission_granted',
    POLL_INTERVAL: 500 // Check status every 500ms
  };

  // [CRN] State tracking
  let state = {
    isStreaming: false,
    lastNotificationTime: 0,
    lastButtonState: null,
    buttonObserver: null,
    pollInterval: null,
    notificationPermissionGranted: false,
    observedForm: null,
    lastUrlPath: window.location.pathname // Track URL changes
  };

  // [CRN] Enhanced logging
  function crnLog(message, data = null) {
    if (CONFIG.DEBUG_MODE) {
      const timestamp = new Date().toISOString();
      const logMessage = `[CRN] [${timestamp}] ${message}`;
      console.log(logMessage);
      if (data !== null && typeof data === 'object') {
        console.log(`[CRN] DATA:`, JSON.stringify(data, null, 2));
      }
    }
  }

  // [CRN] Check notification permission
  async function checkNotificationPermissionNative() {
    crnLog('🔑 Checking native notification permission...');
    if (!('Notification' in window)) return false;
    if (Notification.permission === 'granted') {
      state.notificationPermissionGranted = true;
      return true;
    }
    const userDenied = GM_getValue('notification_permission_denied', false);
    if (userDenied) return false;

    try {
      const permission = await Notification.requestPermission();
      state.notificationPermissionGranted = permission === 'granted';
      if (state.notificationPermissionGranted) {
        GM_setValue(CONFIG.PERMISSION_KEY, true);
        return true;
      } else {
        GM_setValue('notification_permission_denied', true);
        return false;
      }
    } catch (error) {
      return false;
    }
  }

  // [CRN] Get current button state
  function getCurrentButtonState() {
    // Primary selectors for the send/stop button
    const buttonSelectors = [
      '#composer-submit-button',
      'button[data-testid="send-button"]',
      'button[data-testid="stop-button"]',
      'button[aria-label="Send prompt"]',
      'button[aria-label="Stop streaming"]'
    ];

    let button = null;
    for (const selector of buttonSelectors) {
      button = document.querySelector(selector);
      if (button) break;
    }

    if (!button) return null;

    const dataTestId = button.getAttribute('data-testid');
    const ariaLabel = button.getAttribute('aria-label');
    const isDisabled = button.disabled;

    return {
      buttonElement: button,
      dataTestId: dataTestId,
      ariaLabel: ariaLabel,
      isDisabled: isDisabled,
      isSendButton: dataTestId === 'send-button' && ariaLabel === 'Send prompt',
      isStopButton: dataTestId === 'stop-button' && ariaLabel === 'Stop streaming',
      timestamp: Date.now()
    };
  }

  // [CRN] Handle button state changes
  function handleButtonStateChange(buttonState) {
    // Case 1: Streaming started (Stop button visible)
    if (buttonState.isStopButton) {
      if (!state.isStreaming) {
        crnLog('▶️ Streaming started');
        state.isStreaming = true;
      }
      return;
    }

    // Case 2: Response COMPLETED (Send button visible AND disabled)
    // Note: Button is disabled immediately after streaming stops
    if (buttonState.isSendButton && buttonState.isDisabled && state.isStreaming) {
      crnLog('✅ Response completed!');
      state.isStreaming = false;
      showResponseCompleteNotification();
      return;
    }

    // Case 3: Reset states (e.g. user clicked New Chat manually while streaming was stuck, or page refreshed)
    if (buttonState.isSendButton && !buttonState.isDisabled) {
      if (state.isStreaming) {
        // If we were streaming, but now see an enabled send button, we might have missed the stop.
        // But usually this happens on New Chat where context resets.
        crnLog('🔄 Resetting streaming state (Ready)');
        state.isStreaming = false;
      }
    }
  }

  // [CRN] Show notification
  function showResponseCompleteNotification() {
    const now = Date.now();
    if (now - state.lastNotificationTime < CONFIG.NOTIFICATION_COOLDOWN) return;

    crnLog('🔔 Response complete - showing notification');

    if (!state.notificationPermissionGranted) {
      checkNotificationPermissionNative().then(granted => {
        if (granted) actuallyShowNotification();
      });
      return;
    }

    actuallyShowNotification();
  }

  // [CRN] Show native notification
  function actuallyShowNotification() {
    if (!('Notification' in window)) {
      fallbackToGMNotification();
      return;
    }

    try {
      const lastAssistantMessage = document.querySelector([
        'div[data-message-author-role="assistant"]:last-of-type div.markdown',
        'div[data-message-author-role="assistant"]:last-of-type div.prose',
        'div[data-message-author-role="assistant"]:last-of-type > div > div',
        'div[data-message-author-role="assistant"]:last-of-type span',
        'div[data-message-author-role="assistant"]:last-of-type'
      ].join(', '));

      let previewText = 'ChatGPT response completed';

      if (lastAssistantMessage) {
        const content = lastAssistantMessage.textContent?.trim() || '';
        if (content) {
          const cleanContent = content.replace(/\s+/g, ' ').trim();
          previewText = cleanContent.substring(0, 150) + (cleanContent.length > 150 ? '...' : '');
        }
      }

      const notification = new Notification('✅ ChatGPT Response Complete', {
        body: previewText,
        icon: 'https://chatgpt.com/favicon.ico',
        badge: 'https://chatgpt.com/favicon.ico',
        silent: false
      });

      notification.onclick = function () {
        window.focus();
        this.close();
      };

      state.lastNotificationTime = Date.now();

    } catch (error) {
      fallbackToGMNotification();
    }
  }

  // [CRN] Fallback
  function fallbackToGMNotification() {
    try {
      if (typeof GM_notification === 'function') {
        GM_notification({
          title: '✅ ChatGPT Response Complete',
          text: 'Response completed',
          image: 'https://chatgpt.com/favicon.ico',
          timeout: 8000,
          onclick: function () { window.focus(); }
        });
      } else {
        alert('ChatGPT Response Complete!');
        window.focus();
      }
    } catch (e) { }
  }

  // [CRN] Setup Button Observer (Watches attributes of the specific form)
  function setupButtonObserver() {
    if (state.buttonObserver) {
      state.buttonObserver.disconnect();
      state.buttonObserver = null;
    }

    // Find the container form
    const container = document.querySelector('form[aria-label="Chat input form"]') ||
      document.querySelector('div[data-testid="conversation-container"] footer');

    if (!container) {
      crnLog('⚠️ Input container not found yet.');
      return;
    }

    state.observedForm = container;

    state.buttonObserver = new MutationObserver(() => {
      const currentState = getCurrentButtonState();
      if (currentState) handleButtonStateChange(currentState);
    });

    state.buttonObserver.observe(container, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: ['data-testid', 'aria-label', 'disabled', 'class']
    });

    // Initial check
    const initialState = getCurrentButtonState();
    if (initialState) handleButtonStateChange(initialState);
  }

  // [CRN] Main Heartbeat (Polling)
  // Handles "New Chat" detection via URL and Form presence checks
  function startPolling() {
    if (state.pollInterval) clearInterval(state.pollInterval);

    state.pollInterval = setInterval(() => {
      // 1. Check for NEW CHAT via URL
      const currentPath = window.location.pathname;

      // If URL changed (e.g. /c/abc -> /c/def or /c/abc -> /)
      if (state.lastUrlPath !== currentPath) {
        crnLog(`🌐 URL changed from "${state.lastUrlPath}" to "${currentPath}". Resetting context.`);
        state.lastUrlPath = currentPath;
        state.isStreaming = false; // Force reset
        setupButtonObserver(); // Re-attach to new DOM
        return; // Skip button check this tick
      }

      // 2. Check if our observed form is still alive
      // If the form was removed from DOM (e.g. during a UI swap), re-initialize
      if (state.observedForm && !document.body.contains(state.observedForm)) {
        crnLog('⚠️ Observed form removed from DOM. Re-initializing.');
        state.isStreaming = false;
        setupButtonObserver();
        return;
      }

      // 3. Standard Button State Check (if observer is working, this is redundant but safe)
      // The observer handles this efficiently, but we keep a check here just in case
      // the observer missed something or hasn't attached yet.
      const currentState = getCurrentButtonState();
      if (currentState) {
        handleButtonStateChange(currentState);
      }

    }, CONFIG.POLL_INTERVAL);

    crnLog('✅ Polling heartbeat started');
  }

  // [CRN] Initialize
  async function initialize() {
    crnLog('🚀 Initializing (No Spam Version)');

    state.notificationPermissionGranted = await checkNotificationPermissionNative();

    setupButtonObserver();
    startPolling();

    window.addEventListener('beforeunload', () => {
      if (state.buttonObserver) state.buttonObserver.disconnect();
      clearInterval(state.pollInterval);
    });

    crnLog('✅ Initialization Complete');
  }

  // [CRN] Start
  if (document.readyState === 'complete' || document.readyState === 'interactive') {
    initialize();
  } else {
    window.addEventListener('DOMContentLoaded', () => setTimeout(initialize, 200));
    window.addEventListener('load', () => setTimeout(initialize, 500));
  }

  // [CRN] Expose test function to global scope for console testing
  unsafeWindow.testCRNNotification = function () {
    crnLog('🧪 Manual test');
    actuallyShowNotification();
  };
})();