Let me verify the syntax:


Bash

php -l /var/www/mssql.danmarcrm.com/dev1/extension2v1/public/thread_view.php 2>&1

PHP syntax check on thread_view.php


No syntax errors detected in /var/www/mssql.danmarcrm.com/dev1/extension2v1/public/thread_view.php


Here's what changed in the context builder:

1. **Thread search limit: 5 → 100** — now shows up to 100 threads per search (was 5)
2. **Contact search limit: 5 → 10** — shows more matching contacts
3. **"+ Add All X Threads" button** — appears at the top of thread results, adds all non-current/non-added threads in one click
4. **Total count shown** — e.g. "THREADS (296 found, showing 100)"
5. **"Load More" button** — if there are more than 100, click to load the next batch, then "Add All" will include those too
6. **Contact thread limit: 20 → 100** — when clicking "View Threads" on a contact
7. **Scrollable results area: 250px → 400px** — more room to browse results

Refresh the thread view and search for "delta-engineering.be" in the context builder — you should see up to 100 threads with an "Add All" button at the top.


<ide_opened_file>The user opened the file /var/www/mssql.danmarcrm.com/dev1/extension2v1/aiapi/api_opus/config/api_keys.php in the IDE. This may or may not be related to the current task.</ide_opened_file>

Threads 296 found Add All 99 ..
Should have a Add all 296 as well.
Are we sure we add only the emails and not include all quoted emails? It is important as for example this emails are a lot to be analyzed


Good points. Two things to fix:

1. **Add All should cover all 296**, not just the first 100 loaded
2. **Verify quoted content is stripped** when building context for AI

Let me check how context threads are sent to the AI first.


1585:            contextThreads.forEach(function(ct) { total += ct.emailCount * 500; });
1613:        var contextThreads = []; // {threadId, subject, emailCount}
1674:                        return t.thread_id !== currentThreadId && !contextThreads.some(function(ct) { return ct.threadId === t.thread_id; });
1681:                        var already = contextThreads.some(function(ct) { return ct.threadId === t.thread_id; });
1718:                if (t.thread_id !== currentThreadId && !contextThreads.some(function(ct) { return ct.threadId === t.thread_id; })) {
1719:                    contextThreads.push({
1766:                        var already = contextThreads.some(function(ct) { return ct.threadId === t.thread_id; });
1793:                if (t.thread_id !== currentThreadId && !contextThreads.some(function(ct) { return ct.threadId === t.thread_id; })) {
1794:                    contextThreads.push({
1810:            if (contextThreads.some(function(ct) { return ct.threadId === threadId; })) return;
1811:            contextThreads.push({
1824:            contextThreads = contextThreads.filter(function(ct) { return ct.threadId !== threadId; });
1831:            if (contextThreads.length === 0) {
1835:            var totalEmails = contextThreads.reduce(function(sum, ct) { return sum + ct.emailCount; }, 0);
1837:            html += '
' + contextThreads.length + ' additional thread(s), ~' + totalEmails + ' emails
';
1838:            contextThreads.forEach(function(ct) {
2978:            var contextThreadIds = contextThreads.map(function(ct) { return ct.threadId; });
3038:                    fullContent += '\n\nADDITIONAL CONTEXT:\n' + context;
3076:                    fullContent = fullContent.substring(0, maxChars) + '\n\n...(Content truncated to fit model context limit of ' + modelLimit.toLocaleString() + ' tokens)';

[Showing results with pagination = limit: 30, offset: 0]


  2970→                return;
  2971→            }
  2972→
  2973→            var question = document.getElementById('ai-question').value.trim();
  2974→            var context = document.getElementById('ai-context').value.trim();
  2975→            var contentMode = document.getElementById('content-mode').value;
  2976→            var additionalThreads = getSelectedThreads();
  2977→            // Also include context builder threads
  2978→            var contextThreadIds = contextThreads.map(function(ct) { return ct.threadId; });
  2979→            var allAdditionalThreads = additionalThreads.concat(contextThreadIds);
  2980→
  2981→            // Build models map for each selected provider
  2982→            var modelsMap = {};
  2983→            selectedProvidersList.forEach(function(provider) {
  2984→                var modelSelect = document.getElementById('model_' + provider);
  2985→                modelsMap[provider] = modelSelect ? modelSelect.value : '';
  2986→            });
  2987→
  2988→            // Token check
  2989→            var estimatedTokens = estimateCurrentTokens();
  2990→            var modelLimit = getSmallestModelLimit();
  2991→            if (estimatedTokens > modelLimit * 0.95) {
  2992→                if (!confirm('Estimated tokens (' + estimatedTokens.toLocaleString() + ') may exceed model limit (' + modelLimit.toLocaleString() + '). Continue anyway?')) {
  2993→                    return;
  2994→                }
  2995→            }
  2996→
  2997→            // Show loading
  2998→            document.getElementById('loading-overlay').classList.add('active');
  2999→            document.getElementById('submit-ai-btn').disabled = true;
  3000→
  3001→            try {
  3002→                // Build the full content - start with agent system prompt
  3003→                var agentPrompt = '';
  3004→                if (selectedAgent && AI_AGENTS[selectedAgent]) {
  3005→                    agentPrompt = '=== AI AGENT INSTRUCTIONS ===\n' + AI_AGENTS[selectedAgent].systemPrompt + '\n=== END AGENT INSTRUCTIONS ===\n\n';
  3006→                }
  3007→
  3008→                // Inject contact memories if available
  3009→                var memoryBlock = '';
  3010→                if (contactMemories.length > 0 && primaryContactName) {
  3011→                    memoryBlock = '=== KNOWN FACTS ABOUT ' + primaryContactName.toUpperCase() + ' ===\n';
  3012→                    contactMemories.forEach(function(m) {
  3013→                        memoryBlock += '- ' + (m.fact || m) + '\n';
  3014→                    });
  3015→                    memoryBlock += '=== END KNOWN FACTS ===\n\n';
  3016→                }
  3017→
  3018→                var fullContent = agentPrompt + memoryBlock + buildEmailContent();
  3019→
  3020→                // ADD CONVERSATION HISTORY FOR CONTEXT (ChatGPT-like memory)
  3021→                var conversationContext = buildConversationContext();
  3022→                if (conversationContext) {
  3023→                    fullContent += conversationContext;
  3024→                    console.log('Including conversation history:', conversationHistory.length, 'previous exchanges');
  3025→                }
  3026→
  3027→                // Add question if provided
  3028→                var actualQuestion = question || 'Please analyze this email conversation and provide insights on:\n1. Key points and summary\n2. Sentiment and tone analysis\n3. Any potential concerns or red flags\n4. Recommended next steps';
  3029→
  3030→                if (question) {
  3031→                    fullContent += '\n\nCURRENT USER QUESTION:\n' + question;
  3032→                } else {
  3033→                    fullContent += '\n\n' + actualQuestion;
  3034→                }
  3035→
  3036→                // Add context if provided
  3037→                if (context) {
  3038→                    fullContent += '\n\nADDITIONAL CONTEXT:\n' + context;
  3039→                }
  3040→
  3041→                // Fetch additional thread content (checkboxes + context builder threads)
  3042→                // Uses thread_for_ai endpoint (lightweight, stripped bodies, no analysis)
  3043→                if (allAdditionalThreads.length > 0) {
  3044→                    document.getElementById('loading-message').textContent = 'Loading additional threads (0/' + allAdditionalThreads.length + ')...';
  3045→
  3046→                    for (var i = 0; i < allAdditionalThreads.length; i++) {
  3047→                        var tid = allAdditionalThreads[i];
  3048→                        document.getElementById('loading-message').textContent = 'Loading thread ' + (i + 1) + '/' + allAdditionalThreads.length + '...';
  3049→                        try {
  3050→                            var threadResp = await fetch('api.php?action=thread_for_ai&thread_id=' + tid);
  3051→                            if (!threadResp.ok) {
  3052→                                console.warn('Failed to fetch thread ' + tid + ': HTTP ' + threadResp.status);
  3053→                                fullContent += '\n\n--- THREAD ' + (i + 1) + ': FAILED TO LOAD (HTTP ' + threadResp.status + ') ---\n';
  3054→                                continue;
  3055→                            }
  3056→                            var thread = await threadResp.json();
  3057→
  3058→                            if (thread.emails && thread.emails.length > 0) {
  3059→                                fullContent += '\n\n--- ADDITIONAL THREAD ' + (i + 1) + ' OF ' + allAdditionalThreads.length + ' ---\n';

                return;
            }

            var question = document.getElementById('ai-question').value.trim();
            var context = document.getElementById('ai-context').value.trim();
            var contentMode = document.getElementById('content-mode').value;
            var additionalThreads = getSelectedThreads();
            // Also include context builder threads
            var contextThreadIds = contextThreads.map(function(ct) { return ct.threadId; });
            var allAdditionalThreads = additionalThreads.concat(contextThreadIds);

            // Build models map for each selected provider
            var modelsMap = {};
            selectedProvidersList.forEach(function(provider) {
                var modelSelect = document.getElementById('model_' + provider);
                modelsMap[provider] = modelSelect ? modelSelect.value : '';
            });

            // Token check
            var estimatedTokens = estimateCurrentTokens();
            var modelLimit = getSmallestModelLimit();
            if (estimatedTokens > modelLimit * 0.95) {
                if (!confirm('Estimated tokens (' + estimatedTokens.toLocaleString() + ') may exceed model limit (' + modelLimit.toLocaleString() + '). Continue anyway?')) {
                    return;
                }
            }

            // Show loading
            document.getElementById('loading-overlay').classList.add('active');
            document.getElementById('submit-ai-btn').disabled = true;

            try {
                // Build the full content - start with agent system prompt
                var agentPrompt = '';
                if (selectedAgent && AI_AGENTS[selectedAgent]) {
                    agentPrompt = '=== AI AGENT INSTRUCTIONS ===\n' + AI_AGENTS[selectedAgent].systemPrompt + '\n=== END AGENT INSTRUCTIONS ===\n\n';
                }

                // Inject contact memories if available
                var memoryBlock = '';
                if (contactMemories.length > 0 && primaryContactName) {
                    memoryBlock = '=== KNOWN FACTS ABOUT ' + primaryContactName.toUpperCase() + ' ===\n';
                    contactMemories.forEach(function(m) {
                        memoryBlock += '- ' + (m.fact || m) + '\n';
                    });
                    memoryBlock += '=== END KNOWN FACTS ===\n\n';
                }

                var fullContent = agentPrompt + memoryBlock + buildEmailContent();

                // ADD CONVERSATION HISTORY FOR CONTEXT (ChatGPT-like memory)
                var conversationContext = buildConversationContext();
                if (conversationContext) {
                    fullContent += conversationContext;
                    console.log('Including conversation history:', conversationHistory.length, 'previous exchanges');
                }

                // Add question if provided
                var actualQuestion = question || 'Please analyze this email conversation and provide insights on:\n1. Key points and summary\n2. Sentiment and tone analysis\n3. Any potential concerns or red flags\n4. Recommended next steps';

                if (question) {
                    fullContent += '\n\nCURRENT USER QUESTION:\n' + question;
                } else {
                    fullContent += '\n\n' + actualQuestion;
                }

                // Add context if provided
                if (context) {
                    fullContent += '\n\nADDITIONAL CONTEXT:\n' + context;
                }

                // Fetch additional thread content (checkboxes + context builder threads)
                // Uses thread_for_ai endpoint (lightweight, stripped bodies, no analysis)
                if (allAdditionalThreads.length > 0) {
                    document.getElementById('loading-message').textContent = 'Loading additional threads (0/' + allAdditionalThreads.length + ')...';

                    for (var i = 0; i < allAdditionalThreads.length; i++) {
                        var tid = allAdditionalThreads[i];
                        document.getElementById('loading-message').textContent = 'Loading thread ' + (i + 1) + '/' + allAdditionalThreads.length + '...';
                        try {
                            var threadResp = await fetch('api.php?action=thread_for_ai&thread_id=' + tid);
                            if (!threadResp.ok) {
                                console.warn('Failed to fetch thread ' + tid + ': HTTP ' + threadResp.status);
                                fullContent += '\n\n--- THREAD ' + (i + 1) + ': FAILED TO LOAD (HTTP ' + threadResp.status + ') ---\n';
                                continue;
                            }
                            var thread = await threadResp.json();

                            if (thread.emails && thread.emails.length > 0) {
                                fullContent += '\n\n--- ADDITIONAL THREAD ' + (i + 1) + ' OF ' + allAdditionalThreads.length + ' ---\n';


  3058→                            if (thread.emails && thread.emails.length > 0) {
  3059→                                fullContent += '\n\n--- ADDITIONAL THREAD ' + (i + 1) + ' OF ' + allAdditionalThreads.length + ' ---\n';
  3060→                                fullContent += 'Subject: ' + (thread.emails[0].subject || '(No Subject)') + '\n\n';
  3061→                                // Bodies already stripped by thread_for_ai endpoint
  3062→                                fullContent += processAdditionalThreadContent(thread.emails, 'full');
  3063→                            }
  3064→                        } catch (fetchErr) {
  3065→                            console.warn('Error fetching thread ' + tid + ':', fetchErr);
  3066→                            fullContent += '\n\n--- THREAD ' + (i + 1) + ': FETCH ERROR ---\n';
  3067→                        }
  3068→                    }
  3069→                }
  3070→
  3071→                // Enforce content budget: truncate to fit smallest model limit
  3072→                var maxChars = Math.floor(modelLimit * 3.5 * 0.85); // 85% of model limit in chars
  3073→                var wasTruncated = false;
  3074→                if (fullContent.length > maxChars) {
  3075→                    wasTruncated = true;
  3076→                    fullContent = fullContent.substring(0, maxChars) + '\n\n...(Content truncated to fit model context limit of ' + modelLimit.toLocaleString() + ' tokens)';
  3077→                }
  3078→
  3079→                var finalTokens = Math.ceil(fullContent.length / 3.5);
  3080→
  3081→                // Log to debug file BEFORE sending
  3082→                logAiDebug({
  3083→                    type: 'request',
  3084→                    thread_id: currentThreadId,
  3085→                    agent: selectedAgent,
  3086→                    providers: selectedProvidersList,
  3087→                    models: modelsMap,
  3088→                    content_length: fullContent.length,
  3089→                    estimated_tokens: finalTokens,
  3090→                    question: actualQuestion,
  3091→                    context_threads: allAdditionalThreads,
  3092→                    truncated: wasTruncated,
  3093→                    full_content: fullContent.substring(0, 10000) + (fullContent.length > 10000 ? '\n...(truncated in log, full was ' + fullContent.length + ' chars)' : '')
  3094→                });
  3095→
  3096→                // Console log
  3097→                console.log('=== AI REQUEST LOG ===');

                            if (thread.emails && thread.emails.length > 0) {
                                fullContent += '\n\n--- ADDITIONAL THREAD ' + (i + 1) + ' OF ' + allAdditionalThreads.length + ' ---\n';
                                fullContent += 'Subject: ' + (thread.emails[0].subject || '(No Subject)') + '\n\n';
                                // Bodies already stripped by thread_for_ai endpoint
                                fullContent += processAdditionalThreadContent(thread.emails, 'full');
                            }
                        } catch (fetchErr) {
                            console.warn('Error fetching thread ' + tid + ':', fetchErr);
                            fullContent += '\n\n--- THREAD ' + (i + 1) + ': FETCH ERROR ---\n';
                        }
                    }
                }

                // Enforce content budget: truncate to fit smallest model limit
                var maxChars = Math.floor(modelLimit * 3.5 * 0.85); // 85% of model limit in chars
                var wasTruncated = false;
                if (fullContent.length > maxChars) {
                    wasTruncated = true;
                    fullContent = fullContent.substring(0, maxChars) + '\n\n...(Content truncated to fit model context limit of ' + modelLimit.toLocaleString() + ' tokens)';
                }

                var finalTokens = Math.ceil(fullContent.length / 3.5);

                // Log to debug file BEFORE sending
                logAiDebug({
                    type: 'request',
                    thread_id: currentThreadId,
                    agent: selectedAgent,
                    providers: selectedProvidersList,
                    models: modelsMap,
                    content_length: fullContent.length,
                    estimated_tokens: finalTokens,
                    question: actualQuestion,
                    context_threads: allAdditionalThreads,
                    truncated: wasTruncated,
                    full_content: fullContent.substring(0, 10000) + (fullContent.length > 10000 ? '\n...(truncated in log, full was ' + fullContent.length + ' chars)' : '')
                });

                // Console log
                console.log('=== AI REQUEST LOG ===');


Good — line 3061 says "Bodies already stripped by thread_for_ai endpoint". Let me verify that's actually true:


484:        case 'thread_for_ai':
485-            $threadId = $_GET['thread_id'] ?? '';
486-            if (!$threadId) jsonError('Thread ID required');
487-
488-            $threadId = resolveThreadId($threadId);
489-            $adapter = QueryAdapter::getInstance();
490-            $threadData = $adapter->getThreadDetails($threadId);
491-
492-            if (empty($threadData['emails'])) {
493-                jsonError('Thread not found', 404);
494-            }
495-
496-            / Return only what AI needs: sender, date, direction, stripped body
497-            $aiEmails = [];
498-            $totalChars = 0;
499-            foreach ($threadData['emails'] as $email) {
500-                $body = $email['body_text'] ?? $email['body_html'] ?? '';
501-                // Strip quoted content to save tokens
502-                $body = \EmailIntelligence\ThreadAnalyzer::stripQuotedContent($body);
503-                // Truncate individual emails to 3000 chars max
504-                if (strlen($body) > 3000) {
505-                    $body = substr($body, 0, 3000) . "\n...(truncated)";
506-                }
507-                $totalChars += strlen($body);
508-
509-                $aiEmails[] = [
510-                    'from_name' => $email['from_name'] ?? $email['contact_name'] ?? '',
511-                    'from_email' => $email['from_email'] ?? '',
512-                    'date' => $email['date'] ?? '',
513-                    'direction' => $email['direction'] ?? '',
514-                    'subject' => $email['subject'] ?? '',
515-                    'body_text' => $body
516-                ];
517-            }
518-
519-            jsonResponse([
520-                'thread_id' => $threadId,
521-                'emails' => $aiEmails,
522-                'email_count' => count($aiEmails),
523-                'total_chars' => $totalChars
524-            ]);


Good — the `thread_for_ai` endpoint (line 502) already calls `stripQuotedContent()` and truncates each email to 3000 chars. That part is handled correctly.

Now let me also check how `buildEmailContent()` handles the **current** thread's emails:


2894:        function buildEmailContent() {
2895-            const mode = document.getElementById('content-mode').value;
2896-            let content = 'EMAIL THREAD CONVERSATION:\n';
2897-            content += `(Content mode: ${mode})\n\n`;
2898-
2899-            const totalEmails = emailContent.length;
2900-
2901-            emailContent.forEach((email, index) => {
2902-                content += `--- Email ${index + 1} of ${totalEmails} ---\n`;
2903-                content += `From: ${email.from} <${email.from_email}>\n`;
2904-                content += `Date: ${email.date}\n`;
2905-                content += `Direction: ${email.direction}\n\n`;
2906-
2907-                let emailBody = email.body;
2908-
2909-                if (mode === 'stripped') {
2910-                    // Strip quoted content from all emails
2911-                    emailBody = stripQuotedContent(emailBody);
2912-                    if (!emailBody.trim()) {
2913-                        emailBody = '(Only quoted content - no new text in this email)';
2914-                    }
2915-                } else if (mode === 'smart') {
2916-                    // Strip quotes from all emails except the last one
2917-                    if (index < totalEmails - 1) {
2918-                        emailBody = stripQuotedContent(emailBody);
2919-                        if (!emailBody.trim()) {
2920-                            emailBody = '(Only quoted content - no new text in this email)';
2921-                        }
2922-                    }
2923-                    // Last email keeps full content for context
2924-                }


Good — the current thread's content respects the content mode (stripped/smart/full). And additional threads via `thread_for_ai` always strip quotes server-side.

Now let me fix the "Add All" to fetch and add ALL matching threads, not just the loaded 100:


1639→        function searchContext(query) {
  1640→            var resultsEl = document.getElementById('context-search-results');
  1641→            resultsEl.innerHTML = '
Searching...
';
  1642→
  1643→            // Search contacts and threads in parallel — higher limit for threads
  1644→            Promise.all([
  1645→                fetch('api.php?action=contacts&search=' + encodeURIComponent(query) + '&limit=10').then(function(r) { return r.json(); }),
  1646→                fetch('api.php?action=threads&search=' + encodeURIComponent(query) + '&limit=100').then(function(r) { return r.json(); })
  1647→            ]).then(function(results) {
  1648→                var contacts = results[0].contacts || [];
  1649→                var threads = results[1].threads || [];
  1650→                var threadTotal = (results[1].pagination && results[1].pagination.total) || threads.length;
  1651→                var html = '';
  1652→
  1653→                if (contacts.length > 0) {
  1654→                    html += '
CONTACTS (' + contacts.length + ')
';
  1655→                    contacts.forEach(function(c) {
  1656→                        html += '
';
  1657→                        html += '
';
  1658→                        html += '' + escapeHtml(c.name || c.email) + '';
  1659→                        html += '
' + escapeHtml(c.email || '') + ' | ' + (c.email_count || 0) + ' emails
';
  1660→                        html += '
';
  1661→                        html += '<button class="context-add-btn" onclick="loadContactThreads(' + (c.id || c.contact_id) + ', \'' + escapeHtml(c.name || '') + '\')">View Threads</button>';
  1662→                        html += '
';
  1663→                    });
  1664→                }
  1665→
  1666→                if (threads.length > 0) {
  1667→                    // Store for "Add All" functionality
  1668→                    window._lastSearchThreads = threads;
  1669→
  1670→                    html += '
THREADS (' + threadTotal + ' found' + (threadTotal > threads.length ? ', showing ' + threads.length : '') + ')
';
  1671→
  1672→                    // "Add All" button
  1673→                    var notAddedCount = threads.filter(function(t) {
  1674→                        return t.thread_id !== currentThreadId && !contextThreads.some(function(ct) { return ct.threadId === t.thread_id; });
  1675→                    }).length;
  1676→                    if (notAddedCount > 1) {
  1677→                        html += '
<button class="context-add-btn" onclick="addAllSearchThreads()" style="width:100%;text-align:center;padding:6px;font-weight:600;">+ Add All ' + notAddedCount + ' Threads</button>
';
  1678→                    }
  1679→
  1680→                    threads.forEach(function(t) {
  1681→                        var already = contextThreads.some(function(ct) { return ct.threadId === t.thread_id; });
  1682→                        var isCurrent = t.thread_id === currentThreadId;
  1683→                        html += '
';
  1684→                        html += '
';
  1685→                        html += '' + escapeHtml(t.subject || '(No Subject)') + '';
  1686→                        html += '
' + (t.email_count || 0) + ' emails | ' + escapeHtml(t.contact_name || '') + '
';
  1687→                        html += '
';
  1688→                        if (isCurrent) {
  1689→                            html += 'Current';
  1690→                        } else if (already) {
  1691→                            html += 'Added';
  1692→                        } else {
  1693→                            html += '<button class="context-add-btn" onclick="addThreadToContext(\'' + t.thread_id + '\', \'' + escapeHtml(t.subject || '').replace(/'/g, "\\'") + '\', ' + (t.email_count || 0) + ')">+ Add</button>';
  1694→                        }
  1695→                        html += '
';
  1696→                    });
  1697→
  1698→                    // If more threads exist, show "Load More" button
  1699→                    if (threadTotal > threads.length) {
  1700→                        html += '
<button class="context-add-btn" onclick="loadMoreSearchThreads(\'' + escapeHtml(query).replace(/'/g, "\\'") + '\', ' + threads.length + ', ' + threadTotal + ')" style="width:100%;text-align:center;padding:6px;color:var(--text-secondary);">Load More (' + (threadTotal - threads.length) + ' remaining)</button>
';
  1701→                    }
  1702→                }
  1703→
  1704→                if (!html) {
  1705→                    html = '
No results found
';
  1706→                }
  1707→
  1708→                resultsEl.innerHTML = html;
  1709→            }).catch(function(err) {
  1710→                resultsEl.innerHTML = '
Search failed
';
  1711→            });
  1712→        }
  1713→
  1714→        function addAllSearchThreads() {
  1715→            var threads = window._lastSearchThreads || [];
  1716→            var added = 0;
  1717→            threads.forEach(function(t) {
  1718→                if (t.thread_id !== currentThreadId && !contextThreads.some(function(ct) { return ct.threadId === t.thread_id; })) {
  1719→                    contextThreads.push({
  1720→                        threadId: t.thread_id,
  1721→                        subject: t.subject || '(No Subject)',
  1722→                        emailCount: t.email_count || 0
  1723→                    });
  1724→                    added++;
  1725→                }
  1726→            });
  1727→            updateContextCart();
  1728→            updateTokenEstimate();
  1729→            // Refresh to show "Added" labels
  1730→            var q = document.getElementById('context-search').value.trim();
  1731→            if (q) searchContext(q);
  1732→        }
  1733→
  1734→        function loadMoreSearchThreads(query, currentCount, total) {
  1735→            var page = Math.floor(currentCount / 100) + 1;
  1736→            fetch('api.php?action=threads&search=' + encodeURIComponent(query) + '&limit=100&page=' + (page + 1))
  1737→                .then(function(r) { return r.json(); })
  1738→                .then(function(data) {
  1739→                    var newThreads = data.threads || [];
  1740→                    if (newThreads.length > 0) {
  1741→                        window._lastSearchThreads = (window._lastSearchThreads || []).concat(newThreads);
  1742→                        // Re-render with all threads
  1743→                        searchContext(query);
  1744→                    }
  1745→                });
  1746→        }
  1747→
  1748→        function loadContactThreads(contactId, contactName) {
  1749→            var resultsEl = document.getElementById('context-search-results');
  1750→            resultsEl.innerHTML = '
Loading threads for ' + escapeHtml(contactName) + '...
';
  1751→
  1752→            fetch('api.php?action=threads&contact=' + contactId + '&limit=100')
  1753→                .then(function(r) { return r.json(); })
  1754→                .then(function(data) {
  1755→                    var threads = data.threads || [];
  1756→                    var html = '
Threads from ' + escapeHtml(contactName) + '
';
  1757→
  1758→                    if (threads.length > 1) {

function searchContext(query) {
            var resultsEl = document.getElementById('context-search-results');
            resultsEl.innerHTML = '
Searching...
';

            // Search contacts and threads in parallel — higher limit for threads
            Promise.all([
                fetch('api.php?action=contacts&search=' + encodeURIComponent(query) + '&limit=10').then(function(r) { return r.json(); }),
                fetch('api.php?action=threads&search=' + encodeURIComponent(query) + '&limit=100').then(function(r) { return r.json(); })
            ]).then(function(results) {
                var contacts = results[0].contacts || [];
                var threads = results[1].threads || [];
                var threadTotal = (results[1].pagination && results[1].pagination.total) || threads.length;
                var html = '';

                if (contacts.length > 0) {
                    html += '
CONTACTS (' + contacts.length + ')
';
                    contacts.forEach(function(c) {
                        html += '
';
                        html += '
';
                        html += '' + escapeHtml(c.name || c.email) + '';
                        html += '
' + escapeHtml(c.email || '') + ' | ' + (c.email_count || 0) + ' emails
';
                        html += '
';
                        html += '<button class="context-add-btn" onclick="loadContactThreads(' + (c.id || c.contact_id) + ', \'' + escapeHtml(c.name || '') + '\')">View Threads</button>';
                        html += '
';
                    });
                }

                if (threads.length > 0) {
                    // Store for "Add All" functionality
                    window._lastSearchThreads = threads;

                    html += '
THREADS (' + threadTotal + ' found' + (threadTotal > threads.length ? ', showing ' + threads.length : '') + ')
';

                    // "Add All" button
                    var notAddedCount = threads.filter(function(t) {
                        return t.thread_id !== currentThreadId && !contextThreads.some(function(ct) { return ct.threadId === t.thread_id; });
                    }).length;
                    if (notAddedCount > 1) {
                        html += '
<button class="context-add-btn" onclick="addAllSearchThreads()" style="width:100%;text-align:center;padding:6px;font-weight:600;">+ Add All ' + notAddedCount + ' Threads</button>
';
                    }

                    threads.forEach(function(t) {
                        var already = contextThreads.some(function(ct) { return ct.threadId === t.thread_id; });
                        var isCurrent = t.thread_id === currentThreadId;
                        html += '
';
                        html += '
';
                        html += '' + escapeHtml(t.subject || '(No Subject)') + '';
                        html += '
' + (t.email_count || 0) + ' emails | ' + escapeHtml(t.contact_name || '') + '
';
                        html += '
';
                        if (isCurrent) {
                            html += 'Current';
                        } else if (already) {
                            html += 'Added';
                        } else {
                            html += '<button class="context-add-btn" onclick="addThreadToContext(\'' + t.thread_id + '\', \'' + escapeHtml(t.subject || '').replace(/'/g, "\\'") + '\', ' + (t.email_count || 0) + ')">+ Add</button>';
                        }
                        html += '
';
                    });

                    // If more threads exist, show "Load More" button
                    if (threadTotal > threads.length) {
                        html += '
<button class="context-add-btn" onclick="loadMoreSearchThreads(\'' + escapeHtml(query).replace(/'/g, "\\'") + '\', ' + threads.length + ', ' + threadTotal + ')" style="width:100%;text-align:center;padding:6px;color:var(--text-secondary);">Load More (' + (threadTotal - threads.length) + ' remaining)</button>
';
                    }
                }

                if (!html) {
                    html = '
No results found
';
                }

                resultsEl.innerHTML = html;
            }).catch(function(err) {
                resultsEl.innerHTML = '
Search failed
';
            });
        }

        function addAllSearchThreads() {
            var threads = window._lastSearchThreads || [];
            var added = 0;
            threads.forEach(function(t) {
                if (t.thread_id !== currentThreadId && !contextThreads.some(function(ct) { return ct.threadId === t.thread_id; })) {
                    contextThreads.push({
                        threadId: t.thread_id,
                        subject: t.subject || '(No Subject)',
                        emailCount: t.email_count || 0
                    });
                    added++;
                }
            });
            updateContextCart();
            updateTokenEstimate();
            // Refresh to show "Added" labels
            var q = document.getElementById('context-search').value.trim();
            if (q) searchContext(q);
        }

        function loadMoreSearchThreads(query, currentCount, total) {
            var page = Math.floor(currentCount / 100) + 1;
            fetch('api.php?action=threads&search=' + encodeURIComponent(query) + '&limit=100&page=' + (page + 1))
                .then(function(r) { return r.json(); })
                .then(function(data) {
                    var newThreads = data.threads || [];
                    if (newThreads.length > 0) {
                        window._lastSearchThreads = (window._lastSearchThreads || []).concat(newThreads);
                        // Re-render with all threads
                        searchContext(query);
                    }
                });
        }

        function loadContactThreads(contactId, contactName) {
            var resultsEl = document.getElementById('context-search-results');
            resultsEl.innerHTML = '
Loading threads for ' + escapeHtml(contactName) + '...
';

            fetch('api.php?action=threads&contact=' + contactId + '&limit=100')
                .then(function(r) { return r.json(); })
                .then(function(data) {
                    var threads = data.threads || [];
                    var html = '
Threads from ' + escapeHtml(contactName) + '
';

                    if (threads.length > 1) {


Now I'll replace the thread search section to add an "Add All X" button that fetches ALL threads (not just loaded ones), plus show the "Add Shown X" for the ones already loaded:


Edit

if (threads.length > 0) {
                    // Store for "Add All" functionality
                    window._lastSearchThreads = threads;

                    html += '
THREADS (' + threadTotal + ' found' + (threadTotal > threads.length ? ', showing ' + threads.length : '') + ')
';

                    // "Add All" button
                    var notAddedCount = threads.filter(function(t) {
                        return t.thread_id !== currentThreadId && !contextThreads.some(function(ct) { return ct.threadId === t.thread_id; });
                    }).length;
                    if (notAddedCount > 1) {
                        html += '
<button class="context-add-btn" onclick="addAllSearchThreads()" style="width:100%;text-align:center;padding:6px;font-weight:600;">+ Add All ' + notAddedCount + ' Threads</button>
';
                    }

                    threads.forEach(function(t) {
                        var already = contextThreads.some(function(ct) { return ct.threadId === t.thread_id; });
                        var isCurrent = t.thread_id === currentThreadId;
                        html += '
';
                        html += '
';
                        html += '' + escapeHtml(t.subject || '(No Subject)') + '';
                        html += '
' + (t.email_count || 0) + ' emails | ' + escapeHtml(t.contact_name || '') + '
';
                        html += '
';
                        if (isCurrent) {
                            html += 'Current';
                        } else if (already) {
                            html += 'Added';
                        } else {
                            html += '<button class="context-add-btn" onclick="addThreadToContext(\'' + t.thread_id + '\', \'' + escapeHtml(t.subject || '').replace(/'/g, "\\'") + '\', ' + (t.email_count || 0) + ')">+ Add</button>';
                        }
                        html += '
';
                    });

                    // If more threads exist, show "Load More" button
                    if (threadTotal > threads.length) {
                        html += '
<button class="context-add-btn" onclick="loadMoreSearchThreads(\'' + escapeHtml(query).replace(/'/g, "\\'") + '\', ' + threads.length + ', ' + threadTotal + ')" style="width:100%;text-align:center;padding:6px;color:var(--text-secondary);">Load More (' + (threadTotal - threads.length) + ' remaining)</button>
';
                    }
                }

if (threads.length > 0) {
                    // Store for "Add All" functionality
                    window._lastSearchThreads = threads;
                    window._lastSearchQuery = query;
                    window._lastSearchTotal = threadTotal;

                    html += '
THREADS (' + threadTotal + ' found' + (threadTotal > threads.length ? ', showing ' + threads.length : '') + ')
';

                    // "Add All" buttons
                    var notAddedCount = threads.filter(function(t) {
                        return t.thread_id !== currentThreadId && !contextThreads.some(function(ct) { return ct.threadId === t.thread_id; });
                    }).length;

                    html += '
';
                    if (threadTotal > threads.length) {
                        // Button to fetch ALL and add them
                        html += '<button class="context-add-btn" onclick="addAllSearchThreadsFull()" style="flex:1;text-align:center;padding:6px;font-weight:600;background:#3b82f6;color:white;border:none;border-radius:4px;">+ Add All ' + threadTotal + ' Threads</button>';
                    }
                    if (notAddedCount > 1) {
                        html += '<button class="context-add-btn" onclick="addAllSearchThreads()" style="flex:1;text-align:center;padding:6px;">' + (threadTotal > threads.length ? '+ Add Shown ' + notAddedCount : '+ Add All ' + notAddedCount + ' Threads') + '</button>';
                    }
                    html += '
';

                    threads.forEach(function(t) {
                        var already = contextThreads.some(function(ct) { return ct.threadId === t.thread_id; });
                        var isCurrent = t.thread_id === currentThreadId;
                        html += '
';
                        html += '
';
                        html += '' + escapeHtml(t.subject || '(No Subject)') + '';
                        html += '
' + (t.email_count || 0) + ' emails | ' + escapeHtml(t.contact_name || '') + '
';
                        html += '
';
                        if (isCurrent) {
                            html += 'Current';
                        } else if (already) {
                            html += 'Added';
                        } else {
                            html += '<button class="context-add-btn" onclick="addThreadToContext(\'' + t.thread_id + '\', \'' + escapeHtml(t.subject || '').replace(/'/g, "\\'") + '\', ' + (t.email_count || 0) + ')">+ Add</button>';
                        }
                        html += '
';
                    });
                }