Size

20.5 KB

Version

1.1.26

Created

Jan 12, 2026

Updated

19 days ago

1// ==UserScript==
2// @name		AI Chat Auto-Collapse
3// @description		Auto-collapse chat sections on AI chat sites with expand/collapse controls
4// @version		1.1.26
5// @match		https://*.chatgpt.com/*
6// @match		https://gemini.google.com/*
7// @match		https://chat.deepseek.com/*
8// @match		https://claude.ai/*
9// @match		https://copilot.microsoft.com/*
10// @match		https://www.meta.ai/*
11// @match		https://grok.x.ai/*
12// @match		https://grok.com/*
13// @match		https://www.perplexity.ai/*
14// @match		https://replit.com/*
15// @match		https://aistudio.google.com/*
16// @match		https://lovable.dev/*
17// @match		https://bolt.new/*
18// @icon		https://chatgpt.com/cdn/assets/favicon-l4nq08hd.svg
19// ==/UserScript==
20(function() {
21    'use strict';
22
23    console.log('AI Chat Auto-Collapse extension loaded');
24
25    // Debounce function to prevent excessive calls
26    function debounce(func, wait) {
27        let timeout;
28        return function executedFunction(...args) {
29            const later = () => {
30                clearTimeout(timeout);
31                func(...args);
32            };
33            clearTimeout(timeout);
34            timeout = setTimeout(later, wait);
35        };
36    }
37
38    // Site-specific selectors for different AI chat platforms
39    const siteConfigs = {
40        'chatgpt.com': {
41            messageSelector: 'article[data-testid^="conversation-turn-"]',
42            contentSelector: '.text-base',
43            titleSelector: '.whitespace-pre-wrap, .markdown p, .markdown h1, .markdown h2',
44            isUserMessage: (el) => el.getAttribute('data-turn') === 'user',
45            isAssistantMessage: (el) => el.getAttribute('data-turn') === 'assistant'
46        },
47        'gemini.google.com': {
48            messageSelector: 'message-content, model-response, user-query',
49            contentSelector: '.model-response-text, .user-query, .response-container',
50            titleSelector: '.model-response-text, .user-query, p',
51            isUserMessage: (el) => el.tagName === 'USER-QUERY' || el.classList.contains('user-query'),
52            isAssistantMessage: (el) => el.tagName === 'MODEL-RESPONSE' || el.classList.contains('model-response')
53        },
54        'chat.deepseek.com': {
55            messageSelector: 'div[class*="_9663006"], div[class*="_4f9bf79"]',
56            contentSelector: '.ds-message, .ds-markdown',
57            titleSelector: '.ds-message div, .ds-markdown p',
58            isUserMessage: (el) => el.className.includes('_9663006'),
59            isAssistantMessage: (el) => el.className.includes('_4f9bf79')
60        },
61        'claude.ai': {
62            messageSelector: 'div[data-test-render-count]',
63            contentSelector: 'div[data-testid="user-message"], div[data-is-streaming], .font-claude-response',
64            titleSelector: 'p.whitespace-pre-wrap, .font-claude-response-body, p',
65            isUserMessage: (el) => el.querySelector('[data-testid="user-message"]') !== null,
66            isAssistantMessage: (el) => el.querySelector('[data-is-streaming]') !== null || el.querySelector('.font-claude-response') !== null
67        },
68        'copilot.microsoft.com': {
69            messageSelector: 'cib-message, [class*="message-item"]',
70            contentSelector: 'cib-message-group, [class*="message-content"]',
71            titleSelector: '[class*="message-text"], p',
72            isUserMessage: (el) => el.getAttribute('source') === 'user' || el.className.includes('user'),
73            isAssistantMessage: (el) => el.getAttribute('source') === 'bot' || el.className.includes('bot')
74        },
75        'meta.ai': {
76            messageSelector: '[class*="message"], [role="article"]',
77            contentSelector: '[class*="content"], [class*="text"]',
78            titleSelector: 'p, span',
79            isUserMessage: (el) => el.className.includes('user'),
80            isAssistantMessage: (el) => el.className.includes('assistant') || el.className.includes('ai')
81        },
82        'grok.x.ai': {
83            messageSelector: '[data-testid*="message"], [class*="message"]',
84            contentSelector: '[class*="content"], [class*="text"]',
85            titleSelector: 'p, span',
86            isUserMessage: (el) => el.className.includes('user') || el.getAttribute('data-testid')?.includes('user'),
87            isAssistantMessage: (el) => el.className.includes('assistant') || el.className.includes('grok')
88        },
89        'grok.com': {
90            messageSelector: 'div[id^="response-"]',
91            contentSelector: '.message-bubble, .response-content-markdown',
92            titleSelector: '.response-content-markdown p, p',
93            isUserMessage: (el) => el.className.includes('items-end'),
94            isAssistantMessage: (el) => el.className.includes('items-start')
95        },
96        'perplexity.ai': {
97            messageSelector: 'div[class*="gap-y-md"][class*="flex-col"]',
98            contentSelector: 'div',
99            titleSelector: 'p, div[class*="prose"]',
100            isUserMessage: (el) => {
101                // User messages are typically shorter and at the top
102                const hasInput = el.querySelector('input, textarea');
103                return hasInput || (el.textContent.length > 20 && el.textContent.length < 500);
104            },
105            isAssistantMessage: (el) => {
106                // Assistant messages contain prose elements
107                return el.querySelector('[class*="prose"]') !== null;
108            }
109        },
110        'replit.com': {
111            messageSelector: '[data-cy="user-message"], div:has(> div > .rendered-markdown):not([data-cy])',
112            contentSelector: '.rendered-markdown',
113            titleSelector: '.rendered-markdown p, p',
114            isUserMessage: (el) => el.getAttribute('data-cy') === 'user-message',
115            isAssistantMessage: (el) => el.querySelector('.rendered-markdown') !== null && !el.getAttribute('data-cy')
116        },
117        'aistudio.google.com': {
118            messageSelector: '.turn.input, .turn.output',
119            contentSelector: 'div',
120            titleSelector: 'p, div',
121            isUserMessage: (el) => el.classList.contains('input'),
122            isAssistantMessage: (el) => el.classList.contains('output')
123        },
124        'lovable.dev': {
125            messageSelector: '#_r_4r_ > div > div.flex.min-h-0.flex-1.flex-col > div > div.h-full.w-full.overflow-y-auto > div',
126            contentSelector: 'div',
127            titleSelector: 'p, div',
128            isUserMessage: (el) => {
129                // Detect user messages by content or structure
130                const text = el.textContent.toLowerCase();
131                return text.includes('what is') || text.includes('can you') || text.includes('please') || el.querySelector('[data-role="user"]') !== null;
132            },
133            isAssistantMessage: (el) => {
134                // Detect AI messages by content or structure
135                return el.querySelector('[data-role="assistant"]') !== null || el.textContent.length > 100;
136            }
137        },
138        'bolt.new': {
139            messageSelector: 'div[data-message-id]',
140            contentSelector: 'div',
141            titleSelector: 'p, div',
142            isUserMessage: (el) => el.classList.contains('self-end') || el.querySelector('.bg-bolt-elements-messages-background') !== null,
143            isAssistantMessage: (el) => !el.classList.contains('self-end') && el.querySelector('.i-bolt\\:logos-bolt') !== null
144        }
145    };
146
147    // Get current site config
148    function getSiteConfig() {
149        const hostname = window.location.hostname;
150        for (const [domain, config] of Object.entries(siteConfigs)) {
151            if (hostname.includes(domain)) {
152                console.log('Using config for:', domain);
153                return config;
154            }
155        }
156        console.log('Using default ChatGPT config');
157        return siteConfigs['chatgpt.com']; // Default to ChatGPT config
158    }
159
160    const config = getSiteConfig();
161
162    // Extract title from message content
163    function extractTitle(messageElement) {
164        const titleElement = messageElement.querySelector(config.titleSelector);
165        if (!titleElement) {
166            console.log('No title element found');
167            return 'Message';
168        }
169
170        let text = titleElement.textContent.trim();
171        
172        // For ChatGPT, skip the "You said:" or "ChatGPT said:" labels
173        if (window.location.hostname.includes('chatgpt.com')) {
174            // Find the actual message content, not the label
175            const userMessageContent = messageElement.querySelector('.whitespace-pre-wrap');
176            if (userMessageContent) {
177                text = userMessageContent.textContent.trim();
178            } else {
179                // For assistant messages, get first paragraph or heading
180                const firstP = messageElement.querySelector('.markdown p, .markdown h1, .markdown h2, .markdown h3');
181                if (firstP) {
182                    text = firstP.textContent.trim();
183                }
184            }
185        } else if (window.location.hostname.includes('grok.com')) {
186            // For Grok, always get the first paragraph, not headings
187            const firstP = messageElement.querySelector('.response-content-markdown p');
188            if (firstP) {
189                text = firstP.textContent.trim();
190            }
191        } else if (window.location.hostname.includes('aistudio.google.com')) {
192            // For AI Studio, skip the "User" or "Gemini" header and get actual content
193            const allText = messageElement.textContent.trim();
194            // Remove the header text (User or Gemini...)
195            if (allText.startsWith('User ')) {
196                text = allText.substring(5).trim();
197            } else if (allText.includes('Gemini')) {
198                // For AI messages, get text after the Gemini header
199                const parts = allText.split(/Gemini.*?(Canceled|check_circle)?/);
200                text = parts[parts.length - 1]?.trim() || allText;
201            }
202        } else {
203            // For assistant messages, try to get first heading or first paragraph
204            if (config.isAssistantMessage(messageElement)) {
205                const heading = messageElement.querySelector('h1, h2, h3, h4, h5, h6');
206                if (heading) {
207                    text = heading.textContent.trim();
208                } else {
209                    const firstP = messageElement.querySelector('p');
210                    if (firstP) {
211                        text = firstP.textContent.trim();
212                    }
213                }
214            }
215        }
216
217        // Limit title length
218        const maxLength = 60;
219        if (text.length > maxLength) {
220            text = text.substring(0, maxLength) + '...';
221        }
222
223        return text || 'Message';
224    }
225
226    // Create toggle button
227    function createToggleButton(isCollapsed) {
228        const button = document.createElement('button');
229        button.className = 'ai-chat-collapse-toggle';
230        button.innerHTML = isCollapsed ? '▸' : '▾';
231        button.setAttribute('aria-label', isCollapsed ? 'Expand message' : 'Collapse message');
232        button.setAttribute('aria-expanded', !isCollapsed);
233        return button;
234    }
235
236    // Create title bar
237    function createTitleBar(messageElement, title) {
238        const titleBar = document.createElement('div');
239        titleBar.className = 'ai-chat-collapse-title-bar';
240        
241        const toggleButton = createToggleButton(true);
242        const titleText = document.createElement('span');
243        titleText.className = 'ai-chat-collapse-title-text';
244        titleText.textContent = title;
245
246        // Determine message type for styling
247        if (config.isUserMessage(messageElement)) {
248            titleBar.classList.add('user-message');
249        } else if (config.isAssistantMessage(messageElement)) {
250            titleBar.classList.add('assistant-message');
251        }
252
253        // Detect if page has dark background
254        const bgColor = window.getComputedStyle(document.body).backgroundColor;
255        const rgb = bgColor.match(/\d+/g);
256        if (rgb) {
257            const brightness = (parseInt(rgb[0]) * 299 + parseInt(rgb[1]) * 587 + parseInt(rgb[2]) * 114) / 1000;
258            if (brightness < 128) {
259                titleBar.classList.add('dark-bg');
260            }
261        }
262
263        titleBar.appendChild(toggleButton);
264        titleBar.appendChild(titleText);
265
266        return titleBar;
267    }
268
269    // Toggle message collapse state
270    function toggleMessage(messageElement, titleBar, contentWrapper) {
271        const isCollapsed = contentWrapper.style.display === 'none';
272        const toggleButton = titleBar.querySelector('.ai-chat-collapse-toggle');
273
274        if (isCollapsed) {
275            // Expand
276            contentWrapper.style.display = '';
277            toggleButton.innerHTML = '▾';
278            toggleButton.setAttribute('aria-expanded', 'true');
279            messageElement.classList.remove('collapsed');
280        } else {
281            // Collapse
282            contentWrapper.style.display = 'none';
283            toggleButton.innerHTML = '▸';
284            toggleButton.setAttribute('aria-expanded', 'false');
285            messageElement.classList.add('collapsed');
286        }
287    }
288
289    // Process a single message
290    function processMessage(messageElement) {
291        // Skip if already processed
292        if (messageElement.hasAttribute('data-collapse-processed')) {
293            return;
294        }
295
296        // Skip if this element is inside an already-processed element
297        const processedParent = messageElement.parentElement?.closest('[data-collapse-processed]');
298        if (processedParent && processedParent !== messageElement) {
299            console.log('Skipping nested element inside processed message');
300            return;
301        }
302
303        // Mark as processed IMMEDIATELY to prevent re-processing
304        messageElement.setAttribute('data-collapse-processed', 'true');
305
306        console.log('Processing message:', messageElement);
307
308        // Check if element has meaningful content
309        const textContent = messageElement.textContent.trim();
310        if (textContent.length < 10) {
311            console.log('Message too short, skipping');
312            return;
313        }
314
315        // Extract title before modifying DOM
316        const title = extractTitle(messageElement);
317        console.log('Extracted title:', title);
318
319        // Create wrapper for original content
320        const contentWrapper = document.createElement('div');
321        contentWrapper.className = 'ai-chat-collapse-content';
322        
323        // Move all children to wrapper
324        while (messageElement.firstChild) {
325            contentWrapper.appendChild(messageElement.firstChild);
326        }
327
328        // Create title bar
329        const titleBar = createTitleBar(messageElement, title);
330
331        // Add title bar and wrapped content back
332        messageElement.appendChild(titleBar);
333        messageElement.appendChild(contentWrapper);
334
335        // Auto-collapse by default
336        contentWrapper.style.display = 'none';
337        messageElement.classList.add('collapsed');
338
339        // Add click handler
340        titleBar.addEventListener('click', () => {
341            toggleMessage(messageElement, titleBar, contentWrapper);
342        });
343
344        console.log('Message processed successfully');
345    }
346
347    // Process all messages on the page
348    function processAllMessages() {
349        console.log('Processing all messages with selector:', config.messageSelector);
350        const messages = document.querySelectorAll(config.messageSelector);
351        console.log('Found messages:', messages.length);
352        
353        messages.forEach(message => {
354            processMessage(message);
355        });
356    }
357
358    // Initialize the extension
359    function init() {
360        console.log('Initializing AI Chat Auto-Collapse');
361        console.log('Current site:', window.location.hostname);
362        
363        // Process existing messages
364        processAllMessages();
365
366        // Watch for new messages using MutationObserver
367        const observer = new MutationObserver(debounce(() => {
368            console.log('DOM changed, checking for new messages');
369            processAllMessages();
370        }, 300));
371
372        // Start observing
373        const targetNode = document.body;
374        observer.observe(targetNode, {
375            childList: true,
376            subtree: true
377        });
378
379        console.log('MutationObserver started');
380    }
381
382    // Wait for page to be ready
383    if (document.readyState === 'loading') {
384        document.addEventListener('DOMContentLoaded', init);
385    } else {
386        // DOM already loaded, wait a bit for dynamic content
387        setTimeout(init, 1000);
388    }
389
390    // Add CSS styles
391    const styles = `
392        .ai-chat-collapse-title-bar {
393            display: flex;
394            align-items: center;
395            gap: 8px;
396            padding: 12px 16px;
397            cursor: pointer;
398            background: rgba(0, 0, 0, 0.03);
399            border-radius: 8px;
400            margin: 4px 0;
401            transition: background 0.2s ease;
402            user-select: none;
403        }
404
405        .ai-chat-collapse-title-bar:hover {
406            background: rgba(0, 0, 0, 0.06);
407        }
408
409        .ai-chat-collapse-title-bar.user-message {
410            background: rgba(60, 60, 70, 0.15);
411            margin-left: auto;
412            margin-right: auto;
413            max-width: 70%;
414        }
415
416        .ai-chat-collapse-title-bar.user-message:hover {
417            background: rgba(60, 60, 70, 0.25);
418        }
419
420        .ai-chat-collapse-title-bar.assistant-message {
421            background: rgba(142, 142, 160, 0.1);
422        }
423
424        .ai-chat-collapse-title-bar.assistant-message:hover {
425            background: rgba(142, 142, 160, 0.15);
426        }
427
428        .ai-chat-collapse-toggle {
429            background: none;
430            border: none;
431            font-size: 16px;
432            cursor: pointer;
433            padding: 0;
434            width: 20px;
435            height: 20px;
436            display: flex;
437            align-items: center;
438            justify-content: center;
439            color: #374151;
440            flex-shrink: 0;
441        }
442
443        .ai-chat-collapse-toggle:hover {
444            opacity: 0.7;
445        }
446
447        .ai-chat-collapse-title-text {
448            font-weight: 500;
449            font-size: 14px;
450            color: #1f2937;
451            flex: 1;
452            overflow: hidden;
453            text-overflow: ellipsis;
454            white-space: nowrap;
455        }
456
457        .ai-chat-collapse-content {
458            transition: opacity 0.2s ease;
459        }
460
461        [data-collapse-processed].collapsed {
462            margin-bottom: 8px;
463        }
464
465        /* Fix Replit layout - force vertical stacking */
466        [data-cy="user-message"],
467        [data-cy="user-message"][data-collapse-processed] {
468            display: flex !important;
469            flex-direction: column !important;
470        }
471
472        /* Dark mode support - for sites with dark backgrounds */
473        html.dark .ai-chat-collapse-title-bar,
474        body.dark .ai-chat-collapse-title-bar,
475        [data-theme="dark"] .ai-chat-collapse-title-bar,
476        @media (prefers-color-scheme: dark) {
477            .ai-chat-collapse-title-bar {
478                background: rgba(255, 255, 255, 0.05);
479            }
480
481            .ai-chat-collapse-title-bar:hover {
482                background: rgba(255, 255, 255, 0.08);
483            }
484
485            .ai-chat-collapse-title-bar.user-message {
486                background: rgba(80, 80, 90, 0.3);
487            }
488
489            .ai-chat-collapse-title-bar.user-message:hover {
490                background: rgba(80, 80, 90, 0.4);
491            }
492
493            .ai-chat-collapse-title-bar.assistant-message {
494                background: rgba(142, 142, 160, 0.15);
495            }
496
497            .ai-chat-collapse-title-bar.assistant-message:hover {
498                background: rgba(142, 142, 160, 0.2);
499            }
500
501            .ai-chat-collapse-title-text {
502                color: #e2e8f0;
503            }
504
505            .ai-chat-collapse-toggle {
506                color: #e2e8f0;
507            }
508        }
509
510        /* Additional dark mode selectors for sites that use class-based themes */
511        html.dark .ai-chat-collapse-title-text,
512        body.dark .ai-chat-collapse-title-text,
513        [data-theme="dark"] .ai-chat-collapse-title-text {
514            color: #e2e8f0;
515        }
516
517        html.dark .ai-chat-collapse-toggle,
518        body.dark .ai-chat-collapse-toggle,
519        [data-theme="dark"] .ai-chat-collapse-toggle {
520            color: #e2e8f0;
521        }
522
523        /* Dynamic dark background detection */
524        .ai-chat-collapse-title-bar.dark-bg .ai-chat-collapse-title-text {
525            color: #e2e8f0;
526        }
527
528        .ai-chat-collapse-title-bar.dark-bg .ai-chat-collapse-toggle {
529            color: #e2e8f0;
530        }
531    `;
532
533    // Inject styles
534    const styleElement = document.createElement('style');
535    styleElement.textContent = styles;
536    if (document.head) {
537        document.head.appendChild(styleElement);
538    }
539
540})();