Claude typing lag fix

Fix typing lag in long claude chats by replacing the text entry field.

// ==UserScript==
// @name         Claude typing lag fix
// @namespace    https://lugia19.com
// @version      1.2.3
// @description  Fix typing lag in long claude chats by replacing the text entry field.
// @author       lugia19
// @match        https://claude.ai/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @run-at       document-idle
// @license      MIT
// ==/UserScript==


(function () {
	'use strict';

	const processedProseMirrors = new WeakSet();
	let currentTextarea = null;
	let draftSaveTimer;
	let draftDebounce = 300; // 0.3 seconds debounce for draft saving
	const messageCountThreshold = 200; // Threshold for long conversations
	const longConversations = new Set();

	document.addEventListener('keydown', (e) => {
		// Check for body element OR thought process containers
		const isTargetBody = e.target.tagName === 'BODY' &&
			e.target.classList.contains('bg-bg-100') &&
			e.target.classList.contains('text-text-100');

		const isThoughtProcessContainer = e.target.tagName === 'DIV' &&
			e.target.classList.contains('h-full') &&
			e.target.classList.contains('overflow-y-auto') &&
			e.target.classList.contains('overflow-x-hidden');

		const hasModifiers = e.ctrlKey || e.altKey || e.metaKey;

		// Blacklist navigation/special keys instead of whitelisting characters
		const isNavigationKey = [
			'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
			'PageUp', 'PageDown', 'Home', 'End',
			'Tab', 'Escape', 'Delete', 'Backspace', 'Enter',
			'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12',
			'Shift', 'Control', 'Alt', 'Meta'
		].includes(e.key);

		if (!isNavigationKey && (isTargetBody || isThoughtProcessContainer) && !hasModifiers && currentTextarea) {
			console.log('🎯 Intercepting typing character:', e.key);

			e.stopImmediatePropagation();
			e.preventDefault();

			currentTextarea.focus();
			currentTextarea.value += e.key;
			currentTextarea.dispatchEvent(new Event('input', { bubbles: true }));

			return false;
		}
	}, { capture: true });


	//Draft storage
	function getDraftKey() {
		if (window.location.pathname.indexOf('/new') != -1) {
			// New chat, common key
			return "claude-draft-homepage";
		}
		const match = window.location.pathname.match(/\/chat\/([a-f0-9-]+)/);
		const uuid = match ? match[1] : null;
		return uuid ? `claude-draft-${uuid}` : null;
	}

	function saveDraft(text) {
		const key = getDraftKey();
		if (!key) return;

		clearTimeout(draftSaveTimer);
		draftSaveTimer = setTimeout(() => {
			if (text.trim()) {
				GM_setValue(key, text);
				console.log('💾 Draft saved for chat:', getDraftKey());
			} else {
				GM_deleteValue(key);
				console.log('🗑️ Empty draft deleted for chat:', getDraftKey());
			}
		}, draftDebounce); // 0.5 second debounce
	}

	function loadDraft() {
		const key = getDraftKey();
		if (!key) return '';

		const draft = GM_getValue(key, '');
		if (draft) {
			console.log('📂 Draft loaded for chat:', getDraftKey());
		}
		return draft;
	}

	function clearDraft() {
		const key = getDraftKey();
		if (key) {
			GM_deleteValue(key);
			console.log('🗑️ Draft cleared for chat:', getDraftKey());
		}
	}

	//Actual replacement
	function replaceProseMirror() {
		const proseMirrorDiv = document.querySelector('.ProseMirror');
		if (!proseMirrorDiv || processedProseMirrors.has(proseMirrorDiv)) {
			return;
		}

		console.log('📝 Replacing ProseMirror with textarea');
		processedProseMirrors.add(proseMirrorDiv);

		// Hide and clear original
		proseMirrorDiv.innerHTML = '';
		proseMirrorDiv.textContent = '';
		proseMirrorDiv.setAttribute('contenteditable', 'false');
		proseMirrorDiv.setAttribute('tabindex', '-1');
		proseMirrorDiv.style.cssText = `
			opacity: 0 !important;
			pointer-events: none !important;
			position: absolute !important;
			z-index: -1 !important;
			height: 0 !important;
			overflow: hidden !important;
		`;

		// Create textarea
		// In the replaceProseMirror function, update the textarea creation:

		const simpleTextarea = document.createElement('textarea');
		simpleTextarea.className = 'claude-simple-input';
		simpleTextarea.style.cssText = `
			width: 100%;
			min-height: 1.5rem;
			max-height: none;
			border: none;
			outline: none;
			resize: none;
			overflow: hidden;
			font-family: inherit;
			font-size: inherit;
			line-height: inherit;
			padding: 0;
			background: transparent;
			color: inherit;
		`;
		simpleTextarea.placeholder = 'Write your prompt to Claude';



		// Auto-resize function
		function autoResize() {
			// Reset height to measure scrollHeight accurately
			simpleTextarea.style.height = 'auto';

			// Calculate new height
			const newHeight = Math.max(24, simpleTextarea.scrollHeight); // 24px minimum (1.5rem)
			const maxHeight = unsafeWindow.innerHeight * 0.4; // Max 40% of viewport height

			// Apply the height
			simpleTextarea.style.height = Math.min(newHeight, maxHeight) + 'px';

			// If we hit max height, show scrollbar
			if (newHeight > maxHeight) {
				simpleTextarea.style.overflowY = 'auto';
			} else {
				simpleTextarea.style.overflowY = 'hidden';
			}
		}

		// Add auto-resize to input events
		simpleTextarea.addEventListener('input', () => {
			saveDraft(simpleTextarea.value);
			autoResize();
		});

		// Load existing draft
		const existingDraft = loadDraft();
		if (existingDraft) {
			simpleTextarea.value = existingDraft;
		}

		// Initial resize
		setTimeout(autoResize, 0);

		// Insert textarea
		proseMirrorDiv.parentNode.insertBefore(simpleTextarea, proseMirrorDiv);
		currentTextarea = simpleTextarea;

		// Handle focus hijacking
		proseMirrorDiv.addEventListener('focus', (e) => {
			e.preventDefault();
			e.stopPropagation();
			if (currentTextarea) currentTextarea.focus();
		}, true);

		// Also intercept clicks on the container area
		const container = proseMirrorDiv.parentNode;
		container.addEventListener('click', (e) => {
			// If they clicked in the general area but not on our textarea
			if (e.target !== currentTextarea && currentTextarea) {
				console.log('🖱️ Redirecting container click to textarea');
				currentTextarea.focus();
			}
		});

		// Handle Enter key
		simpleTextarea.addEventListener('keydown', (e) => {
			if (e.key === 'Enter' && !e.shiftKey) {
				e.preventDefault();
				const text = simpleTextarea.value.trim();
				if (text) {
					submitMessage(text);
				}
			}
		});

		simpleTextarea.focus();
	}

	function replaceSubmitButton() {
		const originalSubmitButton = document.querySelector('button[aria-label="Send message"]:not(.claude-custom-submit)');
		const existingCustomButton = document.querySelector('.claude-custom-submit');

		if (!originalSubmitButton || existingCustomButton) {
			return; // No original button or custom already exists
		}

		console.log('🔘 Replacing submit button');

		// Create our button
		const newSubmitButton = document.createElement('button');
		newSubmitButton.innerHTML = originalSubmitButton.innerHTML;
		newSubmitButton.className = originalSubmitButton.className + ' claude-custom-submit';
		newSubmitButton.type = 'button';
		newSubmitButton.setAttribute('aria-label', 'Send message');
		newSubmitButton.disabled = false;

		// Replace the button
		originalSubmitButton.style.display = 'none';
		originalSubmitButton.parentNode.insertBefore(newSubmitButton, originalSubmitButton);

		// Handle click
		newSubmitButton.addEventListener('click', (e) => {
			e.preventDefault();
			if (currentTextarea) {
				const text = currentTextarea.value.trim();
				if (text) {
					submitMessage(text);
				}
			}
		});
	}

	function processMarkdownCodeBlocks(text) {
		// Replace ```language\ncode\n``` with proper HTML
		return text.replace(/```(\w+)?\n([\s\S]*?)```/g, (match, language, code) => {
			let lang = language || '';
			const escapedCode = code.replace(/&/g, '&')
				.replace(/</g, '&lt;')
				.replace(/>/g, '&gt;');
			return `<pre><code class="language-${lang}">${escapedCode}</code></pre>`;
		});
	}

	function submitMessage(text) {
		console.log('📤 Submitting message');

		const proseMirrorDiv = document.querySelector('.ProseMirror');
		if (proseMirrorDiv) {
			// Temporarily re-enable it for submission
			proseMirrorDiv.setAttribute('contenteditable', 'true');

			// Process markdown first
			const processedText = processMarkdownCodeBlocks(text);

			// If it has code blocks, use innerHTML (but escaped), otherwise use paragraphs
			if (processedText !== text) {
				proseMirrorDiv.innerHTML = processedText;
			} else {
				// Original paragraph approach for non-code text
				proseMirrorDiv.innerHTML = '';
				const lines = text.split('\n');
				lines.forEach(line => {
					const p = document.createElement('p');
					p.textContent = line || '\u00A0';
					proseMirrorDiv.appendChild(p);
				});
			}

			proseMirrorDiv.dispatchEvent(new Event('input', { bubbles: true }));
			proseMirrorDiv.dispatchEvent(new Event('change', { bubbles: true }));

			setTimeout(() => {
				// Find and click submit...
				let hiddenSubmit = document.querySelector('button[aria-label="Send message"][style*="display: none"]');
				if (!hiddenSubmit) {
					hiddenSubmit = document.querySelector('button[aria-label="Send message"]:not(.claude-custom-submit)');
				}

				if (hiddenSubmit && !hiddenSubmit.disabled) {
					hiddenSubmit.click();
				}

				// Disable it again after submission
				// Clear our textarea and clean up the original
				setTimeout(() => {
					console.log('🧹 Cleaning up after submission');
					if (currentTextarea) {
						currentTextarea.value = '';
						currentTextarea.style.height = 'auto';
						currentTextarea.style.height = '1.5rem';
						currentTextarea.style.overflowY = 'hidden';
						currentTextarea.focus();
					}

					// Clear the original
					proseMirrorDiv.innerHTML = '';
					proseMirrorDiv.textContent = '';
					proseMirrorDiv.setAttribute('contenteditable', 'false');

					// Scroll to bottom with multiple attempts
					const scrollToBottom = () => {
						const chatContainer = document.querySelector('.relative.h-full.flex-1.flex.overflow-x-hidden.overflow-y-scroll.pt-6');
						if (chatContainer) {
							chatContainer.scrollTo(0, chatContainer.scrollHeight);
						}
					};

					scrollToBottom(); // Immediate
					setTimeout(scrollToBottom, 1000); // 1s
					setTimeout(scrollToBottom, 2000); // 2s
					setTimeout(scrollToBottom, 3000); // 3s
					setTimeout(clearDraft, 200);
				}, 100);
			}, 50);
		}
	}

	// Separate polling for each component
	function checkAndMaintain() {
		const currentUrlMatch = unsafeWindow.location.pathname.match(/\/chat\/([a-f0-9-]+)/);
		const currentConvId = currentUrlMatch ? currentUrlMatch[1] : null;

		// Only enable performance mode if current conversation is in the long conversations set
		if (currentConvId && longConversations.has(currentConvId)) {
			const proseMirrorExists = !!document.querySelector('.ProseMirror');
			const ourTextareaExists = !!document.querySelector('.claude-simple-input');
			const ourButtonExists = !!document.querySelector('.claude-custom-submit');

			if (proseMirrorExists && !ourTextareaExists) {
				replaceProseMirror();
			}

			if (!ourButtonExists) {
				replaceSubmitButton();
			}
		}
	}

	// Monkey patch fetch to monitor conversation updates
	// Monkey patch fetch
	const originalFetch = unsafeWindow.fetch;
	unsafeWindow.fetch = function (...args) {
		const url = args[0];

		if (typeof url === 'string' && url.includes('/chat_conversations/') && url.includes('tree=True')) {
			console.log('🔍 Intercepted tree=True call, making tree=False call');

			// Extract conversation ID
			const conversationIdMatch = url.match(/chat_conversations\/([a-f0-9-]+)/);
			const fetchedConvId = conversationIdMatch ? conversationIdMatch[1] : null;

			if (fetchedConvId) {
				// Make our own call with tree=False to get visible message count
				const visibleMessagesUrl = url.replace('tree=True', 'tree=False');

				originalFetch(visibleMessagesUrl, args[1])
					.then(response => response.json())
					.then(data => {
						const visibleMessageCount = data.chat_messages?.length || 0;
						console.log(`📊 Conversation ${fetchedConvId} has ${visibleMessageCount} visible messages`);

						if (visibleMessageCount > messageCountThreshold) {
							longConversations.add(fetchedConvId);
							console.log('📝 Added to long conversations set (based on visible messages)');
						} else {
							longConversations.delete(fetchedConvId);
							console.log('📝 Removed from long conversations set');
						}
					})
					.catch(err => {
						console.log('❌ Failed to fetch visible messages:', err);
					});
			}
		}

		return originalFetch.apply(this, args);
	};
	// Start
	setInterval(checkAndMaintain, 100);

})();