Auto-collapse chat sections on AI chat sites with expand/collapse controls
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})();