|
| 1 | +/** |
| 2 | + * LightSession - Compactor Module |
| 3 | + * |
| 4 | + * Post-trim optimization to simplify kept messages. |
| 5 | + * Dehighlights syntax-highlighted code blocks to reduce DOM complexity. |
| 6 | + * |
| 7 | + * Runs via requestIdleCallback to avoid blocking the main thread. |
| 8 | + * Uses WeakSet to track processed nodes - automatically cleaned up by GC |
| 9 | + * when nodes are removed from DOM. |
| 10 | + */ |
| 11 | + |
| 12 | +import { logDebug, logInfo, isDebugMode } from '../shared/logger'; |
| 13 | + |
| 14 | +// Track processed nodes to avoid re-processing |
| 15 | +// WeakSet automatically removes references when DOM nodes are garbage collected |
| 16 | +const processedNodes = new WeakSet<HTMLElement>(); |
| 17 | + |
| 18 | +// Mutation suppression - refcount-based with watchdog safety |
| 19 | +// Refcount: tracks overlapping runCompactor() calls |
| 20 | +// Watchdog: clears stuck suppression if extendSuppression() stops being called |
| 21 | +let activeRuns = 0; |
| 22 | +let mutationSuppressionUntil = 0; |
| 23 | +const MUTATION_SUPPRESSION_SAFETY_MS = 1500; // Safety margin for heavy code blocks |
| 24 | +let watchdogTimer: number | undefined; |
| 25 | + |
| 26 | +/** |
| 27 | + * Arm the watchdog timer. |
| 28 | + * If extendSuppression() isn't called within the safety window, watchdog clears suppression. |
| 29 | + * This prevents stuck suppression if compactor crashes/errors without cleanup. |
| 30 | + */ |
| 31 | +function armWatchdog(): void { |
| 32 | + if (watchdogTimer !== undefined) { |
| 33 | + window.clearTimeout(watchdogTimer); |
| 34 | + } |
| 35 | + watchdogTimer = window.setTimeout(() => { |
| 36 | + if (activeRuns === 0) return; // Already cleaned up |
| 37 | + logDebug(`Compactor: Watchdog cleared stuck suppression (activeRuns was ${activeRuns})`); |
| 38 | + activeRuns = 0; |
| 39 | + mutationSuppressionUntil = 0; |
| 40 | + watchdogTimer = undefined; |
| 41 | + }, MUTATION_SUPPRESSION_SAFETY_MS + 250); // 250ms grace period |
| 42 | +} |
| 43 | + |
| 44 | +/** |
| 45 | + * Clear the watchdog timer. |
| 46 | + */ |
| 47 | +function clearWatchdog(): void { |
| 48 | + if (watchdogTimer !== undefined) { |
| 49 | + window.clearTimeout(watchdogTimer); |
| 50 | + watchdogTimer = undefined; |
| 51 | + } |
| 52 | +} |
| 53 | + |
| 54 | +/** |
| 55 | + * Extend the suppression timeout and re-arm watchdog. |
| 56 | + * Called periodically during processing to prevent timeout during heavy work. |
| 57 | + */ |
| 58 | +function extendSuppression(): void { |
| 59 | + mutationSuppressionUntil = performance.now() + MUTATION_SUPPRESSION_SAFETY_MS; |
| 60 | + armWatchdog(); |
| 61 | +} |
| 62 | + |
| 63 | +/** |
| 64 | + * Check if mutations should be suppressed (compactor is working). |
| 65 | + * Uses OR logic: suppressed if any run is active OR within safety window. |
| 66 | + * Watchdog handles stuck flags by clearing activeRuns if no ping received. |
| 67 | + * @returns true if mutations should be ignored |
| 68 | + */ |
| 69 | +export function isMutationSuppressed(): boolean { |
| 70 | + return activeRuns > 0 || performance.now() < mutationSuppressionUntil; |
| 71 | +} |
| 72 | + |
| 73 | +/** |
| 74 | + * Start suppressing mutations. |
| 75 | + * Called when compactor begins work. Supports overlapping runs via refcount. |
| 76 | + */ |
| 77 | +function startMutationSuppression(): void { |
| 78 | + activeRuns++; |
| 79 | + mutationSuppressionUntil = performance.now() + MUTATION_SUPPRESSION_SAFETY_MS; |
| 80 | + armWatchdog(); |
| 81 | + logDebug(`Compactor: Mutation suppression started (activeRuns: ${activeRuns})`); |
| 82 | +} |
| 83 | + |
| 84 | +/** |
| 85 | + * Stop suppressing mutations. |
| 86 | + * Called when compactor finishes work. Only fully stops when all runs complete. |
| 87 | + */ |
| 88 | +function stopMutationSuppression(): void { |
| 89 | + activeRuns = Math.max(0, activeRuns - 1); |
| 90 | + if (activeRuns === 0) { |
| 91 | + mutationSuppressionUntil = 0; |
| 92 | + clearWatchdog(); |
| 93 | + logDebug('Compactor: Mutation suppression ended (all runs complete)'); |
| 94 | + } else { |
| 95 | + logDebug(`Compactor: Run finished, ${activeRuns} still active`); |
| 96 | + } |
| 97 | +} |
| 98 | + |
| 99 | +// Selectors for syntax-highlighted code blocks |
| 100 | +// These are common patterns used by ChatGPT and highlight.js |
| 101 | +// Note: :has() is NOT used - it throws DOMException on older browsers |
| 102 | +const CODE_BLOCK_SELECTORS = [ |
| 103 | + // Highlight.js patterns |
| 104 | + 'pre code[class*="hljs"]', |
| 105 | + 'pre code[class*="language-"]', |
| 106 | + 'pre[class*="hljs"]', |
| 107 | + // ChatGPT specific patterns |
| 108 | + '.code-block pre', |
| 109 | + '[data-testid*="code-block"] pre', |
| 110 | + 'pre.code-block', |
| 111 | + 'div[class*="code-block"] pre', |
| 112 | + // Broader patterns for ChatGPT's current structure |
| 113 | + '[class*="overflow-y-auto"] pre', // Code container with scroll |
| 114 | + '[class*="bg-black"] pre', // Dark background code blocks |
| 115 | + // Fallback: any pre with code child (will be filtered by span count) |
| 116 | + 'pre code', |
| 117 | +]; |
| 118 | + |
| 119 | +// Minimum number of spans to consider a code block "highlighted" |
| 120 | +const MIN_SPANS_FOR_DEHIGHLIGHT = 5; |
| 121 | + |
| 122 | +/** |
| 123 | + * Replace syntax-highlighted code with plain text. |
| 124 | + * Preserves the code content, removes highlighting spans. |
| 125 | + * |
| 126 | + * Expects canonical element (pre) - caller handles normalization. |
| 127 | + * |
| 128 | + * @param preElement The canonical pre element to dehighlight |
| 129 | + * @returns true if dehighlighting was performed, false otherwise |
| 130 | + */ |
| 131 | +function dehighlightCodeBlock(preElement: HTMLElement): boolean { |
| 132 | + // Skip if already processed (WeakSet tracks across all calls) |
| 133 | + if (processedNodes.has(preElement)) { |
| 134 | + return false; |
| 135 | + } |
| 136 | + |
| 137 | + // Mark as processed before any work |
| 138 | + processedNodes.add(preElement); |
| 139 | + |
| 140 | + // Count spans before (for logging and threshold check) |
| 141 | + const spanCount = preElement.querySelectorAll('span').length; |
| 142 | + |
| 143 | + if (spanCount < MIN_SPANS_FOR_DEHIGHLIGHT) { |
| 144 | + // Not significantly highlighted, skip |
| 145 | + logDebug(`Skipping code block with ${spanCount} spans (threshold: ${MIN_SPANS_FOR_DEHIGHLIGHT})`); |
| 146 | + return false; |
| 147 | + } |
| 148 | + |
| 149 | + // Find the innermost code element if this is a pre > code structure |
| 150 | + const codeChild = preElement.querySelector('code'); |
| 151 | + const targetElement = codeChild ?? preElement; |
| 152 | + |
| 153 | + // Get plain text from codeChild if available, otherwise from pre |
| 154 | + // This avoids capturing "Copy code" buttons or other UI elements inside pre |
| 155 | + const plainText = codeChild?.textContent ?? preElement.textContent ?? ''; |
| 156 | + |
| 157 | + // Replace innerHTML with plain text |
| 158 | + // This removes all highlighting spans while preserving the text |
| 159 | + targetElement.textContent = plainText; |
| 160 | + |
| 161 | + // Add a marker class so we know this was dehighlighted |
| 162 | + targetElement.classList.add('ls-dehighlighted'); |
| 163 | + |
| 164 | + logDebug(`Dehighlighted code block: removed ${spanCount} spans`); |
| 165 | + return true; |
| 166 | +} |
| 167 | + |
| 168 | +// Pre-joined selector for single querySelectorAll (faster than N separate queries) |
| 169 | +const CODE_BLOCK_SELECTOR_JOINED = CODE_BLOCK_SELECTORS.join(','); |
| 170 | + |
| 171 | +/** |
| 172 | + * Process code blocks in the given container. |
| 173 | + * Uses single querySelectorAll with joined selectors for performance. |
| 174 | + * Deduplicates to canonical <pre> elements before processing to avoid |
| 175 | + * function call overhead when selectors match both pre and pre>code. |
| 176 | + * |
| 177 | + * @param container The container element to search within |
| 178 | + * @returns Number of code blocks that were dehighlighted |
| 179 | + */ |
| 180 | +function processCodeBlocks(container: HTMLElement): number { |
| 181 | + // Single query with all selectors joined - faster than N separate queries |
| 182 | + const matches = container.querySelectorAll<HTMLElement>(CODE_BLOCK_SELECTOR_JOINED); |
| 183 | + |
| 184 | + // Deduplicate to canonical <pre> elements before processing |
| 185 | + // This avoids function call overhead when selectors match both `pre` and `pre>code` |
| 186 | + const uniquePres = new Set<HTMLElement>(); |
| 187 | + for (let i = 0; i < matches.length; i++) { |
| 188 | + const el = matches[i]; |
| 189 | + if (!el) continue; |
| 190 | + // Normalize to <pre> if possible, otherwise use the element itself |
| 191 | + const canonical = el.tagName === 'PRE' ? el : (el.closest('pre') as HTMLElement | null); |
| 192 | + uniquePres.add(canonical ?? el); |
| 193 | + } |
| 194 | + |
| 195 | + // Process unique elements |
| 196 | + let processed = 0; |
| 197 | + for (const block of uniquePres) { |
| 198 | + if (dehighlightCodeBlock(block)) { |
| 199 | + processed++; |
| 200 | + } |
| 201 | + } |
| 202 | + |
| 203 | + // Debug: log what we found (only if debug mode AND no matches) |
| 204 | + if (matches.length === 0 && isDebugMode()) { |
| 205 | + const anyPre = container.querySelectorAll('pre'); |
| 206 | + const anyCode = container.querySelectorAll('code'); |
| 207 | + if (anyPre.length > 0 || anyCode.length > 0) { |
| 208 | + logDebug(`Compactor: Found ${anyPre.length} <pre>, ${anyCode.length} <code>, but none matched selectors`); |
| 209 | + if (anyPre.length > 0) { |
| 210 | + const firstPre = anyPre[0]; |
| 211 | + logDebug(`Compactor: First <pre> classes: "${firstPre?.className}", parent: "${firstPre?.parentElement?.className}"`); |
| 212 | + } |
| 213 | + } |
| 214 | + } |
| 215 | + |
| 216 | + return processed; |
| 217 | +} |
| 218 | + |
| 219 | +// Number of most recent messages to skip (preserve readability of latest responses) |
| 220 | +const SKIP_LAST_N_MESSAGES = 2; |
| 221 | + |
| 222 | +/** |
| 223 | + * Run compactor on kept messages via requestIdleCallback. |
| 224 | + * This is the main entry point called after trim completes. |
| 225 | + * |
| 226 | + * Skips the last N messages to preserve UX - user is likely reading the most recent response. |
| 227 | + * |
| 228 | + * @param keptNodes Array of kept message nodes to process |
| 229 | + */ |
| 230 | +export function runCompactor(keptNodes: HTMLElement[]): void { |
| 231 | + // Skip last N messages to preserve readability of latest responses |
| 232 | + const processableCount = Math.max(0, keptNodes.length - SKIP_LAST_N_MESSAGES); |
| 233 | + const nodesToProcess = keptNodes.slice(0, processableCount); |
| 234 | + |
| 235 | + if (nodesToProcess.length === 0) { |
| 236 | + logDebug(`Compactor: No nodes to process (${keptNodes.length} kept, skipping last ${SKIP_LAST_N_MESSAGES})`); |
| 237 | + return; |
| 238 | + } |
| 239 | + |
| 240 | + logDebug(`Compactor: Starting processing of ${nodesToProcess.length} nodes (skipping last ${SKIP_LAST_N_MESSAGES})`); |
| 241 | + |
| 242 | + // Track if this is the first chunk (suppression starts on first actual DOM work) |
| 243 | + let isFirstChunk = true; |
| 244 | + |
| 245 | + const runTask = (deadline: IdleDeadline, nodes: HTMLElement[], startIndex: number): void => { |
| 246 | + // Start suppression on first chunk (when we actually do DOM work, not before) |
| 247 | + if (isFirstChunk) { |
| 248 | + isFirstChunk = false; |
| 249 | + startMutationSuppression(); |
| 250 | + } else { |
| 251 | + // Extend safety timeout for subsequent chunks |
| 252 | + extendSuppression(); |
| 253 | + } |
| 254 | + |
| 255 | + let totalProcessed = 0; |
| 256 | + let index = startIndex; |
| 257 | + let scheduledNext = false; |
| 258 | + |
| 259 | + try { |
| 260 | + // Process nodes while we have idle time (at least 2ms per iteration) |
| 261 | + while (index < nodes.length && deadline.timeRemaining() > 2) { |
| 262 | + // Extend suppression every 4 iterations (not every iteration - cheaper) |
| 263 | + if ((index - startIndex) % 4 === 0) { |
| 264 | + extendSuppression(); |
| 265 | + } |
| 266 | + |
| 267 | + const node = nodes[index]; |
| 268 | + if (node && node.isConnected) { |
| 269 | + totalProcessed += processCodeBlocks(node); |
| 270 | + } |
| 271 | + index++; |
| 272 | + } |
| 273 | + |
| 274 | + if (totalProcessed > 0) { |
| 275 | + logInfo(`Compactor: Dehighlighted ${totalProcessed} code blocks`); |
| 276 | + } |
| 277 | + |
| 278 | + // Continue if more nodes to process |
| 279 | + if (index < nodes.length) { |
| 280 | + logDebug(`Compactor: ${nodes.length - index} nodes remaining, scheduling next chunk`); |
| 281 | + scheduledNext = true; |
| 282 | + requestIdleCallback( |
| 283 | + (nextDeadline) => runTask(nextDeadline, nodes, index), |
| 284 | + { timeout: 1000 } |
| 285 | + ); |
| 286 | + } else { |
| 287 | + logDebug('Compactor: Processing complete'); |
| 288 | + } |
| 289 | + } finally { |
| 290 | + // Stop suppression if we're not continuing (done or error) |
| 291 | + if (!scheduledNext) { |
| 292 | + stopMutationSuppression(); |
| 293 | + } |
| 294 | + } |
| 295 | + }; |
| 296 | + |
| 297 | + // Start processing with 2 second timeout (will run even if no idle time) |
| 298 | + requestIdleCallback( |
| 299 | + (deadline) => runTask(deadline, nodesToProcess, 0), |
| 300 | + { timeout: 2000 } |
| 301 | + ); |
| 302 | +} |
| 303 | + |
| 304 | +/** |
| 305 | + * Check if compactor should run based on settings. |
| 306 | + * |
| 307 | + * @param ultraLean Whether ultra lean mode is enabled |
| 308 | + * @returns true if compactor should run |
| 309 | + */ |
| 310 | +export function shouldRunCompactor(ultraLean: boolean): boolean { |
| 311 | + return ultraLean; |
| 312 | +} |
| 313 | + |
| 314 | +/** |
| 315 | + * Reset processed nodes tracking. |
| 316 | + * Call this on navigation to new chat to allow re-processing. |
| 317 | + * Note: WeakSet automatically handles cleanup when nodes are removed, |
| 318 | + * but this is useful for explicit reset scenarios. |
| 319 | + */ |
| 320 | +export function resetCompactorState(): void { |
| 321 | + // WeakSet doesn't have a clear() method, but since it uses weak references, |
| 322 | + // old entries will be garbage collected when their DOM nodes are removed. |
| 323 | + // For explicit reset, we could create a new WeakSet, but it's not necessary |
| 324 | + // since the old nodes will be GC'd anyway. |
| 325 | + logDebug('Compactor: State reset (WeakSet will auto-clean)'); |
| 326 | +} |
0 commit comments